Upload File
Sebuah upload file hanyalah body request bertipe konten multipart/form-data, jadi pembaca penanganan request yang sama yang mengurai JSON atau teks juga membongkar file yang diunggah. Deserve bersandar pada Web FormData API native, jadi file yang diunggah tiba sebagai objek File standar dengan bytes, nama, dan tipe yang sudah melekat.
Struktur Proyek
Handler upload tinggal di direktori routes sementara file tersimpan berada di folder uploads di sebelahnya yang disajikan kembali oleh penyajian statis. Entri server menyatukan keduanya:
.
├── main.ts → Setup Router dan serve
├── routes/
│ └── api/
│ └── upload.ts → POST /api/upload
└── uploads/ → File tersimpan mendarat di siniMembaca Upload
Sebuah request multipart mengalir lewat ctx.get.formData(), dan tiap field bernama kembali dari form.get(). Field teks mengembalikan string sementara field file mengembalikan File:
import type { Context } from '@neabyte/deserve'
// POST /api/upload dengan form multipart
export async function POST(ctx: Context): Promise<Response> {
const form = await ctx.get.formData()
const title = form.get('title') // Field teks sebagai string
const file = form.get('file') // File atau string atau null
// Pantulkan kedua jenis field
return ctx.send.json({
title,
filename: file instanceof File ? file.name : null
})
}Memanggil ctx.get.body() mencapai parser yang sama, karena ia membaca header Content-Type dan mengarahkan baik multipart/form-data maupun application/x-www-form-urlencoded ke FormData. Memilih ctx.get.formData() membuat niat jelas di titik panggilan.
Memastikan File Tiba
form.get() mengembalikan null untuk field yang hilang dan string untuk field teks, jadi pengecekan instanceof File memisahkan upload nyata dari sisanya sebelum pekerjaan apa pun berjalan:
export async function POST(ctx: Context): Promise<Response> {
const form = await ctx.get.formData()
const file = form.get('file')
// Tolak saat tidak ada file
if (!(file instanceof File)) {
return ctx.send.json(
{
error: 'No file uploaded'
},
{
status: 400
}
)
}
// Metadata ikut bersama File
return ctx.send.json({
name: file.name,
type: file.type,
size: file.size
})
}Objek File memaparkan name, type, dan size miliknya sendiri, jadi melaporkan kembali sebuah upload tidak pernah perlu menyentuh disk.
Handler yang memeriksa beberapa field bisa memindahkan pemeriksaan itu ke depan dirinya dengan kontrak validasi pada sumber body, sehingga hanya request yang sudah membawa field yang benar mencapai handler.
Menyimpan ke Disk
Bytes tetap di dalam File sampai arrayBuffer() mengeluarkannya, dan membungkus buffer itu dalam Uint8Array memberi Deno.writeFile persis bentuk yang diharapkannya. Deserve tidak pernah menulis upload sendiri, jadi path tujuan tetap dalam kendali penuh:
export async function POST(ctx: Context): Promise<Response> {
const form = await ctx.get.formData()
const file = form.get('file')
// Tolak saat tidak ada file
if (!(file instanceof File)) {
return ctx.send.json(
{
error: 'No file uploaded'
},
{
status: 400
}
)
}
// Baca bytes mentah dari File
const bytes = new Uint8Array(await file.arrayBuffer())
// Nama unik mencegah saling timpa
const path = `./uploads/${crypto.randomUUID()}-${file.name}`
await Deno.writeFile(path, bytes)
// Laporkan yang mendarat di disk
return ctx.send.json({
saved: file.name,
size: file.size
})
}Menulis ke disk butuh izin tulis Deno, jadi server berjalan dengan --allow-write atau --allow-write=./uploads yang dibatasi untuk folder yang dituju handler.
Membaca Body Sekali
Tiap Context mengurai body-nya satu kali lalu men-cache hasilnya, jadi format yang dibaca lebih dulu adalah format yang terkunci untuk request itu. Memilih salah satu dari formData(), json(), text(), bytes(), atau blob() per request menjaga kontrak itu:
export async function POST(ctx: Context): Promise<Response> {
const form = await ctx.get.formData() // Body dikonsumsi sekali di sini
// Baca form ter-cache, bukan body
return ctx.send.json({
fields: [...form.keys()]
})
}Pembaca kedua dengan format berbeda seperti ctx.get.json() pada request ini akan melempar 409 alih-alih mengembalikan data kosong, karena body sudah dikonsumsi sebagai form data. Membaca format yang sama dua kali aman, karena nilai terurai di-cache dan diberikan kembali tanpa menyentuh body lagi. Payload multipart yang rusak juga tidak pernah membuat pipeline crash, karena parser memetakan body rusak ke 400 yang mengalir lewat penanganan error terpusat. Setiap pembaca dan tipe kembaliannya ada di referensi penanganan request.
Membatasi Ukuran Upload
FormData tidak membatasi berapa banyak bytes yang dikirim klien, jadi rute upload berpasangan dengan middleware body limit untuk menolak payload yang terlalu besar dengan 413 sebelum memenuhi memori. Pemeriksaan ini membaca header Content-Length, jadi ukuran yang diumumkan melebihi batas ditolak sebelum body dibaca. Request tanpa Content-Length, seperti stream chunked, melewati pemeriksaan ini dan mencapai handler:
import { Mware, Router } from '@neabyte/deserve'
const router = new Router({
routes: { directory: './routes' }
})
// Batasi rute upload pada 5MB
router.use(
'/api/upload',
Mware.bodyLimit({
limit: 5 * 1024 * 1024
})
)
await router.serve(8000)Menyajikan Upload Kembali
File tersimpan di bawah ./uploads bisa dijangkau lagi lewat penyajian statis, di mana prefix URL memetakan ke folder di disk. Upload pengguna sering berubah, jadi kebijakan etag: false dan cacheControl: 0 menjaga salinan basi keluar dari browser:
// Sajikan upload tersimpan tanpa cache
router.static('/uploads', {
path: './uploads',
etag: false,
cacheControl: 0
})Untuk unduhan sekali pakai yang digerakkan handler alih-alih prefix statis, ctx.send.download() mengalirkan satu file langsung dari disk dengan Content-Disposition yang tepat terpasang.
Alur Lengkap
Dua file membawa seluruh alur. Entri main.ts membatasi ukuran di router dan memaparkan folder tersimpan, sementara file rute di routes/api/upload.ts memvalidasi field, menyimpan bytes, dan melaporkan URL publik kembali. Deserve menyatukannya lewat routing berbasis file, jadi file rute tidak pernah di-import dengan tangan.
Pertama entri server yang menyiapkan router:
import { Mware, Router } from '@neabyte/deserve'
// Titik masuk main.ts
const router = new Router({
routes: { directory: './routes' }
})
// Jaga ukuran sebelum handler jalan
router.use(
'/api/upload',
Mware.bodyLimit({
limit: 5 * 1024 * 1024
})
)
// Sajikan file tersimpan ke klien
router.static('/uploads', {
path: './uploads',
etag: false,
cacheControl: 0
})
await router.serve(8000)Lalu file rute yang menangani upload:
// Handler routes/api/upload.ts
export async function POST(ctx: Context): Promise<Response> {
const form = await ctx.get.formData()
const file = form.get('file')
// Tolak saat tidak ada file
if (!(file instanceof File)) {
return ctx.send.json(
{
error: 'No file uploaded'
},
{
status: 400
}
)
}
// Baca bytes, lalu tulis nama unik
const bytes = new Uint8Array(await file.arrayBuffer())
const name = `${crypto.randomUUID()}-${file.name}`
await Deno.writeFile(`./uploads/${name}`, bytes)
// Kembalikan URL unduhan publik
return ctx.send.json({
url: `/uploads/${name}`,
size: file.size
})
}