jskelet 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +628 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +667 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +348 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +675 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +351 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +369 -356
  134. package/src/server/port-guard.js +255 -255
  135. package/src/server/prewarm.js +1082 -1082
  136. package/src/server/redis.js +588 -588
  137. package/src/server/render.js +910 -910
  138. package/src/server/router.js +157 -157
  139. package/src/server/status-page.js +265 -265
  140. package/src/server/upstream-limiter.js +376 -376
  141. package/src/server/upstream-tracking.js +166 -166
  142. package/src/shared/cookie-domain.js +66 -66
  143. package/src/start.mjs +22 -22
  144. package/src/templates/layout.ejs +30 -30
  145. package/src/templates/layout.jsk +30 -30
  146. package/src/version.mjs +31 -31
  147. package/src/views/components/loader.js +101 -101
  148. package/src/views/helpers/html.js +102 -102
  149. package/src/views/helpers/tags.js +375 -375
  150. package/types/config/defaults.d.ts +6 -0
  151. package/types/config/index.d.ts +6 -0
  152. package/types/server/cache-control.d.ts +28 -0
  153. package/types/server/og-image.d.ts +5 -0
@@ -1,1219 +1,1229 @@
1
- # 07 — Configuration reference
2
-
3
- This document is the complete reference for `jskelet.config.mjs`: every field,
4
- its type, its default and an example. After that come the `source` pattern
5
- syntax and a table of every environment variable the framework reads. Links to
6
- the relevant documents are given for behavioural details of the fields; the goal
7
- here is to present the full list at a glance.
8
-
9
- ## Where the file lives and how it is loaded
10
-
11
- The config file is looked up in the project root under the name
12
- `jskelet.config.mjs` and it is **not required**. If it is missing or cannot be
13
- read, a warning is printed and the server comes up with defaults; a broken edit
14
- should not make the site impossible to open.
15
-
16
- ```js
17
- // jskelet.config.mjs
18
- export default {
19
- // …
20
- };
21
- ```
22
-
23
- If there is no default export, the module itself is used as the config (named
24
- exports).
25
-
26
- The `headers()`, `redirects()`, `rewrites()` and `cache()` sections may be a
27
- function **or a plain value**; when they are functions they may be `async`, and
28
- `this` is bound to the config object. If a section throws, only that section is
29
- ignored.
30
-
31
- When the config loads successfully, a summary is printed:
32
- `[config] jskelet.config.mjs loaded — 3 headers, 2 redirects, 1 cache rule`
33
-
34
- ## Full example
35
-
36
- ```js
37
- // jskelet.config.mjs
38
- export default {
39
- paths: {
40
- views: "views",
41
- public: "public",
42
- client: "client",
43
- routes: "routes",
44
- styles: "styles/globals.css",
45
- generated: ".jskelet",
46
- },
47
-
48
- brand: {
49
- name: "Example",
50
- poweredBy: "Example",
51
- cacheHeader: "X-Example-Cache",
52
- devBasePath: "/__example/dev",
53
- prewarmUserAgent: "example-prewarm",
54
- devTokenCookie: "dev_token",
55
- lang: "tr",
56
- },
57
-
58
- layout: "views/layout.jsk",
59
- routes: ["./routes/10-pages.mjs", "./routes/99-catch-all.mjs"],
60
- trailingSlash: false,
61
-
62
- static: {
63
- extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2"],
64
- prefixes: ["/assets/", "/fonts/"],
65
- },
66
-
67
- devGateBypass: ["/api/healthcheck", "/robots.txt"],
68
- preconnect: ["https://cdn.example.com"],
69
-
70
- security: {
71
- trustProxy: true,
72
- cookieSecret: process.env.JSKELET_SECRET,
73
- csrf: {
74
- enabled: true,
75
- token: false,
76
- allowedOrigins: [],
77
- exclude: ["/webhook/:path*"],
78
- cookieName: "csrf_token",
79
- fieldName: "_csrf",
80
- headerName: "x-csrf-token",
81
- },
82
- },
83
-
84
- navigation: {
85
- prefetch: "moderate",
86
- prerender: "conservative",
87
- viewTransition: true,
88
- exclude: ["/logout"],
89
- },
90
-
91
- prewarmSkip: ["/api/", "/_fragment/", "/__example/"],
92
- watch: ["data"],
93
-
94
- fonts: [{ family: "Inter", weights: [400, 600, 700] }],
95
- icons: { dir: "icons", scan: ["views", "client", "routes", "lib"] },
96
- images: { widths: [400, 800, 1200], quality: 78, skip: ["downloads"] },
97
- clientEnv: ["PUBLIC_WS_URL"],
98
-
99
- async headers() {
100
- return [
101
- {
102
- source: "/:path*",
103
- headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }],
104
- },
105
- ];
106
- },
107
-
108
- async redirects() {
109
- return [{ source: "/eski/:slug", destination: "/yeni/:slug", permanent: true }];
110
- },
111
-
112
- async rewrites() {
113
- return {
114
- afterFiles: [
115
- { source: "/api/:path*", destination: "https://api.example.com/:path*" },
116
- ],
117
- };
118
- },
119
-
120
- async cache() {
121
- return {
122
- html: { "/": 60, "/news/:slug": 300 },
123
- query: { "/search": ["q", "page"] },
124
- maxEntries: 500,
125
- data: { maxEntries: 10000, staleFactor: 10 },
126
- prewarm: {
127
- enabled: true,
128
- max: 400,
129
- concurrency: 4,
130
- rps: 0,
131
- intervalSeconds: 0,
132
- rotate: true,
133
- priority: ["/", "/news/:slug"],
134
- },
135
- };
136
- },
137
-
138
- hooks: {
139
- metadata() { /* … */ },
140
- layoutContext() { /* … */ },
141
- notFound() { /* … */ },
142
- error() { /* … */ },
143
- prewarmPaths() { /* … */ },
144
- },
145
- };
146
- ```
147
-
148
- ## `paths`
149
-
150
- **Type:** `Record<string, string>` — **Default:** the table below
151
-
152
- Names of the directories (and, for `styles`, the file) in the project root.
153
- Values are resolved relative to the project root and turned into absolute paths
154
- internally.
155
-
156
- | Key | Default | Contents |
157
- | --- | --- | --- |
158
- | `views` | `"views"` | Layout, pages, components (classic root; `.jsk` / `.ejs`) |
159
- | `features` | `"features"` | Feature-first slices (`<name>/{server,views,client}`) |
160
- | `shared` | `"shared"` | Cross-feature server/views/client |
161
- | `public` | `"public"` | Static files; build output lands here too |
162
- | `client` | `"client"` | Island runtime sources and entries |
163
- | `routes` | `"routes"` | Route modules |
164
- | `styles` | `"styles/globals.css"` | Tailwind/PostCSS entry **file** |
165
- | `generated` | `".jskelet"` | `manifest.json`, `templates/`, `metafile.json`, `images.json` |
166
-
167
- Even though `styles` is a file path it goes through the same resolution; keeping
168
- a separate field for it is not worth it.
169
-
170
- Two paths are always derived and cannot be overridden: `public/assets` (hashed
171
- build output) and `public/fonts` (self-hosted fonts).
172
-
173
- ```js
174
- paths: { views: "src/views", routes: "src/routes", styles: "src/styles/main.css" }
175
- ```
176
-
177
- ## `brand`
178
-
179
- **Type:** `object` — **Default:** the table below
180
-
181
- Branding and names that can be changed from a single place. Projects that fork
182
- the framework or white-label it can put their own name in. Provided fields are
183
- shallow-merged with the defaults.
184
-
185
- | Field | Type | Default | Meaning |
186
- | --- | --- | --- | --- |
187
- | `name` | `string` | `"JSkelet"` | Display name |
188
- | `poweredBy` | `string` | `"JSkelet"` | Value of the `X-Powered-By` header |
189
- | `cacheHeader` | `string` | `"X-JSkelet-Cache"` | HTML cache status header ([06-caching.md](./06-caching.md)) |
190
- | `devBasePath` | `string` | `"/__jskelet/dev"` | Root of the dev overlay and report endpoints |
191
- | `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | UA of prewarm requests; the dev panel filters on it |
192
- | `devTokenCookie` | `string` | `"dev_token"` | Name of the dev gate's cookie and query parameter |
193
- | `lang` | `string` | — | Default for `<html lang>`. If not given, the layout uses `"en"`. |
194
- | `sharedCookieRoots` | `string[]` | `[]` | Shared cookie Domain roots (e.g. `.investvio.com`, `.localhost`). [12](./12-dashboards-and-sessions.md) |
195
-
196
- Precedence for `lang`: `hooks.layoutContext()` → `lang` **>** `brand.lang`
197
- **>** `"en"`.
198
-
199
- ```js
200
- brand: {
201
- lang: "tr",
202
- poweredBy: "Example",
203
- sharedCookieRoots: [".investvio.com", ".localhost"],
204
- }
205
- ```
206
-
207
- ## `auth`
208
-
209
- **Type:** `object` — **Default:** `{ crossSubdomainHandoff: false }`
210
-
211
- The framework does not provide identity; this section only opens the
212
- cross-subdomain handoff bridge for a short session id.
213
-
214
- | Field | Type | Default | Meaning |
215
- | --- | --- | --- | --- |
216
- | `crossSubdomainHandoff` | `boolean \| object` | `false` | When on: `POST /_jskelet/auth/handoff` + `?handoff=` redeem. Object: `allowedCookieNames` (required), `ttlSeconds?`, `path?`, `maxValueBytes?`, `maxPendingTickets?`, `maxMintsPerIpPerMinute?` |
217
-
218
- ```js
219
- auth: {
220
- crossSubdomainHandoff: {
221
- allowedCookieNames: ["sid"],
222
- ttlSeconds: 60,
223
- },
224
- },
225
- ```
226
-
227
- The mint endpoint is mounted **after** the CSRF middleware (origin checks).
228
- Cookie names outside the allowlist or that are not RFC 6265 tokens get 400.
229
- Details: [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
230
-
231
- ## `layout`
232
-
233
- **Type:** `string` — **Default:** none (automatic resolution)
234
-
235
- Path of the layout file (`.jsk` or legacy `.ejs`). The value given is resolved
236
- relative to the **parent directory of the views directory**, so with the default
237
- `views`, `"views/custom.jsk"` → `<root>/views/custom.jsk`.
238
-
239
- If not given, in order: `views/layout.jsk`, `views/layout.ejs` (legacy),
240
- otherwise the framework's `src/templates/layout.jsk` default. Details:
241
- [04-rendering.md](./04-rendering.md).
242
-
243
- ## `routes`
244
-
245
- **Type:** `string[]` — **Default:** `null` (directory scan)
246
-
247
- Explicit list of route modules, relative to the project root. They are loaded in
248
- the given order. If not given, the `paths.routes` directory is scanned
249
- alphabetically and recursively. Details: [03-routing.md](./03-routing.md).
250
-
251
- ```js
252
- routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"]
253
- ```
254
-
255
- ## `trailingSlash`
256
-
257
- **Type:** `boolean` — **Default:** `false`
258
-
259
- When `true`, canonical URLs end with `/`: `/about/` returns **200** directly;
260
- bare `/about` is sent to `/about/` with a **308** (not 301 — a permanent
261
- redirect that preserves the method, same as the framework's other `permanent`
262
- redirects). The query string is kept.
263
-
264
- Exceptions: the root `/`, paths with a file extension (`/robots.txt`,
265
- `/assets/app.js`) and `/.well-known/**`. Those do not get a slash appended.
266
-
267
- When `false` (the default) no slash is enforced. Express non-strict matching may
268
- serve both `/x` and `/x/` as 200 — a deliberate difference from Next.js's
269
- default "strip the slash" behaviour, so existing sites are not broken.
270
-
271
- With the option on, write `href`s, sitemap entries and `redirects()` destinations
272
- with a trailing slash too; otherwise every click pays an extra 308.
273
-
274
- ```js
275
- trailingSlash: true
276
- ```
277
-
278
- ## `static`
279
-
280
- **Type:** `{ extensions?: string[], prefixes?: string[] }` — **Default:**
281
- below
282
-
283
- Static file detection by extension and prefix. Paths matching this list get
284
- `Cache-Control: public, max-age=31536000, immutable`.
285
-
286
- | Field | Default |
287
- | --- | --- |
288
- | `extensions` | `[".svg", ".png", ".webp", ".avif", ".ico", ".woff2"]` |
289
- | `prefixes` | `["/assets/", "/fonts/"]` |
290
-
291
- If provided, it **replaces** the default (it is not merged), so if you want to
292
- add to the default, write out the full list.
293
-
294
- ```js
295
- static: {
296
- extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2", ".mp4"],
297
- prefixes: ["/assets/", "/fonts/", "/video/"],
298
- }
299
- ```
300
-
301
- ## `devGate`
302
-
303
- **Type:** `boolean` — **Default:** `false`
304
-
305
- Hides an environment that is not public yet. **`DEV_TOKEN` alone does not lock
306
- the site.** A shared task definition can carry the same variable into
307
- production; visitors are not required to present a token, and the site stays
308
- open.
309
-
310
- Turn the gate on with `devGate: true` or `DEV_GATE=1`. Then a request without
311
- the token gets a 404. `DEV_GATE=0` also turns off a gate the config enabled.
312
- If the token is empty, requests still pass even when the gate is on.
313
-
314
- Details: [09-dev-tools.md](./09-dev-tools.md).
315
-
316
- ## `devGateBypass`
317
-
318
- **Type:** `string[]` — **Default:**
319
- `["/api/healthcheck", "/robots.txt", "/sitemap.xml", "/site.webmanifest", "/favicon.ico"]`
320
-
321
- **Exact** paths the dev gate never closes off under any circumstances (not a
322
- prefix, an exact match). This is so that the health check and the robots files
323
- stay reachable while the gate is on. If provided, it replaces the default.
324
-
325
- Details: [09-dev-tools.md](./09-dev-tools.md).
326
-
327
- ## `preconnect`
328
-
329
- **Type:** `string[]` — **Default:** `[]`
330
-
331
- Third-party origins; printed as `<link rel="preconnect">` in the `<head>` of
332
- every page. The image CDN, the API origin, the font host go here. Values are
333
- normalised with `new URL(...).origin`; an invalid URL is skipped and a warning
334
- is printed.
335
-
336
- Since the list is the same on every page, it is computed once and stored. An
337
- empty list is a valid configuration.
338
-
339
- ```js
340
- preconnect: ["https://cdn.example.com", "https://api.example.com"]
341
- ```
342
-
343
- ## `security`
344
-
345
- **Type:** `object` — **Default:**
346
- `{ trustProxy: true, cookieSecret: null, csrf: { enabled: true, token: false, … } }`
347
-
348
- The whole picture for per-visitor pages, with the reasoning, is in
349
- [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md); this is the
350
- field reference.
351
-
352
- | Field | Type | Default | Meaning |
353
- | --- | --- | --- | --- |
354
- | `trustProxy` | `boolean` | `true` | Express's `trust proxy` setting. Needed behind a reverse proxy for the correct protocol and client IP. |
355
- | `cookieSecret` | `string \| null` | `null` | The signed cookie secret. When absent, `JSKELET_SECRET` is read. |
356
- | `csrf.enabled` | `boolean` | `true` | The origin / `Sec-Fetch-Site` check. |
357
- | `csrf.token` | `boolean` | `false` | The double-submit token layer. **Turn on** for cookie-session forms. |
358
- | `csrf.allowedOrigins` | `string[]` | `[]` | Origins accepted alongside our own host. |
359
- | `csrf.exclude` | `string[]` | `[]` | Paths exempt from the check; `source` pattern syntax. |
360
- | `csrf.cookieName` | `string` | `"csrf_token"` | Name of the token cookie. |
361
- | `csrf.fieldName` | `string` | `"_csrf"` | Field name printed by `csrfField()`. |
362
- | `csrf.headerName` | `string` | `"x-csrf-token"` | Header the token is also accepted in. |
363
-
364
- `trustProxy` should be **turned off** on a server exposed directly to the
365
- internet: while it is on, a client can forge `X-Forwarded-For` /
366
- `X-Forwarded-Proto` / Host, and rate limits, admin IP allowlists, Secure
367
- cookies, and cache `vary.host` see the wrong address. Behind a reverse proxy
368
- (nginx, Caddy, Cloudflare), `true` is the right default.
369
-
370
- The CSRF check only rejects requests that are **known** to be cross-site — when
371
- `Origin` does not match or `Sec-Fetch-Site: cross-site` arrives. If neither is
372
- present the request passes, because browsers always send `Origin` on a
373
- cross-origin POST while webhooks never do. For cookie-session dashboards,
374
- enable `csrf.token: true` and `csrfField()` as a second layer; put webhook
375
- paths in `csrf.exclude`.
376
-
377
- ## `navigation`
378
-
379
- **Type:** `object` — **Default:**
380
- `{ prefetch: "moderate", prerender: false, viewTransition: false, exclude: [] }`
381
-
382
- `<head>` hints that speed up in-site navigation. Since JSkelet is a classic MPA,
383
- every click is a full page load; this section makes the browser do that load
384
- **ahead of time**. No client runtime is added — Speculation Rules and view
385
- transitions are browser capabilities, and in a browser that does not support
386
- them they are silently ignored.
387
-
388
- | Field | Type | Default | Meaning |
389
- | --- | --- | --- | --- |
390
- | `prefetch` | `false \| "conservative" \| "moderate" \| "eager"` | `"moderate"` | Downloads the link target's **document** ahead of time |
391
- | `prerender` | same | `false` | **Fully renders** the target in the background; it opens the moment you click |
392
- | `viewTransition` | `boolean` | `false` | Emits `@view-transition { navigation: auto }` |
393
- | `exclude` | `string[]` | `[]` | href patterns to keep out of speculation |
394
-
395
- If `true` is given, `prefetch`/`prerender` fall back to the default eagerness; an
396
- unrecognised value prints a warning and reverts to the default.
397
-
398
- **What eagerness means:** `conservative` triggers the moment the link is pressed,
399
- `moderate` when the pointer lingers on the link for a while, `eager` as soon as
400
- the link becomes visible. The further up you go, the higher the hit rate — and
401
- the more wasted requests.
402
-
403
- **Why `prerender` ships off.** The scripts of a prerendered page really do run.
404
- In an application that does not hook its measurement code to the
405
- `prerenderingchange` event, visit counts get inflated. Review your analytics
406
- before turning it on; the cost on the server side is low, because a speculative
407
- request is also served from the HTML cache
408
- ([06-caching.md](./06-caching.md)).
409
-
410
- **Always exempt.** Paths under `/api/*`, `/_fragment/*` and `brand.devBasePath`
411
- are excluded automatically; `exclude` is added on top of those. Additionally,
412
- links carrying `rel="nofollow"`, `target="_blank"` or `data-no-prefetch` are not
413
- covered by any rule. The easiest way to keep a single link with side effects out
414
- is the last one:
415
-
416
- ```html
417
- <a href="/logout" data-no-prefetch>Logout</a>
418
- ```
419
-
420
- **When turning on `viewTransition`, put the background on `<html>`.** During the
421
- transition the browser cross-fades snapshots of the old and the new page; a
422
- background set on `<body>` stays inside that snapshot and the canvas underneath
423
- becomes visible. The result is one frame of white flash on every transition, and
424
- it does not go unnoticed in a dark theme. If the colour is on `<html>` (or
425
- `:root`), no such gap appears:
426
-
427
- ```html
428
- <html lang="tr" class="bg-white dark:bg-slate-950">
429
- <body class="text-slate-900 dark:text-slate-100">
430
- ```
431
-
432
- The reduced-motion preference is handled by the framework: under
433
- `prefers-reduced-motion: reduce` the transition is disabled, and you do not need
434
- to write anything extra.
435
-
436
- **Scope the transition to the content.** The default behaviour cross-fades the
437
- whole document as a single piece, which means the header and footer — which
438
- never change across navigations — flicker too. Giving those regions a
439
- `view-transition-name` puts them in their own group; because the browser sees the
440
- same name in both documents, it treats them as "the same element". Once you turn
441
- off the animation of the named element, the transition stays in the content
442
- only:
443
-
444
- ```css
445
- body > header { view-transition-name: site-header; }
446
- body > footer { view-transition-name: site-footer; }
447
-
448
- ::view-transition-old(site-header),
449
- ::view-transition-old(site-footer) { animation: none; opacity: 0; }
450
- ::view-transition-new(site-header),
451
- ::view-transition-new(site-footer) { animation: none; opacity: 1; }
452
-
453
- /* The remaining content; the default 250ms makes navigation feel slow. */
454
- ::view-transition-old(root),
455
- ::view-transition-new(root) { animation-duration: 180ms; }
456
- ```
457
-
458
- Copy the Tailwind `@source` directives and view-transition CSS into your own
459
- app's `styles/globals.css`; the blocks above are a starting point.
460
-
461
- **If you use CSP**, the rules are emitted as an inline
462
- `<script type="speculationrules">`; your `script-src` policy needs to allow it.
463
-
464
- ```js
465
- navigation: {
466
- prefetch: "moderate",
467
- prerender: "conservative",
468
- viewTransition: true,
469
- exclude: ["/logout", "/cart/*"],
470
- }
471
- ```
472
-
473
- ## `prewarmSkip`
474
-
475
- **Type:** `string[]` — **Default:** `["/api/", "/_fragment/", "/__jskelet/"]`
476
-
477
- Path **prefixes** that prewarming skips. Session-dependent or fragment endpoints
478
- should not be prewarmed. If provided, it replaces the default — if you changed
479
- `brand.devBasePath`, do not forget to update this list too.
480
-
481
- Details: [06-caching.md](./06-caching.md).
482
-
483
- ## `watch`
484
-
485
- **Type:** `string[]` — **Default:** `[]`
486
-
487
- **Additional** directories that `jskelet dev` watches for server restarts,
488
- relative to the project root. `routes`, `views` and `lib` are already watched;
489
- `client/` and `styles/` are handled by the esbuild and CSS watchers and should
490
- not be put here.
491
-
492
- Only files with the `.js`, `.mjs`, `.json` and `.ejs` extensions are triggers.
493
-
494
- ```js
495
- watch: ["data", "content"]
496
- ```
497
-
498
- Details: [09-dev-tools.md](./09-dev-tools.md).
499
-
500
- ## `fonts`
501
-
502
- **Type:** `{ family: string, slug?: string, weights?: number[] }[]` —
503
- **Default:** `[]`
504
-
505
- Google Fonts families to self-host. If left empty, the font step never runs.
506
-
507
- | Field | Type | Default | Meaning |
508
- | --- | --- | --- | --- |
509
- | `family` | `string` | — | Google Fonts family name: `"Inter"`, `"Noto Sans"` |
510
- | `slug` | `string` | derived from `family` (lower case, space → `-`) | File name prefix |
511
- | `weights` | `number[]` | `[400]` | Weights to download |
512
-
513
- Output: `public/fonts/<slug>-<weight>.woff2`, with the same file name as the
514
- manifest key. The files have **fixed names** (no hash) and are **expected to be
515
- committed**. Details: [08-build.md](./08-build.md).
516
-
517
- ```js
518
- fonts: [
519
- { family: "Inter", weights: [400, 600, 700] },
520
- { family: "Noto Serif", slug: "serif", weights: [400] },
521
- ]
522
- ```
523
-
524
- ## `icons`
525
-
526
- **Type:** `{ scan?: string[], dir?: string } | false` — **Default:** `{ dir: "icons" }`
527
-
528
- SVG icon sprite generation. The source is chosen **XOR**: if the `icons.dir`
529
- directory exists, only the flat SVGs there are used; otherwise
530
- `@phosphor-icons/core` (when installed).
531
-
532
- | Value | Result |
533
- | --- | --- |
534
- | `{}` (default) | `dir: "icons"`; scanned directories are `["views", "client", "routes", "lib", "features", "shared"]` |
535
- | `{ dir: "assets/icons" }` | Changes the local SVG root |
536
- | `{ scan: [...] }` | Changes the scanned directories |
537
- | `false` | The sprite step is skipped entirely |
538
-
539
- A local directory (when present) uses flat file names: `house.svg` →
540
- `house:regular`, `house-bold.svg` → `house:bold`. An empty `icons/` directory
541
- does not fall back to Phosphor — delete the directory to open the fallback.
542
- Details: [08-build.md](./08-build.md).
543
-
544
- ```js
545
- icons: {
546
- dir: "icons",
547
- scan: ["views", "client", "routes", "lib", "content"],
548
- }
549
- ```
550
-
551
- ## `images`
552
-
553
- **Type:**
554
- `{ widths?: number[], quality?: number, skip?: string[], remote?: { allowHosts: string[], path?: string, maxWidth?: number, cacheMaxAge?: number, fetchTimeoutMs?: number, maxBytes?: number } | false } | false`
555
- — **Default:** `{ widths, quality, skip, remote: false }` (remote off)
556
-
557
- Generates webp variants of png/jpg files under `public/` at **build** time.
558
- When `remote.allowHosts` is set, also proxies remote images at runtime
559
- (`/_jskelet/image?url=&w=&q=` → webp).
560
-
561
- | Field | Type | Default | Meaning |
562
- | --- | --- | --- | --- |
563
- | `widths` | `number[]` | `[400, 640, 960, 1280, 1920]` | Candidates for build and remote `srcset`. Ones larger than the source are dropped at build; the source's own width (at most 1920) is always added. |
564
- | `quality` | `number` | `78` | webp quality. Part of the build encoder signature; default `q` on the remote endpoint. |
565
- | `skip` | `string[]` | `[]` | **Directory names** not to scan at build. `assets` and `fonts` are always skipped. |
566
- | `remote` | `object \| false` | off | Runtime optimizer. `allowHosts` is **required**; empty disables the route. |
567
-
568
- ### `images.remote`
569
-
570
- | Field | Type | Default | Meaning |
571
- | --- | --- | --- | --- |
572
- | `allowHosts` | `string[]` | `[]` | Hosts that may be fetched. Supports a `*.cdn.example.com` suffix wildcard. |
573
- | `path` | `string` | `/_jskelet/image` | Optimizer GET path. |
574
- | `maxWidth` | `number` | `1920` | Cap for `w`. |
575
- | `cacheMaxAge` | `number` | `2592000` (30 days) | Response `Cache-Control` max-age (seconds). Disk cache under `.jskelet/image-cache/`; past 256 MB the oldest file is deleted. |
576
- | `fetchTimeoutMs` | `number` | `10000` | Upstream fetch timeout. |
577
- | `maxBytes` | `number` | `10485760` (10 MiB) | Upstream body size limit. |
578
-
579
- If `false` is given, neither surface runs. The build step requires `sharp` and
580
- never runs on a watch pass. With remote enabled, `sharp` is also needed at
581
- **runtime**; without it the optimizer 302-redirects to the source URL. Fetch
582
- does not auto-follow redirects: every hop is re-checked against `allowHosts`
583
- and private addresses. Details: [08-build.md](./08-build.md).
584
-
585
- ```js
586
- images: {
587
- widths: [400, 800, 1200],
588
- quality: 82,
589
- skip: ["downloads"],
590
- remote: {
591
- allowHosts: ["static.example.com", "*.cdn.example.com"],
592
- },
593
- }
594
- ```
595
-
596
- `image({ src: "https://static.example.com/a.jpg", width: 96, alt: "…" })`
597
- rewrites `src` / `srcset` to `/_jskelet/image?url=…&w=96`. To build URLs by
598
- hand, use `remoteImageUrl(src, { width })` from `jskelet`.
599
-
600
- ## `clientEnv`
601
-
602
- **Type:** `string[]` — **Default:** `[]`
603
-
604
- Environment variable keys to inline into the client bundle at build time. The
605
- same contract as `NEXT_PUBLIC_*` in Next, except which key is public is decided
606
- by the config rather than by the name. `NODE_ENV` is always inlined.
607
-
608
- Because the whole of `process.env` is defined as a single object, reading a key
609
- that is not in the list returns `undefined` instead of crashing.
610
-
611
- ```js
612
- clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN_ORIGIN"]
613
- ```
614
-
615
- **Do not put secrets here** — the values sit in the bundle in plain text. Keys
616
- whose names look secret-like (`SECRET`, `PASSWORD`, `TOKEN`, `API_KEY`,
617
- `PRIVATE`, …) are **rejected at build time** (`PUBLIC` / `PUBLISHABLE` names
618
- are exempt).
619
-
620
- ## `headers()`
621
-
622
- **Type:** `() => { source: string, headers: { key: string, value: string }[] }[]`
623
- — **Default:** `[]`
624
-
625
- Response headers by path pattern. The framework only writes long-lived cache
626
- headers for static files; every other header (CSP, COOP, HSTS,
627
- X-Frame-Options…) comes from here and takes precedence over the defaults.
628
- Production sites should at least define the security headers below.
629
-
630
- **All** matching rules are applied (unlike redirects, it does not stop at the
631
- first match), in order; if two rules write the same header, the later one wins.
632
-
633
- Entries without a `key` or with an `undefined` `value` are skipped; a rule left
634
- with no valid headers at all is not added.
635
-
636
- ```js
637
- async headers() {
638
- return [
639
- {
640
- source: "/:path*",
641
- headers: [
642
- { key: "X-Frame-Options", value: "SAMEORIGIN" },
643
- { key: "X-Content-Type-Options", value: "nosniff" },
644
- { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
645
- {
646
- key: "Permissions-Policy",
647
- value: "camera=(), microphone=(), geolocation=()",
648
- },
649
- {
650
- key: "Content-Security-Policy",
651
- value: "default-src 'self'; img-src 'self' https://cdn.example.com data:; script-src 'self'",
652
- },
653
- // Only when you terminate HTTPS yourself:
654
- // { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
655
- ],
656
- },
657
- {
658
- source: "/download/:path*",
659
- headers: [{ key: "Cache-Control", value: "no-store" }],
660
- },
661
- ];
662
- }
663
- ```
664
-
665
- ## `redirects()`
666
-
667
- **Type:**
668
- `() => { source: string, destination: string, permanent?: boolean, statusCode?: number }[]`
669
- — **Default:** `[]`
670
-
671
- | Field | Type | Meaning |
672
- | --- | --- | --- |
673
- | `source` | `string` | Pattern (syntax below) |
674
- | `destination` | `string` | Target; `:param` placeholders are filled in |
675
- | `permanent` | `boolean` | `true` → 308, otherwise 307 |
676
- | `statusCode` | `number` | Explicit status code; overrides `permanent` |
677
-
678
- The first matching rule wins and the query string is preserved. Details:
679
- [03-routing.md](./03-routing.md).
680
-
681
- ## `rewrites()`
682
-
683
- **Type:** `() => Rule[] | { beforeFiles?: Rule[], afterFiles?: Rule[] }`
684
- where `Rule = { source: string, destination: string }` — **Default:** `[]`
685
-
686
- If an array is returned, all of it counts as `afterFiles`.
687
-
688
- - `beforeFiles` runs even before static files.
689
- - `afterFiles` runs after static has been tried, before the routes.
690
- - Absolute target (`http://`/`https://`) → built-in reverse proxy.
691
- - Relative target → only `req.url` changes.
692
-
693
- Details: [03-routing.md](./03-routing.md).
694
-
695
- ## `cache()`
696
-
697
- **Type:**
698
- `() => { 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 }` —
699
- **Default:**
700
- `{ 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: [] } }`
701
-
702
- ### `cache().html`
703
-
704
- A pattern → seconds mapping. A matching rule **overrides** the route's own
705
- `revalidate` value. Negative or non-finite values are ignored; `0` means "no
706
- caching". Before TTL ends the framework starts an early background refresh
707
- based on the last render duration (no separate config field; see
708
- [06-caching.md](./06-caching.md)).
709
-
710
- The one exception is `route(fn, { private: true })`: on that route a matching
711
- pattern is ignored. The lock is deliberately one-way — a mistake in the other
712
- direction means one user's HTML is served to another.
713
-
714
- ```js
715
- html: {
716
- "/": 60,
717
- "/news/:slug": 300,
718
- "/search": 0,
719
- }
720
- ```
721
-
722
- ### `cache().query`
723
-
724
- A pattern → list of query parameters allowed into the cache key.
725
-
726
- **By default a request that carries a query parameter is dynamic**: even when
727
- `cache().html` covers that path, the response never enters the HTML cache and
728
- is sent with `private, no-store`. The reason is simple — caching every variant
729
- of a path mints an unbounded number of keys (`?utm_source=…` and friends), and
730
- once the `maxEntries` limit is reached those keys evict the real pages. Only the
731
- application knows which parameter actually changes the output.
732
-
733
- ```js
734
- query: {
735
- "/search": ["q", "page"], // only these two belong to the key
736
- "/products": ["category"],
737
- "/report/:id": true, // every parameter belongs to the key
738
- "/campaign": [], // the query is ignored entirely
739
- }
740
- ```
741
-
742
- - **Allowlist** (`string[]`): the listed parameters become part of the key and
743
- each distinct value gets its own entry. Parameters outside the list are
744
- **ignored** — the page is still cached and every campaign variant shares one
745
- copy.
746
- - **`true`**: every parameter belongs to the key. Nothing but `maxEntries`
747
- bounds the number of entries, so use it only where the value set is closed.
748
- - **`[]`**: the query is not considered at all; every variant is served the HTML
749
- of the query-less version.
750
-
751
- Parameters are written into the key **sorted**, so `?a=1&b=2` and `?b=2&a=1`
752
- share one entry. `route(fn, { private: true })` is unaffected by this section; a
753
- private route is never cached under any condition.
754
-
755
- ### `cache().vary`
756
-
757
- Adds fixed segments to the HTML cache key **independently** of the query
758
- allowlist. On sites that derive locale from the host, `host: true` is
759
- **required**; otherwise the first locale's HTML is served to the other host. A
760
- CDN already separates by full URL — this setting is for the origin L1 and the
761
- Redis HTML key.
762
-
763
- ```js
764
- vary: {
765
- host: true, // h=tr.example.com|…
766
- // headers: ["x-locale"],
767
- // fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
768
- }
769
- ```
770
-
771
- | Field | Type | Default | Meaning |
772
- | --- | --- | --- | --- |
773
- | `host` | `boolean` | `false` | Public Host (`x-forwarded-host` else `Host`), lowercase, no port → `h=…` |
774
- | `headers` | `string[]` | `[]` | Request headers added as `name=value` |
775
- | `fn` | `(req) => string \| null` | — | Return value appended as a segment |
776
-
777
- Key shape: `${vary}|${path}?${query}` (no prefix when vary is empty). Details:
778
- [06-caching.md](./06-caching.md).
779
-
780
- ### `cache().maxEntries`
781
-
782
- **Type:** `number` — **Default:** `500`
783
-
784
- The entry limit of the HTML cache. Because an entry costs a hundred kilobytes,
785
- raising this number burns through memory quickly; trying to solve a site with
786
- tens of thousands of paths from here is the wrong layer — the right place is
787
- `cache().data`.
788
-
789
- **Ceiling 800.** A higher value is clamped to 800 with a warning at load.
790
- In-process HTML plus compressed bodies also cannot exceed 256 MB; config
791
- cannot raise that budget. Only one compressed copy is kept (brotli or gzip).
792
-
793
- ### `cache().data`
794
-
795
- The upstream data cache (`withDataCache`). Details:
796
- [06-caching.md](./06-caching.md).
797
-
798
- | Field | Type | Default | Meaning |
799
- | --- | --- | --- | --- |
800
- | `maxEntries` | `number` | `10000` | The LRU entry limit. The limit is high because JSON is tens of times smaller than HTML. **Ceiling 20,000**; a higher value is clamped with a warning. In-process JSON also cannot exceed **64 MB**; config cannot raise that budget. |
801
- | `staleFactor` | `number` | `10` | For how many TTLs an entry stays usable after the TTL expired. `0` → no stale serving. |
802
-
803
- ### `cache().trackUpstream`
804
-
805
- **Type:** `boolean` — **Default:** `true`
806
-
807
- When on, `globalThis.fetch` is wrapped and transient upstream failures (`429`,
808
- `5xx`, network) during a render are reported automatically; calling
809
- `reportUpstreamFailure()` is not required. An application that wraps `fetch`
810
- itself can turn this off.
811
-
812
- ### `cache().trackDependencies`
813
-
814
- **Type:** `boolean` — **Default:** `true`
815
-
816
- When on, the `withDataCache` keys a render reads are recorded, and
817
- `clearDataCache()` also stales the HTML pages that read that data — targeted
818
- invalidation without the application declaring anything
819
- ([06-caching.md](./06-caching.md)). An application that does not use
820
- `withDataCache` has nothing to record; turning this off also removes the cost of
821
- setting up the context.
822
-
823
- ### `cache().transientRetry`
824
-
825
- **Type:** `{ attempts?: number, delayMs?: number } | false` —
826
- **Default:** `{ attempts: 1, delayMs: 300 }`
827
-
828
- How many extra times a page is tried when `notFound()` was called because of a
829
- transient upstream failure. The point is that an existing page never turns into
830
- a 404; if the retries are exhausted the response is an uncached 503. `false` or
831
- `attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
832
-
833
- ### `cache().upstream`
834
-
835
- A per-host rate limit for the `fetch` calls that go to upstream APIs. Off by
836
- default: unless `rate` is given, no request ever waits. `rate` is a ceiling; the
837
- actual rate pulls itself down in response to 429s and climbs back step by step
838
- during clean windows.
839
-
840
- | Field | Type | Default | Meaning |
841
- | --- | --- | --- | --- |
842
- | `rate` | `number` | `0` | Maximum calls per second. `0` → brake disabled |
843
- | `burst` | `number` | `0` | Bucket size; `0` → one second's budget of burst |
844
- | `concurrency` | `number` | `8` | Calls allowed in flight at once |
845
- | `minRate` | `number` | `0.5` | Floor of the decrease; the rate never goes below it |
846
- | `increaseStep` | `number` | `1` | Step of the additive increase (calls/second) |
847
- | `increaseIntervalMs` | `number` | `5000` | Increase period |
848
- | `decreaseIntervalMs` | `number` | `1000` | Minimum time between two decreases |
849
- | `breakerFailures` | `number` | `5` | Consecutive 429s after which the host is bypassed |
850
- | `breakerCooldownMs` | `number` | `10000` | How long the bypass lasts |
851
- | `hosts` | `Record<string, object>` | `{}` | Per-host overrides; same fields apply |
852
-
853
- Only `429` and `503` penalise the rate: a `400`/`404`/`500` is not a quota
854
- problem. Read the state with `getUpstreamLimiterStatus()` or from the dev
855
- panel's **Server** tab. Details, and what to check before turning it on:
856
- [06-caching.md](./06-caching.md).
857
-
858
- ```js
859
- upstream: {
860
- rate: 10,
861
- concurrency: 4,
862
- hosts: { "api.example.com": { rate: 3 } },
863
- }
864
- ```
865
-
866
- ### `cache().redis`
867
-
868
- An optional Redis second tier (L2). The in-process cache stays primary; Redis
869
- only skips the render for a path that is not in L1 and spreads invalidation to
870
- the other instances. `ioredis` has to be installed in the application
871
- (`npm install ioredis`); if it is missing or unreachable a warning is printed and
872
- the site keeps running on the in-process cache.
873
-
874
- | Field | Type | Default | Meaning |
875
- | --- | --- | --- | --- |
876
- | `enabled` | `boolean` | `false` | Only turns on when `true` is passed explicitly |
877
- | `url` | `string \| null` | `null` | `redis://` or `rediss://`. When empty, the ioredis default (`localhost:6379`) |
878
- | `namespace` | `string` | `"default"` | Separates applications sharing one Redis |
879
- | `keyPrefix` | `string` | `"_jskelet"` | Root of the key layout |
880
- | `html` | `boolean` | `true` | Whether HTML bodies are shared |
881
- | `data` | `boolean` | `true` | Whether `withDataCache` entries are shared |
882
- | `storeEncoded` | `boolean` | `false` | Whether brotli/gzip bodies are shared too; doubles or triples the size per entry |
883
- | `events` | `boolean` | `true` | Invalidation broadcast over pub/sub |
884
- | `commandTimeoutMs` | `number` | `200` | At most how long a single command may block |
885
-
886
- Keys live as `_jskelet:{namespace}:{buildId}:html:{path}?{query}`. `buildId`
887
- changes with every build, so old HTML becomes invalid on its own after a deploy.
888
- Personalised (`storable: false`), `degraded` and non-200 responses are never
889
- written to the shared tier. Trade-offs and diagnosis:
890
- [06-caching.md](./06-caching.md).
891
-
892
- ```js
893
- redis: {
894
- enabled: process.env.NODE_ENV === "production",
895
- url: process.env.REDIS_URL,
896
- namespace: "news-site",
897
- }
898
- ```
899
-
900
- ### `logs`
901
-
902
- Persistent log sinks. Everything is off by default: stdout and the admin panel
903
- ring keep their current behaviour. When enabled, HTTP access logs and framework
904
- events (`event` / `error`) go out as NDJSON to a file, `drainLog`, and/or S3.
905
- File chunks are zstd and stay at most 5 minutes; the oldest expired chunk is
906
- deleted.
907
-
908
- | Field | Type | Default | Meaning |
909
- | --- | --- | --- | --- |
910
- | `console` | `boolean` | `true` | Whether runtime `http` / `event` / `error` lines go to stdout (banner/build lines are unaffected) |
911
- | `kinds` | `("http" \| "event" \| "error")[]` | all | Which kinds reach the sinks |
912
- | `file.enabled` | `boolean` | `false` | File spool. Lines become `jskelet-<time>-<n>.ndjson.zst` about every 1s or 32 lines. Kept at most 5 minutes; the oldest chunk is deleted. |
913
- | `file.dir` | `string` | `"logs"` | Directory relative to the project root |
914
- | `drainLog` | `(chunk) => void \| Promise<void>` | `null` | Forwards each sealed zstd chunk (`{ body, encoding, bytes, lines, at }`) wherever the app wants. A throw warns and does not take the site down. With the file sink off, nothing is written to disk. |
915
- | `s3.enabled` | `boolean` | `false` | S3 batch PutObject sink |
916
- | `s3.bucket` | `string \| null` | `null` | Bucket or a `bucket/prefix/…` path; `JSKELET_LOG_BUCKET` overrides |
917
- | `s3.prefix` | `string` | `"jskelet/logs/"` | Object key prefix (when not given in the path) |
918
- | `s3.region` | `string \| null` | `"auto"` | Region; falls back to `JSKELET_S3_REGION`, otherwise `auto` |
919
- | `s3.endpoint` | `string \| null` | `null` | S3-compatible API URL; `JSKELET_S3_API_URL` overrides |
920
- | `s3.flushIntervalMs` | `number` | `5000` | Batch flush interval |
921
- | `s3.maxBatch` | `number` | `100` | Flush early after this many lines |
922
-
923
- S3 credentials are not written in the config: `JSKELET_S3_ACCESS_KEY_ID`,
924
- `JSKELET_S3_SECRET_ACCESS_KEY`, optional `JSKELET_S3_SESSION_TOKEN`. Missing
925
- bucket/region/credentials warn and disable the S3 sink; the site still starts.
926
- The framework does not ship `@aws-sdk` — PutObject is embedded with SigV4.
927
-
928
- ```js
929
- logs: {
930
- console: true,
931
- kinds: ["http", "error"],
932
- file: { enabled: true, dir: "logs" },
933
- async drainLog(chunk) {
934
- // chunk.body is zstd NDJSON. The file is deleted after 5 minutes; keep a copy here.
935
- },
936
- s3: {
937
- enabled: process.env.NODE_ENV === "production",
938
- bucket: process.env.JSKELET_LOG_BUCKET,
939
- prefix: "my-app/logs/",
940
- region: process.env.JSKELET_S3_REGION,
941
- endpoint: process.env.JSKELET_S3_API_URL,
942
- },
943
- }
944
- ```
945
-
946
- ### `admin()`
947
-
948
- The framework admin panel (`/_jskelet/admin`). It manages the in-process /
949
- Redis / Cloudflare caches and exposes route and view inventories plus a live
950
- log queue.
951
-
952
- It does not look at the environment: without `enabled` **nothing is mounted**
953
- and the path does not exist. When it is on it also works in production — that is
954
- where the real questions ("why is this page stale", "did the webhook purge
955
- land") get asked. It is separate from the `cache()` section.
956
-
957
- | Field | Type | Default | Meaning |
958
- | --- | --- | --- | --- |
959
- | `enabled` | `boolean` | `false` | Only turns on when explicitly `true` (`JSKELET_ADMIN` overrides it) |
960
- | `basePath` | `string` | `"/_jskelet/admin"` | Root of the panel |
961
- | `allowIps` | `string[]` | `[]` | Exact IP or CIDR; empty = no restriction. Anyone outside gets 404 |
962
- | `blockBots` | `boolean` | `true` | Known crawler UAs get 404 |
963
- | `banAttempts` | `number` | `3` | How many failed attempts ban an IP |
964
- | `banHours` | `number` | `24` | How long the ban lasts |
965
- | `sessionHours` | `number` | `12` | Lifetime of the session cookie |
966
- | `logSize` | `number` | `500` | Live log ring size |
967
-
968
- The password is generated **on every process start** and only appears in the
969
- server log `ADMIN` box. Banned and unauthorised requests all get a `404`.
970
- Usage and screens: [06-caching.md](./06-caching.md).
971
-
972
- ```js
973
- admin() {
974
- return {
975
- enabled: process.env.JSKELET_ADMIN === "1",
976
- allowIps: ["203.0.113.10", "10.0.0.0/8"],
977
- };
978
- }
979
- ```
980
-
981
- ### `cache().cloudflare`
982
-
983
- The CDN tier. JSkelet's cache is the origin cache; the copy your visitors get
984
- sits at the edge. With this section connected, the panel can purge the edge,
985
- read and change cache related zone settings and show the cache hit ratio.
986
-
987
- | Field | Type | Default | Meaning |
988
- | --- | --- | --- | --- |
989
- | `enabled` | `boolean` | `true` | Set `false` to keep the surface off even when a token is present in the environment |
990
- | `zoneId` | `string \| null` | `null` | Zone identifier (`JSKELET_CLOUDFLARE_ZONE_ID` overrides it) |
991
- | `apiToken` | `string \| null` | `null` | The token; **prefer the environment**, putting it here puts a secret in the repo |
992
- | `hostname` | `string \| null` | `null` | Purging wants absolute URLs; paths are resolved against this name. Falls back to the origin the panel was opened on |
993
- | `analyticsHours` | `number` | `24` | Analytics window, at most `72` |
994
-
995
- Passing the token only through `JSKELET_CLOUDFLARE_KEY` keeps the config file
996
- clean. Permissions follow what you intend to do: `Zone.Cache Purge` to purge,
997
- `Zone.Zone Settings` for settings, `Zone.Analytics` (read) for the hit ratio.
998
- The token is never returned in a panel response — only the fact that it came
999
- from the environment.
1000
-
1001
- With no zone connected the panel shows a setup snippet rather than a warning,
1002
- and if Cloudflare returns an error that section reports it while the rest of the
1003
- panel keeps working. What can actually be asked — in particular why "how many
1004
- edges hold this page" has no exact answer — is in
1005
- [06-caching.md](./06-caching.md).
1006
-
1007
- ### `cache().prewarm`
1008
-
1009
- Two modes: **classic** (list + startup pass) or **`onVisit`** (links from the
1010
- page just visited). They cannot be combined — config load throws.
1011
-
1012
- #### Classic fields
1013
-
1014
- | Field | Type | Default | Meaning |
1015
- | --- | --- | --- | --- |
1016
- | `enabled` | `boolean` | `true` | If `false`, no prewarming happens (can be overridden with `PREWARM=1`) |
1017
- | `max` | `number` | `400` | At most how many paths are prewarmed per pass |
1018
- | `concurrency` | `number` | prod 4, dev 1 | Number of parallel workers |
1019
- | `rps` | `number` | prod `0`, dev 4 | At most how many prewarm requests per second; `0` is unlimited. This is the setting that protects the upstream quota. The default brake in dev keeps prewarming from holding page requests up. |
1020
- | `delayMs` | `number` | prod 500, dev 3000 | Delay of the first pass after startup |
1021
- | `retryDelayMs` | `number` | `2000` | How long to wait before the retry pass |
1022
- | `intervalSeconds` | `number` | `0` | If greater than 0, the pass repeats periodically |
1023
- | `rotate` | `boolean` | `true` | If the list is longer than `max`, periodic passes continue where they left off |
1024
- | `priority` | `(string \| RegExp)[]` | `[]` | Warm-up order; matching paths are taken first on every pass |
1025
- | `origins` | `string[]` | `[]` | Origins for the classic pass. Empty → `http://127.0.0.1:<port>`. With `vary.host`, list the locale hosts here |
1026
-
1027
- `priority` accepts two forms: the pattern syntax used everywhere in the config,
1028
- and a plain `RegExp`. Whatever is written first is warmed first.
1029
-
1030
- ```js
1031
- prewarm: {
1032
- max: 500,
1033
- rps: 4,
1034
- intervalSeconds: 300,
1035
- // with vary.host, loopback alone is not enough:
1036
- origins: ["http://localhost", "http://tr.localhost"],
1037
- priority: [
1038
- "/", // the home page
1039
- "/markets/:path*", // the whole markets section
1040
- /-comments$/, // a rule the pattern syntax does not cover
1041
- ],
1042
- }
1043
- ```
1044
-
1045
- #### `onVisit`
1046
-
1047
- | Field | Type | Default | Meaning |
1048
- | --- | --- | --- | --- |
1049
- | `onVisit` | `true \| false \| object` | off | Visit-driven warming |
1050
- | `onVisit.perPage` | `number` | `20` | At most how many links per page (top to bottom). **Ceiling 20** |
1051
- | `onVisit.concurrency` | `number` | `2` | Parallel workers. **Ceiling 2** |
1052
- | `onVisit.rps` | `number` | `2` | Requests per second cap. **Ceiling 2**; `0` is clamped to 2 as well |
1053
-
1054
- ```js
1055
- prewarm: {
1056
- onVisit: { perPage: 20, rps: 2 },
1057
- }
1058
- ```
1059
-
1060
- `hooks.prewarmPaths` and classic fields (`max`, `priority`, …) are **forbidden**
1061
- with `onVisit`. Details: [06-caching.md](./06-caching.md).
1062
-
1063
- Each numeric field can be overridden by an environment variable of the same
1064
- name; env takes precedence. Details: [06-caching.md](./06-caching.md).
1065
-
1066
- ## `hooks`
1067
-
1068
- **Type:** `Record<string, Function>` — **Default:** `{}`
1069
-
1070
- All optional, all may be `async`. If a hook throws, the framework falls back to
1071
- its own default and warns — the page does not go down.
1072
-
1073
- | Hook | Signature | What it returns | Document |
1074
- | --- | --- | --- | --- |
1075
- | `metadata` | `(page) => object` | Metadata default for every page; the controller's `metadata` is layered on top | [04](./04-rendering.md) |
1076
- | `layoutContext` | `({ pathname, metadata }) => object` | Layout locals; `lang`, `structuredData`, `extraHead` and `bodyClass` get special treatment | [04](./04-rendering.md) |
1077
- | `notFound` | `() => object \| null` | 404 page definition; if `null`, the framework's error page | [03](./03-routing.md) |
1078
- | `error` | `({ status, error }) => object \| string \| null` | Error pages other than 404 (and 404 when there is no `notFound`); a page definition or HTML directly | [03](./03-routing.md) |
1079
- | `prewarmPaths` | `() => string[]` | Paths for classic prewarm; if omitted, the classic pass is never set up. **Forbidden** with `onVisit` | [06](./06-caching.md) |
1080
-
1081
- ```js
1082
- hooks: {
1083
- metadata() {
1084
- return { titleTemplate: "%s | Example", siteUrl: "https://example.com" };
1085
- },
1086
-
1087
- async layoutContext({ pathname }) {
1088
- return { navigation: await getNavigation(), isHome: pathname === "/" };
1089
- },
1090
-
1091
- notFound() {
1092
- return {
1093
- view: "pages/not-found",
1094
- metadata: { title: "Page not found", robots: { index: false } },
1095
- };
1096
- },
1097
-
1098
- error({ status }) {
1099
- return {
1100
- view: "pages/error",
1101
- data: { status },
1102
- metadata: { title: "Something went wrong", robots: { index: false } },
1103
- };
1104
- },
1105
-
1106
- async prewarmPaths() {
1107
- return ["/", ...(await getArticlePaths())];
1108
- },
1109
- }
1110
- ```
1111
-
1112
- ## `source` pattern syntax
1113
-
1114
- `headers()`, `redirects()`, `rewrites()` and `cache().html` all use the same
1115
- small compiler. This is not Next's full `path-to-regexp` surface; the subset
1116
- actually used in configuration was chosen deliberately, and an unrecognised
1117
- syntax is not silently accepted as a literal — it produces a warning.
1118
-
1119
- | Pattern | Regex equivalent | Example match |
1120
- | --- | --- | --- |
1121
- | `/about` | exact match | `/about` |
1122
- | `/news/:slug` | `([^/]+)` — a single segment | `/news/abc` (✗ `/news/a/b`) |
1123
- | `/:path*` | `(.*)` — zero or more segments | `/`, `/a`, `/a/b/c` |
1124
- | `/blog/:path*` | wildcard sub-path; the leading `/` is optional | `/blog`, `/blog/`, `/blog/a/b` |
1125
- | `/:path*.svg` | wildcard + fixed suffix | `/ikon.svg`, `/a/b/c.svg` |
1126
- | `/tag-:slug` | a parameter in the middle of a segment | `/tag-finance` |
1127
-
1128
- Rules:
1129
-
1130
- - `source` **must start with `/`**; if it does not, the rule is ignored and a
1131
- warning is printed.
1132
- - The parameter name must match the pattern `[A-Za-z_][A-Za-z0-9_]*`.
1133
- - A pattern always matches **from start to end** (`^…$`); use `:path*` for
1134
- prefix matching.
1135
- - `:path*` also captures zero segments and the `/` immediately before it is
1136
- optional: `/account/:path*` covers the section's root path (`/account`) too.
1137
- Otherwise a rule that wanted to close off a whole section was skipping
1138
- precisely its landing page.
1139
- - Every character other than parameters is treated as a literal and escaped for
1140
- the regex — `.` really means a dot.
1141
- - Captured values are written into the same-named `:param`s in `destination`. A
1142
- placeholder with no counterpart is left as is.
1143
-
1144
- ## Environment variables
1145
-
1146
- Every variable the framework reads. If a `.env` file exists it is loaded
1147
- automatically by the CLI (`--env-file=.env`); if not, the flag is never passed
1148
- and no warning is printed.
1149
-
1150
- | Variable | Who reads it | Default | Meaning |
1151
- | --- | --- | --- | --- |
1152
- | `NODE_ENV` | everywhere | `production` (start/build), `development` (dev) | Determines the dev overlay, EJS cache, manifest re-reading, route error behaviour and prewarm defaults. `jskelet dev` sets it itself — `cross-env` is not needed. |
1153
- | `PORT` | `startServer` | `3000` | Port to listen on. If busy, the process refuses to start; `jskelet start|dev --murder` kills the listener |
1154
- | `HOST` | `startServer` | `::` | Interface to bind to. The default listens dual-stack (IPv6 + IPv4); it falls back to `0.0.0.0` where IPv6 is unavailable |
1155
- | `JSKELET_SECRET` | `jskelet/cookies` | — | The signed cookie secret. Read when `security.cookieSecret` is not set; if neither exists, the signed cookie API throws. [12](./12-dashboards-and-sessions.md) |
1156
- | `DEV_GATE` | `devGate` | off | `1` turns the gate on, `0` turns it off even when config enabled it. `DEV_TOKEN` alone does not turn it on. [09](./09-dev-tools.md) |
1157
- | `DEV_TOKEN` | `devGate`, `prewarm` | — | The secret expected while the gate is on. If it is missing, or the gate is off, the site stays public. Prewarming carries the token as a cookie only while the gate is on. [09](./09-dev-tools.md) |
1158
- | `JSKELET_ADMIN` | `createApp` | — | When set, turns the admin panel on; `0` turns off a panel enabled in the config. The env wins because the panel is usually opened once during an incident. [06](./06-caching.md) |
1159
- | `JSKELET_LOG_BUCKET` | `logs.s3` | — | Log target: bucket or `bucket/prefix` path. With credentials, the sink turns on automatically |
1160
- | `JSKELET_S3_BUCKET` | `logs.s3` | — | Bucket when `JSKELET_LOG_BUCKET` is unset; joins with `JSKELET_S3_KEY_PREFIX` |
1161
- | `JSKELET_S3_KEY_PREFIX` | `logs.s3` | — | Used with `JSKELET_S3_BUCKET` (`bucket/prefix`) |
1162
- | `JSKELET_S3_ACCESS_KEY_ID` | `logs.s3` | — | Signs PutObject |
1163
- | `JSKELET_S3_SECRET_ACCESS_KEY` | `logs.s3` | — | Signing secret (`JSKELET_S3_ACCESS_SECRET` is an alias) |
1164
- | `JSKELET_S3_SESSION_TOKEN` | `logs.s3` | — | Optional, for temporary credentials |
1165
- | `JSKELET_S3_REGION` | `logs.s3` | `auto` | Defaults to `auto` when unset |
1166
- | `JSKELET_S3_API_URL` | `logs.s3` | — | S3-compatible endpoint; overrides `logs.s3.endpoint` |
1167
- | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache surface | — | API token. Until it is set, CDN purging and edge analytics stay off; it overrides `apiToken` in the config. The token is never returned in a response. [06](./06-caching.md) |
1168
- | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
1169
- | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
1170
- | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
1171
- | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
1172
- | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Number of parallel workers |
1173
- | `PREWARM_RPS` | `prewarm` | `0` | At most how many prewarm requests per second; `0` is unlimited |
1174
- | `PREWARM_DELAY_MS` | `startPrewarm` | prod 500, dev 3000 | Delay of the first pass |
1175
- | `PREWARM_RETRY_DELAY_MS` | `prewarm` | `2000` | The wait before the retry pass |
1176
- | `PREWARM_INTERVAL_SECONDS` | `startPrewarm` | `0` | If greater than 0, a periodic pass |
1177
- | `JSKELET_VERBOSE` | `jskelet dev` | — | If `1`, all of the changed files are listed on restart |
1178
- | `JSKELET_COLOR` | `jskelet/log` | — | If `1`, colour is forced. Because child processes write to a pipe, colour detection turns off; `jskelet dev` sets this itself. |
1179
- | `JSKELET_CHILD` | `jskelet build` | — | Set by the dev script; suppresses the build banner and the "Ready" summary |
1180
- | `NO_COLOR` | `jskelet/log` | — | If set, colour is never used (it overrides `JSKELET_COLOR` too) |
1181
-
1182
- Your application's own variables (API origin, tokens) are not read by the
1183
- framework; use them directly via `process.env`. Declare the ones that need to
1184
- reach the browser with `clientEnv`.
1185
-
1186
- The numeric prewarm settings only accept **positive and finite** values; an
1187
- invalid value silently falls through to the next layer (config → code default).
1188
-
1189
- ## Programmatic access
1190
-
1191
- ```js
1192
- import { getConfig, loadConfig } from "jskelet";
1193
-
1194
- await loadConfig(); // reads from the project root
1195
- await loadConfig({ root: "/baska/proje" }); // a different root
1196
- await loadConfig({ configFile: "jskelet.test.mjs" });
1197
- await loadConfig({ force: true }); // bypass the cache and re-read
1198
-
1199
- const config = getConfig(); // the resolved config
1200
- ```
1201
-
1202
- `loadConfig()` hits the cache on a second call in the same process: `jskelet
1203
- start` calls it through both `ensure-build` and `createApp`, and there is no
1204
- benefit in reading and logging the config twice.
1205
-
1206
- If `getConfig()` is used without `loadConfig()` having been called, it
1207
- **throws**: a silently wrong path turns into problems that are hard to diagnose,
1208
- like "why is there no stylesheet".
1209
-
1210
- In the resolved config the directories are available as absolute paths under
1211
- `config.dirs` (`views`, `public`, `client`, `routes`, `styles`, `generated`,
1212
- `assets`, `fonts`), the patterns are in compiled form, and `config.loaded` tells
1213
- you whether the file was actually read.
1214
-
1215
- ## What's next
1216
-
1217
- - The effect of the build-side fields: [08-build.md](./08-build.md)
1218
- - The dev flow and `DEV_TOKEN`: [09-dev-tools.md](./09-dev-tools.md)
1219
- - Using environment variables in deployment: [10-deployment.md](./10-deployment.md)
1
+ # 07 — Configuration reference
2
+
3
+ This document is the complete reference for `jskelet.config.mjs`: every field,
4
+ its type, its default and an example. After that come the `source` pattern
5
+ syntax and a table of every environment variable the framework reads. Links to
6
+ the relevant documents are given for behavioural details of the fields; the goal
7
+ here is to present the full list at a glance.
8
+
9
+ ## Where the file lives and how it is loaded
10
+
11
+ The config file is looked up in the project root under the name
12
+ `jskelet.config.mjs` and it is **not required**. If it is missing or cannot be
13
+ read, a warning is printed and the server comes up with defaults; a broken edit
14
+ should not make the site impossible to open.
15
+
16
+ ```js
17
+ // jskelet.config.mjs
18
+ export default {
19
+ // …
20
+ };
21
+ ```
22
+
23
+ If there is no default export, the module itself is used as the config (named
24
+ exports).
25
+
26
+ The `headers()`, `redirects()`, `rewrites()` and `cache()` sections may be a
27
+ function **or a plain value**; when they are functions they may be `async`, and
28
+ `this` is bound to the config object. If a section throws, only that section is
29
+ ignored.
30
+
31
+ When the config loads successfully, a summary is printed:
32
+ `[config] jskelet.config.mjs loaded — 3 headers, 2 redirects, 1 cache rule`
33
+
34
+ ## Full example
35
+
36
+ ```js
37
+ // jskelet.config.mjs
38
+ export default {
39
+ paths: {
40
+ views: "views",
41
+ public: "public",
42
+ client: "client",
43
+ routes: "routes",
44
+ styles: "styles/globals.css",
45
+ generated: ".jskelet",
46
+ },
47
+
48
+ brand: {
49
+ name: "Example",
50
+ poweredBy: "Example",
51
+ cacheHeader: "X-Example-Cache",
52
+ devBasePath: "/__example/dev",
53
+ prewarmUserAgent: "example-prewarm",
54
+ devTokenCookie: "dev_token",
55
+ lang: "tr",
56
+ },
57
+
58
+ layout: "views/layout.jsk",
59
+ routes: ["./routes/10-pages.mjs", "./routes/99-catch-all.mjs"],
60
+ trailingSlash: false,
61
+
62
+ static: {
63
+ extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2"],
64
+ prefixes: ["/assets/", "/fonts/"],
65
+ },
66
+
67
+ devGateBypass: ["/api/healthcheck", "/robots.txt"],
68
+ preconnect: ["https://cdn.example.com"],
69
+
70
+ security: {
71
+ trustProxy: true,
72
+ cookieSecret: process.env.JSKELET_SECRET,
73
+ csrf: {
74
+ enabled: true,
75
+ token: false,
76
+ allowedOrigins: [],
77
+ exclude: ["/webhook/:path*"],
78
+ cookieName: "csrf_token",
79
+ fieldName: "_csrf",
80
+ headerName: "x-csrf-token",
81
+ },
82
+ },
83
+
84
+ navigation: {
85
+ prefetch: "moderate",
86
+ prerender: "conservative",
87
+ viewTransition: true,
88
+ exclude: ["/logout"],
89
+ },
90
+
91
+ prewarmSkip: ["/api/", "/_fragment/", "/__example/"],
92
+ watch: ["data"],
93
+
94
+ fonts: [{ family: "Inter", weights: [400, 600, 700] }],
95
+ icons: { dir: "icons", scan: ["views", "client", "routes", "lib"] },
96
+ images: { widths: [400, 800, 1200], quality: 78, skip: ["downloads"] },
97
+ clientEnv: ["PUBLIC_WS_URL"],
98
+
99
+ async headers() {
100
+ return [
101
+ {
102
+ source: "/:path*",
103
+ headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }],
104
+ },
105
+ ];
106
+ },
107
+
108
+ async redirects() {
109
+ return [{ source: "/eski/:slug", destination: "/yeni/:slug", permanent: true }];
110
+ },
111
+
112
+ async rewrites() {
113
+ return {
114
+ afterFiles: [
115
+ { source: "/api/:path*", destination: "https://api.example.com/:path*" },
116
+ ],
117
+ };
118
+ },
119
+
120
+ async cache() {
121
+ return {
122
+ html: { "/": 60, "/news/:slug": 300 },
123
+ staleWhileRevalidate: 60,
124
+ query: { "/search": ["q", "page"] },
125
+ maxEntries: 500,
126
+ data: { maxEntries: 10000, staleFactor: 10 },
127
+ prewarm: {
128
+ enabled: true,
129
+ max: 400,
130
+ concurrency: 4,
131
+ rps: 0,
132
+ intervalSeconds: 0,
133
+ rotate: true,
134
+ priority: ["/", "/news/:slug"],
135
+ },
136
+ };
137
+ },
138
+
139
+ hooks: {
140
+ metadata() { /* … */ },
141
+ layoutContext() { /* … */ },
142
+ notFound() { /* … */ },
143
+ error() { /* … */ },
144
+ prewarmPaths() { /* … */ },
145
+ },
146
+ };
147
+ ```
148
+
149
+ ## `paths`
150
+
151
+ **Type:** `Record<string, string>` — **Default:** the table below
152
+
153
+ Names of the directories (and, for `styles`, the file) in the project root.
154
+ Values are resolved relative to the project root and turned into absolute paths
155
+ internally.
156
+
157
+ | Key | Default | Contents |
158
+ | --- | --- | --- |
159
+ | `views` | `"views"` | Layout, pages, components (classic root; `.jsk` / `.ejs`) |
160
+ | `features` | `"features"` | Feature-first slices (`<name>/{server,views,client}`) |
161
+ | `shared` | `"shared"` | Cross-feature server/views/client |
162
+ | `public` | `"public"` | Static files; build output lands here too |
163
+ | `client` | `"client"` | Island runtime sources and entries |
164
+ | `routes` | `"routes"` | Route modules |
165
+ | `styles` | `"styles/globals.css"` | Tailwind/PostCSS entry **file** |
166
+ | `generated` | `".jskelet"` | `manifest.json`, `templates/`, `metafile.json`, `images.json` |
167
+
168
+ Even though `styles` is a file path it goes through the same resolution; keeping
169
+ a separate field for it is not worth it.
170
+
171
+ Two paths are always derived and cannot be overridden: `public/assets` (hashed
172
+ build output) and `public/fonts` (self-hosted fonts).
173
+
174
+ ```js
175
+ paths: { views: "src/views", routes: "src/routes", styles: "src/styles/main.css" }
176
+ ```
177
+
178
+ ## `brand`
179
+
180
+ **Type:** `object` — **Default:** the table below
181
+
182
+ Branding and names that can be changed from a single place. Projects that fork
183
+ the framework or white-label it can put their own name in. Provided fields are
184
+ shallow-merged with the defaults.
185
+
186
+ | Field | Type | Default | Meaning |
187
+ | --- | --- | --- | --- |
188
+ | `name` | `string` | `"JSkelet"` | Display name |
189
+ | `poweredBy` | `string` | `"JSkelet"` | Value of the `X-Powered-By` header |
190
+ | `cacheHeader` | `string` | `"X-JSkelet-Cache"` | HTML cache status header ([06-caching.md](./06-caching.md)) |
191
+ | `devBasePath` | `string` | `"/__jskelet/dev"` | Root of the dev overlay and report endpoints |
192
+ | `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | UA of prewarm requests; the dev panel filters on it |
193
+ | `devTokenCookie` | `string` | `"dev_token"` | Name of the dev gate's cookie and query parameter |
194
+ | `lang` | `string` | — | Default for `<html lang>`. If not given, the layout uses `"en"`. |
195
+ | `sharedCookieRoots` | `string[]` | `[]` | Shared cookie Domain roots (e.g. `.investvio.com`, `.localhost`). [12](./12-dashboards-and-sessions.md) |
196
+
197
+ Precedence for `lang`: `hooks.layoutContext()` → `lang` **>** `brand.lang`
198
+ **>** `"en"`.
199
+
200
+ ```js
201
+ brand: {
202
+ lang: "tr",
203
+ poweredBy: "Example",
204
+ sharedCookieRoots: [".investvio.com", ".localhost"],
205
+ }
206
+ ```
207
+
208
+ ## `auth`
209
+
210
+ **Type:** `object` — **Default:** `{ crossSubdomainHandoff: false }`
211
+
212
+ The framework does not provide identity; this section only opens the
213
+ cross-subdomain handoff bridge for a short session id.
214
+
215
+ | Field | Type | Default | Meaning |
216
+ | --- | --- | --- | --- |
217
+ | `crossSubdomainHandoff` | `boolean \| object` | `false` | When on: `POST /_jskelet/auth/handoff` + `?handoff=` redeem. Object: `allowedCookieNames` (required), `ttlSeconds?`, `path?`, `maxValueBytes?`, `maxPendingTickets?`, `maxMintsPerIpPerMinute?` |
218
+
219
+ ```js
220
+ auth: {
221
+ crossSubdomainHandoff: {
222
+ allowedCookieNames: ["sid"],
223
+ ttlSeconds: 60,
224
+ },
225
+ },
226
+ ```
227
+
228
+ The mint endpoint is mounted **after** the CSRF middleware (origin checks).
229
+ Cookie names outside the allowlist or that are not RFC 6265 tokens get 400.
230
+ Details: [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
231
+
232
+ ## `layout`
233
+
234
+ **Type:** `string` — **Default:** none (automatic resolution)
235
+
236
+ Path of the layout file (`.jsk` or legacy `.ejs`). The value given is resolved
237
+ relative to the **parent directory of the views directory**, so with the default
238
+ `views`, `"views/custom.jsk"` → `<root>/views/custom.jsk`.
239
+
240
+ If not given, in order: `views/layout.jsk`, `views/layout.ejs` (legacy),
241
+ otherwise the framework's `src/templates/layout.jsk` default. Details:
242
+ [04-rendering.md](./04-rendering.md).
243
+
244
+ ## `routes`
245
+
246
+ **Type:** `string[]` — **Default:** `null` (directory scan)
247
+
248
+ Explicit list of route modules, relative to the project root. They are loaded in
249
+ the given order. If not given, the `paths.routes` directory is scanned
250
+ alphabetically and recursively. Details: [03-routing.md](./03-routing.md).
251
+
252
+ ```js
253
+ routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"]
254
+ ```
255
+
256
+ ## `trailingSlash`
257
+
258
+ **Type:** `boolean` — **Default:** `false`
259
+
260
+ When `true`, canonical URLs end with `/`: `/about/` returns **200** directly;
261
+ bare `/about` is sent to `/about/` with a **308** (not 301 — a permanent
262
+ redirect that preserves the method, same as the framework's other `permanent`
263
+ redirects). The query string is kept.
264
+
265
+ Exceptions: the root `/`, paths with a file extension (`/robots.txt`,
266
+ `/assets/app.js`) and `/.well-known/**`. Those do not get a slash appended.
267
+
268
+ When `false` (the default) no slash is enforced. Express non-strict matching may
269
+ serve both `/x` and `/x/` as 200 — a deliberate difference from Next.js's
270
+ default "strip the slash" behaviour, so existing sites are not broken.
271
+
272
+ With the option on, write `href`s, sitemap entries and `redirects()` destinations
273
+ with a trailing slash too; otherwise every click pays an extra 308.
274
+
275
+ ```js
276
+ trailingSlash: true
277
+ ```
278
+
279
+ ## `static`
280
+
281
+ **Type:** `{ extensions?: string[], prefixes?: string[] }` — **Default:**
282
+ below
283
+
284
+ Static file detection by extension and prefix. Paths matching this list get
285
+ `Cache-Control: public, max-age=31536000, immutable`.
286
+
287
+ | Field | Default |
288
+ | --- | --- |
289
+ | `extensions` | `[".svg", ".png", ".webp", ".avif", ".ico", ".woff2"]` |
290
+ | `prefixes` | `["/assets/", "/fonts/"]` |
291
+
292
+ If provided, it **replaces** the default (it is not merged), so if you want to
293
+ add to the default, write out the full list.
294
+
295
+ ```js
296
+ static: {
297
+ extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2", ".mp4"],
298
+ prefixes: ["/assets/", "/fonts/", "/video/"],
299
+ }
300
+ ```
301
+
302
+ ## `devGate`
303
+
304
+ **Type:** `boolean` — **Default:** `false`
305
+
306
+ Hides an environment that is not public yet. **`DEV_TOKEN` alone does not lock
307
+ the site.** A shared task definition can carry the same variable into
308
+ production; visitors are not required to present a token, and the site stays
309
+ open.
310
+
311
+ Turn the gate on with `devGate: true` or `DEV_GATE=1`. Then a request without
312
+ the token gets a 404. `DEV_GATE=0` also turns off a gate the config enabled.
313
+ If the token is empty, requests still pass even when the gate is on.
314
+
315
+ Details: [09-dev-tools.md](./09-dev-tools.md).
316
+
317
+ ## `devGateBypass`
318
+
319
+ **Type:** `string[]` — **Default:**
320
+ `["/api/healthcheck", "/robots.txt", "/sitemap.xml", "/site.webmanifest", "/favicon.ico"]`
321
+
322
+ **Exact** paths the dev gate never closes off under any circumstances (not a
323
+ prefix, an exact match). This is so that the health check and the robots files
324
+ stay reachable while the gate is on. If provided, it replaces the default.
325
+
326
+ Details: [09-dev-tools.md](./09-dev-tools.md).
327
+
328
+ ## `preconnect`
329
+
330
+ **Type:** `string[]` — **Default:** `[]`
331
+
332
+ Third-party origins; printed as `<link rel="preconnect">` in the `<head>` of
333
+ every page. The image CDN, the API origin, the font host go here. Values are
334
+ normalised with `new URL(...).origin`; an invalid URL is skipped and a warning
335
+ is printed.
336
+
337
+ Since the list is the same on every page, it is computed once and stored. An
338
+ empty list is a valid configuration.
339
+
340
+ ```js
341
+ preconnect: ["https://cdn.example.com", "https://api.example.com"]
342
+ ```
343
+
344
+ ## `security`
345
+
346
+ **Type:** `object` — **Default:**
347
+ `{ trustProxy: true, cookieSecret: null, csrf: { enabled: true, token: false, … } }`
348
+
349
+ The whole picture for per-visitor pages, with the reasoning, is in
350
+ [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md); this is the
351
+ field reference.
352
+
353
+ | Field | Type | Default | Meaning |
354
+ | --- | --- | --- | --- |
355
+ | `trustProxy` | `boolean` | `true` | Express's `trust proxy` setting. Needed behind a reverse proxy for the correct protocol and client IP. |
356
+ | `cookieSecret` | `string \| null` | `null` | The signed cookie secret. When absent, `JSKELET_SECRET` is read. |
357
+ | `csrf.enabled` | `boolean` | `true` | The origin / `Sec-Fetch-Site` check. |
358
+ | `csrf.token` | `boolean` | `false` | The double-submit token layer. **Turn on** for cookie-session forms. |
359
+ | `csrf.allowedOrigins` | `string[]` | `[]` | Origins accepted alongside our own host. |
360
+ | `csrf.exclude` | `string[]` | `[]` | Paths exempt from the check; `source` pattern syntax. |
361
+ | `csrf.cookieName` | `string` | `"csrf_token"` | Name of the token cookie. |
362
+ | `csrf.fieldName` | `string` | `"_csrf"` | Field name printed by `csrfField()`. |
363
+ | `csrf.headerName` | `string` | `"x-csrf-token"` | Header the token is also accepted in. |
364
+
365
+ `trustProxy` should be **turned off** on a server exposed directly to the
366
+ internet: while it is on, a client can forge `X-Forwarded-For` /
367
+ `X-Forwarded-Proto` / Host, and rate limits, admin IP allowlists, Secure
368
+ cookies, and cache `vary.host` see the wrong address. Behind a reverse proxy
369
+ (nginx, Caddy, Cloudflare), `true` is the right default.
370
+
371
+ The CSRF check only rejects requests that are **known** to be cross-site — when
372
+ `Origin` does not match or `Sec-Fetch-Site: cross-site` arrives. If neither is
373
+ present the request passes, because browsers always send `Origin` on a
374
+ cross-origin POST while webhooks never do. For cookie-session dashboards,
375
+ enable `csrf.token: true` and `csrfField()` as a second layer; put webhook
376
+ paths in `csrf.exclude`.
377
+
378
+ ## `navigation`
379
+
380
+ **Type:** `object` — **Default:**
381
+ `{ prefetch: "moderate", prerender: false, viewTransition: false, exclude: [] }`
382
+
383
+ `<head>` hints that speed up in-site navigation. Since JSkelet is a classic MPA,
384
+ every click is a full page load; this section makes the browser do that load
385
+ **ahead of time**. No client runtime is added — Speculation Rules and view
386
+ transitions are browser capabilities, and in a browser that does not support
387
+ them they are silently ignored.
388
+
389
+ | Field | Type | Default | Meaning |
390
+ | --- | --- | --- | --- |
391
+ | `prefetch` | `false \| "conservative" \| "moderate" \| "eager"` | `"moderate"` | Downloads the link target's **document** ahead of time |
392
+ | `prerender` | same | `false` | **Fully renders** the target in the background; it opens the moment you click |
393
+ | `viewTransition` | `boolean` | `false` | Emits `@view-transition { navigation: auto }` |
394
+ | `exclude` | `string[]` | `[]` | href patterns to keep out of speculation |
395
+
396
+ If `true` is given, `prefetch`/`prerender` fall back to the default eagerness; an
397
+ unrecognised value prints a warning and reverts to the default.
398
+
399
+ **What eagerness means:** `conservative` triggers the moment the link is pressed,
400
+ `moderate` when the pointer lingers on the link for a while, `eager` as soon as
401
+ the link becomes visible. The further up you go, the higher the hit rate — and
402
+ the more wasted requests.
403
+
404
+ **Why `prerender` ships off.** The scripts of a prerendered page really do run.
405
+ In an application that does not hook its measurement code to the
406
+ `prerenderingchange` event, visit counts get inflated. Review your analytics
407
+ before turning it on; the cost on the server side is low, because a speculative
408
+ request is also served from the HTML cache
409
+ ([06-caching.md](./06-caching.md)).
410
+
411
+ **Always exempt.** Paths under `/api/*`, `/_fragment/*` and `brand.devBasePath`
412
+ are excluded automatically; `exclude` is added on top of those. Additionally,
413
+ links carrying `rel="nofollow"`, `target="_blank"` or `data-no-prefetch` are not
414
+ covered by any rule. The easiest way to keep a single link with side effects out
415
+ is the last one:
416
+
417
+ ```html
418
+ <a href="/logout" data-no-prefetch>Logout</a>
419
+ ```
420
+
421
+ **When turning on `viewTransition`, put the background on `<html>`.** During the
422
+ transition the browser cross-fades snapshots of the old and the new page; a
423
+ background set on `<body>` stays inside that snapshot and the canvas underneath
424
+ becomes visible. The result is one frame of white flash on every transition, and
425
+ it does not go unnoticed in a dark theme. If the colour is on `<html>` (or
426
+ `:root`), no such gap appears:
427
+
428
+ ```html
429
+ <html lang="tr" class="bg-white dark:bg-slate-950">
430
+ <body class="text-slate-900 dark:text-slate-100">
431
+ ```
432
+
433
+ The reduced-motion preference is handled by the framework: under
434
+ `prefers-reduced-motion: reduce` the transition is disabled, and you do not need
435
+ to write anything extra.
436
+
437
+ **Scope the transition to the content.** The default behaviour cross-fades the
438
+ whole document as a single piece, which means the header and footer — which
439
+ never change across navigations — flicker too. Giving those regions a
440
+ `view-transition-name` puts them in their own group; because the browser sees the
441
+ same name in both documents, it treats them as "the same element". Once you turn
442
+ off the animation of the named element, the transition stays in the content
443
+ only:
444
+
445
+ ```css
446
+ body > header { view-transition-name: site-header; }
447
+ body > footer { view-transition-name: site-footer; }
448
+
449
+ ::view-transition-old(site-header),
450
+ ::view-transition-old(site-footer) { animation: none; opacity: 0; }
451
+ ::view-transition-new(site-header),
452
+ ::view-transition-new(site-footer) { animation: none; opacity: 1; }
453
+
454
+ /* The remaining content; the default 250ms makes navigation feel slow. */
455
+ ::view-transition-old(root),
456
+ ::view-transition-new(root) { animation-duration: 180ms; }
457
+ ```
458
+
459
+ Copy the Tailwind `@source` directives and view-transition CSS into your own
460
+ app's `styles/globals.css`; the blocks above are a starting point.
461
+
462
+ **If you use CSP**, the rules are emitted as an inline
463
+ `<script type="speculationrules">`; your `script-src` policy needs to allow it.
464
+
465
+ ```js
466
+ navigation: {
467
+ prefetch: "moderate",
468
+ prerender: "conservative",
469
+ viewTransition: true,
470
+ exclude: ["/logout", "/cart/*"],
471
+ }
472
+ ```
473
+
474
+ ## `prewarmSkip`
475
+
476
+ **Type:** `string[]` — **Default:** `["/api/", "/_fragment/", "/__jskelet/"]`
477
+
478
+ Path **prefixes** that prewarming skips. Session-dependent or fragment endpoints
479
+ should not be prewarmed. If provided, it replaces the default — if you changed
480
+ `brand.devBasePath`, do not forget to update this list too.
481
+
482
+ Details: [06-caching.md](./06-caching.md).
483
+
484
+ ## `watch`
485
+
486
+ **Type:** `string[]` — **Default:** `[]`
487
+
488
+ **Additional** directories that `jskelet dev` watches for server restarts,
489
+ relative to the project root. `routes`, `views` and `lib` are already watched;
490
+ `client/` and `styles/` are handled by the esbuild and CSS watchers and should
491
+ not be put here.
492
+
493
+ Only files with the `.js`, `.mjs`, `.json` and `.ejs` extensions are triggers.
494
+
495
+ ```js
496
+ watch: ["data", "content"]
497
+ ```
498
+
499
+ Details: [09-dev-tools.md](./09-dev-tools.md).
500
+
501
+ ## `fonts`
502
+
503
+ **Type:** `{ family: string, slug?: string, weights?: number[] }[]` —
504
+ **Default:** `[]`
505
+
506
+ Google Fonts families to self-host. If left empty, the font step never runs.
507
+
508
+ | Field | Type | Default | Meaning |
509
+ | --- | --- | --- | --- |
510
+ | `family` | `string` | — | Google Fonts family name: `"Inter"`, `"Noto Sans"` |
511
+ | `slug` | `string` | derived from `family` (lower case, space → `-`) | File name prefix |
512
+ | `weights` | `number[]` | `[400]` | Weights to download |
513
+
514
+ Output: `public/fonts/<slug>-<weight>.woff2`, with the same file name as the
515
+ manifest key. The files have **fixed names** (no hash) and are **expected to be
516
+ committed**. Details: [08-build.md](./08-build.md).
517
+
518
+ ```js
519
+ fonts: [
520
+ { family: "Inter", weights: [400, 600, 700] },
521
+ { family: "Noto Serif", slug: "serif", weights: [400] },
522
+ ]
523
+ ```
524
+
525
+ ## `icons`
526
+
527
+ **Type:** `{ scan?: string[], dir?: string } | false` — **Default:** `{ dir: "icons" }`
528
+
529
+ SVG icon sprite generation. The source is chosen **XOR**: if the `icons.dir`
530
+ directory exists, only the flat SVGs there are used; otherwise
531
+ `@phosphor-icons/core` (when installed).
532
+
533
+ | Value | Result |
534
+ | --- | --- |
535
+ | `{}` (default) | `dir: "icons"`; scanned directories are `["views", "client", "routes", "lib", "features", "shared"]` |
536
+ | `{ dir: "assets/icons" }` | Changes the local SVG root |
537
+ | `{ scan: [...] }` | Changes the scanned directories |
538
+ | `false` | The sprite step is skipped entirely |
539
+
540
+ A local directory (when present) uses flat file names: `house.svg` →
541
+ `house:regular`, `house-bold.svg` → `house:bold`. An empty `icons/` directory
542
+ does not fall back to Phosphor — delete the directory to open the fallback.
543
+ Details: [08-build.md](./08-build.md).
544
+
545
+ ```js
546
+ icons: {
547
+ dir: "icons",
548
+ scan: ["views", "client", "routes", "lib", "content"],
549
+ }
550
+ ```
551
+
552
+ ## `images`
553
+
554
+ **Type:**
555
+ `{ widths?: number[], quality?: number, skip?: string[], remote?: { allowHosts: string[], path?: string, maxWidth?: number, cacheMaxAge?: number, fetchTimeoutMs?: number, maxBytes?: number } | false } | false`
556
+ — **Default:** `{ widths, quality, skip, remote: false }` (remote off)
557
+
558
+ Generates webp variants of png/jpg files under `public/` at **build** time.
559
+ When `remote.allowHosts` is set, also proxies remote images at runtime
560
+ (`/_jskelet/image?url=&w=&q=` → webp).
561
+
562
+ | Field | Type | Default | Meaning |
563
+ | --- | --- | --- | --- |
564
+ | `widths` | `number[]` | `[400, 640, 960, 1280, 1920]` | Candidates for build and remote `srcset`. Ones larger than the source are dropped at build; the source's own width (at most 1920) is always added. |
565
+ | `quality` | `number` | `78` | webp quality. Part of the build encoder signature; default `q` on the remote endpoint. |
566
+ | `skip` | `string[]` | `[]` | **Directory names** not to scan at build. `assets` and `fonts` are always skipped. |
567
+ | `remote` | `object \| false` | off | Runtime optimizer. `allowHosts` is **required**; empty disables the route. |
568
+
569
+ ### `images.remote`
570
+
571
+ | Field | Type | Default | Meaning |
572
+ | --- | --- | --- | --- |
573
+ | `allowHosts` | `string[]` | `[]` | Hosts that may be fetched. Supports a `*.cdn.example.com` suffix wildcard. |
574
+ | `path` | `string` | `/_jskelet/image` | Optimizer GET path. |
575
+ | `maxWidth` | `number` | `1920` | Cap for `w`. |
576
+ | `cacheMaxAge` | `number` | `2592000` (30 days) | Response `Cache-Control` max-age (seconds). Disk cache under `.jskelet/image-cache/`; past 256 MB the oldest file is deleted. |
577
+ | `fetchTimeoutMs` | `number` | `10000` | Upstream fetch timeout. |
578
+ | `maxBytes` | `number` | `10485760` (10 MiB) | Upstream body size limit. |
579
+
580
+ If `false` is given, neither surface runs. The build step requires `sharp` and
581
+ never runs on a watch pass. With remote enabled, `sharp` is also needed at
582
+ **runtime**; without it the optimizer 302-redirects to the source URL. Fetch
583
+ does not auto-follow redirects: every hop is re-checked against `allowHosts`
584
+ and private addresses. Details: [08-build.md](./08-build.md).
585
+
586
+ ```js
587
+ images: {
588
+ widths: [400, 800, 1200],
589
+ quality: 82,
590
+ skip: ["downloads"],
591
+ remote: {
592
+ allowHosts: ["static.example.com", "*.cdn.example.com"],
593
+ },
594
+ }
595
+ ```
596
+
597
+ `image({ src: "https://static.example.com/a.jpg", width: 96, alt: "…" })`
598
+ rewrites `src` / `srcset` to `/_jskelet/image?url=…&w=96`. To build URLs by
599
+ hand, use `remoteImageUrl(src, { width })` from `jskelet`.
600
+
601
+ ## `clientEnv`
602
+
603
+ **Type:** `string[]` — **Default:** `[]`
604
+
605
+ Environment variable keys to inline into the client bundle at build time. The
606
+ same contract as `NEXT_PUBLIC_*` in Next, except which key is public is decided
607
+ by the config rather than by the name. `NODE_ENV` is always inlined.
608
+
609
+ Because the whole of `process.env` is defined as a single object, reading a key
610
+ that is not in the list returns `undefined` instead of crashing.
611
+
612
+ ```js
613
+ clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN_ORIGIN"]
614
+ ```
615
+
616
+ **Do not put secrets here** — the values sit in the bundle in plain text. Keys
617
+ whose names look secret-like (`SECRET`, `PASSWORD`, `TOKEN`, `API_KEY`,
618
+ `PRIVATE`, …) are **rejected at build time** (`PUBLIC` / `PUBLISHABLE` names
619
+ are exempt).
620
+
621
+ ## `headers()`
622
+
623
+ **Type:** `() => { source: string, headers: { key: string, value: string }[] }[]`
624
+ — **Default:** `[]`
625
+
626
+ Response headers by path pattern. The framework only writes long-lived cache
627
+ headers for static files; every other header (CSP, COOP, HSTS,
628
+ X-Frame-Options…) comes from here and takes precedence over the defaults.
629
+ Production sites should at least define the security headers below.
630
+
631
+ **All** matching rules are applied (unlike redirects, it does not stop at the
632
+ first match), in order; if two rules write the same header, the later one wins.
633
+
634
+ Entries without a `key` or with an `undefined` `value` are skipped; a rule left
635
+ with no valid headers at all is not added.
636
+
637
+ ```js
638
+ async headers() {
639
+ return [
640
+ {
641
+ source: "/:path*",
642
+ headers: [
643
+ { key: "X-Frame-Options", value: "SAMEORIGIN" },
644
+ { key: "X-Content-Type-Options", value: "nosniff" },
645
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
646
+ {
647
+ key: "Permissions-Policy",
648
+ value: "camera=(), microphone=(), geolocation=()",
649
+ },
650
+ {
651
+ key: "Content-Security-Policy",
652
+ value: "default-src 'self'; img-src 'self' https://cdn.example.com data:; script-src 'self'",
653
+ },
654
+ // Only when you terminate HTTPS yourself:
655
+ // { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
656
+ ],
657
+ },
658
+ {
659
+ source: "/download/:path*",
660
+ headers: [{ key: "Cache-Control", value: "no-store" }],
661
+ },
662
+ ];
663
+ }
664
+ ```
665
+
666
+ ## `redirects()`
667
+
668
+ **Type:**
669
+ `() => { source: string, destination: string, permanent?: boolean, statusCode?: number }[]`
670
+ — **Default:** `[]`
671
+
672
+ | Field | Type | Meaning |
673
+ | --- | --- | --- |
674
+ | `source` | `string` | Pattern (syntax below) |
675
+ | `destination` | `string` | Target; `:param` placeholders are filled in |
676
+ | `permanent` | `boolean` | `true` → 308, otherwise 307 |
677
+ | `statusCode` | `number` | Explicit status code; overrides `permanent` |
678
+
679
+ The first matching rule wins and the query string is preserved. Details:
680
+ [03-routing.md](./03-routing.md).
681
+
682
+ ## `rewrites()`
683
+
684
+ **Type:** `() => Rule[] | { beforeFiles?: Rule[], afterFiles?: Rule[] }`
685
+ where `Rule = { source: string, destination: string }` — **Default:** `[]`
686
+
687
+ If an array is returned, all of it counts as `afterFiles`.
688
+
689
+ - `beforeFiles` runs even before static files.
690
+ - `afterFiles` runs after static has been tried, before the routes.
691
+ - Absolute target (`http://`/`https://`) → built-in reverse proxy.
692
+ - Relative target → only `req.url` changes.
693
+
694
+ Details: [03-routing.md](./03-routing.md).
695
+
696
+ ## `cache()`
697
+
698
+ **Type:**
699
+ `() => { html?: Record<string, number>, staleWhileRevalidate?: 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 }` —
700
+ **Default:**
701
+ `{ html: {}, staleWhileRevalidate: 60, 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: [] } }`
702
+
703
+ ### `cache().html`
704
+
705
+ A pattern → seconds mapping. A matching rule **overrides** the route's own
706
+ `revalidate` value. Negative or non-finite values are ignored; `0` means "no
707
+ caching". Before TTL ends the framework starts an early background refresh
708
+ based on the last render duration (no separate config field; see
709
+ [06-caching.md](./06-caching.md)).
710
+
711
+ The one exception is `route(fn, { private: true })`: on that route a matching
712
+ pattern is ignored. The lock is deliberately one-way — a mistake in the other
713
+ direction means one user's HTML is served to another.
714
+
715
+ ```js
716
+ html: {
717
+ "/": 60,
718
+ "/news/:slug": 300,
719
+ "/search": 0,
720
+ }
721
+ ```
722
+
723
+ ### `cache().staleWhileRevalidate`
724
+
725
+ **Type:** `number` — **Default:** `60`
726
+
727
+ How long the edge serves stale HTML after the fresh window (`cache().html` /
728
+ `revalidate`) ends, in seconds. Written as `stale-while-revalidate` on
729
+ `CDN-Cache-Control`. `0` omits the directive. It does not change the in-process
730
+ HTML cache's stale window. Details: [06-caching.md](./06-caching.md).
731
+
732
+ ### `cache().query`
733
+
734
+ A pattern → list of query parameters allowed into the cache key.
735
+
736
+ **By default a request that carries a query parameter is dynamic**: even when
737
+ `cache().html` covers that path, the response never enters the HTML cache and
738
+ is sent with `private, no-store`. The reason is simple — caching every variant
739
+ of a path mints an unbounded number of keys (`?utm_source=…` and friends), and
740
+ once the `maxEntries` limit is reached those keys evict the real pages. Only the
741
+ application knows which parameter actually changes the output.
742
+
743
+ ```js
744
+ query: {
745
+ "/search": ["q", "page"], // only these two belong to the key
746
+ "/products": ["category"],
747
+ "/report/:id": true, // every parameter belongs to the key
748
+ "/campaign": [], // the query is ignored entirely
749
+ }
750
+ ```
751
+
752
+ - **Allowlist** (`string[]`): the listed parameters become part of the key and
753
+ each distinct value gets its own entry. Parameters outside the list are
754
+ **ignored** — the page is still cached and every campaign variant shares one
755
+ copy.
756
+ - **`true`**: every parameter belongs to the key. Nothing but `maxEntries`
757
+ bounds the number of entries, so use it only where the value set is closed.
758
+ - **`[]`**: the query is not considered at all; every variant is served the HTML
759
+ of the query-less version.
760
+
761
+ Parameters are written into the key **sorted**, so `?a=1&b=2` and `?b=2&a=1`
762
+ share one entry. `route(fn, { private: true })` is unaffected by this section; a
763
+ private route is never cached under any condition.
764
+
765
+ ### `cache().vary`
766
+
767
+ Adds fixed segments to the HTML cache key **independently** of the query
768
+ allowlist. On sites that derive locale from the host, `host: true` is
769
+ **required**; otherwise the first locale's HTML is served to the other host. A
770
+ CDN already separates by full URL — this setting is for the origin L1 and the
771
+ Redis HTML key.
772
+
773
+ ```js
774
+ vary: {
775
+ host: true, // h=tr.example.com|…
776
+ // headers: ["x-locale"],
777
+ // fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
778
+ }
779
+ ```
780
+
781
+ | Field | Type | Default | Meaning |
782
+ | --- | --- | --- | --- |
783
+ | `host` | `boolean` | `false` | Public Host (`x-forwarded-host` else `Host`), lowercase, no port → `h=…` |
784
+ | `headers` | `string[]` | `[]` | Request headers added as `name=value` |
785
+ | `fn` | `(req) => string \| null` | — | Return value appended as a segment |
786
+
787
+ Key shape: `${vary}|${path}?${query}` (no prefix when vary is empty). Details:
788
+ [06-caching.md](./06-caching.md).
789
+
790
+ ### `cache().maxEntries`
791
+
792
+ **Type:** `number` — **Default:** `500`
793
+
794
+ The entry limit of the HTML cache. Because an entry costs a hundred kilobytes,
795
+ raising this number burns through memory quickly; trying to solve a site with
796
+ tens of thousands of paths from here is the wrong layer — the right place is
797
+ `cache().data`.
798
+
799
+ **Ceiling 800.** A higher value is clamped to 800 with a warning at load.
800
+ In-process HTML plus compressed bodies also cannot exceed 256 MB; config
801
+ cannot raise that budget. Only one compressed copy is kept (brotli or gzip).
802
+
803
+ ### `cache().data`
804
+
805
+ The upstream data cache (`withDataCache`). Details:
806
+ [06-caching.md](./06-caching.md).
807
+
808
+ | Field | Type | Default | Meaning |
809
+ | --- | --- | --- | --- |
810
+ | `maxEntries` | `number` | `10000` | The LRU entry limit. The limit is high because JSON is tens of times smaller than HTML. **Ceiling 20,000**; a higher value is clamped with a warning. In-process JSON also cannot exceed **64 MB**; config cannot raise that budget. |
811
+ | `staleFactor` | `number` | `10` | For how many TTLs an entry stays usable after the TTL expired. `0` → no stale serving. |
812
+
813
+ ### `cache().trackUpstream`
814
+
815
+ **Type:** `boolean` — **Default:** `true`
816
+
817
+ When on, `globalThis.fetch` is wrapped and transient upstream failures (`429`,
818
+ `5xx`, network) during a render are reported automatically; calling
819
+ `reportUpstreamFailure()` is not required. An application that wraps `fetch`
820
+ itself can turn this off.
821
+
822
+ ### `cache().trackDependencies`
823
+
824
+ **Type:** `boolean` — **Default:** `true`
825
+
826
+ When on, the `withDataCache` keys a render reads are recorded, and
827
+ `clearDataCache()` also stales the HTML pages that read that data — targeted
828
+ invalidation without the application declaring anything
829
+ ([06-caching.md](./06-caching.md)). An application that does not use
830
+ `withDataCache` has nothing to record; turning this off also removes the cost of
831
+ setting up the context.
832
+
833
+ ### `cache().transientRetry`
834
+
835
+ **Type:** `{ attempts?: number, delayMs?: number } | false` —
836
+ **Default:** `{ attempts: 1, delayMs: 300 }`
837
+
838
+ How many extra times a page is tried when `notFound()` was called because of a
839
+ transient upstream failure. The point is that an existing page never turns into
840
+ a 404; if the retries are exhausted the response is an uncached 503. `false` or
841
+ `attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
842
+
843
+ ### `cache().upstream`
844
+
845
+ A per-host rate limit for the `fetch` calls that go to upstream APIs. Off by
846
+ default: unless `rate` is given, no request ever waits. `rate` is a ceiling; the
847
+ actual rate pulls itself down in response to 429s and climbs back step by step
848
+ during clean windows.
849
+
850
+ | Field | Type | Default | Meaning |
851
+ | --- | --- | --- | --- |
852
+ | `rate` | `number` | `0` | Maximum calls per second. `0` → brake disabled |
853
+ | `burst` | `number` | `0` | Bucket size; `0` → one second's budget of burst |
854
+ | `concurrency` | `number` | `8` | Calls allowed in flight at once |
855
+ | `minRate` | `number` | `0.5` | Floor of the decrease; the rate never goes below it |
856
+ | `increaseStep` | `number` | `1` | Step of the additive increase (calls/second) |
857
+ | `increaseIntervalMs` | `number` | `5000` | Increase period |
858
+ | `decreaseIntervalMs` | `number` | `1000` | Minimum time between two decreases |
859
+ | `breakerFailures` | `number` | `5` | Consecutive 429s after which the host is bypassed |
860
+ | `breakerCooldownMs` | `number` | `10000` | How long the bypass lasts |
861
+ | `hosts` | `Record<string, object>` | `{}` | Per-host overrides; same fields apply |
862
+
863
+ Only `429` and `503` penalise the rate: a `400`/`404`/`500` is not a quota
864
+ problem. Read the state with `getUpstreamLimiterStatus()` or from the dev
865
+ panel's **Server** tab. Details, and what to check before turning it on:
866
+ [06-caching.md](./06-caching.md).
867
+
868
+ ```js
869
+ upstream: {
870
+ rate: 10,
871
+ concurrency: 4,
872
+ hosts: { "api.example.com": { rate: 3 } },
873
+ }
874
+ ```
875
+
876
+ ### `cache().redis`
877
+
878
+ An optional Redis second tier (L2). The in-process cache stays primary; Redis
879
+ only skips the render for a path that is not in L1 and spreads invalidation to
880
+ the other instances. `ioredis` has to be installed in the application
881
+ (`npm install ioredis`); if it is missing or unreachable a warning is printed and
882
+ the site keeps running on the in-process cache.
883
+
884
+ | Field | Type | Default | Meaning |
885
+ | --- | --- | --- | --- |
886
+ | `enabled` | `boolean` | `false` | Only turns on when `true` is passed explicitly |
887
+ | `url` | `string \| null` | `null` | `redis://` or `rediss://`. When empty, the ioredis default (`localhost:6379`) |
888
+ | `namespace` | `string` | `"default"` | Separates applications sharing one Redis |
889
+ | `keyPrefix` | `string` | `"_jskelet"` | Root of the key layout |
890
+ | `html` | `boolean` | `true` | Whether HTML bodies are shared |
891
+ | `data` | `boolean` | `true` | Whether `withDataCache` entries are shared |
892
+ | `storeEncoded` | `boolean` | `false` | Whether brotli/gzip bodies are shared too; doubles or triples the size per entry |
893
+ | `events` | `boolean` | `true` | Invalidation broadcast over pub/sub |
894
+ | `commandTimeoutMs` | `number` | `200` | At most how long a single command may block |
895
+
896
+ Keys live as `_jskelet:{namespace}:{buildId}:html:{path}?{query}`. `buildId`
897
+ changes with every build, so old HTML becomes invalid on its own after a deploy.
898
+ Personalised (`storable: false`), `degraded` and non-200 responses are never
899
+ written to the shared tier. Trade-offs and diagnosis:
900
+ [06-caching.md](./06-caching.md).
901
+
902
+ ```js
903
+ redis: {
904
+ enabled: process.env.NODE_ENV === "production",
905
+ url: process.env.REDIS_URL,
906
+ namespace: "news-site",
907
+ }
908
+ ```
909
+
910
+ ### `logs`
911
+
912
+ Persistent log sinks. Everything is off by default: stdout and the admin panel
913
+ ring keep their current behaviour. When enabled, HTTP access logs and framework
914
+ events (`event` / `error`) go out as NDJSON to a file, `drainLog`, and/or S3.
915
+ File chunks are zstd and stay at most 5 minutes; the oldest expired chunk is
916
+ deleted.
917
+
918
+ | Field | Type | Default | Meaning |
919
+ | --- | --- | --- | --- |
920
+ | `console` | `boolean` | `true` | Whether runtime `http` / `event` / `error` lines go to stdout (banner/build lines are unaffected) |
921
+ | `kinds` | `("http" \| "event" \| "error")[]` | all | Which kinds reach the sinks |
922
+ | `file.enabled` | `boolean` | `false` | File spool. Lines become `jskelet-<time>-<n>.ndjson.zst` about every 1s or 32 lines. Kept at most 5 minutes; the oldest chunk is deleted. |
923
+ | `file.dir` | `string` | `"logs"` | Directory relative to the project root |
924
+ | `drainLog` | `(chunk) => void \| Promise<void>` | `null` | Forwards each sealed zstd chunk (`{ body, encoding, bytes, lines, at }`) wherever the app wants. A throw warns and does not take the site down. With the file sink off, nothing is written to disk. |
925
+ | `s3.enabled` | `boolean` | `false` | S3 batch PutObject sink |
926
+ | `s3.bucket` | `string \| null` | `null` | Bucket or a `bucket/prefix/…` path; `JSKELET_LOG_BUCKET` overrides |
927
+ | `s3.prefix` | `string` | `"jskelet/logs/"` | Object key prefix (when not given in the path) |
928
+ | `s3.region` | `string \| null` | `"auto"` | Region; falls back to `JSKELET_S3_REGION`, otherwise `auto` |
929
+ | `s3.endpoint` | `string \| null` | `null` | S3-compatible API URL; `JSKELET_S3_API_URL` overrides |
930
+ | `s3.flushIntervalMs` | `number` | `5000` | Batch flush interval |
931
+ | `s3.maxBatch` | `number` | `100` | Flush early after this many lines |
932
+
933
+ S3 credentials are not written in the config: `JSKELET_S3_ACCESS_KEY_ID`,
934
+ `JSKELET_S3_SECRET_ACCESS_KEY`, optional `JSKELET_S3_SESSION_TOKEN`. Missing
935
+ bucket/region/credentials warn and disable the S3 sink; the site still starts.
936
+ The framework does not ship `@aws-sdk` — PutObject is embedded with SigV4.
937
+
938
+ ```js
939
+ logs: {
940
+ console: true,
941
+ kinds: ["http", "error"],
942
+ file: { enabled: true, dir: "logs" },
943
+ async drainLog(chunk) {
944
+ // chunk.body is zstd NDJSON. The file is deleted after 5 minutes; keep a copy here.
945
+ },
946
+ s3: {
947
+ enabled: process.env.NODE_ENV === "production",
948
+ bucket: process.env.JSKELET_LOG_BUCKET,
949
+ prefix: "my-app/logs/",
950
+ region: process.env.JSKELET_S3_REGION,
951
+ endpoint: process.env.JSKELET_S3_API_URL,
952
+ },
953
+ }
954
+ ```
955
+
956
+ ### `admin()`
957
+
958
+ The framework admin panel (`/_jskelet/admin`). It manages the in-process /
959
+ Redis / Cloudflare caches and exposes route and view inventories plus a live
960
+ log queue.
961
+
962
+ It does not look at the environment: without `enabled` **nothing is mounted**
963
+ and the path does not exist. When it is on it also works in production — that is
964
+ where the real questions ("why is this page stale", "did the webhook purge
965
+ land") get asked. It is separate from the `cache()` section.
966
+
967
+ | Field | Type | Default | Meaning |
968
+ | --- | --- | --- | --- |
969
+ | `enabled` | `boolean` | `false` | Only turns on when explicitly `true` (`JSKELET_ADMIN` overrides it) |
970
+ | `basePath` | `string` | `"/_jskelet/admin"` | Root of the panel |
971
+ | `allowIps` | `string[]` | `[]` | Exact IP or CIDR; empty = no restriction. Anyone outside gets 404 |
972
+ | `blockBots` | `boolean` | `true` | Known crawler UAs get 404 |
973
+ | `banAttempts` | `number` | `3` | How many failed attempts ban an IP |
974
+ | `banHours` | `number` | `24` | How long the ban lasts |
975
+ | `sessionHours` | `number` | `12` | Lifetime of the session cookie |
976
+ | `logSize` | `number` | `500` | Live log ring size |
977
+
978
+ The password is generated **on every process start** and only appears in the
979
+ server log `ADMIN` box. Banned and unauthorised requests all get a `404`.
980
+ Usage and screens: [06-caching.md](./06-caching.md).
981
+
982
+ ```js
983
+ admin() {
984
+ return {
985
+ enabled: process.env.JSKELET_ADMIN === "1",
986
+ allowIps: ["203.0.113.10", "10.0.0.0/8"],
987
+ };
988
+ }
989
+ ```
990
+
991
+ ### `cache().cloudflare`
992
+
993
+ The CDN tier. JSkelet's cache is the origin cache; the copy your visitors get
994
+ sits at the edge. With this section connected, the panel can purge the edge,
995
+ read and change cache related zone settings and show the cache hit ratio.
996
+
997
+ | Field | Type | Default | Meaning |
998
+ | --- | --- | --- | --- |
999
+ | `enabled` | `boolean` | `true` | Set `false` to keep the surface off even when a token is present in the environment |
1000
+ | `zoneId` | `string \| null` | `null` | Zone identifier (`JSKELET_CLOUDFLARE_ZONE_ID` overrides it) |
1001
+ | `apiToken` | `string \| null` | `null` | The token; **prefer the environment**, putting it here puts a secret in the repo |
1002
+ | `hostname` | `string \| null` | `null` | Purging wants absolute URLs; paths are resolved against this name. Falls back to the origin the panel was opened on |
1003
+ | `analyticsHours` | `number` | `24` | Analytics window, at most `72` |
1004
+
1005
+ Passing the token only through `JSKELET_CLOUDFLARE_KEY` keeps the config file
1006
+ clean. Permissions follow what you intend to do: `Zone.Cache Purge` to purge,
1007
+ `Zone.Zone Settings` for settings, `Zone.Analytics` (read) for the hit ratio.
1008
+ The token is never returned in a panel response — only the fact that it came
1009
+ from the environment.
1010
+
1011
+ With no zone connected the panel shows a setup snippet rather than a warning,
1012
+ and if Cloudflare returns an error that section reports it while the rest of the
1013
+ panel keeps working. What can actually be asked — in particular why "how many
1014
+ edges hold this page" has no exact answer — is in
1015
+ [06-caching.md](./06-caching.md).
1016
+
1017
+ ### `cache().prewarm`
1018
+
1019
+ Two modes: **classic** (list + startup pass) or **`onVisit`** (links from the
1020
+ page just visited). They cannot be combined — config load throws.
1021
+
1022
+ #### Classic fields
1023
+
1024
+ | Field | Type | Default | Meaning |
1025
+ | --- | --- | --- | --- |
1026
+ | `enabled` | `boolean` | `true` | If `false`, no prewarming happens (can be overridden with `PREWARM=1`) |
1027
+ | `max` | `number` | `400` | At most how many paths are prewarmed per pass |
1028
+ | `concurrency` | `number` | prod 4, dev 1 | Number of parallel workers |
1029
+ | `rps` | `number` | prod `0`, dev 4 | At most how many prewarm requests per second; `0` is unlimited. This is the setting that protects the upstream quota. The default brake in dev keeps prewarming from holding page requests up. |
1030
+ | `delayMs` | `number` | prod 500, dev 3000 | Delay of the first pass after startup |
1031
+ | `retryDelayMs` | `number` | `2000` | How long to wait before the retry pass |
1032
+ | `intervalSeconds` | `number` | `0` | If greater than 0, the pass repeats periodically |
1033
+ | `rotate` | `boolean` | `true` | If the list is longer than `max`, periodic passes continue where they left off |
1034
+ | `priority` | `(string \| RegExp)[]` | `[]` | Warm-up order; matching paths are taken first on every pass |
1035
+ | `origins` | `string[]` | `[]` | Origins for the classic pass. Empty → `http://127.0.0.1:<port>`. With `vary.host`, list the locale hosts here |
1036
+
1037
+ `priority` accepts two forms: the pattern syntax used everywhere in the config,
1038
+ and a plain `RegExp`. Whatever is written first is warmed first.
1039
+
1040
+ ```js
1041
+ prewarm: {
1042
+ max: 500,
1043
+ rps: 4,
1044
+ intervalSeconds: 300,
1045
+ // with vary.host, loopback alone is not enough:
1046
+ origins: ["http://localhost", "http://tr.localhost"],
1047
+ priority: [
1048
+ "/", // the home page
1049
+ "/markets/:path*", // the whole markets section
1050
+ /-comments$/, // a rule the pattern syntax does not cover
1051
+ ],
1052
+ }
1053
+ ```
1054
+
1055
+ #### `onVisit`
1056
+
1057
+ | Field | Type | Default | Meaning |
1058
+ | --- | --- | --- | --- |
1059
+ | `onVisit` | `true \| false \| object` | off | Visit-driven warming |
1060
+ | `onVisit.perPage` | `number` | `20` | At most how many links per page (top to bottom). **Ceiling 20** |
1061
+ | `onVisit.concurrency` | `number` | `2` | Parallel workers. **Ceiling 2** |
1062
+ | `onVisit.rps` | `number` | `2` | Requests per second cap. **Ceiling 2**; `0` is clamped to 2 as well |
1063
+
1064
+ ```js
1065
+ prewarm: {
1066
+ onVisit: { perPage: 20, rps: 2 },
1067
+ }
1068
+ ```
1069
+
1070
+ `hooks.prewarmPaths` and classic fields (`max`, `priority`, …) are **forbidden**
1071
+ with `onVisit`. Details: [06-caching.md](./06-caching.md).
1072
+
1073
+ Each numeric field can be overridden by an environment variable of the same
1074
+ name; env takes precedence. Details: [06-caching.md](./06-caching.md).
1075
+
1076
+ ## `hooks`
1077
+
1078
+ **Type:** `Record<string, Function>` — **Default:** `{}`
1079
+
1080
+ All optional, all may be `async`. If a hook throws, the framework falls back to
1081
+ its own default and warns — the page does not go down.
1082
+
1083
+ | Hook | Signature | What it returns | Document |
1084
+ | --- | --- | --- | --- |
1085
+ | `metadata` | `(page) => object` | Metadata default for every page; the controller's `metadata` is layered on top | [04](./04-rendering.md) |
1086
+ | `layoutContext` | `({ pathname, metadata }) => object` | Layout locals; `lang`, `structuredData`, `extraHead` and `bodyClass` get special treatment | [04](./04-rendering.md) |
1087
+ | `notFound` | `() => object \| null` | 404 page definition; if `null`, the framework's error page | [03](./03-routing.md) |
1088
+ | `error` | `({ status, error }) => object \| string \| null` | Error pages other than 404 (and 404 when there is no `notFound`); a page definition or HTML directly | [03](./03-routing.md) |
1089
+ | `prewarmPaths` | `() => string[]` | Paths for classic prewarm; if omitted, the classic pass is never set up. **Forbidden** with `onVisit` | [06](./06-caching.md) |
1090
+
1091
+ ```js
1092
+ hooks: {
1093
+ metadata() {
1094
+ return { titleTemplate: "%s | Example", siteUrl: "https://example.com" };
1095
+ },
1096
+
1097
+ async layoutContext({ pathname }) {
1098
+ return { navigation: await getNavigation(), isHome: pathname === "/" };
1099
+ },
1100
+
1101
+ notFound() {
1102
+ return {
1103
+ view: "pages/not-found",
1104
+ metadata: { title: "Page not found", robots: { index: false } },
1105
+ };
1106
+ },
1107
+
1108
+ error({ status }) {
1109
+ return {
1110
+ view: "pages/error",
1111
+ data: { status },
1112
+ metadata: { title: "Something went wrong", robots: { index: false } },
1113
+ };
1114
+ },
1115
+
1116
+ async prewarmPaths() {
1117
+ return ["/", ...(await getArticlePaths())];
1118
+ },
1119
+ }
1120
+ ```
1121
+
1122
+ ## `source` pattern syntax
1123
+
1124
+ `headers()`, `redirects()`, `rewrites()` and `cache().html` all use the same
1125
+ small compiler. This is not Next's full `path-to-regexp` surface; the subset
1126
+ actually used in configuration was chosen deliberately, and an unrecognised
1127
+ syntax is not silently accepted as a literal — it produces a warning.
1128
+
1129
+ | Pattern | Regex equivalent | Example match |
1130
+ | --- | --- | --- |
1131
+ | `/about` | exact match | `/about` |
1132
+ | `/news/:slug` | `([^/]+)` — a single segment | `/news/abc` (✗ `/news/a/b`) |
1133
+ | `/:path*` | `(.*)` — zero or more segments | `/`, `/a`, `/a/b/c` |
1134
+ | `/blog/:path*` | wildcard sub-path; the leading `/` is optional | `/blog`, `/blog/`, `/blog/a/b` |
1135
+ | `/:path*.svg` | wildcard + fixed suffix | `/ikon.svg`, `/a/b/c.svg` |
1136
+ | `/tag-:slug` | a parameter in the middle of a segment | `/tag-finance` |
1137
+
1138
+ Rules:
1139
+
1140
+ - `source` **must start with `/`**; if it does not, the rule is ignored and a
1141
+ warning is printed.
1142
+ - The parameter name must match the pattern `[A-Za-z_][A-Za-z0-9_]*`.
1143
+ - A pattern always matches **from start to end** (`^…$`); use `:path*` for
1144
+ prefix matching.
1145
+ - `:path*` also captures zero segments and the `/` immediately before it is
1146
+ optional: `/account/:path*` covers the section's root path (`/account`) too.
1147
+ Otherwise a rule that wanted to close off a whole section was skipping
1148
+ precisely its landing page.
1149
+ - Every character other than parameters is treated as a literal and escaped for
1150
+ the regex — `.` really means a dot.
1151
+ - Captured values are written into the same-named `:param`s in `destination`. A
1152
+ placeholder with no counterpart is left as is.
1153
+
1154
+ ## Environment variables
1155
+
1156
+ Every variable the framework reads. If a `.env` file exists it is loaded
1157
+ automatically by the CLI (`--env-file=.env`); if not, the flag is never passed
1158
+ and no warning is printed.
1159
+
1160
+ | Variable | Who reads it | Default | Meaning |
1161
+ | --- | --- | --- | --- |
1162
+ | `NODE_ENV` | everywhere | `production` (start/build), `development` (dev) | Determines the dev overlay, EJS cache, manifest re-reading, route error behaviour and prewarm defaults. `jskelet dev` sets it itself — `cross-env` is not needed. |
1163
+ | `PORT` | `startServer` | `3000` | Port to listen on. If busy, the process refuses to start; `jskelet start|dev --murder` kills the listener |
1164
+ | `HOST` | `startServer` | `::` | Interface to bind to. The default listens dual-stack (IPv6 + IPv4); it falls back to `0.0.0.0` where IPv6 is unavailable |
1165
+ | `JSKELET_SECRET` | `jskelet/cookies` | — | The signed cookie secret. Read when `security.cookieSecret` is not set; if neither exists, the signed cookie API throws. [12](./12-dashboards-and-sessions.md) |
1166
+ | `DEV_GATE` | `devGate` | off | `1` turns the gate on, `0` turns it off even when config enabled it. `DEV_TOKEN` alone does not turn it on. [09](./09-dev-tools.md) |
1167
+ | `DEV_TOKEN` | `devGate`, `prewarm` | — | The secret expected while the gate is on. If it is missing, or the gate is off, the site stays public. Prewarming carries the token as a cookie only while the gate is on. [09](./09-dev-tools.md) |
1168
+ | `JSKELET_ADMIN` | `createApp` | — | When set, turns the admin panel on; `0` turns off a panel enabled in the config. The env wins because the panel is usually opened once during an incident. [06](./06-caching.md) |
1169
+ | `JSKELET_LOG_BUCKET` | `logs.s3` | — | Log target: bucket or `bucket/prefix` path. With credentials, the sink turns on automatically |
1170
+ | `JSKELET_S3_BUCKET` | `logs.s3` | — | Bucket when `JSKELET_LOG_BUCKET` is unset; joins with `JSKELET_S3_KEY_PREFIX` |
1171
+ | `JSKELET_S3_KEY_PREFIX` | `logs.s3` | — | Used with `JSKELET_S3_BUCKET` (`bucket/prefix`) |
1172
+ | `JSKELET_S3_ACCESS_KEY_ID` | `logs.s3` | — | Signs PutObject |
1173
+ | `JSKELET_S3_SECRET_ACCESS_KEY` | `logs.s3` | — | Signing secret (`JSKELET_S3_ACCESS_SECRET` is an alias) |
1174
+ | `JSKELET_S3_SESSION_TOKEN` | `logs.s3` | — | Optional, for temporary credentials |
1175
+ | `JSKELET_S3_REGION` | `logs.s3` | `auto` | Defaults to `auto` when unset |
1176
+ | `JSKELET_S3_API_URL` | `logs.s3` | — | S3-compatible endpoint; overrides `logs.s3.endpoint` |
1177
+ | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache surface | — | API token. Until it is set, CDN purging and edge analytics stay off; it overrides `apiToken` in the config. The token is never returned in a response. [06](./06-caching.md) |
1178
+ | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
1179
+ | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
1180
+ | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
1181
+ | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
1182
+ | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Number of parallel workers |
1183
+ | `PREWARM_RPS` | `prewarm` | `0` | At most how many prewarm requests per second; `0` is unlimited |
1184
+ | `PREWARM_DELAY_MS` | `startPrewarm` | prod 500, dev 3000 | Delay of the first pass |
1185
+ | `PREWARM_RETRY_DELAY_MS` | `prewarm` | `2000` | The wait before the retry pass |
1186
+ | `PREWARM_INTERVAL_SECONDS` | `startPrewarm` | `0` | If greater than 0, a periodic pass |
1187
+ | `JSKELET_VERBOSE` | `jskelet dev` | — | If `1`, all of the changed files are listed on restart |
1188
+ | `JSKELET_COLOR` | `jskelet/log` | — | If `1`, colour is forced. Because child processes write to a pipe, colour detection turns off; `jskelet dev` sets this itself. |
1189
+ | `JSKELET_CHILD` | `jskelet build` | — | Set by the dev script; suppresses the build banner and the "Ready" summary |
1190
+ | `NO_COLOR` | `jskelet/log` | — | If set, colour is never used (it overrides `JSKELET_COLOR` too) |
1191
+
1192
+ Your application's own variables (API origin, tokens) are not read by the
1193
+ framework; use them directly via `process.env`. Declare the ones that need to
1194
+ reach the browser with `clientEnv`.
1195
+
1196
+ The numeric prewarm settings only accept **positive and finite** values; an
1197
+ invalid value silently falls through to the next layer (config → code default).
1198
+
1199
+ ## Programmatic access
1200
+
1201
+ ```js
1202
+ import { getConfig, loadConfig } from "jskelet";
1203
+
1204
+ await loadConfig(); // reads from the project root
1205
+ await loadConfig({ root: "/baska/proje" }); // a different root
1206
+ await loadConfig({ configFile: "jskelet.test.mjs" });
1207
+ await loadConfig({ force: true }); // bypass the cache and re-read
1208
+
1209
+ const config = getConfig(); // the resolved config
1210
+ ```
1211
+
1212
+ `loadConfig()` hits the cache on a second call in the same process: `jskelet
1213
+ start` calls it through both `ensure-build` and `createApp`, and there is no
1214
+ benefit in reading and logging the config twice.
1215
+
1216
+ If `getConfig()` is used without `loadConfig()` having been called, it
1217
+ **throws**: a silently wrong path turns into problems that are hard to diagnose,
1218
+ like "why is there no stylesheet".
1219
+
1220
+ In the resolved config the directories are available as absolute paths under
1221
+ `config.dirs` (`views`, `public`, `client`, `routes`, `styles`, `generated`,
1222
+ `assets`, `fonts`), the patterns are in compiled form, and `config.loaded` tells
1223
+ you whether the file was actually read.
1224
+
1225
+ ## What's next
1226
+
1227
+ - The effect of the build-side fields: [08-build.md](./08-build.md)
1228
+ - The dev flow and `DEV_TOKEN`: [09-dev-tools.md](./09-dev-tools.md)
1229
+ - Using environment variables in deployment: [10-deployment.md](./10-deployment.md)