03 — Routing
Bu sayfada
- Route modülü sözleşmesi
- Yükleme sırası
- Bozuk modül davranışı
- route() — controller sarmalayıcısı
- fragment() — layout'suz parça
- ctx — controller bağlamı
- Controller'ın döndürdüğü sayfa tanımı
- notFound() ve redirect()
- 404 sayfası
- Hata sayfaları (500 ve diğerleri)
- Layout'suz render: renderView
- Config: redirects()
- Config: trailingSlash
- Config: rewrites()
- Elle proxy: createProxy
- source desen sözdizimi
- Sırada ne var
Bu belge bir isteğin hangi controller'a düştüğünü belirleyen her mekanizmayı anlatır: route modüllerinin sözleşmesi ve yükleme sırası, route() sarmalayıcısı, controller'ın döndürdüğü sayfa tanımı, ctx nesnesi, params, notFound() ve redirect() kontrol akışı, ve jskelet.config.mjs üzerinden gelen redirect/rewrite kuralları. Sayfa tanımının şablon tarafı Render ve şablonlar'de, revalidate davranışı Cache'de anlatılıyor.
Route modülü sözleşmesi
Bir route modülü, default export ya da register adlı named export olarak (app, api) => void | Promise<void> imzalı bir fonksiyon açar.
// routes/10-pages.mjs
export default function register(app, { route }) {
app.get("/", route(async () => ({ view: "pages/home" })));
}
app doğrudan Express uygulamasıdır: app.get, app.post, app.use, app.all — Express 5'in tüm yüzeyi kullanılabilir. api ise framework'ün route dosyalarına geçirdiği hazır yüzeydir, böylece her dosyada tek tek import yapmak gerekmez:
| Alan | Karşılığı |
|---|---|
route | jskelet → route |
fragment | jskelet → fragment |
renderView | jskelet → renderView |
renderPage | jskelet → renderPage |
notFound | jskelet → notFound |
redirect | jskelet → redirect |
permanentRedirect | jskelet → permanentRedirect |
seeOther | jskelet → seeOther |
ogHandler | jskelet → ogHandler |
ogImage | jskelet → ogImage |
sendOgImage | jskelet → sendOgImage |
ImageResponse | jskelet → ImageResponse |
İstersen doğrudan import da edebilirsin; api yalnızca kolaylık:
import { route, notFound } from "jskelet";
export function register(app) {
app.get("/haber/:slug", route(async ({ params }) => { /* … */ }));
}
Modül geçerli bir fonksiyon açmazsa uyarı basılır ve atlanır: [router] <file> exports neither a default nor a 'register' function, skipped.
Yükleme sırası
Dosya sistemine dayalı otomatik URL türetme yok. Sıra iki şekilde belirlenir:
1. Açık liste (jskelet.config.mjs → routes). Proje köküne göre göreli yollar, verdiğin sırada yüklenir:
export default {
routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"],
};
2. Liste yoksa routes/ dizini alfabetik taranır, ardından her features/<name>/index.js (veya .mjs) alfabetik eklenir. Tarama özyinelemelidir (alt dizinler de dâhil), yalnızca .js ve .mjs dosyaları alınır ve adı _ ile başlayan dosyalar atlanır (_helpers.js gibi paylaşılan modüller için).
Feature-first düzen zorunlu değildir; bir feature örneği:
features/piyasalar/
index.js # register(app, api) — URL'ler açıkça yazılır
server/
views/pages/… # .jsk veya .ejs
views/components/
client/ # island'lar; client/entries'ten registerAll
jskelet generate feature|page|island iskelet üretir. Filesystem URL routing yoktur.
Bu durumda dosya adlarına sayısal önek verin:
routes/
├── 10-pages.mjs
├── 50-blog.mjs
└── 99-catch-all.mjs
Sıranın açık olması bir tasarım kararı: /:slug gibi tek segmentli bir yakalayıcı /hakkinda rotasından önce kaydedilirse "hakkinda" bir slug sanılır. Sırayı dosya adına gizlemek yerine görünür kılmak teşhisi kolaylaştırıyor (Mimari).
Hiç route modülü bulunamazsa uyarı basılır ve sunucu yalnızca statik dosyalar + 404 ile ayağa kalkar.
Bozuk modül davranışı
- Development: modül import edilemezse uyarı basılır ve atlanır; sunucu ayakta kalır.
- Production: hata fırlatılır ve süreç açılmaz. Yarım route tablosuyla yayına çıkmak, sessizce 404 dönen sayfalar demek.
route() — controller sarmalayıcısı
route(controller, options?) bir Express request handler döndürür ve şu işleri üstlenir:
ctxnesnesini kurar ve controller'ı çağırır.- HTML TTL cache'ini uygular (
revalidatevarsa ve metotGETise). notFound()/redirect()kontrol akışını yakalar.- Yanıt başlıklarını yazar:
Content-Typeve cache durumuna göreCache-Control(+ önbelleklenebilir yanıtlardaX-JSkelet-Cache). - Önbellekte saklanan sıkıştırılmış gövdeyi kullanarak yanıtı gönderir.
app.get(
"/hakkinda",
route(
async () => ({
view: "pages/about",
metadata: { title: "Hakkında", canonical: "/hakkinda" },
}),
{ revalidate: 300 },
),
);
options iki alan kabul eder:
| Alan | Tip | Anlamı |
|---|---|---|
revalidate | number (saniye) | HTML önbellek TTL'i. Verilmezse ya da 0 ise bu route önbelleklenmez. jskelet.config.mjs → cache().html içindeki eşleşen bir kural bu değeri ezer. |
private | boolean | Sayfa ziyaretçiye bağlı. Önbellek devre dışı kalır, cache().html deseni bunu ezemez, yanıt private, no-store ve Vary: Cookie ile ETag'siz gider. |
revalidate verilse bile query parametresi taşıyan istek varsayılan olarak dinamiktir; o yol için cache().query altında bir izin listesi tanımlamak gerekir (Cache).
Oturuma bağlı her sayfa private: true almalı; önbellek anahtarında kimlik olmadığı için bayrak olmadan bir kullanıcının HTML'i bir başkasına servis edilir. Framework bu hatayı çalışma zamanında da yakalıyor (controller cookie okuduğunda render önbelleğe yazılmaz), ama doğru yer bayrak. Ayrıntılar 12-panel-ve-oturum.md'de.
fragment() — layout'suz parça
Bir bölgeyi tazeleyen uçlar için. Layout basılmaz, yanıt private, no-store ve ETag'siz gider, HTML önbelleğine hiç uğramaz.
app.get(
"/_fragment/satirlar",
fragment(async ({ query }) => ({
view: "partials/rows",
data: { rows: getRows(Number(query.sayfa ?? 1)) },
})),
);
Controller { view, data?, status? } ya da doğrudan bir HTML string döner. Hata durumunda tüm sayfa yerine küçük bir uyarı parçası döner (<div role="alert" data-fragment-error>), çünkü takas edilen bölge bir hata sayfasının tamamını içine almamalı.
fragment() POST için de kullanılabilir: form gönderiminin cevabı olarak güncellenmiş parçayı döndürmenin yolu bu, ve şablonda csrfField() çalışabilmesi için gereken istek bağlamını da kuruyor.
ctx — controller bağlamı
Controller tek argüman alır:
{
params, // Express route parametreleri (req.params)
query, // Ayrıştırılmış query string (req.query)
pathname, // req.path — query'siz yol
req, // Express Request; ihtiyaç olursa tam erişim
}
params Express'in kendi desen sözdizimini kullanır (Express 5 / path-to-regexp), config'teki source sözdizimini değil:
app.get("/haber/:slug", route(async ({ params }) => {
const article = await getArticle(params.slug);
if (!article) notFound();
return { view: "pages/article", data: { article } };
}));
pathname hem cache anahtarında hem de renderPage'e geçen pathname local'inde kullanılır; layout'un "bu ana sayfa mı" gibi kararları buna bakar.
Controller'ın döndürdüğü sayfa tanımı
Controller async (ctx) => sayfa biçimindedir ve şu alanları döndürebilir:
| Alan | Tip | Varsayılan | Anlamı |
|---|---|---|---|
view | string | — | views/ altındaki şablon yolu, uzantısız: "pages/home" → views/pages/home.ejs. |
data | object | {} | Şablona local olarak geçen veriler. |
metadata | object | {} | <head> etiketlerine çevrilir; hooks.metadata() çıktısının üzerine biner. Şema: Render ve şablonlar. |
status | number | 200 | HTTP durum kodu. Yalnızca 200 önbelleğe yazılır. |
head | string | "" | <head>e olduğu gibi basılacak ham HTML (ör. LCP preload'ı). |
bodyClass | string | hooks.layoutContext().bodyClass ?? "" | <body class="…">. |
entries | string[] | [] | Bu sayfada ek olarak yüklenecek client entry adları: ["chart.js"]. |
styles | string[] | [] | Bu sayfada ek olarak yüklenecek stylesheet adları: ["home.css"] → styles/pages/home.css. |
revalidate route()'un ikinci argümanıdır, controller'ın döndürdüğü nesnenin alanı değil.
Örnek, hepsi bir arada:
import { headHints } from "jskelet";
app.get(
"/piyasalar",
route(
async ({ query }) => {
const data = await getMarkets(query.tab ?? "hisse");
return {
view: "pages/markets",
data: { markets: data.items, tab: query.tab ?? "hisse" },
metadata: {
title: "Piyasalar",
canonical: "/piyasalar",
openGraph: { image: data.cover },
},
head: headHints({ href: data.cover }),
bodyClass: "bg-slate-50",
entries: ["chart.js"],
styles: ["markets.css"],
};
},
{ revalidate: 30 },
),
);
notFound() ve redirect()
next/navigation içindeki kontrol akışının karşılığı: derinlerdeki bir fonksiyon throw eder, framework yakalar. Böylece veri katmanındaki bir fonksiyon, controller'a dönüş değeri taşımak zorunda kalmadan 404 üretebilir.
import { notFound, redirect, permanentRedirect, seeOther } from "jskelet";
notFound(); // 404 → hooks.notFound() sayfası
redirect("/yeni-adres"); // 307 (geçici, metodu korur)
permanentRedirect("/yeni"); // 308 (kalıcı, metodu korur)
seeOther("/panel"); // 303 (POST sonrası)
Dördü de never döner (her zaman fırlatır). Ayrıntı:
notFound()→NotFoundError(statusCode: 404)redirect(location)→RedirectError(statusCode: 307)permanentRedirect(location)→RedirectError(statusCode: 308)seeOther(location)→RedirectError(statusCode: 303)
Bir POST handler'ında redirect() değil seeOther() kullanılır: 307 metodu koruyor, yani tarayıcı hedefe yeniden POST ediyor. "Post/redirect/get" akışı — geri tuşunun formu yeniden göndermediği akış — 303 gerektiriyor.
Özel bir durum kodu gerekiyorsa sınıfı doğrudan kullanabilirsin:
import { RedirectError } from "jskelet";
throw new RedirectError("/eski-kurulum-uyumu", 301);
Ayırt etmek için isNotFoundError(error) ve isRedirectError(error) dışa açık.
Yakalanma noktaları:
route()içinde: redirect doğrudan yanıta yazılır; notFoundproduce()içinde yakalanır ve 404 sayfası üretilir (bu çıktı önbelleğe yazılmaz, çünkü yalnızca 200 saklanır).- Express hata yöneticisinde: bir middleware ya da route dışı kodda fırlatılmışsa burada karşılanır.
404 sayfası
Bir istek hiçbir route'a düşmezse framework hooks.notFound() hook'unu çağırır ve dönen sayfa tanımını pathname: "/404" ile render eder.
// jskelet.config.mjs
export default {
hooks: {
notFound() {
return {
view: "pages/not-found",
metadata: { title: "Sayfa bulunamadı", robots: { index: false } },
};
},
},
};
Hook tanımlı değilse ya da 404 render'ı da hata verirse framework şablonsuz, minimal bir HTML döner. Bu geri dönüş bilinçli olarak şablonsuz: 404 render'ı da patlarsa ziyaretçi boş yanıt görmesin.
Hata sayfaları (500 ve diğerleri)
Bir controller ya da middleware beklenmeyen bir hata fırlattığında Express'in hata yöneticisi devreye girer, hatayı loglar ve framework'ün kendi hata sayfasını Cache-Control: no-store ile döner. Durum kodu hatanın statusCode (ya da status) alanından okunur; 400–599 aralığında değilse 500 kullanılır.
Development (NODE_ENV=development, yani jskelet dev): 5xx yanıtlarında gömülü 500 sayfası ve hooks.error() atlanır; mesaj, yığın izi ve varsa cause zinciri içeren bir teşhis sayfası döner. 4xx (404 vb.) development'ta da her zamanki gibi durum sayfasıdır.
Production: framework'ün sayfası bilinçli olarak yalın — durum kodu, tek satır başlık ve tek satır açıklama. Marka adı, gezinme ya da hata ayrıntısı taşımaz; sunucunun içi ziyaretçiye açılmaz. Dil brand.langten gelir (tr ve en hazır, diğerleri ene düşer).
Kendi sayfanı vermek için hooks.error() (yalnızca production / 4xx):
// jskelet.config.mjs
export default {
hooks: {
error({ status }) {
return {
view: "pages/error",
data: { status },
metadata: { title: "Bir hata oluştu", robots: { index: false } },
};
},
},
};
Hook bir sayfa tanımı yerine doğrudan HTML string de döndürebilir; layout'a bağlı olmayan bir hata sayfası istiyorsan bu yol daha güvenli, çünkü layout'un kendisi hata veriyorsa sayfa tanımı da render edilemez. Hook yoksa, null dönerse ya da render'ı patlarsa framework gömülü sayfaya düşer.
404 için hooks.notFound() önceliklidir; yalnızca o tanımlı değilse hooks.error() status: 404 ile çağrılır.
Sayfayı programatik olarak da üretebilirsin:
import { renderStatusPage } from "jskelet";
const html = await renderStatusPage(503);
Layout'suz render: renderView
renderView(view, data) tek bir şablonu layout olmadan render eder ve string döner. Fragment uçları, e-posta şablonları ve island'ların sonradan çektiği HTML parçaları için:
export default function register(app, { renderView }) {
app.get("/_fragment/yorumlar/:id", async (req, res) => {
const comments = await getComments(req.params.id);
res.type("html").send(await renderView("fragments/comments", { comments }));
});
}
/_fragment/ öneki varsayılan prewarmSkip listesinde yer alır, yani ısıtma turu bu uçları taramaz (Cache).
Config: redirects()
jskelet.config.mjs → redirects() bir dizi döndürür ve middleware zincirinde route'lardan önce çalışır (bkz. Mimari).
export default {
async redirects() {
return [
{ source: "/eski-blog/:slug", destination: "/blog/:slug", permanent: true },
{ source: "/kampanya", destination: "/kampanyalar" },
{ source: "/legacy", destination: "/", statusCode: 301 },
];
},
};
Davranış:
- İlk eşleşen kural kazanır, sonrası denenmez. Sıralama config'teki yazım sırasıdır.
- Query string korunur:
/eski-blog/x?utm=a→/blog/x?utm=a. Yönlendirme kampanya parametrelerini düşürürse trafik kaynağı kaybolur. - Durum kodu:
permanent: true→ 308, aksi hâlde 307 (Next semantiği). Farklı bir kod isteyenstatusCodeverebilir; örneğin eski kurulumlarla uyum için 301. sourceya dadestinationgeçersizse kural sessizce düşmez, uyarı basılır.
Config: trailingSlash
trailingSlash: true iken kanonik sayfa URL'leri / ile biter ve 200 döner; slash'sız istek 308 ile slash'lıya gider. Ayrıntı ve istisnalar: Yapılandırma.
Config: rewrites()
Rewrite, tarayıcının adres çubuğunu değiştirmeden isteği başka bir yere taşır. İki faz vardır:
export default {
async rewrites() {
return {
beforeFiles: [
{ source: "/sitemap-:page.xml", destination: "/sitemap?page=:page" },
],
afterFiles: [
{ source: "/api/:path*", destination: "https://api.example.com/:path*" },
],
};
},
};
Bir dizi döndürürsen tamamı afterFiles sayılır:
async rewrites() {
return [{ source: "/api/:path*", destination: "https://api.example.com/:path*" }];
}
beforeFilesstatik dosyalardan da önce çalışır./assets/…gibi yolları yeniden yazmak gerekiyorsa buraya konmalı.afterFilesstatik denendikten sonra, route'lardan önce çalışır.
Hedefin biçimi davranışı belirler:
- Mutlak (
http:///https://): istek gömülü ters proxy ile dışa taşınır. Harici paket yok;fetchile stream eden ince bir katman. Hop-by-hop başlıklar (host,connection,content-length,accept-encoding) temizlenir; yanıttacontent-encoding,content-length,transfer-encoding,connectiondüşürülür.redirect: "manual"sayesinde upstream'in 302'si burada tüketilmez, tarayıcıya iletilir. - Göreli: yalnızca
req.urldeğiştirilir ve istek kendi route tablosunda devam eder. Bu fazda ilk eşleşen kural döngüyü kırar.
Tipik kullanım /api/* yolunu backend'e taşımaktır. Tarayıcı bunu same-origin çağırdığı için CORS ve third-party cookie sorunları oluşmaz.
Elle proxy: createProxy
Aynı proxy'yi kendi route'unda da kullanabilirsin:
import { createProxy } from "jskelet";
export default function register(app) {
app.use("/ws-api", createProxy((req) => `${process.env.API_ORIGIN}${req.url}`));
}
resolveTarget fırlatırsa ya da boş döndürürse istek proxy'lenmez ve zincire devam eder: hedef origin yapılandırılmamış bir kurulumda 500 yerine normal bir 404 almak daha doğru.
source desen sözdizimi
redirects(), rewrites(), headers() ve cache().html aynı küçük desen derleyicisini kullanır. Bu, Next'in tam path-to-regexp yüzeyi değil; config'te fiilen kullanılan alt küme bilinçli olarak seçildi.
| Desen | Anlamı |
|---|---|
/haber/:slug | Tek segment yakalar ([^/]+) |
/:path* | Sıfır veya daha fazla segment yakalar; öndeki / opsiyoneldir, yani /blog/:path* /blogu da kapsar |
/:path*.svg | Joker + sabit son ek; uzantı kuralları böyle yazılır |
/etiket-:slug | Segment ortasında parametre |
Yakalanan değerler destination içindeki aynı adlı :param'lara yazılır. Parametre adı [A-Za-z_][A-Za-z0-9_]* kalıbına uymalıdır.
source mutlaka / ile başlamalı; başlamazsa kural yok sayılır ve uyarı basılır (`[config] invalid source (must start with /): …`). Tanınmayan bir sözdizimi sessizce literal kabul edilmez.
Tam desen listesi ve config referansı: Yapılandırma.
Sırada ne var
- Şablon katmanı, bileşenler ve metadata: Render ve şablonlar
revalidate, cache anahtarı veX-JSkelet-Cache: Cache- Config alanlarının tam referansı: Yapılandırma