jskelet 0.2.4 → 0.2.5

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 (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +8 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1209
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1239
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -670
  40. package/src/client/cache-panel/login.html +74 -74
  41. package/src/client/cache-panel/panel.css +756 -756
  42. package/src/client/cache-panel/panel.html +308 -308
  43. package/src/client/cache-panel/panel.js +915 -915
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -759
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,986 +1,986 @@
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.ejs",
59
- routes: ["./routes/10-pages.mjs", "./routes/99-catch-all.mjs"],
60
-
61
- static: {
62
- extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2"],
63
- prefixes: ["/assets/", "/fonts/"],
64
- },
65
-
66
- devGateBypass: ["/api/healthcheck", "/robots.txt"],
67
- preconnect: ["https://cdn.example.com"],
68
-
69
- security: {
70
- trustProxy: true,
71
- cookieSecret: process.env.JSKELET_SECRET,
72
- csrf: {
73
- enabled: true,
74
- token: false,
75
- allowedOrigins: [],
76
- exclude: ["/webhook/:path*"],
77
- cookieName: "csrf_token",
78
- fieldName: "_csrf",
79
- headerName: "x-csrf-token",
80
- },
81
- },
82
-
83
- navigation: {
84
- prefetch: "moderate",
85
- prerender: "conservative",
86
- viewTransition: true,
87
- exclude: ["/logout"],
88
- },
89
-
90
- prewarmSkip: ["/api/", "/_fragment/", "/__example/"],
91
- watch: ["data"],
92
-
93
- fonts: [{ family: "Inter", weights: [400, 600, 700] }],
94
- icons: { scan: ["views", "client", "routes", "lib"] },
95
- images: { widths: [400, 800, 1200], quality: 78, skip: ["downloads"] },
96
- clientEnv: ["PUBLIC_WS_URL"],
97
-
98
- async headers() {
99
- return [
100
- {
101
- source: "/:path*",
102
- headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }],
103
- },
104
- ];
105
- },
106
-
107
- async redirects() {
108
- return [{ source: "/eski/:slug", destination: "/yeni/:slug", permanent: true }];
109
- },
110
-
111
- async rewrites() {
112
- return {
113
- afterFiles: [
114
- { source: "/api/:path*", destination: "https://api.example.com/:path*" },
115
- ],
116
- };
117
- },
118
-
119
- async cache() {
120
- return {
121
- html: { "/": 60, "/news/:slug": 300 },
122
- query: { "/search": ["q", "page"] },
123
- maxEntries: 500,
124
- data: { maxEntries: 10000, staleFactor: 10 },
125
- prewarm: {
126
- enabled: true,
127
- max: 400,
128
- concurrency: 4,
129
- rps: 0,
130
- intervalSeconds: 0,
131
- rotate: true,
132
- priority: ["/", "/news/:slug"],
133
- },
134
- };
135
- },
136
-
137
- hooks: {
138
- metadata() { /* … */ },
139
- layoutContext() { /* … */ },
140
- notFound() { /* … */ },
141
- error() { /* … */ },
142
- prewarmPaths() { /* … */ },
143
- },
144
- };
145
- ```
146
-
147
- ## `paths`
148
-
149
- **Type:** `Record<string, string>` — **Default:** the table below
150
-
151
- Names of the directories (and, for `styles`, the file) in the project root.
152
- Values are resolved relative to the project root and turned into absolute paths
153
- internally.
154
-
155
- | Key | Default | Contents |
156
- | --- | --- | --- |
157
- | `views` | `"views"` | EJS layout, pages, components |
158
- | `public` | `"public"` | Static files; the build output is written here too |
159
- | `client` | `"client"` | Island runtime sources and entries |
160
- | `routes` | `"routes"` | Route modules |
161
- | `styles` | `"styles/globals.css"` | Tailwind/PostCSS entry **file** |
162
- | `generated` | `".jskelet"` | `manifest.json`, `metafile.json`, `images.json` |
163
-
164
- Even though `styles` is a file path it goes through the same resolution; keeping
165
- a separate field for it is not worth it.
166
-
167
- Two paths are always derived and cannot be overridden: `public/assets` (hashed
168
- build output) and `public/fonts` (self-hosted fonts).
169
-
170
- ```js
171
- paths: { views: "src/views", routes: "src/routes", styles: "src/styles/main.css" }
172
- ```
173
-
174
- ## `brand`
175
-
176
- **Type:** `object` — **Default:** the table below
177
-
178
- Branding and names that can be changed from a single place. Projects that fork
179
- the framework or white-label it can put their own name in. Provided fields are
180
- shallow-merged with the defaults.
181
-
182
- | Field | Type | Default | Meaning |
183
- | --- | --- | --- | --- |
184
- | `name` | `string` | `"JSkelet"` | Display name |
185
- | `poweredBy` | `string` | `"JSkelet"` | Value of the `X-Powered-By` header |
186
- | `cacheHeader` | `string` | `"X-JSkelet-Cache"` | HTML cache status header ([06-caching.md](./06-caching.md)) |
187
- | `devBasePath` | `string` | `"/__jskelet/dev"` | Root of the dev overlay and report endpoints |
188
- | `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | UA of prewarm requests; the dev panel filters on it |
189
- | `devTokenCookie` | `string` | `"dev_token"` | Name of the dev gate's cookie and query parameter |
190
- | `lang` | `string` | — | Default for `<html lang>`. If not given, the layout uses `"en"`. |
191
-
192
- Precedence for `lang`: `hooks.layoutContext()` → `lang` **>** `brand.lang`
193
- **>** `"en"`.
194
-
195
- ```js
196
- brand: { lang: "tr", poweredBy: "Example", cacheHeader: "X-Example-Cache" }
197
- ```
198
-
199
- ## `layout`
200
-
201
- **Type:** `string` — **Default:** none (automatic resolution)
202
-
203
- Path of the layout `.ejs` file. The value given is resolved relative to the
204
- **parent directory of the views directory**, so with the default `views`,
205
- `"views/custom.ejs"` → `<root>/views/custom.ejs`.
206
-
207
- If not given, in order: `views/layout.ejs` if it exists, otherwise the
208
- framework's minimal layout. Details: [04-rendering.md](./04-rendering.md).
209
-
210
- ## `routes`
211
-
212
- **Type:** `string[]` — **Default:** `null` (directory scan)
213
-
214
- Explicit list of route modules, relative to the project root. They are loaded in
215
- the given order. If not given, the `paths.routes` directory is scanned
216
- alphabetically and recursively. Details: [03-routing.md](./03-routing.md).
217
-
218
- ```js
219
- routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"]
220
- ```
221
-
222
- ## `static`
223
-
224
- **Type:** `{ extensions?: string[], prefixes?: string[] }` — **Default:**
225
- below
226
-
227
- Static file detection by extension and prefix. Paths matching this list get
228
- `Cache-Control: public, max-age=31536000, immutable`.
229
-
230
- | Field | Default |
231
- | --- | --- |
232
- | `extensions` | `[".svg", ".png", ".webp", ".avif", ".ico", ".woff2"]` |
233
- | `prefixes` | `["/assets/", "/fonts/"]` |
234
-
235
- If provided, it **replaces** the default (it is not merged), so if you want to
236
- add to the default, write out the full list.
237
-
238
- ```js
239
- static: {
240
- extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2", ".mp4"],
241
- prefixes: ["/assets/", "/fonts/", "/video/"],
242
- }
243
- ```
244
-
245
- ## `devGateBypass`
246
-
247
- **Type:** `string[]` — **Default:**
248
- `["/api/healthcheck", "/robots.txt", "/sitemap.xml", "/site.webmanifest", "/favicon.ico"]`
249
-
250
- **Exact** paths the dev gate never closes off under any circumstances (not a
251
- prefix, an exact match). This is so that the health check and the robots files
252
- stay reachable in an environment where `DEV_TOKEN` is set. If provided, it
253
- replaces the default.
254
-
255
- Details: [09-dev-tools.md](./09-dev-tools.md).
256
-
257
- ## `preconnect`
258
-
259
- **Type:** `string[]` — **Default:** `[]`
260
-
261
- Third-party origins; printed as `<link rel="preconnect">` in the `<head>` of
262
- every page. The image CDN, the API origin, the font host go here. Values are
263
- normalised with `new URL(...).origin`; an invalid URL is skipped and a warning
264
- is printed.
265
-
266
- Since the list is the same on every page, it is computed once and stored. An
267
- empty list is a valid configuration.
268
-
269
- ```js
270
- preconnect: ["https://cdn.example.com", "https://api.example.com"]
271
- ```
272
-
273
- ## `security`
274
-
275
- **Type:** `object` — **Default:**
276
- `{ trustProxy: true, cookieSecret: null, csrf: { enabled: true, token: false, … } }`
277
-
278
- The whole picture for per-visitor pages, with the reasoning, is in
279
- [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md); this is the
280
- field reference.
281
-
282
- | Field | Type | Default | Meaning |
283
- | --- | --- | --- | --- |
284
- | `trustProxy` | `boolean` | `true` | Express's `trust proxy` setting. Needed behind a reverse proxy for the correct protocol and client IP. |
285
- | `cookieSecret` | `string \| null` | `null` | The signed cookie secret. When absent, `JSKELET_SECRET` is read. |
286
- | `csrf.enabled` | `boolean` | `true` | The origin / `Sec-Fetch-Site` check. |
287
- | `csrf.token` | `boolean` | `false` | The double-submit token layer. |
288
- | `csrf.allowedOrigins` | `string[]` | `[]` | Origins accepted alongside our own host. |
289
- | `csrf.exclude` | `string[]` | `[]` | Paths exempt from the check; `source` pattern syntax. |
290
- | `csrf.cookieName` | `string` | `"csrf_token"` | Name of the token cookie. |
291
- | `csrf.fieldName` | `string` | `"_csrf"` | Field name printed by `csrfField()`. |
292
- | `csrf.headerName` | `string` | `"x-csrf-token"` | Header the token is also accepted in. |
293
-
294
- `trustProxy` should be **turned off** on a server exposed directly to the
295
- internet: while it is on, a client can forge its own `X-Forwarded-For` and rate
296
- limiting or audit logs see the wrong address.
297
-
298
- The CSRF check only rejects requests that are **known** to be cross-site — when
299
- `Origin` does not match or `Sec-Fetch-Site: cross-site` arrives. If neither is
300
- present the request passes, because browsers always send `Origin` on a
301
- cross-origin POST while webhooks never do. Even so, listing non-browser
302
- endpoints in `csrf.exclude` makes the intent readable.
303
-
304
- ## `navigation`
305
-
306
- **Type:** `object` — **Default:**
307
- `{ prefetch: "moderate", prerender: false, viewTransition: false, exclude: [] }`
308
-
309
- `<head>` hints that speed up in-site navigation. Since JSkelet is a classic MPA,
310
- every click is a full page load; this section makes the browser do that load
311
- **ahead of time**. No client runtime is added — Speculation Rules and view
312
- transitions are browser capabilities, and in a browser that does not support
313
- them they are silently ignored.
314
-
315
- | Field | Type | Default | Meaning |
316
- | --- | --- | --- | --- |
317
- | `prefetch` | `false \| "conservative" \| "moderate" \| "eager"` | `"moderate"` | Downloads the link target's **document** ahead of time |
318
- | `prerender` | same | `false` | **Fully renders** the target in the background; it opens the moment you click |
319
- | `viewTransition` | `boolean` | `false` | Emits `@view-transition { navigation: auto }` |
320
- | `exclude` | `string[]` | `[]` | href patterns to keep out of speculation |
321
-
322
- If `true` is given, `prefetch`/`prerender` fall back to the default eagerness; an
323
- unrecognised value prints a warning and reverts to the default.
324
-
325
- **What eagerness means:** `conservative` triggers the moment the link is pressed,
326
- `moderate` when the pointer lingers on the link for a while, `eager` as soon as
327
- the link becomes visible. The further up you go, the higher the hit rate — and
328
- the more wasted requests.
329
-
330
- **Why `prerender` ships off.** The scripts of a prerendered page really do run.
331
- In an application that does not hook its measurement code to the
332
- `prerenderingchange` event, visit counts get inflated. Review your analytics
333
- before turning it on; the cost on the server side is low, because a speculative
334
- request is also served from the HTML cache
335
- ([06-caching.md](./06-caching.md)).
336
-
337
- **Always exempt.** Paths under `/api/*`, `/_fragment/*` and `brand.devBasePath`
338
- are excluded automatically; `exclude` is added on top of those. Additionally,
339
- links carrying `rel="nofollow"`, `target="_blank"` or `data-no-prefetch` are not
340
- covered by any rule. The easiest way to keep a single link with side effects out
341
- is the last one:
342
-
343
- ```html
344
- <a href="/logout" data-no-prefetch>Logout</a>
345
- ```
346
-
347
- **When turning on `viewTransition`, put the background on `<html>`.** During the
348
- transition the browser cross-fades snapshots of the old and the new page; a
349
- background set on `<body>` stays inside that snapshot and the canvas underneath
350
- becomes visible. The result is one frame of white flash on every transition, and
351
- it does not go unnoticed in a dark theme. If the colour is on `<html>` (or
352
- `:root`), no such gap appears:
353
-
354
- ```html
355
- <html lang="tr" class="bg-white dark:bg-slate-950">
356
- <body class="text-slate-900 dark:text-slate-100">
357
- ```
358
-
359
- The reduced-motion preference is handled by the framework: under
360
- `prefers-reduced-motion: reduce` the transition is disabled, and you do not need
361
- to write anything extra.
362
-
363
- **Scope the transition to the content.** The default behaviour cross-fades the
364
- whole document as a single piece, which means the header and footer — which
365
- never change across navigations — flicker too. Giving those regions a
366
- `view-transition-name` puts them in their own group; because the browser sees the
367
- same name in both documents, it treats them as "the same element". Once you turn
368
- off the animation of the named element, the transition stays in the content
369
- only:
370
-
371
- ```css
372
- body > header { view-transition-name: site-header; }
373
- body > footer { view-transition-name: site-footer; }
374
-
375
- ::view-transition-old(site-header),
376
- ::view-transition-old(site-footer) { animation: none; opacity: 0; }
377
- ::view-transition-new(site-header),
378
- ::view-transition-new(site-footer) { animation: none; opacity: 1; }
379
-
380
- /* The remaining content; the default 250ms makes navigation feel slow. */
381
- ::view-transition-old(root),
382
- ::view-transition-new(root) { animation-duration: 180ms; }
383
- ```
384
-
385
- A working version lives in `examples/marketing/styles/globals.css`.
386
-
387
- **If you use CSP**, the rules are emitted as an inline
388
- `<script type="speculationrules">`; your `script-src` policy needs to allow it.
389
-
390
- ```js
391
- navigation: {
392
- prefetch: "moderate",
393
- prerender: "conservative",
394
- viewTransition: true,
395
- exclude: ["/logout", "/cart/*"],
396
- }
397
- ```
398
-
399
- ## `prewarmSkip`
400
-
401
- **Type:** `string[]` — **Default:** `["/api/", "/_fragment/", "/__jskelet/"]`
402
-
403
- Path **prefixes** that prewarming skips. Session-dependent or fragment endpoints
404
- should not be prewarmed. If provided, it replaces the default — if you changed
405
- `brand.devBasePath`, do not forget to update this list too.
406
-
407
- Details: [06-caching.md](./06-caching.md).
408
-
409
- ## `watch`
410
-
411
- **Type:** `string[]` — **Default:** `[]`
412
-
413
- **Additional** directories that `jskelet dev` watches for server restarts,
414
- relative to the project root. `routes`, `views` and `lib` are already watched;
415
- `client/` and `styles/` are handled by the esbuild and CSS watchers and should
416
- not be put here.
417
-
418
- Only files with the `.js`, `.mjs`, `.json` and `.ejs` extensions are triggers.
419
-
420
- ```js
421
- watch: ["data", "content"]
422
- ```
423
-
424
- Details: [09-dev-tools.md](./09-dev-tools.md).
425
-
426
- ## `fonts`
427
-
428
- **Type:** `{ family: string, slug?: string, weights?: number[] }[]` —
429
- **Default:** `[]`
430
-
431
- Google Fonts families to self-host. If left empty, the font step never runs.
432
-
433
- | Field | Type | Default | Meaning |
434
- | --- | --- | --- | --- |
435
- | `family` | `string` | — | Google Fonts family name: `"Inter"`, `"Noto Sans"` |
436
- | `slug` | `string` | derived from `family` (lower case, space → `-`) | File name prefix |
437
- | `weights` | `number[]` | `[400]` | Weights to download |
438
-
439
- Output: `public/fonts/<slug>-<weight>.woff2`, with the same file name as the
440
- manifest key. The files have **fixed names** (no hash) and are **expected to be
441
- committed**. Details: [08-build.md](./08-build.md).
442
-
443
- ```js
444
- fonts: [
445
- { family: "Inter", weights: [400, 600, 700] },
446
- { family: "Noto Serif", slug: "serif", weights: [400] },
447
- ]
448
- ```
449
-
450
- ## `icons`
451
-
452
- **Type:** `{ scan?: string[] } | false` — **Default:** `{}`
453
-
454
- Phosphor SVG sprite generation.
455
-
456
- | Value | Result |
457
- | --- | --- |
458
- | `{}` (default) | The sprite is generated; scanned directories are `["views", "client", "routes", "lib"]` |
459
- | `{ scan: [...] }` | Changes the scanned directories |
460
- | `false` | The sprite step is skipped entirely |
461
-
462
- If `@phosphor-icons/core` is not in the application's `node_modules`, the step is
463
- silently skipped. Details: [08-build.md](./08-build.md).
464
-
465
- ```js
466
- icons: { scan: ["views", "client", "routes", "lib", "content"] }
467
- ```
468
-
469
- ## `images`
470
-
471
- **Type:** `{ widths?: number[], quality?: number, skip?: string[] } | false` —
472
- **Default:** `{}`
473
-
474
- Generates webp variants of the png/jpg images under `public/`.
475
-
476
- | Field | Type | Default | Meaning |
477
- | --- | --- | --- | --- |
478
- | `widths` | `number[]` | `[400, 640, 960, 1280, 1920]` | Widths to generate. Ones larger than the source are dropped; the source's own width (at most 1920) is always added. |
479
- | `quality` | `number` | `78` | webp quality. When it changes, the encoder signature changes and every image is re-encoded. |
480
- | `skip` | `string[]` | `[]` | **Directory names** not to scan. `assets` and `fonts` are always skipped. |
481
-
482
- If `false` is given, the image step never runs. The step requires `sharp` and
483
- never runs on a watch pass. Details: [08-build.md](./08-build.md).
484
-
485
- ```js
486
- images: { widths: [400, 800, 1200], quality: 82, skip: ["downloads"] }
487
- ```
488
-
489
- ## `clientEnv`
490
-
491
- **Type:** `string[]` — **Default:** `[]`
492
-
493
- Environment variable keys to inline into the client bundle at build time. The
494
- same contract as `NEXT_PUBLIC_*` in Next, except which key is public is decided
495
- by the config rather than by the name. `NODE_ENV` is always inlined.
496
-
497
- Because the whole of `process.env` is defined as a single object, reading a key
498
- that is not in the list returns `undefined` instead of crashing.
499
-
500
- ```js
501
- clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN_ORIGIN"]
502
- ```
503
-
504
- **Do not put secrets here** — the values sit in the bundle in plain text.
505
-
506
- ## `headers()`
507
-
508
- **Type:** `() => { source: string, headers: { key: string, value: string }[] }[]`
509
- — **Default:** `[]`
510
-
511
- Response headers by path pattern. The framework only writes long-lived cache
512
- headers for static files; every other header (CSP, COOP, HSTS,
513
- X-Frame-Options…) comes from here and takes precedence over the defaults.
514
-
515
- **All** matching rules are applied (unlike redirects, it does not stop at the
516
- first match), in order; if two rules write the same header, the later one wins.
517
-
518
- Entries without a `key` or with an `undefined` `value` are skipped; a rule left
519
- with no valid headers at all is not added.
520
-
521
- ```js
522
- async headers() {
523
- return [
524
- {
525
- source: "/:path*",
526
- headers: [
527
- { key: "X-Frame-Options", value: "SAMEORIGIN" },
528
- { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
529
- {
530
- key: "Content-Security-Policy",
531
- value: "default-src 'self'; img-src 'self' https://cdn.example.com data:",
532
- },
533
- ],
534
- },
535
- {
536
- source: "/download/:path*",
537
- headers: [{ key: "Cache-Control", value: "no-store" }],
538
- },
539
- ];
540
- }
541
- ```
542
-
543
- ## `redirects()`
544
-
545
- **Type:**
546
- `() => { source: string, destination: string, permanent?: boolean, statusCode?: number }[]`
547
- — **Default:** `[]`
548
-
549
- | Field | Type | Meaning |
550
- | --- | --- | --- |
551
- | `source` | `string` | Pattern (syntax below) |
552
- | `destination` | `string` | Target; `:param` placeholders are filled in |
553
- | `permanent` | `boolean` | `true` → 308, otherwise 307 |
554
- | `statusCode` | `number` | Explicit status code; overrides `permanent` |
555
-
556
- The first matching rule wins and the query string is preserved. Details:
557
- [03-routing.md](./03-routing.md).
558
-
559
- ## `rewrites()`
560
-
561
- **Type:** `() => Rule[] | { beforeFiles?: Rule[], afterFiles?: Rule[] }`
562
- where `Rule = { source: string, destination: string }` — **Default:** `[]`
563
-
564
- If an array is returned, all of it counts as `afterFiles`.
565
-
566
- - `beforeFiles` runs even before static files.
567
- - `afterFiles` runs after static has been tried, before the routes.
568
- - Absolute target (`http://`/`https://`) → built-in reverse proxy.
569
- - Relative target → only `req.url` changes.
570
-
571
- Details: [03-routing.md](./03-routing.md).
572
-
573
- ## `cache()`
574
-
575
- **Type:**
576
- `() => { html?: Record<string, number>, query?: Record<string, string[] | true>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
577
- **Default:**
578
- `{ html: {}, query: {}, 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 } }`
579
-
580
- ### `cache().html`
581
-
582
- A pattern → seconds mapping. A matching rule **overrides** the route's own
583
- `revalidate` value. Negative or non-finite values are ignored; `0` means "no
584
- caching".
585
-
586
- The one exception is `route(fn, { private: true })`: on that route a matching
587
- pattern is ignored. The lock is deliberately one-way — a mistake in the other
588
- direction means one user's HTML is served to another.
589
-
590
- ```js
591
- html: {
592
- "/": 60,
593
- "/news/:slug": 300,
594
- "/search": 0,
595
- }
596
- ```
597
-
598
- ### `cache().query`
599
-
600
- A pattern → list of query parameters allowed into the cache key.
601
-
602
- **By default a request that carries a query parameter is dynamic**: even when
603
- `cache().html` covers that path, the response never enters the HTML cache and
604
- is sent with `private, no-store`. The reason is simple — caching every variant
605
- of a path mints an unbounded number of keys (`?utm_source=…` and friends), and
606
- once the `maxEntries` limit is reached those keys evict the real pages. Only the
607
- application knows which parameter actually changes the output.
608
-
609
- ```js
610
- query: {
611
- "/search": ["q", "page"], // only these two belong to the key
612
- "/products": ["category"],
613
- "/report/:id": true, // every parameter belongs to the key
614
- "/campaign": [], // the query is ignored entirely
615
- }
616
- ```
617
-
618
- - **Allowlist** (`string[]`): the listed parameters become part of the key and
619
- each distinct value gets its own entry. Parameters outside the list are
620
- **ignored** — the page is still cached and every campaign variant shares one
621
- copy.
622
- - **`true`**: every parameter belongs to the key. Nothing but `maxEntries`
623
- bounds the number of entries, so use it only where the value set is closed.
624
- - **`[]`**: the query is not considered at all; every variant is served the HTML
625
- of the query-less version.
626
-
627
- Parameters are written into the key **sorted**, so `?a=1&b=2` and `?b=2&a=1`
628
- share one entry. `route(fn, { private: true })` is unaffected by this section; a
629
- private route is never cached under any condition.
630
-
631
- ### `cache().maxEntries`
632
-
633
- **Type:** `number` — **Default:** `500`
634
-
635
- The entry limit of the HTML cache. Because an entry costs a hundred kilobytes,
636
- raising this number burns through memory quickly; trying to solve a site with
637
- tens of thousands of paths from here is the wrong layer — the right place is
638
- `cache().data`.
639
-
640
- ### `cache().data`
641
-
642
- The upstream data cache (`withDataCache`). Details:
643
- [06-caching.md](./06-caching.md).
644
-
645
- | Field | Type | Default | Meaning |
646
- | --- | --- | --- | --- |
647
- | `maxEntries` | `number` | `10000` | The LRU entry limit. The limit is high because JSON is tens of times smaller than HTML. |
648
- | `staleFactor` | `number` | `10` | For how many TTLs an entry stays usable after the TTL expired. `0` → no stale serving. |
649
-
650
- ### `cache().trackUpstream`
651
-
652
- **Type:** `boolean` — **Default:** `true`
653
-
654
- When on, `globalThis.fetch` is wrapped and transient upstream failures (`429`,
655
- `5xx`, network) during a render are reported automatically; calling
656
- `reportUpstreamFailure()` is not required. An application that wraps `fetch`
657
- itself can turn this off.
658
-
659
- ### `cache().trackDependencies`
660
-
661
- **Type:** `boolean` — **Default:** `true`
662
-
663
- When on, the `withDataCache` keys a render reads are recorded, and
664
- `clearDataCache()` also stales the HTML pages that read that data — targeted
665
- invalidation without the application declaring anything
666
- ([06-caching.md](./06-caching.md)). An application that does not use
667
- `withDataCache` has nothing to record; turning this off also removes the cost of
668
- setting up the context.
669
-
670
- ### `cache().transientRetry`
671
-
672
- **Type:** `{ attempts?: number, delayMs?: number } | false` —
673
- **Default:** `{ attempts: 1, delayMs: 300 }`
674
-
675
- How many extra times a page is tried when `notFound()` was called because of a
676
- transient upstream failure. The point is that an existing page never turns into
677
- a 404; if the retries are exhausted the response is an uncached 503. `false` or
678
- `attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
679
-
680
- ### `cache().upstream`
681
-
682
- A per-host rate limit for the `fetch` calls that go to upstream APIs. Off by
683
- default: unless `rate` is given, no request ever waits. `rate` is a ceiling; the
684
- actual rate pulls itself down in response to 429s and climbs back step by step
685
- during clean windows.
686
-
687
- | Field | Type | Default | Meaning |
688
- | --- | --- | --- | --- |
689
- | `rate` | `number` | `0` | Maximum calls per second. `0` → brake disabled |
690
- | `burst` | `number` | `0` | Bucket size; `0` → one second's budget of burst |
691
- | `concurrency` | `number` | `8` | Calls allowed in flight at once |
692
- | `minRate` | `number` | `0.5` | Floor of the decrease; the rate never goes below it |
693
- | `increaseStep` | `number` | `1` | Step of the additive increase (calls/second) |
694
- | `increaseIntervalMs` | `number` | `5000` | Increase period |
695
- | `decreaseIntervalMs` | `number` | `1000` | Minimum time between two decreases |
696
- | `breakerFailures` | `number` | `5` | Consecutive 429s after which the host is bypassed |
697
- | `breakerCooldownMs` | `number` | `10000` | How long the bypass lasts |
698
- | `hosts` | `Record<string, object>` | `{}` | Per-host overrides; same fields apply |
699
-
700
- Only `429` and `503` penalise the rate: a `400`/`404`/`500` is not a quota
701
- problem. Read the state with `getUpstreamLimiterStatus()` or from the dev
702
- panel's **Server** tab. Details, and what to check before turning it on:
703
- [06-caching.md](./06-caching.md).
704
-
705
- ```js
706
- upstream: {
707
- rate: 10,
708
- concurrency: 4,
709
- hosts: { "api.example.com": { rate: 3 } },
710
- }
711
- ```
712
-
713
- ### `cache().redis`
714
-
715
- An optional Redis second tier (L2). The in-process cache stays primary; Redis
716
- only skips the render for a path that is not in L1 and spreads invalidation to
717
- the other instances. `ioredis` has to be installed in the application
718
- (`npm install ioredis`); if it is missing or unreachable a warning is printed and
719
- the site keeps running on the in-process cache.
720
-
721
- | Field | Type | Default | Meaning |
722
- | --- | --- | --- | --- |
723
- | `enabled` | `boolean` | `false` | Only turns on when `true` is passed explicitly |
724
- | `url` | `string \| null` | `null` | `redis://` or `rediss://`. When empty, the ioredis default (`localhost:6379`) |
725
- | `namespace` | `string` | `"default"` | Separates applications sharing one Redis |
726
- | `keyPrefix` | `string` | `"_jskelet"` | Root of the key layout |
727
- | `html` | `boolean` | `true` | Whether HTML bodies are shared |
728
- | `data` | `boolean` | `true` | Whether `withDataCache` entries are shared |
729
- | `storeEncoded` | `boolean` | `false` | Whether brotli/gzip bodies are shared too; doubles or triples the size per entry |
730
- | `events` | `boolean` | `true` | Invalidation broadcast over pub/sub |
731
- | `commandTimeoutMs` | `number` | `200` | At most how long a single command may block |
732
-
733
- Keys live as `_jskelet:{namespace}:{buildId}:html:{path}?{query}`. `buildId`
734
- changes with every build, so old HTML becomes invalid on its own after a deploy.
735
- Personalised (`storable: false`), `degraded` and non-200 responses are never
736
- written to the shared tier. Trade-offs and diagnosis:
737
- [06-caching.md](./06-caching.md).
738
-
739
- ```js
740
- redis: {
741
- enabled: process.env.NODE_ENV === "production",
742
- url: process.env.REDIS_URL,
743
- namespace: "news-site",
744
- }
745
- ```
746
-
747
- ### `cache().panel`
748
-
749
- The cache admin panel. It shows the state of the in-process tier and the Redis
750
- tier, and it triggers targeted invalidation, single-entry drops and prewarming.
751
-
752
- It does not look at the environment: without `enabled` **nothing is mounted**
753
- and the path does not exist. When it is on it also works in production — that is
754
- where the real questions ("why is this page stale", "did the webhook purge
755
- land") get asked.
756
-
757
- | Field | Type | Default | Meaning |
758
- | --- | --- | --- | --- |
759
- | `enabled` | `boolean` | `false` | Only turns on when explicitly `true` (`JSKELET_CACHE_PANEL` overrides it) |
760
- | `basePath` | `string` | `"/_jskelet/cache"` | Root of the panel |
761
- | `banAttempts` | `number` | `3` | How many failed attempts ban an IP |
762
- | `banHours` | `number` | `24` | How long the ban lasts |
763
- | `sessionHours` | `number` | `12` | Lifetime of the session cookie |
764
-
765
- The password is generated **on every process start** and only appears in the
766
- server log:
767
-
768
- ```
769
- [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
770
- ```
771
-
772
- There is no persistent secret (no config field, no environment variable):
773
- leaking one means handing out the right to flush the cache, and a deploy should
774
- revoke old access on its own. Banned and unauthorised requests all get a `404`.
775
- Usage and screens: [06-caching.md](./06-caching.md).
776
-
777
- ### `cache().cloudflare`
778
-
779
- The CDN tier. JSkelet's cache is the origin cache; the copy your visitors get
780
- sits at the edge. With this section connected, the panel can purge the edge,
781
- read and change cache related zone settings and show the cache hit ratio.
782
-
783
- | Field | Type | Default | Meaning |
784
- | --- | --- | --- | --- |
785
- | `enabled` | `boolean` | `true` | Set `false` to keep the surface off even when a token is present in the environment |
786
- | `zoneId` | `string \| null` | `null` | Zone identifier (`JSKELET_CLOUDFLARE_ZONE_ID` overrides it) |
787
- | `apiToken` | `string \| null` | `null` | The token; **prefer the environment**, putting it here puts a secret in the repo |
788
- | `hostname` | `string \| null` | `null` | Purging wants absolute URLs; paths are resolved against this name. Falls back to the origin the panel was opened on |
789
- | `analyticsHours` | `number` | `24` | Analytics window, at most `72` |
790
-
791
- Passing the token only through `JSKELET_CLOUDFLARE_KEY` keeps the config file
792
- clean. Permissions follow what you intend to do: `Zone.Cache Purge` to purge,
793
- `Zone.Zone Settings` for settings, `Zone.Analytics` (read) for the hit ratio.
794
- The token is never returned in a panel response — only the fact that it came
795
- from the environment.
796
-
797
- With no zone connected the panel shows a setup snippet rather than a warning,
798
- and if Cloudflare returns an error that section reports it while the rest of the
799
- panel keeps working. What can actually be asked — in particular why "how many
800
- edges hold this page" has no exact answer — is in
801
- [06-caching.md](./06-caching.md).
802
-
803
- ```js
804
- panel: {
805
- enabled: process.env.CACHE_PANEL === "1",
806
- }
807
- ```
808
-
809
- ### `cache().prewarm`
810
-
811
- | Field | Type | Default | Meaning |
812
- | --- | --- | --- | --- |
813
- | `enabled` | `boolean` | `true` | If `false`, no prewarming happens (can be overridden with `PREWARM=1`) |
814
- | `max` | `number` | `400` | At most how many paths are prewarmed per pass |
815
- | `concurrency` | `number` | prod 4, dev 1 | Number of parallel workers |
816
- | `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. |
817
- | `delayMs` | `number` | prod 500, dev 3000 | Delay of the first pass after startup |
818
- | `retryDelayMs` | `number` | `2000` | How long to wait before the retry pass |
819
- | `intervalSeconds` | `number` | `0` | If greater than 0, the pass repeats periodically |
820
- | `rotate` | `boolean` | `true` | If the list is longer than `max`, periodic passes continue where they left off |
821
- | `priority` | `(string \| RegExp)[]` | `[]` | Warm-up order; matching paths are taken first on every pass |
822
-
823
- `priority` accepts two forms: the pattern syntax used everywhere in the config,
824
- and a plain `RegExp`. Whatever is written first is warmed first.
825
-
826
- ```js
827
- prewarm: {
828
- max: 500,
829
- rps: 4,
830
- intervalSeconds: 300,
831
- priority: [
832
- "/", // the home page
833
- "/markets/:path*", // the whole markets section
834
- /-comments$/, // a rule the pattern syntax does not cover
835
- ],
836
- }
837
- ```
838
-
839
- Each numeric field can be overridden by an environment variable of the same
840
- name; env takes precedence. Details: [06-caching.md](./06-caching.md).
841
-
842
- ## `hooks`
843
-
844
- **Type:** `Record<string, Function>` — **Default:** `{}`
845
-
846
- All optional, all may be `async`. If a hook throws, the framework falls back to
847
- its own default and warns — the page does not go down.
848
-
849
- | Hook | Signature | What it returns | Document |
850
- | --- | --- | --- | --- |
851
- | `metadata` | `(page) => object` | Metadata default for every page; the controller's `metadata` is layered on top | [04](./04-rendering.md) |
852
- | `layoutContext` | `({ pathname, metadata }) => object` | Layout locals; `lang`, `structuredData`, `extraHead` and `bodyClass` get special treatment | [04](./04-rendering.md) |
853
- | `notFound` | `() => object \| null` | 404 page definition; if `null`, the framework's error page | [03](./03-routing.md) |
854
- | `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) |
855
- | `prewarmPaths` | `() => string[]` | Paths to prewarm; if it is not defined, prewarming is never set up | [06](./06-caching.md) |
856
-
857
- ```js
858
- hooks: {
859
- metadata() {
860
- return { titleTemplate: "%s | Example", siteUrl: "https://example.com" };
861
- },
862
-
863
- async layoutContext({ pathname }) {
864
- return { navigation: await getNavigation(), isHome: pathname === "/" };
865
- },
866
-
867
- notFound() {
868
- return {
869
- view: "pages/not-found",
870
- metadata: { title: "Page not found", robots: { index: false } },
871
- };
872
- },
873
-
874
- error({ status }) {
875
- return {
876
- view: "pages/error",
877
- data: { status },
878
- metadata: { title: "Something went wrong", robots: { index: false } },
879
- };
880
- },
881
-
882
- async prewarmPaths() {
883
- return ["/", ...(await getArticlePaths())];
884
- },
885
- }
886
- ```
887
-
888
- ## `source` pattern syntax
889
-
890
- `headers()`, `redirects()`, `rewrites()` and `cache().html` all use the same
891
- small compiler. This is not Next's full `path-to-regexp` surface; the subset
892
- actually used in configuration was chosen deliberately, and an unrecognised
893
- syntax is not silently accepted as a literal — it produces a warning.
894
-
895
- | Pattern | Regex equivalent | Example match |
896
- | --- | --- | --- |
897
- | `/about` | exact match | `/about` |
898
- | `/news/:slug` | `([^/]+)` — a single segment | `/news/abc` (✗ `/news/a/b`) |
899
- | `/:path*` | `(.*)` — zero or more segments | `/`, `/a`, `/a/b/c` |
900
- | `/blog/:path*` | wildcard sub-path; the leading `/` is optional | `/blog`, `/blog/`, `/blog/a/b` |
901
- | `/:path*.svg` | wildcard + fixed suffix | `/ikon.svg`, `/a/b/c.svg` |
902
- | `/tag-:slug` | a parameter in the middle of a segment | `/tag-finance` |
903
-
904
- Rules:
905
-
906
- - `source` **must start with `/`**; if it does not, the rule is ignored and a
907
- warning is printed.
908
- - The parameter name must match the pattern `[A-Za-z_][A-Za-z0-9_]*`.
909
- - A pattern always matches **from start to end** (`^…$`); use `:path*` for
910
- prefix matching.
911
- - `:path*` also captures zero segments and the `/` immediately before it is
912
- optional: `/account/:path*` covers the section's root path (`/account`) too.
913
- Otherwise a rule that wanted to close off a whole section was skipping
914
- precisely its landing page.
915
- - Every character other than parameters is treated as a literal and escaped for
916
- the regex — `.` really means a dot.
917
- - Captured values are written into the same-named `:param`s in `destination`. A
918
- placeholder with no counterpart is left as is.
919
-
920
- ## Environment variables
921
-
922
- Every variable the framework reads. If a `.env` file exists it is loaded
923
- automatically by the CLI (`--env-file=.env`); if not, the flag is never passed
924
- and no warning is printed.
925
-
926
- | Variable | Who reads it | Default | Meaning |
927
- | --- | --- | --- | --- |
928
- | `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. |
929
- | `PORT` | `startServer` | `3000` | Port to listen on |
930
- | `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 |
931
- | `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) |
932
- | `DEV_TOKEN` | `devGate`, `prewarm` | — | If set, every request without a token gets a 404. Prewarming carries the token as a cookie. [09](./09-dev-tools.md) |
933
- | `JSKELET_CACHE_PANEL` | `createApp` | — | When set, turns the cache 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) |
934
- | `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) |
935
- | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
936
- | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
937
- | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
938
- | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
939
- | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Number of parallel workers |
940
- | `PREWARM_RPS` | `prewarm` | `0` | At most how many prewarm requests per second; `0` is unlimited |
941
- | `PREWARM_DELAY_MS` | `startPrewarm` | prod 500, dev 3000 | Delay of the first pass |
942
- | `PREWARM_RETRY_DELAY_MS` | `prewarm` | `2000` | The wait before the retry pass |
943
- | `PREWARM_INTERVAL_SECONDS` | `startPrewarm` | `0` | If greater than 0, a periodic pass |
944
- | `JSKELET_VERBOSE` | `jskelet dev` | — | If `1`, all of the changed files are listed on restart |
945
- | `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. |
946
- | `JSKELET_CHILD` | `jskelet build` | — | Set by the dev script; suppresses the build banner and the "Ready" summary |
947
- | `NO_COLOR` | `jskelet/log` | — | If set, colour is never used (it overrides `JSKELET_COLOR` too) |
948
-
949
- Your application's own variables (API origin, tokens) are not read by the
950
- framework; use them directly via `process.env`. Declare the ones that need to
951
- reach the browser with `clientEnv`.
952
-
953
- The numeric prewarm settings only accept **positive and finite** values; an
954
- invalid value silently falls through to the next layer (config → code default).
955
-
956
- ## Programmatic access
957
-
958
- ```js
959
- import { getConfig, loadConfig } from "jskelet";
960
-
961
- await loadConfig(); // reads from the project root
962
- await loadConfig({ root: "/baska/proje" }); // a different root
963
- await loadConfig({ configFile: "jskelet.test.mjs" });
964
- await loadConfig({ force: true }); // bypass the cache and re-read
965
-
966
- const config = getConfig(); // the resolved config
967
- ```
968
-
969
- `loadConfig()` hits the cache on a second call in the same process: `jskelet
970
- start` calls it through both `ensure-build` and `createApp`, and there is no
971
- benefit in reading and logging the config twice.
972
-
973
- If `getConfig()` is used without `loadConfig()` having been called, it
974
- **throws**: a silently wrong path turns into problems that are hard to diagnose,
975
- like "why is there no stylesheet".
976
-
977
- In the resolved config the directories are available as absolute paths under
978
- `config.dirs` (`views`, `public`, `client`, `routes`, `styles`, `generated`,
979
- `assets`, `fonts`), the patterns are in compiled form, and `config.loaded` tells
980
- you whether the file was actually read.
981
-
982
- ## What's next
983
-
984
- - The effect of the build-side fields: [08-build.md](./08-build.md)
985
- - The dev flow and `DEV_TOKEN`: [09-dev-tools.md](./09-dev-tools.md)
986
- - 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.ejs",
59
+ routes: ["./routes/10-pages.mjs", "./routes/99-catch-all.mjs"],
60
+
61
+ static: {
62
+ extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2"],
63
+ prefixes: ["/assets/", "/fonts/"],
64
+ },
65
+
66
+ devGateBypass: ["/api/healthcheck", "/robots.txt"],
67
+ preconnect: ["https://cdn.example.com"],
68
+
69
+ security: {
70
+ trustProxy: true,
71
+ cookieSecret: process.env.JSKELET_SECRET,
72
+ csrf: {
73
+ enabled: true,
74
+ token: false,
75
+ allowedOrigins: [],
76
+ exclude: ["/webhook/:path*"],
77
+ cookieName: "csrf_token",
78
+ fieldName: "_csrf",
79
+ headerName: "x-csrf-token",
80
+ },
81
+ },
82
+
83
+ navigation: {
84
+ prefetch: "moderate",
85
+ prerender: "conservative",
86
+ viewTransition: true,
87
+ exclude: ["/logout"],
88
+ },
89
+
90
+ prewarmSkip: ["/api/", "/_fragment/", "/__example/"],
91
+ watch: ["data"],
92
+
93
+ fonts: [{ family: "Inter", weights: [400, 600, 700] }],
94
+ icons: { scan: ["views", "client", "routes", "lib"] },
95
+ images: { widths: [400, 800, 1200], quality: 78, skip: ["downloads"] },
96
+ clientEnv: ["PUBLIC_WS_URL"],
97
+
98
+ async headers() {
99
+ return [
100
+ {
101
+ source: "/:path*",
102
+ headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }],
103
+ },
104
+ ];
105
+ },
106
+
107
+ async redirects() {
108
+ return [{ source: "/eski/:slug", destination: "/yeni/:slug", permanent: true }];
109
+ },
110
+
111
+ async rewrites() {
112
+ return {
113
+ afterFiles: [
114
+ { source: "/api/:path*", destination: "https://api.example.com/:path*" },
115
+ ],
116
+ };
117
+ },
118
+
119
+ async cache() {
120
+ return {
121
+ html: { "/": 60, "/news/:slug": 300 },
122
+ query: { "/search": ["q", "page"] },
123
+ maxEntries: 500,
124
+ data: { maxEntries: 10000, staleFactor: 10 },
125
+ prewarm: {
126
+ enabled: true,
127
+ max: 400,
128
+ concurrency: 4,
129
+ rps: 0,
130
+ intervalSeconds: 0,
131
+ rotate: true,
132
+ priority: ["/", "/news/:slug"],
133
+ },
134
+ };
135
+ },
136
+
137
+ hooks: {
138
+ metadata() { /* … */ },
139
+ layoutContext() { /* … */ },
140
+ notFound() { /* … */ },
141
+ error() { /* … */ },
142
+ prewarmPaths() { /* … */ },
143
+ },
144
+ };
145
+ ```
146
+
147
+ ## `paths`
148
+
149
+ **Type:** `Record<string, string>` — **Default:** the table below
150
+
151
+ Names of the directories (and, for `styles`, the file) in the project root.
152
+ Values are resolved relative to the project root and turned into absolute paths
153
+ internally.
154
+
155
+ | Key | Default | Contents |
156
+ | --- | --- | --- |
157
+ | `views` | `"views"` | EJS layout, pages, components |
158
+ | `public` | `"public"` | Static files; the build output is written here too |
159
+ | `client` | `"client"` | Island runtime sources and entries |
160
+ | `routes` | `"routes"` | Route modules |
161
+ | `styles` | `"styles/globals.css"` | Tailwind/PostCSS entry **file** |
162
+ | `generated` | `".jskelet"` | `manifest.json`, `metafile.json`, `images.json` |
163
+
164
+ Even though `styles` is a file path it goes through the same resolution; keeping
165
+ a separate field for it is not worth it.
166
+
167
+ Two paths are always derived and cannot be overridden: `public/assets` (hashed
168
+ build output) and `public/fonts` (self-hosted fonts).
169
+
170
+ ```js
171
+ paths: { views: "src/views", routes: "src/routes", styles: "src/styles/main.css" }
172
+ ```
173
+
174
+ ## `brand`
175
+
176
+ **Type:** `object` — **Default:** the table below
177
+
178
+ Branding and names that can be changed from a single place. Projects that fork
179
+ the framework or white-label it can put their own name in. Provided fields are
180
+ shallow-merged with the defaults.
181
+
182
+ | Field | Type | Default | Meaning |
183
+ | --- | --- | --- | --- |
184
+ | `name` | `string` | `"JSkelet"` | Display name |
185
+ | `poweredBy` | `string` | `"JSkelet"` | Value of the `X-Powered-By` header |
186
+ | `cacheHeader` | `string` | `"X-JSkelet-Cache"` | HTML cache status header ([06-caching.md](./06-caching.md)) |
187
+ | `devBasePath` | `string` | `"/__jskelet/dev"` | Root of the dev overlay and report endpoints |
188
+ | `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | UA of prewarm requests; the dev panel filters on it |
189
+ | `devTokenCookie` | `string` | `"dev_token"` | Name of the dev gate's cookie and query parameter |
190
+ | `lang` | `string` | — | Default for `<html lang>`. If not given, the layout uses `"en"`. |
191
+
192
+ Precedence for `lang`: `hooks.layoutContext()` → `lang` **>** `brand.lang`
193
+ **>** `"en"`.
194
+
195
+ ```js
196
+ brand: { lang: "tr", poweredBy: "Example", cacheHeader: "X-Example-Cache" }
197
+ ```
198
+
199
+ ## `layout`
200
+
201
+ **Type:** `string` — **Default:** none (automatic resolution)
202
+
203
+ Path of the layout `.ejs` file. The value given is resolved relative to the
204
+ **parent directory of the views directory**, so with the default `views`,
205
+ `"views/custom.ejs"` → `<root>/views/custom.ejs`.
206
+
207
+ If not given, in order: `views/layout.ejs` if it exists, otherwise the
208
+ framework's minimal layout. Details: [04-rendering.md](./04-rendering.md).
209
+
210
+ ## `routes`
211
+
212
+ **Type:** `string[]` — **Default:** `null` (directory scan)
213
+
214
+ Explicit list of route modules, relative to the project root. They are loaded in
215
+ the given order. If not given, the `paths.routes` directory is scanned
216
+ alphabetically and recursively. Details: [03-routing.md](./03-routing.md).
217
+
218
+ ```js
219
+ routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"]
220
+ ```
221
+
222
+ ## `static`
223
+
224
+ **Type:** `{ extensions?: string[], prefixes?: string[] }` — **Default:**
225
+ below
226
+
227
+ Static file detection by extension and prefix. Paths matching this list get
228
+ `Cache-Control: public, max-age=31536000, immutable`.
229
+
230
+ | Field | Default |
231
+ | --- | --- |
232
+ | `extensions` | `[".svg", ".png", ".webp", ".avif", ".ico", ".woff2"]` |
233
+ | `prefixes` | `["/assets/", "/fonts/"]` |
234
+
235
+ If provided, it **replaces** the default (it is not merged), so if you want to
236
+ add to the default, write out the full list.
237
+
238
+ ```js
239
+ static: {
240
+ extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2", ".mp4"],
241
+ prefixes: ["/assets/", "/fonts/", "/video/"],
242
+ }
243
+ ```
244
+
245
+ ## `devGateBypass`
246
+
247
+ **Type:** `string[]` — **Default:**
248
+ `["/api/healthcheck", "/robots.txt", "/sitemap.xml", "/site.webmanifest", "/favicon.ico"]`
249
+
250
+ **Exact** paths the dev gate never closes off under any circumstances (not a
251
+ prefix, an exact match). This is so that the health check and the robots files
252
+ stay reachable in an environment where `DEV_TOKEN` is set. If provided, it
253
+ replaces the default.
254
+
255
+ Details: [09-dev-tools.md](./09-dev-tools.md).
256
+
257
+ ## `preconnect`
258
+
259
+ **Type:** `string[]` — **Default:** `[]`
260
+
261
+ Third-party origins; printed as `<link rel="preconnect">` in the `<head>` of
262
+ every page. The image CDN, the API origin, the font host go here. Values are
263
+ normalised with `new URL(...).origin`; an invalid URL is skipped and a warning
264
+ is printed.
265
+
266
+ Since the list is the same on every page, it is computed once and stored. An
267
+ empty list is a valid configuration.
268
+
269
+ ```js
270
+ preconnect: ["https://cdn.example.com", "https://api.example.com"]
271
+ ```
272
+
273
+ ## `security`
274
+
275
+ **Type:** `object` — **Default:**
276
+ `{ trustProxy: true, cookieSecret: null, csrf: { enabled: true, token: false, … } }`
277
+
278
+ The whole picture for per-visitor pages, with the reasoning, is in
279
+ [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md); this is the
280
+ field reference.
281
+
282
+ | Field | Type | Default | Meaning |
283
+ | --- | --- | --- | --- |
284
+ | `trustProxy` | `boolean` | `true` | Express's `trust proxy` setting. Needed behind a reverse proxy for the correct protocol and client IP. |
285
+ | `cookieSecret` | `string \| null` | `null` | The signed cookie secret. When absent, `JSKELET_SECRET` is read. |
286
+ | `csrf.enabled` | `boolean` | `true` | The origin / `Sec-Fetch-Site` check. |
287
+ | `csrf.token` | `boolean` | `false` | The double-submit token layer. |
288
+ | `csrf.allowedOrigins` | `string[]` | `[]` | Origins accepted alongside our own host. |
289
+ | `csrf.exclude` | `string[]` | `[]` | Paths exempt from the check; `source` pattern syntax. |
290
+ | `csrf.cookieName` | `string` | `"csrf_token"` | Name of the token cookie. |
291
+ | `csrf.fieldName` | `string` | `"_csrf"` | Field name printed by `csrfField()`. |
292
+ | `csrf.headerName` | `string` | `"x-csrf-token"` | Header the token is also accepted in. |
293
+
294
+ `trustProxy` should be **turned off** on a server exposed directly to the
295
+ internet: while it is on, a client can forge its own `X-Forwarded-For` and rate
296
+ limiting or audit logs see the wrong address.
297
+
298
+ The CSRF check only rejects requests that are **known** to be cross-site — when
299
+ `Origin` does not match or `Sec-Fetch-Site: cross-site` arrives. If neither is
300
+ present the request passes, because browsers always send `Origin` on a
301
+ cross-origin POST while webhooks never do. Even so, listing non-browser
302
+ endpoints in `csrf.exclude` makes the intent readable.
303
+
304
+ ## `navigation`
305
+
306
+ **Type:** `object` — **Default:**
307
+ `{ prefetch: "moderate", prerender: false, viewTransition: false, exclude: [] }`
308
+
309
+ `<head>` hints that speed up in-site navigation. Since JSkelet is a classic MPA,
310
+ every click is a full page load; this section makes the browser do that load
311
+ **ahead of time**. No client runtime is added — Speculation Rules and view
312
+ transitions are browser capabilities, and in a browser that does not support
313
+ them they are silently ignored.
314
+
315
+ | Field | Type | Default | Meaning |
316
+ | --- | --- | --- | --- |
317
+ | `prefetch` | `false \| "conservative" \| "moderate" \| "eager"` | `"moderate"` | Downloads the link target's **document** ahead of time |
318
+ | `prerender` | same | `false` | **Fully renders** the target in the background; it opens the moment you click |
319
+ | `viewTransition` | `boolean` | `false` | Emits `@view-transition { navigation: auto }` |
320
+ | `exclude` | `string[]` | `[]` | href patterns to keep out of speculation |
321
+
322
+ If `true` is given, `prefetch`/`prerender` fall back to the default eagerness; an
323
+ unrecognised value prints a warning and reverts to the default.
324
+
325
+ **What eagerness means:** `conservative` triggers the moment the link is pressed,
326
+ `moderate` when the pointer lingers on the link for a while, `eager` as soon as
327
+ the link becomes visible. The further up you go, the higher the hit rate — and
328
+ the more wasted requests.
329
+
330
+ **Why `prerender` ships off.** The scripts of a prerendered page really do run.
331
+ In an application that does not hook its measurement code to the
332
+ `prerenderingchange` event, visit counts get inflated. Review your analytics
333
+ before turning it on; the cost on the server side is low, because a speculative
334
+ request is also served from the HTML cache
335
+ ([06-caching.md](./06-caching.md)).
336
+
337
+ **Always exempt.** Paths under `/api/*`, `/_fragment/*` and `brand.devBasePath`
338
+ are excluded automatically; `exclude` is added on top of those. Additionally,
339
+ links carrying `rel="nofollow"`, `target="_blank"` or `data-no-prefetch` are not
340
+ covered by any rule. The easiest way to keep a single link with side effects out
341
+ is the last one:
342
+
343
+ ```html
344
+ <a href="/logout" data-no-prefetch>Logout</a>
345
+ ```
346
+
347
+ **When turning on `viewTransition`, put the background on `<html>`.** During the
348
+ transition the browser cross-fades snapshots of the old and the new page; a
349
+ background set on `<body>` stays inside that snapshot and the canvas underneath
350
+ becomes visible. The result is one frame of white flash on every transition, and
351
+ it does not go unnoticed in a dark theme. If the colour is on `<html>` (or
352
+ `:root`), no such gap appears:
353
+
354
+ ```html
355
+ <html lang="tr" class="bg-white dark:bg-slate-950">
356
+ <body class="text-slate-900 dark:text-slate-100">
357
+ ```
358
+
359
+ The reduced-motion preference is handled by the framework: under
360
+ `prefers-reduced-motion: reduce` the transition is disabled, and you do not need
361
+ to write anything extra.
362
+
363
+ **Scope the transition to the content.** The default behaviour cross-fades the
364
+ whole document as a single piece, which means the header and footer — which
365
+ never change across navigations — flicker too. Giving those regions a
366
+ `view-transition-name` puts them in their own group; because the browser sees the
367
+ same name in both documents, it treats them as "the same element". Once you turn
368
+ off the animation of the named element, the transition stays in the content
369
+ only:
370
+
371
+ ```css
372
+ body > header { view-transition-name: site-header; }
373
+ body > footer { view-transition-name: site-footer; }
374
+
375
+ ::view-transition-old(site-header),
376
+ ::view-transition-old(site-footer) { animation: none; opacity: 0; }
377
+ ::view-transition-new(site-header),
378
+ ::view-transition-new(site-footer) { animation: none; opacity: 1; }
379
+
380
+ /* The remaining content; the default 250ms makes navigation feel slow. */
381
+ ::view-transition-old(root),
382
+ ::view-transition-new(root) { animation-duration: 180ms; }
383
+ ```
384
+
385
+ A working version lives in `examples/marketing/styles/globals.css`.
386
+
387
+ **If you use CSP**, the rules are emitted as an inline
388
+ `<script type="speculationrules">`; your `script-src` policy needs to allow it.
389
+
390
+ ```js
391
+ navigation: {
392
+ prefetch: "moderate",
393
+ prerender: "conservative",
394
+ viewTransition: true,
395
+ exclude: ["/logout", "/cart/*"],
396
+ }
397
+ ```
398
+
399
+ ## `prewarmSkip`
400
+
401
+ **Type:** `string[]` — **Default:** `["/api/", "/_fragment/", "/__jskelet/"]`
402
+
403
+ Path **prefixes** that prewarming skips. Session-dependent or fragment endpoints
404
+ should not be prewarmed. If provided, it replaces the default — if you changed
405
+ `brand.devBasePath`, do not forget to update this list too.
406
+
407
+ Details: [06-caching.md](./06-caching.md).
408
+
409
+ ## `watch`
410
+
411
+ **Type:** `string[]` — **Default:** `[]`
412
+
413
+ **Additional** directories that `jskelet dev` watches for server restarts,
414
+ relative to the project root. `routes`, `views` and `lib` are already watched;
415
+ `client/` and `styles/` are handled by the esbuild and CSS watchers and should
416
+ not be put here.
417
+
418
+ Only files with the `.js`, `.mjs`, `.json` and `.ejs` extensions are triggers.
419
+
420
+ ```js
421
+ watch: ["data", "content"]
422
+ ```
423
+
424
+ Details: [09-dev-tools.md](./09-dev-tools.md).
425
+
426
+ ## `fonts`
427
+
428
+ **Type:** `{ family: string, slug?: string, weights?: number[] }[]` —
429
+ **Default:** `[]`
430
+
431
+ Google Fonts families to self-host. If left empty, the font step never runs.
432
+
433
+ | Field | Type | Default | Meaning |
434
+ | --- | --- | --- | --- |
435
+ | `family` | `string` | — | Google Fonts family name: `"Inter"`, `"Noto Sans"` |
436
+ | `slug` | `string` | derived from `family` (lower case, space → `-`) | File name prefix |
437
+ | `weights` | `number[]` | `[400]` | Weights to download |
438
+
439
+ Output: `public/fonts/<slug>-<weight>.woff2`, with the same file name as the
440
+ manifest key. The files have **fixed names** (no hash) and are **expected to be
441
+ committed**. Details: [08-build.md](./08-build.md).
442
+
443
+ ```js
444
+ fonts: [
445
+ { family: "Inter", weights: [400, 600, 700] },
446
+ { family: "Noto Serif", slug: "serif", weights: [400] },
447
+ ]
448
+ ```
449
+
450
+ ## `icons`
451
+
452
+ **Type:** `{ scan?: string[] } | false` — **Default:** `{}`
453
+
454
+ Phosphor SVG sprite generation.
455
+
456
+ | Value | Result |
457
+ | --- | --- |
458
+ | `{}` (default) | The sprite is generated; scanned directories are `["views", "client", "routes", "lib"]` |
459
+ | `{ scan: [...] }` | Changes the scanned directories |
460
+ | `false` | The sprite step is skipped entirely |
461
+
462
+ If `@phosphor-icons/core` is not in the application's `node_modules`, the step is
463
+ silently skipped. Details: [08-build.md](./08-build.md).
464
+
465
+ ```js
466
+ icons: { scan: ["views", "client", "routes", "lib", "content"] }
467
+ ```
468
+
469
+ ## `images`
470
+
471
+ **Type:** `{ widths?: number[], quality?: number, skip?: string[] } | false` —
472
+ **Default:** `{}`
473
+
474
+ Generates webp variants of the png/jpg images under `public/`.
475
+
476
+ | Field | Type | Default | Meaning |
477
+ | --- | --- | --- | --- |
478
+ | `widths` | `number[]` | `[400, 640, 960, 1280, 1920]` | Widths to generate. Ones larger than the source are dropped; the source's own width (at most 1920) is always added. |
479
+ | `quality` | `number` | `78` | webp quality. When it changes, the encoder signature changes and every image is re-encoded. |
480
+ | `skip` | `string[]` | `[]` | **Directory names** not to scan. `assets` and `fonts` are always skipped. |
481
+
482
+ If `false` is given, the image step never runs. The step requires `sharp` and
483
+ never runs on a watch pass. Details: [08-build.md](./08-build.md).
484
+
485
+ ```js
486
+ images: { widths: [400, 800, 1200], quality: 82, skip: ["downloads"] }
487
+ ```
488
+
489
+ ## `clientEnv`
490
+
491
+ **Type:** `string[]` — **Default:** `[]`
492
+
493
+ Environment variable keys to inline into the client bundle at build time. The
494
+ same contract as `NEXT_PUBLIC_*` in Next, except which key is public is decided
495
+ by the config rather than by the name. `NODE_ENV` is always inlined.
496
+
497
+ Because the whole of `process.env` is defined as a single object, reading a key
498
+ that is not in the list returns `undefined` instead of crashing.
499
+
500
+ ```js
501
+ clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN_ORIGIN"]
502
+ ```
503
+
504
+ **Do not put secrets here** — the values sit in the bundle in plain text.
505
+
506
+ ## `headers()`
507
+
508
+ **Type:** `() => { source: string, headers: { key: string, value: string }[] }[]`
509
+ — **Default:** `[]`
510
+
511
+ Response headers by path pattern. The framework only writes long-lived cache
512
+ headers for static files; every other header (CSP, COOP, HSTS,
513
+ X-Frame-Options…) comes from here and takes precedence over the defaults.
514
+
515
+ **All** matching rules are applied (unlike redirects, it does not stop at the
516
+ first match), in order; if two rules write the same header, the later one wins.
517
+
518
+ Entries without a `key` or with an `undefined` `value` are skipped; a rule left
519
+ with no valid headers at all is not added.
520
+
521
+ ```js
522
+ async headers() {
523
+ return [
524
+ {
525
+ source: "/:path*",
526
+ headers: [
527
+ { key: "X-Frame-Options", value: "SAMEORIGIN" },
528
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
529
+ {
530
+ key: "Content-Security-Policy",
531
+ value: "default-src 'self'; img-src 'self' https://cdn.example.com data:",
532
+ },
533
+ ],
534
+ },
535
+ {
536
+ source: "/download/:path*",
537
+ headers: [{ key: "Cache-Control", value: "no-store" }],
538
+ },
539
+ ];
540
+ }
541
+ ```
542
+
543
+ ## `redirects()`
544
+
545
+ **Type:**
546
+ `() => { source: string, destination: string, permanent?: boolean, statusCode?: number }[]`
547
+ — **Default:** `[]`
548
+
549
+ | Field | Type | Meaning |
550
+ | --- | --- | --- |
551
+ | `source` | `string` | Pattern (syntax below) |
552
+ | `destination` | `string` | Target; `:param` placeholders are filled in |
553
+ | `permanent` | `boolean` | `true` → 308, otherwise 307 |
554
+ | `statusCode` | `number` | Explicit status code; overrides `permanent` |
555
+
556
+ The first matching rule wins and the query string is preserved. Details:
557
+ [03-routing.md](./03-routing.md).
558
+
559
+ ## `rewrites()`
560
+
561
+ **Type:** `() => Rule[] | { beforeFiles?: Rule[], afterFiles?: Rule[] }`
562
+ where `Rule = { source: string, destination: string }` — **Default:** `[]`
563
+
564
+ If an array is returned, all of it counts as `afterFiles`.
565
+
566
+ - `beforeFiles` runs even before static files.
567
+ - `afterFiles` runs after static has been tried, before the routes.
568
+ - Absolute target (`http://`/`https://`) → built-in reverse proxy.
569
+ - Relative target → only `req.url` changes.
570
+
571
+ Details: [03-routing.md](./03-routing.md).
572
+
573
+ ## `cache()`
574
+
575
+ **Type:**
576
+ `() => { html?: Record<string, number>, query?: Record<string, string[] | true>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
577
+ **Default:**
578
+ `{ html: {}, query: {}, 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 } }`
579
+
580
+ ### `cache().html`
581
+
582
+ A pattern → seconds mapping. A matching rule **overrides** the route's own
583
+ `revalidate` value. Negative or non-finite values are ignored; `0` means "no
584
+ caching".
585
+
586
+ The one exception is `route(fn, { private: true })`: on that route a matching
587
+ pattern is ignored. The lock is deliberately one-way — a mistake in the other
588
+ direction means one user's HTML is served to another.
589
+
590
+ ```js
591
+ html: {
592
+ "/": 60,
593
+ "/news/:slug": 300,
594
+ "/search": 0,
595
+ }
596
+ ```
597
+
598
+ ### `cache().query`
599
+
600
+ A pattern → list of query parameters allowed into the cache key.
601
+
602
+ **By default a request that carries a query parameter is dynamic**: even when
603
+ `cache().html` covers that path, the response never enters the HTML cache and
604
+ is sent with `private, no-store`. The reason is simple — caching every variant
605
+ of a path mints an unbounded number of keys (`?utm_source=…` and friends), and
606
+ once the `maxEntries` limit is reached those keys evict the real pages. Only the
607
+ application knows which parameter actually changes the output.
608
+
609
+ ```js
610
+ query: {
611
+ "/search": ["q", "page"], // only these two belong to the key
612
+ "/products": ["category"],
613
+ "/report/:id": true, // every parameter belongs to the key
614
+ "/campaign": [], // the query is ignored entirely
615
+ }
616
+ ```
617
+
618
+ - **Allowlist** (`string[]`): the listed parameters become part of the key and
619
+ each distinct value gets its own entry. Parameters outside the list are
620
+ **ignored** — the page is still cached and every campaign variant shares one
621
+ copy.
622
+ - **`true`**: every parameter belongs to the key. Nothing but `maxEntries`
623
+ bounds the number of entries, so use it only where the value set is closed.
624
+ - **`[]`**: the query is not considered at all; every variant is served the HTML
625
+ of the query-less version.
626
+
627
+ Parameters are written into the key **sorted**, so `?a=1&b=2` and `?b=2&a=1`
628
+ share one entry. `route(fn, { private: true })` is unaffected by this section; a
629
+ private route is never cached under any condition.
630
+
631
+ ### `cache().maxEntries`
632
+
633
+ **Type:** `number` — **Default:** `500`
634
+
635
+ The entry limit of the HTML cache. Because an entry costs a hundred kilobytes,
636
+ raising this number burns through memory quickly; trying to solve a site with
637
+ tens of thousands of paths from here is the wrong layer — the right place is
638
+ `cache().data`.
639
+
640
+ ### `cache().data`
641
+
642
+ The upstream data cache (`withDataCache`). Details:
643
+ [06-caching.md](./06-caching.md).
644
+
645
+ | Field | Type | Default | Meaning |
646
+ | --- | --- | --- | --- |
647
+ | `maxEntries` | `number` | `10000` | The LRU entry limit. The limit is high because JSON is tens of times smaller than HTML. |
648
+ | `staleFactor` | `number` | `10` | For how many TTLs an entry stays usable after the TTL expired. `0` → no stale serving. |
649
+
650
+ ### `cache().trackUpstream`
651
+
652
+ **Type:** `boolean` — **Default:** `true`
653
+
654
+ When on, `globalThis.fetch` is wrapped and transient upstream failures (`429`,
655
+ `5xx`, network) during a render are reported automatically; calling
656
+ `reportUpstreamFailure()` is not required. An application that wraps `fetch`
657
+ itself can turn this off.
658
+
659
+ ### `cache().trackDependencies`
660
+
661
+ **Type:** `boolean` — **Default:** `true`
662
+
663
+ When on, the `withDataCache` keys a render reads are recorded, and
664
+ `clearDataCache()` also stales the HTML pages that read that data — targeted
665
+ invalidation without the application declaring anything
666
+ ([06-caching.md](./06-caching.md)). An application that does not use
667
+ `withDataCache` has nothing to record; turning this off also removes the cost of
668
+ setting up the context.
669
+
670
+ ### `cache().transientRetry`
671
+
672
+ **Type:** `{ attempts?: number, delayMs?: number } | false` —
673
+ **Default:** `{ attempts: 1, delayMs: 300 }`
674
+
675
+ How many extra times a page is tried when `notFound()` was called because of a
676
+ transient upstream failure. The point is that an existing page never turns into
677
+ a 404; if the retries are exhausted the response is an uncached 503. `false` or
678
+ `attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
679
+
680
+ ### `cache().upstream`
681
+
682
+ A per-host rate limit for the `fetch` calls that go to upstream APIs. Off by
683
+ default: unless `rate` is given, no request ever waits. `rate` is a ceiling; the
684
+ actual rate pulls itself down in response to 429s and climbs back step by step
685
+ during clean windows.
686
+
687
+ | Field | Type | Default | Meaning |
688
+ | --- | --- | --- | --- |
689
+ | `rate` | `number` | `0` | Maximum calls per second. `0` → brake disabled |
690
+ | `burst` | `number` | `0` | Bucket size; `0` → one second's budget of burst |
691
+ | `concurrency` | `number` | `8` | Calls allowed in flight at once |
692
+ | `minRate` | `number` | `0.5` | Floor of the decrease; the rate never goes below it |
693
+ | `increaseStep` | `number` | `1` | Step of the additive increase (calls/second) |
694
+ | `increaseIntervalMs` | `number` | `5000` | Increase period |
695
+ | `decreaseIntervalMs` | `number` | `1000` | Minimum time between two decreases |
696
+ | `breakerFailures` | `number` | `5` | Consecutive 429s after which the host is bypassed |
697
+ | `breakerCooldownMs` | `number` | `10000` | How long the bypass lasts |
698
+ | `hosts` | `Record<string, object>` | `{}` | Per-host overrides; same fields apply |
699
+
700
+ Only `429` and `503` penalise the rate: a `400`/`404`/`500` is not a quota
701
+ problem. Read the state with `getUpstreamLimiterStatus()` or from the dev
702
+ panel's **Server** tab. Details, and what to check before turning it on:
703
+ [06-caching.md](./06-caching.md).
704
+
705
+ ```js
706
+ upstream: {
707
+ rate: 10,
708
+ concurrency: 4,
709
+ hosts: { "api.example.com": { rate: 3 } },
710
+ }
711
+ ```
712
+
713
+ ### `cache().redis`
714
+
715
+ An optional Redis second tier (L2). The in-process cache stays primary; Redis
716
+ only skips the render for a path that is not in L1 and spreads invalidation to
717
+ the other instances. `ioredis` has to be installed in the application
718
+ (`npm install ioredis`); if it is missing or unreachable a warning is printed and
719
+ the site keeps running on the in-process cache.
720
+
721
+ | Field | Type | Default | Meaning |
722
+ | --- | --- | --- | --- |
723
+ | `enabled` | `boolean` | `false` | Only turns on when `true` is passed explicitly |
724
+ | `url` | `string \| null` | `null` | `redis://` or `rediss://`. When empty, the ioredis default (`localhost:6379`) |
725
+ | `namespace` | `string` | `"default"` | Separates applications sharing one Redis |
726
+ | `keyPrefix` | `string` | `"_jskelet"` | Root of the key layout |
727
+ | `html` | `boolean` | `true` | Whether HTML bodies are shared |
728
+ | `data` | `boolean` | `true` | Whether `withDataCache` entries are shared |
729
+ | `storeEncoded` | `boolean` | `false` | Whether brotli/gzip bodies are shared too; doubles or triples the size per entry |
730
+ | `events` | `boolean` | `true` | Invalidation broadcast over pub/sub |
731
+ | `commandTimeoutMs` | `number` | `200` | At most how long a single command may block |
732
+
733
+ Keys live as `_jskelet:{namespace}:{buildId}:html:{path}?{query}`. `buildId`
734
+ changes with every build, so old HTML becomes invalid on its own after a deploy.
735
+ Personalised (`storable: false`), `degraded` and non-200 responses are never
736
+ written to the shared tier. Trade-offs and diagnosis:
737
+ [06-caching.md](./06-caching.md).
738
+
739
+ ```js
740
+ redis: {
741
+ enabled: process.env.NODE_ENV === "production",
742
+ url: process.env.REDIS_URL,
743
+ namespace: "news-site",
744
+ }
745
+ ```
746
+
747
+ ### `cache().panel`
748
+
749
+ The cache admin panel. It shows the state of the in-process tier and the Redis
750
+ tier, and it triggers targeted invalidation, single-entry drops and prewarming.
751
+
752
+ It does not look at the environment: without `enabled` **nothing is mounted**
753
+ and the path does not exist. When it is on it also works in production — that is
754
+ where the real questions ("why is this page stale", "did the webhook purge
755
+ land") get asked.
756
+
757
+ | Field | Type | Default | Meaning |
758
+ | --- | --- | --- | --- |
759
+ | `enabled` | `boolean` | `false` | Only turns on when explicitly `true` (`JSKELET_CACHE_PANEL` overrides it) |
760
+ | `basePath` | `string` | `"/_jskelet/cache"` | Root of the panel |
761
+ | `banAttempts` | `number` | `3` | How many failed attempts ban an IP |
762
+ | `banHours` | `number` | `24` | How long the ban lasts |
763
+ | `sessionHours` | `number` | `12` | Lifetime of the session cookie |
764
+
765
+ The password is generated **on every process start** and only appears in the
766
+ server log:
767
+
768
+ ```
769
+ [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
770
+ ```
771
+
772
+ There is no persistent secret (no config field, no environment variable):
773
+ leaking one means handing out the right to flush the cache, and a deploy should
774
+ revoke old access on its own. Banned and unauthorised requests all get a `404`.
775
+ Usage and screens: [06-caching.md](./06-caching.md).
776
+
777
+ ### `cache().cloudflare`
778
+
779
+ The CDN tier. JSkelet's cache is the origin cache; the copy your visitors get
780
+ sits at the edge. With this section connected, the panel can purge the edge,
781
+ read and change cache related zone settings and show the cache hit ratio.
782
+
783
+ | Field | Type | Default | Meaning |
784
+ | --- | --- | --- | --- |
785
+ | `enabled` | `boolean` | `true` | Set `false` to keep the surface off even when a token is present in the environment |
786
+ | `zoneId` | `string \| null` | `null` | Zone identifier (`JSKELET_CLOUDFLARE_ZONE_ID` overrides it) |
787
+ | `apiToken` | `string \| null` | `null` | The token; **prefer the environment**, putting it here puts a secret in the repo |
788
+ | `hostname` | `string \| null` | `null` | Purging wants absolute URLs; paths are resolved against this name. Falls back to the origin the panel was opened on |
789
+ | `analyticsHours` | `number` | `24` | Analytics window, at most `72` |
790
+
791
+ Passing the token only through `JSKELET_CLOUDFLARE_KEY` keeps the config file
792
+ clean. Permissions follow what you intend to do: `Zone.Cache Purge` to purge,
793
+ `Zone.Zone Settings` for settings, `Zone.Analytics` (read) for the hit ratio.
794
+ The token is never returned in a panel response — only the fact that it came
795
+ from the environment.
796
+
797
+ With no zone connected the panel shows a setup snippet rather than a warning,
798
+ and if Cloudflare returns an error that section reports it while the rest of the
799
+ panel keeps working. What can actually be asked — in particular why "how many
800
+ edges hold this page" has no exact answer — is in
801
+ [06-caching.md](./06-caching.md).
802
+
803
+ ```js
804
+ panel: {
805
+ enabled: process.env.CACHE_PANEL === "1",
806
+ }
807
+ ```
808
+
809
+ ### `cache().prewarm`
810
+
811
+ | Field | Type | Default | Meaning |
812
+ | --- | --- | --- | --- |
813
+ | `enabled` | `boolean` | `true` | If `false`, no prewarming happens (can be overridden with `PREWARM=1`) |
814
+ | `max` | `number` | `400` | At most how many paths are prewarmed per pass |
815
+ | `concurrency` | `number` | prod 4, dev 1 | Number of parallel workers |
816
+ | `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. |
817
+ | `delayMs` | `number` | prod 500, dev 3000 | Delay of the first pass after startup |
818
+ | `retryDelayMs` | `number` | `2000` | How long to wait before the retry pass |
819
+ | `intervalSeconds` | `number` | `0` | If greater than 0, the pass repeats periodically |
820
+ | `rotate` | `boolean` | `true` | If the list is longer than `max`, periodic passes continue where they left off |
821
+ | `priority` | `(string \| RegExp)[]` | `[]` | Warm-up order; matching paths are taken first on every pass |
822
+
823
+ `priority` accepts two forms: the pattern syntax used everywhere in the config,
824
+ and a plain `RegExp`. Whatever is written first is warmed first.
825
+
826
+ ```js
827
+ prewarm: {
828
+ max: 500,
829
+ rps: 4,
830
+ intervalSeconds: 300,
831
+ priority: [
832
+ "/", // the home page
833
+ "/markets/:path*", // the whole markets section
834
+ /-comments$/, // a rule the pattern syntax does not cover
835
+ ],
836
+ }
837
+ ```
838
+
839
+ Each numeric field can be overridden by an environment variable of the same
840
+ name; env takes precedence. Details: [06-caching.md](./06-caching.md).
841
+
842
+ ## `hooks`
843
+
844
+ **Type:** `Record<string, Function>` — **Default:** `{}`
845
+
846
+ All optional, all may be `async`. If a hook throws, the framework falls back to
847
+ its own default and warns — the page does not go down.
848
+
849
+ | Hook | Signature | What it returns | Document |
850
+ | --- | --- | --- | --- |
851
+ | `metadata` | `(page) => object` | Metadata default for every page; the controller's `metadata` is layered on top | [04](./04-rendering.md) |
852
+ | `layoutContext` | `({ pathname, metadata }) => object` | Layout locals; `lang`, `structuredData`, `extraHead` and `bodyClass` get special treatment | [04](./04-rendering.md) |
853
+ | `notFound` | `() => object \| null` | 404 page definition; if `null`, the framework's error page | [03](./03-routing.md) |
854
+ | `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) |
855
+ | `prewarmPaths` | `() => string[]` | Paths to prewarm; if it is not defined, prewarming is never set up | [06](./06-caching.md) |
856
+
857
+ ```js
858
+ hooks: {
859
+ metadata() {
860
+ return { titleTemplate: "%s | Example", siteUrl: "https://example.com" };
861
+ },
862
+
863
+ async layoutContext({ pathname }) {
864
+ return { navigation: await getNavigation(), isHome: pathname === "/" };
865
+ },
866
+
867
+ notFound() {
868
+ return {
869
+ view: "pages/not-found",
870
+ metadata: { title: "Page not found", robots: { index: false } },
871
+ };
872
+ },
873
+
874
+ error({ status }) {
875
+ return {
876
+ view: "pages/error",
877
+ data: { status },
878
+ metadata: { title: "Something went wrong", robots: { index: false } },
879
+ };
880
+ },
881
+
882
+ async prewarmPaths() {
883
+ return ["/", ...(await getArticlePaths())];
884
+ },
885
+ }
886
+ ```
887
+
888
+ ## `source` pattern syntax
889
+
890
+ `headers()`, `redirects()`, `rewrites()` and `cache().html` all use the same
891
+ small compiler. This is not Next's full `path-to-regexp` surface; the subset
892
+ actually used in configuration was chosen deliberately, and an unrecognised
893
+ syntax is not silently accepted as a literal — it produces a warning.
894
+
895
+ | Pattern | Regex equivalent | Example match |
896
+ | --- | --- | --- |
897
+ | `/about` | exact match | `/about` |
898
+ | `/news/:slug` | `([^/]+)` — a single segment | `/news/abc` (✗ `/news/a/b`) |
899
+ | `/:path*` | `(.*)` — zero or more segments | `/`, `/a`, `/a/b/c` |
900
+ | `/blog/:path*` | wildcard sub-path; the leading `/` is optional | `/blog`, `/blog/`, `/blog/a/b` |
901
+ | `/:path*.svg` | wildcard + fixed suffix | `/ikon.svg`, `/a/b/c.svg` |
902
+ | `/tag-:slug` | a parameter in the middle of a segment | `/tag-finance` |
903
+
904
+ Rules:
905
+
906
+ - `source` **must start with `/`**; if it does not, the rule is ignored and a
907
+ warning is printed.
908
+ - The parameter name must match the pattern `[A-Za-z_][A-Za-z0-9_]*`.
909
+ - A pattern always matches **from start to end** (`^…$`); use `:path*` for
910
+ prefix matching.
911
+ - `:path*` also captures zero segments and the `/` immediately before it is
912
+ optional: `/account/:path*` covers the section's root path (`/account`) too.
913
+ Otherwise a rule that wanted to close off a whole section was skipping
914
+ precisely its landing page.
915
+ - Every character other than parameters is treated as a literal and escaped for
916
+ the regex — `.` really means a dot.
917
+ - Captured values are written into the same-named `:param`s in `destination`. A
918
+ placeholder with no counterpart is left as is.
919
+
920
+ ## Environment variables
921
+
922
+ Every variable the framework reads. If a `.env` file exists it is loaded
923
+ automatically by the CLI (`--env-file=.env`); if not, the flag is never passed
924
+ and no warning is printed.
925
+
926
+ | Variable | Who reads it | Default | Meaning |
927
+ | --- | --- | --- | --- |
928
+ | `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. |
929
+ | `PORT` | `startServer` | `3000` | Port to listen on |
930
+ | `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 |
931
+ | `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) |
932
+ | `DEV_TOKEN` | `devGate`, `prewarm` | — | If set, every request without a token gets a 404. Prewarming carries the token as a cookie. [09](./09-dev-tools.md) |
933
+ | `JSKELET_CACHE_PANEL` | `createApp` | — | When set, turns the cache 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) |
934
+ | `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) |
935
+ | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
936
+ | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
937
+ | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
938
+ | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
939
+ | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Number of parallel workers |
940
+ | `PREWARM_RPS` | `prewarm` | `0` | At most how many prewarm requests per second; `0` is unlimited |
941
+ | `PREWARM_DELAY_MS` | `startPrewarm` | prod 500, dev 3000 | Delay of the first pass |
942
+ | `PREWARM_RETRY_DELAY_MS` | `prewarm` | `2000` | The wait before the retry pass |
943
+ | `PREWARM_INTERVAL_SECONDS` | `startPrewarm` | `0` | If greater than 0, a periodic pass |
944
+ | `JSKELET_VERBOSE` | `jskelet dev` | — | If `1`, all of the changed files are listed on restart |
945
+ | `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. |
946
+ | `JSKELET_CHILD` | `jskelet build` | — | Set by the dev script; suppresses the build banner and the "Ready" summary |
947
+ | `NO_COLOR` | `jskelet/log` | — | If set, colour is never used (it overrides `JSKELET_COLOR` too) |
948
+
949
+ Your application's own variables (API origin, tokens) are not read by the
950
+ framework; use them directly via `process.env`. Declare the ones that need to
951
+ reach the browser with `clientEnv`.
952
+
953
+ The numeric prewarm settings only accept **positive and finite** values; an
954
+ invalid value silently falls through to the next layer (config → code default).
955
+
956
+ ## Programmatic access
957
+
958
+ ```js
959
+ import { getConfig, loadConfig } from "jskelet";
960
+
961
+ await loadConfig(); // reads from the project root
962
+ await loadConfig({ root: "/baska/proje" }); // a different root
963
+ await loadConfig({ configFile: "jskelet.test.mjs" });
964
+ await loadConfig({ force: true }); // bypass the cache and re-read
965
+
966
+ const config = getConfig(); // the resolved config
967
+ ```
968
+
969
+ `loadConfig()` hits the cache on a second call in the same process: `jskelet
970
+ start` calls it through both `ensure-build` and `createApp`, and there is no
971
+ benefit in reading and logging the config twice.
972
+
973
+ If `getConfig()` is used without `loadConfig()` having been called, it
974
+ **throws**: a silently wrong path turns into problems that are hard to diagnose,
975
+ like "why is there no stylesheet".
976
+
977
+ In the resolved config the directories are available as absolute paths under
978
+ `config.dirs` (`views`, `public`, `client`, `routes`, `styles`, `generated`,
979
+ `assets`, `fonts`), the patterns are in compiled form, and `config.loaded` tells
980
+ you whether the file was actually read.
981
+
982
+ ## What's next
983
+
984
+ - The effect of the build-side fields: [08-build.md](./08-build.md)
985
+ - The dev flow and `DEV_TOKEN`: [09-dev-tools.md](./09-dev-tools.md)
986
+ - Using environment variables in deployment: [10-deployment.md](./10-deployment.md)