Web Server

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 for tanpa await per request — tangani setiap request tanpa await di 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.response dengan close() — jika tidak, client akan hang menunggu response yang tidak pernah datang.
  • JSON API: gunakan Content-Type: application/json di header response, jsonDecode untuk membaca body, dan jsonEncode untuk 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 dari readAsBytes() lalu tulis.
  • CORS — set Access-Control-Allow-* headers dan tangani preflight OPTIONS request 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.bindSecure dengan 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.

← Sebelumnya: Web Socket   Berikutnya: Unit Test →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact