İçeriğe geç
Bölümlere göz at 08

07 — Yapılandırma referansı

08 v0.5.6 belgeleri
Bu sayfada

Bu belge jskelet.config.mjs'in tam referansıdır: her alan, tipi, varsayılanı ve örneği. Ardından source desen sözdizimi ve framework'ün okuduğu tüm ortam değişkenleri tablosu geliyor. Alanların davranışsal ayrıntıları için ilgili belgelere bağlantı verildi; buradaki amaç tek bakışta tam liste sunmak.

Dosyanın konumu ve yüklenmesi

Config dosyası proje kökünde jskelet.config.mjs adıyla aranır ve zorunlu değildir. Yoksa ya da okunamıyorsa uyarı basılır ve sunucu varsayılanlarla ayağa kalkar; bozuk bir düzenleme siteyi açılamaz hâle getirmemeli.

js
// jskelet.config.mjs
export default {
  // …
};

Default export yoksa modülün kendisi config olarak kullanılır (named export'lar).

headers(), redirects(), rewrites() ve cache() bölümleri fonksiyon ya da düz değer olabilir; fonksiyon olmaları hâlinde async olabilirler ve this config nesnesine bağlıdır. Bir bölüm hata verirse yalnızca o bölüm yok sayılır.

Config başarıyla yüklendiğinde bir özet basılır: [config] jskelet.config.mjs loaded — 3 headers, 2 redirects, 1 cache rule

Tam örnek

js
// jskelet.config.mjs
export default {
  paths: {
    views: "views",
    public: "public",
    client: "client",
    routes: "routes",
    styles: "styles/globals.css",
    generated: ".jskelet",
  },

  brand: {
    name: "Örnek",
    poweredBy: "Örnek",
    cacheHeader: "X-Ornek-Cache",
    devBasePath: "/__ornek/dev",
    prewarmUserAgent: "ornek-prewarm",
    devTokenCookie: "dev_token",
    lang: "tr",
  },

  layout: "views/layout.ejs",
  routes: ["./routes/10-pages.mjs", "./routes/99-catch-all.mjs"],
  trailingSlash: false,

  static: {
    extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2"],
    prefixes: ["/assets/", "/fonts/"],
  },

  devGateBypass: ["/api/healthcheck", "/robots.txt"],
  preconnect: ["https://cdn.ornek.com"],

  security: {
    trustProxy: true,
    cookieSecret: process.env.JSKELET_SECRET,
    csrf: {
      enabled: true,
      token: false,
      allowedOrigins: [],
      exclude: ["/webhook/:path*"],
      cookieName: "csrf_token",
      fieldName: "_csrf",
      headerName: "x-csrf-token",
    },
  },

  navigation: {
    prefetch: "moderate",
    prerender: "conservative",
    viewTransition: true,
    exclude: ["/cikis"],
  },

  prewarmSkip: ["/api/", "/_fragment/", "/__ornek/"],
  watch: ["data"],

  fonts: [{ family: "Inter", weights: [400, 600, 700] }],
  icons: { dir: "icons", scan: ["views", "client", "routes", "lib"] },
  images: { widths: [400, 800, 1200], quality: 78, skip: ["indirmeler"] },
  clientEnv: ["PUBLIC_WS_URL"],

  async headers() {
    return [
      {
        source: "/:path*",
        headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }],
      },
    ];
  },

  async redirects() {
    return [{ source: "/eski/:slug", destination: "/yeni/:slug", permanent: true }];
  },

  async rewrites() {
    return {
      afterFiles: [
        { source: "/api/:path*", destination: "https://api.ornek.com/:path*" },
      ],
    };
  },

  async cache() {
    return {
      html: { "/": 60, "/haber/:slug": 300 },
      query: { "/arama": ["q", "page"] },
      maxEntries: 500,
      data: { maxEntries: 10000, staleFactor: 10 },
      prewarm: {
        enabled: true,
        max: 400,
        concurrency: 4,
        rps: 0,
        intervalSeconds: 0,
        rotate: true,
        priority: ["/", "/haber/:slug"],
      },
    };
  },

  hooks: {
    metadata() { /* … */ },
    layoutContext() { /* … */ },
    notFound() { /* … */ },
    error() { /* … */ },
    prewarmPaths() { /* … */ },
  },
};

paths

Tip: Record<string, string>Varsayılan: aşağıdaki tablo

Proje kökündeki dizin (ve styles için dosya) adları. Değerler proje köküne göre çözülür ve içeride mutlak yola çevrilir.

AnahtarVarsayılanİçeriği
views"views"Layout, sayfalar, bileşenler (klasik kök; .jsk / .ejs)
features"features"Feature-first dilimler (<name>/{server,views,client})
shared"shared"Özellikler arası paylaşılan server/views/client
public"public"Statik dosyalar; build çıktısı da buraya yazılır
client"client"Island runtime kaynakları ve entry'ler
routes"routes"Route modülleri
styles"styles/globals.css"Tailwind/PostCSS giriş dosyası
generated".jskelet"manifest.json, templates/, metafile.json, images.json

styles bir dosya yolu olduğu hâlde aynı çözümlemeden geçer; ayrı bir alan tutmaya değmiyor.

İki yol her zaman türetilir ve ezilemez: public/assets (hash'li build çıktısı) ve public/fonts (self-host fontlar).

js
paths: { views: "src/views", routes: "src/routes", styles: "src/styles/main.css" }

brand

Tip: objectVarsayılan: aşağıdaki tablo

Markalama ve tek yerden değiştirilebilir isimler. Fork eden ya da beyaz etiket kullanan projeler kendi adını verebilir. Verilen alanlar varsayılanlarla sığ birleştirilir.

AlanTipVarsayılanAnlamı
namestring"JSkelet"Görüntü adı
poweredBystring"JSkelet"X-Powered-By başlığının değeri
cacheHeaderstring"X-JSkelet-Cache"HTML cache durumu başlığı (Cache)
devBasePathstring"/__jskelet/dev"Dev overlay ve rapor uçlarının kökü
prewarmUserAgentstring"jskelet-prewarm"Isıtma isteklerinin UA'sı; dev paneli bunu filtreler
devTokenCookiestring"dev_token"Dev gate'in çerez ve query parametresi adı
langstring<html lang> varsayılanı. Verilmezse layout "en" kullanır.
sharedCookieRootsstring[][]Paylaşımlı cookie Domain kökleri (örn. .investvio.com, .localhost). 12

lang için öncelik sırası: hooks.layoutContext()lang > brand.lang > "en".

js
brand: {
  lang: "tr",
  poweredBy: "Örnek",
  sharedCookieRoots: [".investvio.com", ".localhost"],
}

auth

Tip: objectVarsayılan: { crossSubdomainHandoff: false }

Kimlik framework'te yok; bu bölüm yalnızca alt alan adları arasında kısa session id taşımak için handoff köprüsünü açar.

AlanTipVarsayılanAnlamı
crossSubdomainHandoff`boolean \object`falsetrue veya { ttlSeconds?, path?, maxValueBytes? }POST /_jskelet/auth/handoff + ?handoff= redeem
js
auth: {
  crossSubdomainHandoff: { ttlSeconds: 60 },
},

Ayrıntı ve window.name yedeği: 12-panel-ve-oturum.md.

layout

Tip: stringVarsayılan: yok (otomatik çözüm)

Layout .ejs dosyasının yolu. Verilen değer views dizininin üst dizinine göre çözülür, yani varsayılan views ile "views/ozel.ejs"<root>/views/ozel.ejs.

Verilmezse sırayla: views/layout.jsk, views/layout.ejs, yoksa framework'ün minimal layout'u. Ayrıntı: Render ve şablonlar.

routes

Tip: string[]Varsayılan: null (dizin taraması)

Route modüllerinin açık listesi, proje köküne göre. Verilen sırada yüklenir. Verilmezse paths.routes dizini alfabetik ve özyinelemeli olarak taranır. Ayrıntı: Routing.

js
routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"]

trailingSlash

Tip: booleanVarsayılan: false

true iken kanonik URL'ler / ile biter: /hakkinda/ doğrudan 200 döner; slash'sız /hakkinda 308 ile /hakkinda/ adresine gider (301 değil — metodu koruyan kalıcı yönlendirme, framework'ün diğer permanent redirect'leriyle aynı). Query string korunur.

İstisnalar: kök /, uzantılı dosya yolları (/robots.txt, /assets/app.js) ve /.well-known/**. Bunlara slash eklenmez.

false iken (varsayılan) slash dayatılmaz. Express non-strict eşleşme ile /x ve /x/ ikisi de 200 olabilir — Next.js'in varsayılan "slash'ı kırp" davranışından bilinçli fark; mevcut siteleri kırmamak için.

Açıkken şablonlardaki href, sitemap ve redirects() hedeflerini de slash'lı yazın; aksi hâlde tarayıcı her tıklamada ekstra bir 308 görür.

js
trailingSlash: true

static

Tip: { extensions?: string[], prefixes?: string[] }Varsayılan: aşağıda

Uzantı ve önek bazlı statik dosya tespiti. Bu listeye uyan yollara Cache-Control: public, max-age=31536000, immutable yazılır.

AlanVarsayılan
extensions[".svg", ".png", ".webp", ".avif", ".ico", ".woff2"]
prefixes["/assets/", "/fonts/"]

Verilirse varsayılanın yerine geçer (birleştirilmez), yani varsayılana ek yapmak isterseniz tam listeyi yazın.

js
static: {
  extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2", ".mp4"],
  prefixes: ["/assets/", "/fonts/", "/video/"],
}

devGateBypass

Tip: string[]Varsayılan: ["/api/healthcheck", "/robots.txt", "/sitemap.xml", "/site.webmanifest", "/favicon.ico"]

Dev gate'in hiçbir koşulda kapatmadığı tam yollar (önek değil, birebir eşleşme). DEV_TOKEN ayarlı bir ortamda sağlık kontrolünün ve robots dosyalarının erişilebilir kalması için. Verilirse varsayılanın yerine geçer.

Ayrıntı: Dev araçları.

preconnect

Tip: string[]Varsayılan: []

Üçüncü taraf origin'ler; her sayfanın <head>inde <link rel="preconnect"> olarak basılır. Görsel CDN'i, API origin'i, font host'u buraya yazılır. Değerler new URL(...).origin ile normalize edilir; geçersiz bir URL atlanır ve uyarı basılır.

Liste her sayfada aynı olduğu için bir kez hesaplanıp saklanır. Boş liste geçerli bir yapılandırmadır.

js
preconnect: ["https://cdn.ornek.com", "https://api.ornek.com"]

security

Tip: objectVarsayılan: { trustProxy: true, cookieSecret: null, csrf: { enabled: true, token: false, … } }

Kişiye özel sayfaların tamamı ve gerekçeleri 12-panel-ve-oturum.md'de; burada alanların referansı var.

AlanTipVarsayılanAnlamı
trustProxybooleantrueExpress'in trust proxy ayarı. Ters proxy arkasında doğru protokol ve istemci IP'si için gerekli.
cookieSecret`string \null`nullİmzalı cookie sırrı. Verilmezse JSKELET_SECRET okunur.
csrf.enabledbooleantrueOrigin/Sec-Fetch-Site kontrolü.
csrf.tokenbooleanfalseÇift gönderim token'ı katmanı.
csrf.allowedOriginsstring[][]Kendi host'umuzun yanında kabul edilen origin'ler.
csrf.excludestring[][]Kontrolden muaf yollar; source desen sözdizimi.
csrf.cookieNamestring"csrf_token"Token cookie'sinin adı.
csrf.fieldNamestring"_csrf"csrfField()in bastığı alan adı.
csrf.headerNamestring"x-csrf-token"Token'ın kabul edildiği başlık.

trustProxy doğrudan internete açık bir sunucuda kapatılmalı: açıkken istemci kendi X-Forwarded-For başlığını uydurabilir ve rate limit ile audit log yanlış adresi görür.

CSRF kontrolü yalnızca çapraz site olduğu belli olan istekleri reddeder — Origin uyuşmuyorsa ya da Sec-Fetch-Site: cross-site geldiyse. İkisi de yoksa istek geçer, çünkü tarayıcılar çapraz origin bir POST'ta Origin'i her zaman gönderirken webhook'lar hiç göndermez. Yine de tarayıcıdan gelmeyen uçları csrf.exclude listesine yazmak niyeti okunur kılıyor.

Tip: objectVarsayılan: { prefetch: "moderate", prerender: false, viewTransition: false, exclude: [] }

Site içi gezinmeyi hızlandıran <head> ipuçları. JSkelet klasik MPA olduğu için her tıklama tam sayfa yüklemesidir; bu bölüm o yüklemeyi tarayıcının önceden yapmasını sağlar. Client runtime'ı eklenmez — Speculation Rules ve view transition tarayıcı yetenekleridir, desteklemeyen tarayıcıda sessizce yok sayılırlar.

AlanTipVarsayılanAnlamı
prefetch`false \"conservative" \"moderate" \"eager"`"moderate"Bağlantı hedefinin belgesini önceden indirir
prerenderaynıfalseHedefi arka planda tam render eder; tıklama anında açılır
viewTransitionbooleanfalse@view-transition { navigation: auto } basar
excludestring[][]Spekülasyon dışı bırakılacak href desenleri

true verilirse prefetch/prerender varsayılan eagerness'a düşer; tanınmayan bir değer uyarı basıp varsayılana döner.

Eagerness ne demek: conservative bağlantıya basıldığı an, moderate bağlantı üzerinde bir süre duraksandığında, eager bağlantı görünür olur olmaz tetikler. Yukarı çıktıkça isabet artar, boşa giden istek de artar.

prerender neden kapalı geliyor. Prerender edilen sayfanın script'leri gerçekten çalışır. Ölçüm kodunu prerenderingchange olayına bağlamayan bir uygulamada ziyaret sayıları şişer. Açmadan önce analytics'i gözden geçirin; sunucu tarafındaki maliyeti düşüktür, çünkü spekülatif istek de HTML önbelleğinden karşılanır (Cache).

Her koşulda muaf olanlar. /api/*, /_fragment/* ve brand.devBasePath altındaki yollar otomatik dışlanır; exclude bunların üstüne eklenir. Ayrıca rel="nofollow", target="_blank" ve data-no-prefetch taşıyan bağlantılar hiçbir kurala girmez. Yan etkisi olan tek bir bağlantıyı dışarıda bırakmanın en kolay yolu sonuncusu:

html
<a href="/cikis" data-no-prefetch>Çıkış</a>

viewTransition açarken arka planı <html>e verin. Geçiş sırasında tarayıcı eski ve yeni sayfanın anlık görüntülerini çapraz geçirir; <body>ye verilmiş bir arka plan bu görüntünün içinde kalır ve altta kalan canvas görünür. Sonuç, her geçişte bir kare beyaz flaştır ve koyu temada gözden kaçmaz. Renk <html> (ya da :root) üzerindeyse böyle bir boşluk oluşmaz:

html
<html lang="tr" class="bg-white dark:bg-slate-950">
  <body class="text-slate-900 dark:text-slate-100">

Hareket azaltma tercihi framework tarafından karşılanır: prefers-reduced-motion: reduce altında geçiş kapatılır, ayrıca bir şey yazmanız gerekmez.

Geçişi içerikle sınırlayın. Varsayılan davranış tüm belgeyi tek parça olarak çapraz geçirir, yani gezinme boyunca hiç değişmeyen header ve footer da titrer. Bu bölgelere bir view-transition-name vermek onları kendi grubuna alır; tarayıcı aynı adı iki belgede de gördüğü için "aynı öğe" sayar. Adlandırılan öğenin animasyonunu kapatınca geçiş yalnızca içerikte kalır:

css
body > header { view-transition-name: site-header; }
body > footer { view-transition-name: site-footer; }

::view-transition-old(site-header),
::view-transition-old(site-footer) { animation: none; opacity: 0; }
::view-transition-new(site-header),
::view-transition-new(site-footer) { animation: none; opacity: 1; }

/* Kalan içerik; varsayılan 250ms gezinmeyi yavaş hissettiriyor. */
::view-transition-old(root),
::view-transition-new(root) { animation-duration: 180ms; }

Çalışan hâli examples/marketing/styles/globals.css içinde.

CSP kullanıyorsanız kurallar satır içi bir <script type="speculationrules"> olarak basılır; script-src politikanızın buna izin vermesi gerekir.

js
navigation: {
  prefetch: "moderate",
  prerender: "conservative",
  viewTransition: true,
  exclude: ["/cikis", "/sepet/*"],
}

prewarmSkip

Tip: string[]Varsayılan: ["/api/", "/_fragment/", "/__jskelet/"]

Isıtmanın atlayacağı yol önekleri. Oturuma bağlı ya da fragment uçları ısıtılmamalı. Verilirse varsayılanın yerine geçer — brand.devBasePathi değiştirdiyseniz bu listeyi de güncellemeyi unutmayın.

Ayrıntı: Cache.

watch

Tip: string[]Varsayılan: []

jskelet devin sunucu yeniden başlatma için izleyeceği ek dizinler, proje köküne göre. routes, views ve lib zaten izlenir; client/ ve styles/ esbuild ve CSS watcher'ları tarafından ele alınır, buraya konmamalı.

Yalnızca .js, .mjs, .json ve .ejs uzantılı dosyalar tetikleyicidir.

js
watch: ["data", "content"]

Ayrıntı: Dev araçları.

fonts

Tip: { family: string, slug?: string, weights?: number[] }[]Varsayılan: []

Self-host edilecek Google Fonts aileleri. Boş bırakılırsa font adımı hiç çalışmaz.

AlanTipVarsayılanAnlamı
familystringGoogle Fonts aile adı: "Inter", "Noto Sans"
slugstringfamilyden türetilir (küçük harf, boşluk → -)Dosya adı öneki
weightsnumber[][400]İndirilecek ağırlıklar

Çıktı: public/fonts/<slug>-<weight>.woff2, manifest anahtarı aynı dosya adı. Dosyalar sabit isimlidir (hash yok) ve commit edilmesi beklenir. Ayrıntı: Build.

js
fonts: [
  { family: "Inter", weights: [400, 600, 700] },
  { family: "Noto Serif", slug: "serif", weights: [400] },
]

icons

Tip: { scan?: string[], dir?: string } | falseVarsayılan: { dir: "icons" }

SVG ikon sprite üretimi. Kaynak XOR seçilir: icons.dir dizini varsa yalnızca oradaki düz SVG'ler; yoksa @phosphor-icons/core (kuruluysa).

DeğerSonuç
{} (varsayılan)dir: "icons"; taranan dizinler ["views", "client", "routes", "lib", "features", "shared"]
{ dir: "assets/icons" }Yerel SVG kökü değiştirilir
{ scan: [...] }Taranan dizinler değiştirilir
falseSprite adımı tamamen atlanır

Yerel dizin (varsa) düz dosya adları kullanır: house.svghouse:regular, house-bold.svghouse:bold. Boş bir icons/ dizini Phosphor'a düşmez — dizini silmek fallback'i açar. Ayrıntı: Build.

js
icons: {
  dir: "icons",
  scan: ["views", "client", "routes", "lib", "content"],
}

images

Tip: { widths?: number[], quality?: number, skip?: string[], remote?: { allowHosts: string[], path?: string, maxWidth?: number, cacheMaxAge?: number, fetchTimeoutMs?: number, maxBytes?: number } | false } | falseVarsayılan: { widths, quality, skip, remote: false } (remote kapalı)

public/ altındaki png/jpg görsellerin webp varyantlarını build'de üretir. remote.allowHosts verilirse çalışma anında uzak görselleri de proxy eder (/_jskelet/image?url=&w=&q= → webp).

AlanTipVarsayılanAnlamı
widthsnumber[][400, 640, 960, 1280, 1920]Build ve remote srcset adayları. Kaynaktan büyük olanlar build'de elenir; kaynağın kendi genişliği (en fazla 1920) her zaman eklenir.
qualitynumber78webp kalitesi. Build'de imzaya girer; remote uçta q varsayılanı.
skipstring[][]Build'de taranmayacak dizin adları. assets ve fonts her zaman atlanır.
remote`object \false`kapalıRuntime optimizer. allowHosts zorunlu; boşsa uç mount edilmez.

images.remote

AlanTipVarsayılanAnlamı
allowHostsstring[][]Çekilebilecek host'lar. *.cdn.example.com sonek jokerini destekler.
pathstring/_jskelet/imageOptimizer GET yolu.
maxWidthnumber1920w üst sınırı.
cacheMaxAgenumber2592000 (30 gün)Yanıt Cache-Control max-age (saniye). Disk önbelleği .jskelet/image-cache/.
fetchTimeoutMsnumber10000Upstream fetch zaman aşımı.
maxBytesnumber10485760 (10 MiB)Upstream gövde üst sınırı.

false verilirse görsel adımı hiç çalışmaz. Build adımı sharp gerektirir ve watch turunda hiç çalışmaz. Remote açıksa sharp runtime'da da gerekir; yoksa optimizer kaynak URL'ye 302 yönlendirir. Ayrıntı: Build.

js
images: {
  widths: [400, 800, 1200],
  quality: 82,
  skip: ["indirmeler"],
  remote: {
    allowHosts: ["static.ornek.com", "*.cdn.ornek.com"],
  },
}

image({ src: "https://static.ornek.com/a.jpg", width: 96, alt: "…" }) bu ayarla src / srcset'i /_jskelet/image?url=…&w=96 biçimine çevirir. Elle URL kurmak için remoteImageUrl(src, { width }) (jskelet).

clientEnv

Tip: string[]Varsayılan: []

Client bundle'a build zamanında gömülecek ortam değişkeni anahtarları. Next'teki NEXT_PUBLIC_* ile aynı sözleşme, ama hangi anahtarın herkese açık olduğu isimden değil config'ten belli. NODE_ENV her zaman gömülür.

process.envin tamamı tek nesne olarak define edildiği için listede olmayan bir anahtar okunduğunda çökme yerine undefined döner.

js
clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN_ORIGIN"]

Buraya gizli anahtar koymayın — değerler bundle'da düz metin olarak durur.

headers()

Tip: () => { source: string, headers: { key: string, value: string }[] }[]Varsayılan: []

Yol desenine göre yanıt başlıkları. Framework yalnızca statik dosyalara uzun ömürlü cache yazar; bunun dışındaki her başlık (CSP, COOP, HSTS, X-Frame-Options…) buradan gelir ve varsayılanların üstüne biner.

Eşleşen tüm kurallar uygulanır (redirect'lerin aksine ilk eşleşmede durulmaz), sırayla; aynı başlığı iki kural yazarsa sonraki kazanır.

keyi olmayan ya da valueu undefined olan girdiler atlanır; hiç geçerli başlığı kalmayan bir kural hiç eklenmez.

js
async headers() {
  return [
    {
      source: "/:path*",
      headers: [
        { key: "X-Frame-Options", value: "SAMEORIGIN" },
        { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
        {
          key: "Content-Security-Policy",
          value: "default-src 'self'; img-src 'self' https://cdn.ornek.com data:",
        },
      ],
    },
    {
      source: "/indirme/:path*",
      headers: [{ key: "Cache-Control", value: "no-store" }],
    },
  ];
}

redirects()

Tip: () => { source: string, destination: string, permanent?: boolean, statusCode?: number }[]Varsayılan: []

AlanTipAnlamı
sourcestringDesen (aşağıdaki sözdizimi)
destinationstringHedef; :param yer tutucuları doldurulur
permanentbooleantrue → 308, aksi hâlde 307
statusCodenumberAçık durum kodu; permanenti ezer

İlk eşleşen kural kazanır ve query string korunur. Ayrıntı: Routing.

rewrites()

Tip: () => Rule[] | { beforeFiles?: Rule[], afterFiles?: Rule[] } burada Rule = { source: string, destination: string }Varsayılan: []

Dizi döndürülürse tamamı afterFiles sayılır.

  • beforeFiles statik dosyalardan da önce çalışır.
  • afterFiles statik denendikten sonra, route'lardan önce çalışır.
  • Mutlak hedef (http:///https://) → gömülü ters proxy.
  • Göreli hedef → yalnızca req.url değişir.

Ayrıntı: Routing.

cache()

Tip: () => { html?: Record<string, number>, query?: Record<string, string[] | true>, vary?: { host?: boolean, headers?: string[], fn?: (req) => string | null }, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }Varsayılan: { html: {}, query: {}, vary: { host: false }, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0, origins: [] } }

cache().html

Desen → saniye eşlemesi. Eşleşen kural, route'un kendi revalidate değerini ezer. Negatif ya da sonlu olmayan değerler yok sayılır; 0 "önbellekleme" anlamına gelir. TTL dolmadan önce framework, son render süresine göre erken arka plan tazelemesi başlatır (ayrı bir config alanı yok; ayrıntı Cache).

js
html: {
  "/": 60,
  "/haber/:slug": 300,
  "/arama": 0,
}

Tek istisna route(fn, { private: true }): bu route'ta desen eşleşse bile yok sayılır. Kilidin tek yönlü olması bilinçli — ters yönde bir hata, bir kullanıcının HTML'inin bir başkasına servis edilmesi anlamına geliyor.

cache().query

Desen → cache anahtarına girmesine izin verilen query parametreleri.

Varsayılan olarak query parametresi taşıyan istek dinamiktir: cache().html o yolu kapsıyor olsa bile HTML önbelleğine hiç girmez, private, no-store ile gider. Sebebi basit — bir yolun bütün varyantlarını cache'lemek ?utm_source=… gibi sonsuz sayıda anahtar üretiyor ve maxEntries sınırına dayandığında LRU'daki gerçek sayfaları dışarı atıyor. Hangi parametrenin çıktıyı gerçekten değiştirdiğini yalnızca uygulama bilir.

js
query: {
  "/arama": ["q", "page"], // yalnızca bu ikisi anahtara girer
  "/urunler": ["kategori"],
  "/rapor/:id": true, // bütün parametreler anahtara girer
  "/kampanya": [], // query tamamen yok sayılır
}
  • İzin listesi (string[]): listedeki parametreler anahtara girer, her farklı değer kendi girdisini alır. Listede olmayan parametreler yok sayılır — sayfa yine cache'lenir ve bütün kampanya varyantları tek kopyayı paylaşır.
  • true: bütün parametreler anahtara girer. Anahtar sayısını sınırlayan tek şey maxEntries olur; yalnızca değer kümesi kapalı olan yollarda kullan.
  • []: query hiç dikkate alınmaz, bütün varyantlar query'siz sürümün HTML'ini alır.

Parametreler anahtara sıralı yazılır: ?a=1&b=2 ile ?b=2&a=1 aynı girdiyi paylaşır. route(fn, { private: true }) bu bölümden etkilenmez; private route hiçbir koşulda cache'lenmez.

cache().vary

HTML cache anahtarına query allowlist'ten bağımsız sabit parçalar ekler. Host'tan locale üreten sitelerde host: true zorunlu; aksi halde ilk locale'in HTML'i diğer host'a servis edilir. CDN zaten tam URL ile ayırır — bu ayar origin L1 ve Redis HTML anahtarı içindir.

js
vary: {
  host: true, // h=tr.example.com|…
  // headers: ["x-locale"],
  // fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
}
AlanTipVarsayılanAnlamı
hostbooleanfalsePublic Host (x-forwarded-host yoksa Host), lowercase, portsuz → h=…
headersstring[][]İstek başlıkları ad=değer olarak eklenir
fn`(req) => string \null`Dönüş bir segment olarak eklenir

Anahtar biçimi: ${vary}|${yol}?${query} (vary yoksa önek yok). Ayrıntı: Cache.

cache().maxEntries

Tip: numberVarsayılan: 500

HTML önbelleğinin girdi sınırı. Girdi başına yüz kilobayt düştüğü için bu sayıyı yükseltmek belleği hızla tüketir; on binlerce yollu bir siteyi buradan çözmeye çalışmak yanlış katman, doğru yer cache().data.

cache().data

Upstream veri önbelleği (withDataCache). Ayrıntı: Cache.

AlanTipVarsayılanAnlamı
maxEntriesnumber10000LRU girdi sınırı. JSON, HTML'e göre onlarca kat küçük olduğu için sınır yüksek.
staleFactornumber10TTL dolduktan sonra girdinin kaç TTL boyunca daha kullanılabileceği. 0 → bayat servis yok.

cache().trackUpstream

Tip: booleanVarsayılan: true

Açıkken globalThis.fetch sarılır ve render sırasındaki geçici upstream hataları (429, 5xx, ağ) kendiliğinden bildirilir; reportUpstreamFailure() çağırmak gerekmez. fetchi kendisi saran bir uygulama bunu kapatabilir.

cache().trackDependencies

Tip: booleanVarsayılan: true

Açıkken bir render'ın okuduğu withDataCache anahtarları kaydedilir ve clearDataCache() o veriyi okumuş HTML sayfalarını da bayatlatır — hedefli invalidation için uygulamanın hiçbir şey bildirmesi gerekmez (Cache). withDataCache kullanmayan bir uygulamada kaydedilecek bir şey yok; kapatmak bağlam kurma maliyetini de kaldırır.

cache().transientRetry

Tip: { attempts?: number, delayMs?: number } | falseVarsayılan: { attempts: 1, delayMs: 300 }

Geçici bir upstream hatası yüzünden notFound() çağrılan sayfa kaç kez daha denenir. Amaç var olan bir sayfanın 404'e dönüşmemesi; denemeler tükenirse yanıt önbelleğe girmeyen bir 503 olur. false ya da attempts: 0 tekrarı kapatır. Ayrıntı: Cache.

cache().upstream

Upstream API'ye giden fetch çağrılarının host başına hız freni. Varsayılan kapalı: rate verilmedikçe hiçbir istek beklemez. rate bir tavandır; gerçek hız 429 cevaplarına göre kendini aşağı çeker ve temiz geçen pencerelerde kademe kademe geri çıkar.

AlanTipVarsayılanAnlamı
ratenumber0Saniyedeki en fazla çağrı. 0 → fren kapalı
burstnumber0Kova boyu; 0 → bir saniyelik bütçe kadar patlama
concurrencynumber8Aynı anda uçabilecek çağrı
minRatenumber0.5Azalmanın dibi; hız buranın altına inmez
increaseStepnumber1Toplamsal artışın adımı (çağrı/saniye)
increaseIntervalMsnumber5000Artış periyodu
decreaseIntervalMsnumber1000İki azalma arasındaki en kısa süre
breakerFailuresnumber5Art arda kaç 429'dan sonra host baypas edilir
breakerCooldownMsnumber10000Baypasın süresi
hostsRecord<string, object>{}Host bazlı override; aynı alanlar geçerli

Yalnızca 429 ve 503 hızı cezalandırır: 400/404/500 bir kota sorunu değil. Durumu getUpstreamLimiterStatus() ile ya da dev panelinin Server sekmesinden okuyabilirsin. Ayrıntı ve freni açmadan önce bakılacak yer: Cache.

js
upstream: {
  rate: 10,
  concurrency: 4,
  hosts: { "api.example.com": { rate: 3 } },
}

cache().redis

Opsiyonel Redis ikinci kademesi (L2). Bellek içi önbellek birincil kalır; Redis yalnızca L1'de bulunmayan bir yol için render'ı atlatır ve invalidation'ı diğer instance'lara yayar. ioredis uygulamaya kurulmalı (npm install ioredis); kurulmadıysa ya da bağlanılamıyorsa uyarı basılır ve site bellek içi önbellekle çalışmaya devam eder.

AlanTipVarsayılanAnlamı
enabledbooleanfalseYalnızca açıkça true verildiğinde açılır
url`string \null`nullredis:// ya da rediss://. Boşsa ioredis varsayılanı (localhost:6379)
namespacestring"default"Aynı Redis'i paylaşan uygulamaları ayırır
keyPrefixstring"_jskelet"Anahtar düzeninin kökü
htmlbooleantrueHTML gövdeleri paylaşılsın mı
databooleantruewithDataCache girdileri paylaşılsın mı
storeEncodedbooleanfalseBrotli/gzip gövdeleri de paylaşılsın mı; girdi başına boyutu iki-üç katına çıkarır
eventsbooleantruepub/sub üzerinden invalidation yayını
commandTimeoutMsnumber200Tek bir komutun en fazla bekletebileceği süre

Anahtarlar _jskelet:{namespace}:{buildId}:html:{yol}?{query} biçiminde yaşar. buildId her build'de değişir, böylece deploy sonrası eski HTML kendiliğinden geçersiz olur. Kişiye özel (storable: false), degraded ve 200 dışındaki yanıtlar paylaşımlı kademeye hiç yazılmaz. Takaslar ve teşhis: Cache.

js
redis: {
  enabled: process.env.NODE_ENV === "production",
  url: process.env.REDIS_URL,
  namespace: "haber-sitesi",
}

logs

Kalıcı log sink'leri. Varsayılan her şey kapalı: stdout ve admin paneli ring'i mevcut davranışını korur. Açıldığında HTTP access log ile framework olayları (event / error) NDJSON satırları olarak dosyaya ve/veya S3'e yazılır.

AlanTipVarsayılanAnlamı
consolebooleantrueRuntime http / event / error satırları stdout'a basılsın mı (banner/build satırları etkilenmez)
kinds`("http" \"event" \"error")[]`hepsiSink'lere giden kayıt türleri
file.enabledbooleanfalseGünlük dosya sink'i
file.dirstring"logs"Proje köküne göre dizin; jskelet-YYYY-MM-DD.log
file.rotate"daily""daily"Yalnızca günlük rotasyon
s3.enabledbooleanfalseS3 batch PutObject sink'i
s3.bucket`string \null`nullBucket ya da bucket/prefix/… yolu; JSKELET_LOG_BUCKET ezer
s3.prefixstring"jskelet/logs/"Nesne anahtarı öneki (yolda verilmediyse)
s3.region`string \null`"auto"Bölge; verilmezse JSKELET_S3_REGION, yoksa auto
s3.endpoint`string \null`nullS3-uyumlu API adresi; JSKELET_S3_API_URL ezer
s3.flushIntervalMsnumber5000Batch flush aralığı
s3.maxBatchnumber100Bu kadar satırda erken flush

S3 credential'ları config'e yazılmaz: JSKELET_S3_ACCESS_KEY_ID, JSKELET_S3_SECRET_ACCESS_KEY, isteğe bağlı JSKELET_S3_SESSION_TOKEN. Bucket/region/credential eksikse uyarı basılır ve S3 sink kapanır; site ayağa kalkmaya devam eder. Framework @aws-sdk taşımaz — PutObject SigV4 ile gömülüdür.

js
logs: {
  console: true,
  kinds: ["http", "error"],
  file: { enabled: true, dir: "logs" },
  s3: {
    enabled: process.env.NODE_ENV === "production",
    bucket: process.env.JSKELET_LOG_BUCKET,
    prefix: "my-app/logs/",
    region: process.env.JSKELET_S3_REGION,
    endpoint: process.env.JSKELET_S3_API_URL,
  },
}

admin()

Framework yönetim paneli (/_jskelet/admin). Bellek içi / Redis / Cloudflare önbelleğini yönetir; route ve view envanteri ile canlı log kuyruğu sunar.

Ortama bakmaz: enabled verilmedikçe hiç mount edilmez ve yol da yoktur. Açıkken production'da da çalışır — asıl sorular ("bu sayfa neden bayat", "webhook purge'ü geçti mi") orada soruluyor. cache() bölümünden ayrıdır.

AlanTipVarsayılanAnlamı
enabledbooleanfalseYalnızca açıkça true verildiğinde açılır (JSKELET_ADMIN ezer)
basePathstring"/_jskelet/admin"Panelin kökü
allowIpsstring[][]Exact IP veya CIDR; boş = kısıt yok. Listede olmayan her istek 404
blockBotsbooleantrueBilinen crawler UA'ları 404
banAttemptsnumber3Kaç başarısız denemeden sonra IP yasaklanır
banHoursnumber24Yasağın süresi
sessionHoursnumber12Oturum çerezinin ömrü
logSizenumber500Canlı log ring boyutu

Şifre her süreç başlangıcında üretilir ve yalnızca sunucu logundaki ADMIN kutusunda görünür. Yasaklı ve yetkisiz her cevap 404'tür. Kullanım ve ekran ayrıntıları: Cache.

js
admin() {
  return {
    enabled: process.env.JSKELET_ADMIN === "1",
    allowIps: ["203.0.113.10", "10.0.0.0/8"],
  };
}

cache().cloudflare

CDN kademesi. JSkelet'in önbelleği origin önbelleği; ziyaretçinin gördüğü kopya edge'de duruyor. Bu bölüm bağlıysa panelden edge purge'ü, cache ile ilgili zone ayarları ve cache isabet oranı yönetilebilir.

AlanTipVarsayılanAnlamı
enabledbooleantruefalse verilirse env'de token olsa bile yüzey kapalı kalır
zoneId`string \null`nullZone kimliği (JSKELET_CLOUDFLARE_ZONE_ID ezer)
apiToken`string \null`nullToken; env tercih edilir, config'e yazmak sırrı repoya sokar
hostname`string \null`nullPurge tam URL ister; yol → URL çevrimi bu ad üzerinden yapılır. Verilmezse panelin açıldığı origin kullanılır
analyticsHoursnumber24Analitik penceresi, en çok 72

Token yalnızca JSKELET_CLOUDFLARE_KEY ile verildiğinde config dosyası temiz kalır; izinler yapılacak işe göre: purge için Zone.Cache Purge, ayarlar için Zone.Zone Settings, isabet oranı için Zone.Analytics (salt okunur). Token hiçbir panel cevabında dönmez, yalnızca "env'den geldi" bilgisi görünür.

Zone bağlı değilse panel bir uyarı değil kurulum önerisi gösterir; Cloudflare hata dönerse ilgili bölüm hatayı yazar ve panelin kalanı çalışmaya devam eder. Neyin sorulabildiği — özellikle "bu sayfa kaç edge'de cache'li" sorusunun neden tam cevabı olmadığı — Cache içinde.

cache().prewarm

İki mod: klasik (liste + açılış turu) veya onVisit (ziyaret edilen sayfadaki linkler). Birlikte verilemez — config yüklenirken hata.

Klasik alanlar

AlanTipVarsayılanAnlamı
enabledbooleantruefalse ise ısıtma yapılmaz (PREWARM=1 ile ezilebilir)
maxnumber400Bir turda en fazla kaç yol ısıtılır
concurrencynumberprod 4, dev 1Paralel işçi sayısı
rpsnumberprod 0, dev 4Saniyedeki en fazla ısıtma isteği; 0 sınırsız. Upstream kotasını koruyan ayar bu. Dev'deki varsayılan fren, ısıtmanın sayfa isteklerini bekletmemesi için.
delayMsnumberprod 500, dev 3000Açılıştan sonra ilk turun gecikmesi
retryDelayMsnumber2000Tekrar turundan önce beklenen süre
intervalSecondsnumber00'dan büyükse tur periyodik tekrarlanır
rotatebooleantrueListe max'tan uzunsa periyodik turlar kaldığı yerden devam eder
priority`(string \RegExp)[]`[]Isıtma sırası; eşleşen yollar her turda başa alınır
originsstring[][]Klasik turda ısıtılacak origin'ler. Boşsa http://127.0.0.1:<port>. vary.host açıksa locale host'ları buraya yazın

priority iki biçim kabul eder: config'in her yerinde geçerli olan desen sözdizimi ve doğrudan RegExp. Önce yazılan önce ısınır.

js
prewarm: {
  max: 500,
  rps: 4,
  intervalSeconds: 300,
  // vary.host açıksa loopback tek başına yetmez:
  origins: ["http://localhost", "http://tr.localhost"],
  priority: [
    "/",                     // ana sayfa
    "/piyasalar/:path*",      // tüm piyasa bölümü
    /-yorumlar$/,             // desen sözdiziminin karşılamadığı kural
  ],
}

onVisit

AlanTipVarsayılanAnlamı
onVisit`true \false \object`kapalıZiyaret tabanlı ısıtma
onVisit.perPagenumber20Sayfa başına üstten alta en fazla link
onVisit.concurrencynumberklasik ile aynıParalel işçi
onVisit.rpsnumberklasik ile aynıSaniyedeki tavan; 0 sınırsız
js
prewarm: {
  onVisit: { perPage: 20, rps: 4 },
}

hooks.prewarmPaths ve klasik alanlar (max, priority, …) onVisit ile yasaktır. Ayrıntı: Cache.

Sayısal alanların her biri aynı adı taşıyan ortam değişkeniyle ezilebilir; env önceliklidir. Ayrıntı: Cache.

hooks

Tip: Record<string, Function>Varsayılan: {}

Hepsi opsiyonel, hepsi async olabilir. Bir hook hata verirse framework kendi varsayılanına döner ve uyarır — sayfa düşmez.

HookİmzaDöndürdüğüBelge
metadata(page) => objectHer sayfanın metadata varsayılanı; controller metadatası üzerine biner04
layoutContext({ pathname, metadata }) => objectLayout local'leri; lang, structuredData, extraHead, bodyClass özel yorumlanır04
notFound`() => object \null`404 sayfa tanımı; null ise framework'ün hata sayfası03
error`({ status, error }) => object \string \null`404 dışındaki hata sayfaları (ve notFound yoksa 404); sayfa tanımı ya da doğrudan HTML03
prewarmPaths() => string[]Klasik ısıtmada ısıtılacak yollar; tanımlı değilse klasik tur kurulmaz. onVisit ile birlikte yasak06
js
hooks: {
  metadata() {
    return { titleTemplate: "%s | Örnek", siteUrl: "https://ornek.com" };
  },

  async layoutContext({ pathname }) {
    return { navigation: await getNavigation(), isHome: pathname === "/" };
  },

  notFound() {
    return {
      view: "pages/not-found",
      metadata: { title: "Sayfa bulunamadı", robots: { index: false } },
    };
  },

  error({ status }) {
    return {
      view: "pages/error",
      data: { status },
      metadata: { title: "Bir hata oluştu", robots: { index: false } },
    };
  },

  async prewarmPaths() {
    return ["/", ...(await getArticlePaths())];
  },
}

source desen sözdizimi

headers(), redirects(), rewrites() ve cache().html aynı küçük derleyiciyi 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 ve tanınmayan bir sözdizimi sessizce literal kabul edilmez, uyarı üretir.

DesenRegex karşılığıÖrnek eşleşme
/hakkindatam eşleşme/hakkinda
/haber/:slug([^/]+) — tek segment/haber/abc (✗ /haber/a/b)
/:path*(.*) — sıfır veya daha fazla segment/, /a, /a/b/c
/blog/:path*joker alt yol; öndeki / opsiyonel/blog, /blog/, /blog/a/b
/:path*.svgjoker + sabit son ek/ikon.svg, /a/b/c.svg
/etiket-:slugsegment ortasında parametre/etiket-finans

Kurallar:

  • source / ile başlamak zorundadır; başlamazsa kural yok sayılır ve uyarı basılır.
  • Parametre adı [A-Za-z_][A-Za-z0-9_]* kalıbına uymalıdır.
  • Desen daima baştan sona eşleşir (^…$); önek eşleşmesi için :path* kullanın.
  • :path* sıfır segment de yakalar ve hemen öncesindeki / opsiyoneldir: /hesabim/:path* bölümün kök yolunu (/hesabim) da kapsar. Aksi hâlde bir bölümü tamamen kapatmak isteyen kural tam da giriş sayfasını atlıyordu.
  • Parametreler dışındaki tüm karakterler literal kabul edilir ve regex için kaçışlanır — . gerçekten nokta demektir.
  • Yakalanan değerler destination içindeki aynı adlı :param'lara yazılır. Karşılığı olmayan bir yer tutucu olduğu gibi bırakılır.

Ortam değişkenleri

Framework'ün okuduğu tüm değişkenler. .env dosyası varsa CLI tarafından otomatik yüklenir (--env-file=.env); yoksa bayrak hiç geçilmez ve uyarı basılmaz.

DeğişkenKim okurVarsayılanAnlamı
NODE_ENVher yerproduction (start/build), development (dev)Dev overlay, EJS cache, manifest yeniden okuma, route hata davranışı ve prewarm varsayılanlarını belirler. jskelet dev bunu kendisi ayarlar — cross-env gerekmez.
PORTstartServer3000Dinlenecek port
HOSTstartServer::Bağlanılacak arayüz. Varsayılan çift yığın dinler (IPv6 + IPv4); IPv6 yoksa 0.0.0.0'a düşer
JSKELET_SECRETjskelet/cookiesİmzalı cookie sırrı. security.cookieSecret verilmediğinde buradan okunur; ikisi de yoksa imzalı cookie API'si hata verir. 12
DEV_TOKENdevGate, prewarmAyarlıysa token taşımayan her isteğe 404 döner. Isıtma token'ı çerez olarak taşır. 09
JSKELET_ADMINcreateAppAyarlıysa yönetim panelini açar; 0 config'te açık olan paneli kapatır. Env config'i ezer, çünkü panel genelde bir arıza sırasında tek seferlik açılır. 06
JSKELET_LOG_BUCKETlogs.s3Log hedefi: bucket ya da bucket/prefix yolu. Credential ile birlikte varsa sink otomatik açılır
JSKELET_S3_BUCKETlogs.s3JSKELET_LOG_BUCKET yoksa bucket; JSKELET_S3_KEY_PREFIX ile birleşir
JSKELET_S3_KEY_PREFIXlogs.s3JSKELET_S3_BUCKET ile kullanılır (bucket/prefix)
JSKELET_S3_ACCESS_KEY_IDlogs.s3PutObject imzası
JSKELET_S3_SECRET_ACCESS_KEYlogs.s3İmza sırrı (JSKELET_S3_ACCESS_SECRET yedek ad)
JSKELET_S3_SESSION_TOKENlogs.s3Geçici credential için isteğe bağlı
JSKELET_S3_REGIONlogs.s3autoVerilmezse auto
JSKELET_S3_API_URLlogs.s3S3-uyumlu endpoint; logs.s3.endpoint'i ezer
JSKELET_CLOUDFLARE_KEYCloudflare cache yüzeyiAPI token. Verilene kadar CDN purge'ü ve edge analitiği kapalıdır; config'teki apiToken'ı ezer. Token hiçbir cevapta dönmez. 06
JSKELET_CLOUDFLARE_ZONE_IDCloudflare cache yüzeyiZone kimliği. Token'la birlikte verilmedikçe hiçbir Cloudflare ucu çağrılmaz
JSKELET_CLOUDFLARE_HOSTNAMECloudflare cache yüzeyiPurge URL'lerinin kökü. Panel iç bir adresten açılıyorsa gerekir
PREWARMstartPrewarm0 ısıtmayı kapatır; 1 config'teki enabled: false'u ezip açar
PREWARM_MAXprewarm400En fazla kaç yol ısıtılır
PREWARM_CONCURRENCYprewarmprod 4, dev 1Paralel işçi sayısı
PREWARM_RPSprewarm0Saniyedeki en fazla ısıtma isteği; 0 sınırsız
PREWARM_DELAY_MSstartPrewarmprod 500, dev 3000İlk turun gecikmesi
PREWARM_RETRY_DELAY_MSprewarm2000Tekrar turundan önceki bekleme
PREWARM_INTERVAL_SECONDSstartPrewarm00'dan büyükse periyodik tur
JSKELET_VERBOSEjskelet dev1 ise restart'ta değişen dosyaların tamamı listelenir
JSKELET_COLORjskelet/log1 ise renk zorlanır. Alt süreçler boruya yazdığı için renk algılaması kapanır; jskelet dev bunu kendisi ayarlar.
JSKELET_CHILDjskelet buildDev script'i tarafından ayarlanır; build banner'ı ve "Ready" özetini bastırır
NO_COLORjskelet/logAyarlıysa renk hiç kullanılmaz (JSKELET_COLORu da ezer)

Uygulamanızın kendi değişkenleri (API origin'i, token'lar) framework tarafından okunmaz; doğrudan process.env üzerinden kullanın. Tarayıcıya ulaşması gerekenleri clientEnv ile bildirin.

Sayısal prewarm ayarları yalnızca pozitif ve sonlu değer kabul eder; geçersiz bir değer sessizce bir sonraki katmana (config → kod varsayılanı) düşer.

Programatik erişim

js
import { getConfig, loadConfig } from "jskelet";

await loadConfig();                          // proje kökünden okur
await loadConfig({ root: "/baska/proje" });  // farklı kök
await loadConfig({ configFile: "jskelet.test.mjs" });
await loadConfig({ force: true });           // önbelleği atlayıp yeniden oku

const config = getConfig();                  // çözümlenmiş config

loadConfig() aynı süreçte ikinci çağrıda önbelleğe düşer: jskelet start hem ensure-build hem createApp üzerinden çağırıyor ve config'i iki kez okuyup iki kez loglamanın faydası yok.

getConfig() loadConfig() çağrılmadan kullanılırsa hata verir: sessiz yanlış yol, "stylesheet neden yok" gibi teşhisi zor sorunlara dönüşüyor.

Çözümlenmiş config'te dizinler mutlak yol olarak config.dirs altındadır (views, public, client, routes, styles, generated, assets, fonts), desenler derlenmiş hâldedir ve config.loaded dosyanın gerçekten okunup okunmadığını söyler.

Sırada ne var