Web Server #
Dart adalah bahasa yang sangat capable untuk server-side — satu bahasa untuk frontend (Flutter web) dan backend (Dart server). dart:io menyediakan HttpServer tingkat rendah yang memberi kontrol penuh, sementara package shelf menyediakan abstraksi middleware yang lebih ergonomis. Artikel ini membahas keduanya: dari membangun server dari nol dengan HttpServer untuk memahami fundamentalnya, hingga menggunakan shelf untuk produktivitas yang lebih tinggi dalam membangun REST API yang nyata.
HttpServer — Fondasi Server Dart
#
import 'dart:io';
import 'dart:convert';
Future<void> main() async {
final server = await HttpServer.bind(
InternetAddress.anyIPv4,
8080,
shared: true, // izinkan bind ulang setelah restart cepat
);
print('Server berjalan di http://localhost:${server.port}');
// Graceful shutdown saat SIGINT (Ctrl+C)
ProcessSignal.sigint.watch().listen((_) async {
print('\nMematikan server...');
await server.close(force: false); // tunggu request aktif selesai
exit(0);
});
await for (final request in server) {
_tanganiRequest(request); // tanpa await — non-blocking per request
}
}
Future<void> _tanganiRequest(HttpRequest request) async {
try {
// Set CORS header untuk semua response
request.response.headers
..set('Access-Control-Allow-Origin', '*')
..set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
..set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Handle preflight OPTIONS
if (request.method == 'OPTIONS') {
request.response.statusCode = HttpStatus.ok;
await request.response.close();
return;
}
await _router(request);
} catch (e, stackTrace) {
print('Error: $e\n$stackTrace');
await _kirimJson(request.response, HttpStatus.internalServerError,
{'error': 'Internal Server Error'});
}
}
Routing — Pemetaan URL ke Handler #
Router yang baik memisahkan metode HTTP dan path, mendukung path parameter, dan mengembalikan 404 untuk route yang tidak ditemukan:
import 'dart:io';
typedef Handler = Future<void> Function(HttpRequest request);
class Router {
// Map dari 'METODE /path' ke handler
final Map<String, Handler> _routes = {};
void get(String path, Handler handler) =>
_routes['GET $path'] = handler;
void post(String path, Handler handler) =>
_routes['POST $path'] = handler;
void put(String path, Handler handler) =>
_routes['PUT $path'] = handler;
void delete(String path, Handler handler) =>
_routes['DELETE $path'] = handler;
Future<void> tangani(HttpRequest request) async {
final kunci = '${request.method} ${request.uri.path}';
// Cek exact match
final handler = _routes[kunci];
if (handler != null) {
await handler(request);
return;
}
// Cek path parameter — misal: /pengguna/:id
for (final entry in _routes.entries) {
final paramMap = _cocokkanPath(entry.key, request.method, request.uri.path);
if (paramMap != null) {
// Simpan path params di URI attributes
request.uri.queryParameters; // akses params lewat _extractParams
await entry.value(request);
return;
}
}
// 404 jika tidak ada yang cocok
await _kirimJson(request.response, HttpStatus.notFound,
{'error': 'Route tidak ditemukan: ${request.uri.path}'});
}
Map<String, String>? _cocokkanPath(
String kunciRoute, String metode, String path) {
final bagianRoute = kunciRoute.split(' ');
if (bagianRoute[0] != metode) return null;
final segmenRoute = bagianRoute[1].split('/');
final segmenPath = path.split('/');
if (segmenRoute.length != segmenPath.length) return null;
final params = <String, String>{};
for (int i = 0; i < segmenRoute.length; i++) {
if (segmenRoute[i].startsWith(':')) {
params[segmenRoute[i].substring(1)] = segmenPath[i];
} else if (segmenRoute[i] != segmenPath[i]) {
return null;
}
}
return params;
}
}
Menggunakan Router #
import 'dart:io';
import 'dart:convert';
Future<void> main() async {
final router = Router()
..get('/', _halamanUtama)
..get('/api/pengguna', _daftarPengguna)
..get('/api/pengguna/:id', _detailPengguna)
..post('/api/pengguna', _buatPengguna)
..put('/api/pengguna/:id', _updatePengguna)
..delete('/api/pengguna/:id', _hapusPengguna);
final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
await for (final request in server) {
router.tangani(request).catchError((e) {
print('Error tidak tertangani: $e');
});
}
}
Future<void> _halamanUtama(HttpRequest request) async {
request.response
..headers.contentType = ContentType.html
..write('<h1>Dart Web Server</h1>')
..close();
}
Future<void> _daftarPengguna(HttpRequest request) async {
// Baca query parameter
final halaman = int.tryParse(request.uri.queryParameters['halaman'] ?? '1') ?? 1;
final perHalaman = int.tryParse(request.uri.queryParameters['perHalaman'] ?? '10') ?? 10;
final pengguna = await _repository.ambilSemua(halaman: halaman, perHalaman: perHalaman);
await _kirimJson(request.response, HttpStatus.ok, {
'data': pengguna.map((p) => p.toJson()).toList(),
'halaman': halaman,
'perHalaman': perHalaman,
});
}
Membaca dan Menulis JSON #
import 'dart:io';
import 'dart:convert';
// Baca body JSON dari request
Future<Map<String, dynamic>?> bacaJson(HttpRequest request) async {
try {
final contentType = request.headers.contentType;
if (contentType?.mimeType != 'application/json') {
return null;
}
final body = await utf8.decoder.bind(request).join();
return jsonDecode(body) as Map<String, dynamic>;
} on FormatException {
return null;
}
}
// Kirim response JSON
Future<void> _kirimJson(
HttpResponse response,
int statusCode,
Object data,
) async {
response
..statusCode = statusCode
..headers.contentType = ContentType.json
..write(jsonEncode(data));
await response.close();
}
// Contoh endpoint POST yang membaca dan memvalidasi JSON
Future<void> _buatPengguna(HttpRequest request) async {
final body = await bacaJson(request);
if (body == null) {
await _kirimJson(request.response, HttpStatus.badRequest,
{'error': 'Body harus berformat JSON'});
return;
}
final nama = body['nama'] as String?;
final email = body['email'] as String?;
if (nama == null || nama.isEmpty) {
await _kirimJson(request.response, HttpStatus.unprocessableEntity,
{'error': 'Field "nama" wajib diisi'});
return;
}
if (email == null || !email.contains('@')) {
await _kirimJson(request.response, HttpStatus.unprocessableEntity,
{'error': 'Field "email" tidak valid'});
return;
}
final pengguna = await _repository.buat(nama: nama, email: email);
await _kirimJson(request.response, HttpStatus.created, pengguna.toJson());
}
Middleware Pattern — Pipeline Request #
Middleware adalah fungsi yang membungkus handler dan bisa melakukan sesuatu sebelum atau sesudah handler dijalankan — logging, autentikasi, rate limiting, dan lainnya:
import 'dart:io';
typedef MiddlewareHandler = Future<void> Function(HttpRequest request);
typedef Middleware = MiddlewareHandler Function(MiddlewareHandler next);
// Middleware logging
Middleware loggingMiddleware() {
return (next) => (request) async {
final mulai = DateTime.now();
print('[${mulai.toIso8601String()}] ${request.method} ${request.uri.path}');
await next(request);
final durasi = DateTime.now().difference(mulai);
print('[${request.method}] ${request.uri.path} — ${durasi.inMilliseconds}ms '
'— ${request.response.statusCode}');
};
}
// Middleware autentikasi Bearer token
Middleware authMiddleware(Future<bool> Function(String token) verifikasi) {
return (next) => (request) async {
final authHeader = request.headers.value(HttpHeaders.authorizationHeader);
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
await _kirimJson(request.response, HttpStatus.unauthorized,
{'error': 'Token autentikasi diperlukan'});
return;
}
final token = authHeader.substring(7);
if (!await verifikasi(token)) {
await _kirimJson(request.response, HttpStatus.forbidden,
{'error': 'Token tidak valid atau sudah kadaluarsa'});
return;
}
await next(request);
};
}
// Middleware rate limiter sederhana
Middleware rateLimitMiddleware({int maks = 100, Duration per = const Duration(minutes: 1)}) {
final hitungan = <String, List<DateTime>>{};
return (next) => (request) async {
final ip = request.connectionInfo?.remoteAddress.address ?? 'unknown';
final sekarang = DateTime.now();
final batas = sekarang.subtract(per);
hitungan.putIfAbsent(ip, () => []);
hitungan[ip]!.removeWhere((t) => t.isBefore(batas));
if (hitungan[ip]!.length >= maks) {
request.response.headers.set('Retry-After', per.inSeconds.toString());
await _kirimJson(request.response, HttpStatus.tooManyRequests,
{'error': 'Terlalu banyak request, coba lagi nanti'});
return;
}
hitungan[ip]!.add(sekarang);
await next(request);
};
}
// Compose middleware — eksekusi dari kiri ke kanan
MiddlewareHandler compose(List<Middleware> middlewares, MiddlewareHandler handler) {
return middlewares.reversed.fold(handler, (next, mw) => mw(next));
}
// Penggunaan
void main() async {
final pipeline = compose([
loggingMiddleware(),
rateLimitMiddleware(maks: 60),
authMiddleware(_verifikasiToken),
], _tanganiRequest);
final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
await for (final request in server) {
pipeline(request).catchError((e) => print('Error: $e'));
}
}
Shelf — Framework Middleware yang Ergonomis #
Untuk project yang lebih besar, package shelf menyediakan abstraksi yang lebih matang dan composable:
dart pub add shelf shelf_router shelf_static
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
import 'package:shelf_static/shelf_static.dart';
// Handler di Shelf: Request → Response (pure function)
Handler buatApp() {
final router = Router();
// API endpoints
router.get('/api/produk', _daftarProduk);
router.get('/api/produk/<id>', _detailProduk);
router.post('/api/produk', _buatProduk);
router.put('/api/produk/<id>', _updateProduk);
router.delete('/api/produk/<id>', _hapusProduk);
// Static files
final staticHandler = createStaticHandler('public', defaultDocument: 'index.html');
// Gabungkan router dan static handler
final cascade = Cascade()
.add(router)
.add(staticHandler)
.handler;
// Pipeline middleware
return Pipeline()
.addMiddleware(logRequests()) // logging bawaan shelf
.addMiddleware(_corsMiddleware())
.addMiddleware(_authMiddleware())
.addHandler(cascade);
}
// Handler Shelf — mengambil path parameter dengan <namaParam>
Future<Response> _detailProduk(Request request, String id) async {
final produk = await _repository.ambilById(id);
if (produk == null) {
return Response.notFound('{"error": "Produk tidak ditemukan"}',
headers: {'Content-Type': 'application/json'});
}
return Response.ok(jsonEncode(produk.toJson()),
headers: {'Content-Type': 'application/json'});
}
// CORS middleware untuk Shelf
Middleware _corsMiddleware() {
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
return (innerHandler) => (request) async {
if (request.method == 'OPTIONS') {
return Response.ok('', headers: headers);
}
final response = await innerHandler(request);
return response.change(headers: headers);
};
}
// Auth middleware untuk Shelf
Middleware _authMiddleware() {
// Daftar route yang tidak butuh auth
const publik = {'/api/auth/login', '/api/auth/register'};
return (innerHandler) => (request) async {
if (publik.contains(request.url.path)) {
return innerHandler(request);
}
final auth = request.headers['Authorization'];
if (auth == null || !auth.startsWith('Bearer ')) {
return Response.unauthorized('{"error": "Token diperlukan"}',
headers: {'Content-Type': 'application/json'});
}
final token = auth.substring(7);
if (!await _verifikasiToken(token)) {
return Response.forbidden('{"error": "Token tidak valid"}',
headers: {'Content-Type': 'application/json'});
}
return innerHandler(request);
};
}
Future<void> main() async {
final handler = buatApp();
final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080);
print('Server Shelf berjalan di http://localhost:${server.port}');
}
Static Files dengan Content-Type yang Benar #
import 'dart:io';
// Map ekstensi ke MIME type
const _mimeTypes = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.pdf': 'application/pdf',
};
Future<void> sajikanFile(HttpRequest request, String direktoriPublik) async {
var path = request.uri.path;
// Keamanan: cegah path traversal
if (path.contains('..')) {
await _kirimJson(request.response, HttpStatus.forbidden,
{'error': 'Akses ditolak'});
return;
}
if (path == '/') path = '/index.html';
// Gabungkan direktori publik dengan path yang diminta
final filePath = '$direktoriPublik$path';
final file = File(filePath);
if (!await file.exists()) {
// Coba sajikan index.html untuk SPA routing
final index = File('$direktoriPublik/index.html');
if (await index.exists()) {
await _sajikanFileObjek(request, index, 'text/html; charset=utf-8');
return;
}
request.response.statusCode = HttpStatus.notFound;
await request.response.close();
return;
}
final ekstensi = filePath.substring(filePath.lastIndexOf('.'));
final mimeType = _mimeTypes[ekstensi] ?? 'application/octet-stream';
await _sajikanFileObjek(request, file, mimeType);
}
Future<void> _sajikanFileObjek(
HttpRequest request, File file, String mimeType) async {
request.response.headers
..set('Content-Type', mimeType)
..set('Cache-Control', 'public, max-age=3600'); // cache 1 jam
// Pipe file langsung ke response — efisien, tidak perlu load ke RAM dulu
await file.openRead().pipe(request.response);
}
HTTPS dengan TLS #
import 'dart:io';
Future<void> main() async {
final konteks = SecurityContext()
..useCertificateChain('certs/server.crt')
..usePrivateKey('certs/server.key');
final server = await HttpServer.bindSecure(
InternetAddress.anyIPv4,
443,
konteks,
);
print('HTTPS server berjalan di https://localhost:443');
// Redirect HTTP ke HTTPS (jalankan server HTTP terpisah di port 80)
_jalankanRedirectHTTP();
await for (final request in server) {
// Tambahkan HSTS header
request.response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains',
);
await _tanganiRequest(request);
}
}
void _jalankanRedirectHTTP() async {
final serverHttp = await HttpServer.bind(InternetAddress.anyIPv4, 80);
serverHttp.listen((request) {
request.response
..statusCode = HttpStatus.movedPermanently
..headers.set('Location',
'https://${request.headers.host}${request.uri}')
..close();
});
}
Perbandingan: dart:io HttpServer vs Shelf vs Framework
#
| Aspek | dart:io langsung |
Shelf | Framework (Conduit, Serverpod) |
|---|---|---|---|
| Boilerplate | Banyak | Sedang | Sedikit |
| Fleksibilitas | Penuh | Tinggi | Terbatas konfigurasi |
| Middleware | Manual | ✓ composable | ✓ built-in |
| Routing | Manual | shelf_router |
✓ built-in |
| Testing | Sulit | Mudah (pure function) | Bervariasi |
| Cocok untuk | Belajar, kontrol penuh | API produksi | Project besar, ORM, auth |
GUNAKAN dart:io langsung ketika:
✓ Belajar bagaimana HTTP bekerja di level rendah
✓ Butuh kontrol penuh atas setiap aspek request lifecycle
✓ Kasus khusus yang tidak didukung framework
GUNAKAN Shelf ketika:
✓ Membangun REST API produksi
✓ Butuh middleware yang composable dan mudah di-test
✓ Tim familiar dengan konsep middleware (mirip Express.js)
GUNAKAN framework (Conduit, Serverpod) ketika:
✓ Project besar dengan kebutuhan ORM, autentikasi, migrasi database
✓ Ingin konvensi dan struktur yang sudah ditetapkan
Ringkasan #
await fortanpa await per request — tangani setiap request tanpaawaitdi loop utama agar server bisa menerima request berikutnya. Dart event loop menangani concurrency secara otomatis.- Middleware pattern memisahkan cross-cutting concerns (logging, auth, rate limiting) dari logika bisnis. Compose sebagai pipeline dari kiri ke kanan.
- Selalu tutup
request.responsedenganclose()— jika tidak, client akan hang menunggu response yang tidak pernah datang.- JSON API: gunakan
Content-Type: application/jsondi header response,jsonDecodeuntuk membaca body, danjsonEncodeuntuk menulis response.- Path traversal adalah vulnerability serius saat melayani static files — selalu cek apakah path mengandung
..sebelum membuka file.file.openRead().pipe(request.response)untuk melayani file besar tanpa memuat ke RAM — lebih efisien darireadAsBytes()lalu tulis.- CORS — set
Access-Control-Allow-*headers dan tangani preflightOPTIONSrequest agar frontend web bisa mengakses API.- Shelf adalah pilihan yang direkomendasikan untuk REST API produksi — handler-nya adalah pure function (
Request → Response) yang mudah diuji tanpa server nyata.- HTTPS wajib di production — gunakan
HttpServer.bindSecuredengan sertifikat valid, tambahkan HSTS header, dan redirect semua HTTP ke HTTPS.- Graceful shutdown dengan
server.close(force: false)menunggu semua request aktif selesai sebelum mematikan server — penting untuk zero-downtime deployment.