← Semua pembelajaran / Astro Nol → Portal Berita
Fase 1 · Routing, Layout & Model Rendering

Middleware — satu tempat untuk tiap request

Middleware berjalan sebelum tiap halaman on-demand. Ia tempat yang benar untuk header keamanan dan konteks request — dan tempat yang salah untuk hal yang membuat halamanmu jadi personal.

Sumber asli docs.astro.build Resmi Rangkuman ~8 menit baca

Intisari

  • src/middleware.ts meng-export onRequest(context, next). Satu berkas, urutan jelas.
  • Ia tidak berjalan untuk halaman yang di-prerender — hanya untuk yang on-demand.
  • context.locals adalah cara menitipkan data ke halaman, dan ia bertipe lewat App.Locals.
  • sequence() merangkai beberapa middleware; urutannya persis urutan argumennya.
  • Bahaya: membaca cookie di middleware untuk semua rute membuat semua respons berpotensi personal. Batasi ke rute yang memang butuh.

Bentuk dasarnya

// src/middleware.ts
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (context, next) => {
  const mulai = Date.now();

  const respons = await next();

  respons.headers.set("X-Content-Type-Options", "nosniff");
  respons.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  respons.headers.set("X-Waktu-Render", String(Date.now() - mulai));

  return respons;
});

next() menjalankan halamannya dan mengembalikan Response. Kode sebelum next() berjalan sebelum halaman; kode sesudahnya berjalan setelah halaman selesai. Sama seperti middleware di framework mana pun — tapi tanpa konfigurasi urutan yang terpisah dari kodenya.

Merangkai beberapa middleware

// src/middleware.ts
import { sequence } from "astro:middleware";
import { headerKeamanan } from "@/middleware/keamanan";
import { konteksPembaca } from "@/middleware/pembaca";
import { catatAkses } from "@/middleware/log";

export const onRequest = sequence(headerKeamanan, konteksPembaca, catatAkses);

Urutannya persis urutan argumen. Tidak ada prioritas numerik, tidak ada berkas konfigurasi terpisah — apa yang tertulis itu yang berjalan. Ini perbaikan besar dibanding hook CI3, di mana urutan hook ditentukan di config/hooks.php dan sering tidak sama dengan yang kamu kira.

Menitipkan data lewat locals

// src/env.d.ts — memberi tipe pada locals
declare namespace App {
  interface Locals {
    ipPembaca: string;
    idRequest: string;
    member: { id: number; nama: string; langgananAktif: boolean } | null;
  }
}
// src/middleware/pembaca.ts
import { defineMiddleware } from "astro:middleware";
import { randomUUID } from "node:crypto";

export const konteksPembaca = defineMiddleware(async (context, next) => {
  // Di belakang Cloudflare, IP asli ada di cf-connecting-ip.
  // X-Forwarded-For bisa dipalsukan klien kalau origin tidak dikunci (Fase 8).
  context.locals.ipPembaca =
    context.request.headers.get("cf-connecting-ip") ?? "0.0.0.0";

  context.locals.idRequest =
    context.request.headers.get("cf-ray") ?? randomUUID();

  context.locals.member = null;

  return next();
});
---
// di halaman mana pun yang on-demand
const { ipPembaca, idRequest } = Astro.locals;
---

cf-connecting-ip, bukan x-forwarded-for. Di belakang Cloudflare plus ALB, X-Forwarded-For berisi rantai beberapa IP dan bagian depannya berasal dari klien — artinya bisa dipalsukan. Mengambil xff.split(",")[0] untuk rate limiting berarti penyerang bisa memalsukan IP berbeda tiap request dan melewati batasnya sepenuhnya. cf-connecting-ip ditulis Cloudflare sendiri dan tidak bisa ditimpa klien — asalkan origin-mu tidak bisa dihubungi langsung, yang dibahas di Fase 8.

Bahaya yang tidak jelas: middleware bisa merusak cache

// JANGAN LAKUKAN INI untuk semua rute
export const onRequest = defineMiddleware(async (context, next) => {
  const sesi = context.cookies.get("sesi");
  context.locals.member = sesi ? await ambilMember(sesi.value) : null;
  return next();
});

Terlihat rapi, dan persis yang dilakukan CI3 dengan $this->session. Tapi konsekuensinya persis masalah yang sedang kita perbaiki:

Yang benar: middleware hanya menyiapkan cara membaca sesi; yang benar-benar membacanya adalah server island dan rute yang memang personal.

const RUTE_PERSONAL = ["/akun", "/langganan", "/_server-islands"];

export const konteksPembaca = defineMiddleware(async (context, next) => {
  const perlu = RUTE_PERSONAL.some((p) => context.url.pathname.startsWith(p));

  context.locals.member = perlu ? await memberDariCookie(context.cookies) : null;

  const respons = await next();

  // Sabuk pengaman: apa pun yang personal tidak boleh di-cache bersama,
  // bahkan kalau halamannya lupa mengaturnya sendiri.
  if (perlu) {
    respons.headers.set("Cache-Control", "private, no-store");
  }
  return respons;
});

Blok terakhir itu pertahanan berlapis, dan layak ada. Halaman baru akan ditambahkan oleh orang yang tidak membaca materi ini. Kalau satu-satunya yang mencegah kebocoran adalah setiap penulis halaman mengingat untuk menulis private, no-store, cepat atau lambat ada yang lupa. Pasang pagarnya di satu tempat.

Menghentikan request lebih awal

export const jagaAkun = defineMiddleware(async (context, next) => {
  if (context.url.pathname.startsWith("/akun")) {
    const member = await memberDariCookie(context.cookies);
    if (!member) {
      const tujuan = encodeURIComponent(context.url.pathname);
      return context.redirect(`/masuk?tujuan=${tujuan}`, 302);
    }
    context.locals.member = member;
  }
  return next();
});

Mengembalikan Response tanpa memanggil next() menghentikan rantainya — halamannya tidak pernah berjalan. Ini padanan redirect(); exit; di hook CI3, tapi tanpa exit yang mematikan proses.

Perhatikan 302, bukan 301. Redirect ke halaman login harus sementara. Browser meng-cache 301 secara permanen — kalau kamu memakainya, pembaca yang sudah login pun akan terus dilempar ke halaman masuk oleh cache browsernya sendiri, dan satu-satunya perbaikan adalah menyuruh mereka membersihkan cache. Ini kesalahan yang mahal dan sulit dilacak.

Yang perlu diingat

FaktaAkibatnya
Tidak berjalan untuk halaman prerenderHeader keamanan di halaman statis harus diatur di Cloudflare atau nginx
Berjalan untuk endpoint API jugaWebhook Midtrans ikut melewatinya — jangan pasang pengecekan sesi di jalur itu
Berjalan untuk request server islandJalur /_server-islands/ perlu ikut dianggap personal
Tidak berjalan untuk berkas di public/Aset statis dilayani sebelum middleware
Error di middleware = seluruh situs 500Bungkus operasi yang bisa gagal dengan try/catch

Latihan: tulis middleware berantai memakai sequence(): satu memasang header keamanan, satu mengisi locals.idRequest, satu mencatat metode, jalur, status, dan durasi ke console.log dalam bentuk JSON satu baris. Beri tipe App.Locals di env.d.ts. Lalu buktikan batasannya: tandai satu halaman prerender = true, buka halaman itu, dan konfirmasi tidak ada baris log yang muncul untuknya.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.