jskelet 0.1.1 → 0.1.3

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 (66) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +129 -2
  3. package/README.md +21 -7
  4. package/bin/jskelet.mjs +6 -6
  5. package/docs/03-routing.md +48 -9
  6. package/docs/04-render-ve-sablonlar.md +2 -2
  7. package/docs/05-islands.md +59 -6
  8. package/docs/06-cache.md +240 -26
  9. package/docs/07-yapilandirma.md +108 -7
  10. package/docs/08-build.md +4 -4
  11. package/docs/09-dev-araclari.md +5 -0
  12. package/docs/12-panel-ve-oturum.md +384 -0
  13. package/docs/README.md +25 -2
  14. package/docs/en/01-getting-started.md +292 -0
  15. package/docs/en/02-architecture.md +305 -0
  16. package/docs/en/03-routing.md +493 -0
  17. package/docs/en/04-rendering.md +504 -0
  18. package/docs/en/05-islands.md +492 -0
  19. package/docs/en/06-caching.md +640 -0
  20. package/docs/en/07-configuration.md +789 -0
  21. package/docs/en/08-build.md +383 -0
  22. package/docs/en/09-dev-tools.md +314 -0
  23. package/docs/en/10-deployment.md +332 -0
  24. package/docs/en/11-migration.md +360 -0
  25. package/docs/en/12-dashboards-and-sessions.md +392 -0
  26. package/docs/en/README.md +112 -0
  27. package/package.json +4 -2
  28. package/src/build/build.mjs +1 -1
  29. package/src/build/tasks/client.mjs +2 -2
  30. package/src/build/tasks/fonts.mjs +3 -3
  31. package/src/build/tasks/icons.mjs +1 -1
  32. package/src/build/tasks/images.mjs +2 -2
  33. package/src/client/devtools/overlay.js +196 -164
  34. package/src/client/devtools/report.js +96 -96
  35. package/src/client/form.js +192 -0
  36. package/src/client/index.js +10 -1
  37. package/src/client/registry.js +78 -4
  38. package/src/client/swap.js +188 -0
  39. package/src/config/defaults.js +83 -0
  40. package/src/config/index.js +129 -18
  41. package/src/config/pattern.js +1 -1
  42. package/src/dev-server.mjs +1 -1
  43. package/src/http/control-flow.js +16 -1
  44. package/src/http/cookies.js +257 -0
  45. package/src/http/request-context.js +162 -0
  46. package/src/index.js +26 -2
  47. package/src/init.mjs +32 -31
  48. package/src/log.mjs +8 -2
  49. package/src/logo.png +0 -0
  50. package/src/runtime/alias-hooks.mjs +1 -1
  51. package/src/server/assets.js +1 -1
  52. package/src/server/create-app.js +12 -4
  53. package/src/server/data-cache.js +244 -0
  54. package/src/server/dev/devtools.js +6 -2
  55. package/src/server/dev/report.js +8 -1
  56. package/src/server/dev/version-check.mjs +139 -0
  57. package/src/server/head-hints.js +1 -1
  58. package/src/server/html-cache.js +32 -6
  59. package/src/server/middleware/csrf.js +134 -0
  60. package/src/server/prewarm.js +164 -19
  61. package/src/server/render.js +256 -20
  62. package/src/server/router.js +14 -7
  63. package/src/server/status-page.js +1 -1
  64. package/src/version.mjs +9 -4
  65. package/src/views/components/loader.js +1 -1
  66. package/src/views/helpers/tags.js +53 -1
@@ -0,0 +1,789 @@
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
+ maxEntries: 500,
123
+ data: { maxEntries: 10000, staleFactor: 10 },
124
+ prewarm: {
125
+ enabled: true,
126
+ max: 400,
127
+ concurrency: 4,
128
+ rps: 0,
129
+ intervalSeconds: 0,
130
+ rotate: true,
131
+ priority: ["/", "/news/:slug"],
132
+ },
133
+ };
134
+ },
135
+
136
+ hooks: {
137
+ metadata() { /* … */ },
138
+ layoutContext() { /* … */ },
139
+ notFound() { /* … */ },
140
+ error() { /* … */ },
141
+ prewarmPaths() { /* … */ },
142
+ },
143
+ };
144
+ ```
145
+
146
+ ## `paths`
147
+
148
+ **Type:** `Record<string, string>` — **Default:** the table below
149
+
150
+ Names of the directories (and, for `styles`, the file) in the project root.
151
+ Values are resolved relative to the project root and turned into absolute paths
152
+ internally.
153
+
154
+ | Key | Default | Contents |
155
+ | --- | --- | --- |
156
+ | `views` | `"views"` | EJS layout, pages, components |
157
+ | `public` | `"public"` | Static files; the build output is written here too |
158
+ | `client` | `"client"` | Island runtime sources and entries |
159
+ | `routes` | `"routes"` | Route modules |
160
+ | `styles` | `"styles/globals.css"` | Tailwind/PostCSS entry **file** |
161
+ | `generated` | `".jskelet"` | `manifest.json`, `metafile.json`, `images.json` |
162
+
163
+ Even though `styles` is a file path it goes through the same resolution; keeping
164
+ a separate field for it is not worth it.
165
+
166
+ Two paths are always derived and cannot be overridden: `public/assets` (hashed
167
+ build output) and `public/fonts` (self-hosted fonts).
168
+
169
+ ```js
170
+ paths: { views: "src/views", routes: "src/routes", styles: "src/styles/main.css" }
171
+ ```
172
+
173
+ ## `brand`
174
+
175
+ **Type:** `object` — **Default:** the table below
176
+
177
+ Branding and names that can be changed from a single place. Projects that fork
178
+ the framework or white-label it can put their own name in. Provided fields are
179
+ shallow-merged with the defaults.
180
+
181
+ | Field | Type | Default | Meaning |
182
+ | --- | --- | --- | --- |
183
+ | `name` | `string` | `"JSkelet"` | Display name |
184
+ | `poweredBy` | `string` | `"JSkelet"` | Value of the `X-Powered-By` header |
185
+ | `cacheHeader` | `string` | `"X-JSkelet-Cache"` | HTML cache status header ([06-caching.md](./06-caching.md)) |
186
+ | `devBasePath` | `string` | `"/__jskelet/dev"` | Root of the dev overlay and report endpoints |
187
+ | `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | UA of prewarm requests; the dev panel filters on it |
188
+ | `devTokenCookie` | `string` | `"dev_token"` | Name of the dev gate's cookie and query parameter |
189
+ | `lang` | `string` | — | Default for `<html lang>`. If not given, the layout uses `"en"`. |
190
+
191
+ Precedence for `lang`: `hooks.layoutContext()` → `lang` **>** `brand.lang`
192
+ **>** `"en"`.
193
+
194
+ ```js
195
+ brand: { lang: "tr", poweredBy: "Example", cacheHeader: "X-Example-Cache" }
196
+ ```
197
+
198
+ ## `layout`
199
+
200
+ **Type:** `string` — **Default:** none (automatic resolution)
201
+
202
+ Path of the layout `.ejs` file. The value given is resolved relative to the
203
+ **parent directory of the views directory**, so with the default `views`,
204
+ `"views/custom.ejs"` → `<root>/views/custom.ejs`.
205
+
206
+ If not given, in order: `views/layout.ejs` if it exists, otherwise the
207
+ framework's minimal layout. Details: [04-rendering.md](./04-rendering.md).
208
+
209
+ ## `routes`
210
+
211
+ **Type:** `string[]` — **Default:** `null` (directory scan)
212
+
213
+ Explicit list of route modules, relative to the project root. They are loaded in
214
+ the given order. If not given, the `paths.routes` directory is scanned
215
+ alphabetically and recursively. Details: [03-routing.md](./03-routing.md).
216
+
217
+ ```js
218
+ routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"]
219
+ ```
220
+
221
+ ## `static`
222
+
223
+ **Type:** `{ extensions?: string[], prefixes?: string[] }` — **Default:**
224
+ below
225
+
226
+ Static file detection by extension and prefix. Paths matching this list get
227
+ `Cache-Control: public, max-age=31536000, immutable`.
228
+
229
+ | Field | Default |
230
+ | --- | --- |
231
+ | `extensions` | `[".svg", ".png", ".webp", ".avif", ".ico", ".woff2"]` |
232
+ | `prefixes` | `["/assets/", "/fonts/"]` |
233
+
234
+ If provided, it **replaces** the default (it is not merged), so if you want to
235
+ add to the default, write out the full list.
236
+
237
+ ```js
238
+ static: {
239
+ extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2", ".mp4"],
240
+ prefixes: ["/assets/", "/fonts/", "/video/"],
241
+ }
242
+ ```
243
+
244
+ ## `devGateBypass`
245
+
246
+ **Type:** `string[]` — **Default:**
247
+ `["/api/healthcheck", "/robots.txt", "/sitemap.xml", "/site.webmanifest", "/favicon.ico"]`
248
+
249
+ **Exact** paths the dev gate never closes off under any circumstances (not a
250
+ prefix, an exact match). This is so that the health check and the robots files
251
+ stay reachable in an environment where `DEV_TOKEN` is set. If provided, it
252
+ replaces the default.
253
+
254
+ Details: [09-dev-tools.md](./09-dev-tools.md).
255
+
256
+ ## `preconnect`
257
+
258
+ **Type:** `string[]` — **Default:** `[]`
259
+
260
+ Third-party origins; printed as `<link rel="preconnect">` in the `<head>` of
261
+ every page. The image CDN, the API origin, the font host go here. Values are
262
+ normalised with `new URL(...).origin`; an invalid URL is skipped and a warning
263
+ is printed.
264
+
265
+ Since the list is the same on every page, it is computed once and stored. An
266
+ empty list is a valid configuration.
267
+
268
+ ```js
269
+ preconnect: ["https://cdn.example.com", "https://api.example.com"]
270
+ ```
271
+
272
+ ## `security`
273
+
274
+ **Type:** `object` — **Default:**
275
+ `{ trustProxy: true, cookieSecret: null, csrf: { enabled: true, token: false, … } }`
276
+
277
+ The whole picture for per-visitor pages, with the reasoning, is in
278
+ [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md); this is the
279
+ field reference.
280
+
281
+ | Field | Type | Default | Meaning |
282
+ | --- | --- | --- | --- |
283
+ | `trustProxy` | `boolean` | `true` | Express's `trust proxy` setting. Needed behind a reverse proxy for the correct protocol and client IP. |
284
+ | `cookieSecret` | `string \| null` | `null` | The signed cookie secret. When absent, `JSKELET_SECRET` is read. |
285
+ | `csrf.enabled` | `boolean` | `true` | The origin / `Sec-Fetch-Site` check. |
286
+ | `csrf.token` | `boolean` | `false` | The double-submit token layer. |
287
+ | `csrf.allowedOrigins` | `string[]` | `[]` | Origins accepted alongside our own host. |
288
+ | `csrf.exclude` | `string[]` | `[]` | Paths exempt from the check; `source` pattern syntax. |
289
+ | `csrf.cookieName` | `string` | `"csrf_token"` | Name of the token cookie. |
290
+ | `csrf.fieldName` | `string` | `"_csrf"` | Field name printed by `csrfField()`. |
291
+ | `csrf.headerName` | `string` | `"x-csrf-token"` | Header the token is also accepted in. |
292
+
293
+ `trustProxy` should be **turned off** on a server exposed directly to the
294
+ internet: while it is on, a client can forge its own `X-Forwarded-For` and rate
295
+ limiting or audit logs see the wrong address.
296
+
297
+ The CSRF check only rejects requests that are **known** to be cross-site — when
298
+ `Origin` does not match or `Sec-Fetch-Site: cross-site` arrives. If neither is
299
+ present the request passes, because browsers always send `Origin` on a
300
+ cross-origin POST while webhooks never do. Even so, listing non-browser
301
+ endpoints in `csrf.exclude` makes the intent readable.
302
+
303
+ ## `navigation`
304
+
305
+ **Type:** `object` — **Default:**
306
+ `{ prefetch: "moderate", prerender: false, viewTransition: false, exclude: [] }`
307
+
308
+ `<head>` hints that speed up in-site navigation. Since JSkelet is a classic MPA,
309
+ every click is a full page load; this section makes the browser do that load
310
+ **ahead of time**. No client runtime is added — Speculation Rules and view
311
+ transitions are browser capabilities, and in a browser that does not support
312
+ them they are silently ignored.
313
+
314
+ | Field | Type | Default | Meaning |
315
+ | --- | --- | --- | --- |
316
+ | `prefetch` | `false \| "conservative" \| "moderate" \| "eager"` | `"moderate"` | Downloads the link target's **document** ahead of time |
317
+ | `prerender` | same | `false` | **Fully renders** the target in the background; it opens the moment you click |
318
+ | `viewTransition` | `boolean` | `false` | Emits `@view-transition { navigation: auto }` |
319
+ | `exclude` | `string[]` | `[]` | href patterns to keep out of speculation |
320
+
321
+ If `true` is given, `prefetch`/`prerender` fall back to the default eagerness; an
322
+ unrecognised value prints a warning and reverts to the default.
323
+
324
+ **What eagerness means:** `conservative` triggers the moment the link is pressed,
325
+ `moderate` when the pointer lingers on the link for a while, `eager` as soon as
326
+ the link becomes visible. The further up you go, the higher the hit rate — and
327
+ the more wasted requests.
328
+
329
+ **Why `prerender` ships off.** The scripts of a prerendered page really do run.
330
+ In an application that does not hook its measurement code to the
331
+ `prerenderingchange` event, visit counts get inflated. Review your analytics
332
+ before turning it on; the cost on the server side is low, because a speculative
333
+ request is also served from the HTML cache
334
+ ([06-caching.md](./06-caching.md)).
335
+
336
+ **Always exempt.** Paths under `/api/*`, `/_fragment/*` and `brand.devBasePath`
337
+ are excluded automatically; `exclude` is added on top of those. Additionally,
338
+ links carrying `rel="nofollow"`, `target="_blank"` or `data-no-prefetch` are not
339
+ covered by any rule. The easiest way to keep a single link with side effects out
340
+ is the last one:
341
+
342
+ ```html
343
+ <a href="/logout" data-no-prefetch>Logout</a>
344
+ ```
345
+
346
+ **When turning on `viewTransition`, put the background on `<html>`.** During the
347
+ transition the browser cross-fades snapshots of the old and the new page; a
348
+ background set on `<body>` stays inside that snapshot and the canvas underneath
349
+ becomes visible. The result is one frame of white flash on every transition, and
350
+ it does not go unnoticed in a dark theme. If the colour is on `<html>` (or
351
+ `:root`), no such gap appears:
352
+
353
+ ```html
354
+ <html lang="tr" class="bg-white dark:bg-slate-950">
355
+ <body class="text-slate-900 dark:text-slate-100">
356
+ ```
357
+
358
+ The reduced-motion preference is handled by the framework: under
359
+ `prefers-reduced-motion: reduce` the transition is disabled, and you do not need
360
+ to write anything extra.
361
+
362
+ **Scope the transition to the content.** The default behaviour cross-fades the
363
+ whole document as a single piece, which means the header and footer — which
364
+ never change across navigations — flicker too. Giving those regions a
365
+ `view-transition-name` puts them in their own group; because the browser sees the
366
+ same name in both documents, it treats them as "the same element". Once you turn
367
+ off the animation of the named element, the transition stays in the content
368
+ only:
369
+
370
+ ```css
371
+ body > header { view-transition-name: site-header; }
372
+ body > footer { view-transition-name: site-footer; }
373
+
374
+ ::view-transition-old(site-header),
375
+ ::view-transition-old(site-footer) { animation: none; opacity: 0; }
376
+ ::view-transition-new(site-header),
377
+ ::view-transition-new(site-footer) { animation: none; opacity: 1; }
378
+
379
+ /* The remaining content; the default 250ms makes navigation feel slow. */
380
+ ::view-transition-old(root),
381
+ ::view-transition-new(root) { animation-duration: 180ms; }
382
+ ```
383
+
384
+ A working version lives in `examples/marketing/styles/globals.css`.
385
+
386
+ **If you use CSP**, the rules are emitted as an inline
387
+ `<script type="speculationrules">`; your `script-src` policy needs to allow it.
388
+
389
+ ```js
390
+ navigation: {
391
+ prefetch: "moderate",
392
+ prerender: "conservative",
393
+ viewTransition: true,
394
+ exclude: ["/logout", "/cart/*"],
395
+ }
396
+ ```
397
+
398
+ ## `prewarmSkip`
399
+
400
+ **Type:** `string[]` — **Default:** `["/api/", "/_fragment/", "/__jskelet/"]`
401
+
402
+ Path **prefixes** that prewarming skips. Session-dependent or fragment endpoints
403
+ should not be prewarmed. If provided, it replaces the default — if you changed
404
+ `brand.devBasePath`, do not forget to update this list too.
405
+
406
+ Details: [06-caching.md](./06-caching.md).
407
+
408
+ ## `watch`
409
+
410
+ **Type:** `string[]` — **Default:** `[]`
411
+
412
+ **Additional** directories that `jskelet dev` watches for server restarts,
413
+ relative to the project root. `routes`, `views` and `lib` are already watched;
414
+ `client/` and `styles/` are handled by the esbuild and CSS watchers and should
415
+ not be put here.
416
+
417
+ Only files with the `.js`, `.mjs`, `.json` and `.ejs` extensions are triggers.
418
+
419
+ ```js
420
+ watch: ["data", "content"]
421
+ ```
422
+
423
+ Details: [09-dev-tools.md](./09-dev-tools.md).
424
+
425
+ ## `fonts`
426
+
427
+ **Type:** `{ family: string, slug?: string, weights?: number[] }[]` —
428
+ **Default:** `[]`
429
+
430
+ Google Fonts families to self-host. If left empty, the font step never runs.
431
+
432
+ | Field | Type | Default | Meaning |
433
+ | --- | --- | --- | --- |
434
+ | `family` | `string` | — | Google Fonts family name: `"Inter"`, `"Noto Sans"` |
435
+ | `slug` | `string` | derived from `family` (lower case, space → `-`) | File name prefix |
436
+ | `weights` | `number[]` | `[400]` | Weights to download |
437
+
438
+ Output: `public/fonts/<slug>-<weight>.woff2`, with the same file name as the
439
+ manifest key. The files have **fixed names** (no hash) and are **expected to be
440
+ committed**. Details: [08-build.md](./08-build.md).
441
+
442
+ ```js
443
+ fonts: [
444
+ { family: "Inter", weights: [400, 600, 700] },
445
+ { family: "Noto Serif", slug: "serif", weights: [400] },
446
+ ]
447
+ ```
448
+
449
+ ## `icons`
450
+
451
+ **Type:** `{ scan?: string[] } | false` — **Default:** `{}`
452
+
453
+ Phosphor SVG sprite generation.
454
+
455
+ | Value | Result |
456
+ | --- | --- |
457
+ | `{}` (default) | The sprite is generated; scanned directories are `["views", "client", "routes", "lib"]` |
458
+ | `{ scan: [...] }` | Changes the scanned directories |
459
+ | `false` | The sprite step is skipped entirely |
460
+
461
+ If `@phosphor-icons/core` is not in the application's `node_modules`, the step is
462
+ silently skipped. Details: [08-build.md](./08-build.md).
463
+
464
+ ```js
465
+ icons: { scan: ["views", "client", "routes", "lib", "content"] }
466
+ ```
467
+
468
+ ## `images`
469
+
470
+ **Type:** `{ widths?: number[], quality?: number, skip?: string[] } | false` —
471
+ **Default:** `{}`
472
+
473
+ Generates webp variants of the png/jpg images under `public/`.
474
+
475
+ | Field | Type | Default | Meaning |
476
+ | --- | --- | --- | --- |
477
+ | `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. |
478
+ | `quality` | `number` | `78` | webp quality. When it changes, the encoder signature changes and every image is re-encoded. |
479
+ | `skip` | `string[]` | `[]` | **Directory names** not to scan. `assets` and `fonts` are always skipped. |
480
+
481
+ If `false` is given, the image step never runs. The step requires `sharp` and
482
+ never runs on a watch pass. Details: [08-build.md](./08-build.md).
483
+
484
+ ```js
485
+ images: { widths: [400, 800, 1200], quality: 82, skip: ["downloads"] }
486
+ ```
487
+
488
+ ## `clientEnv`
489
+
490
+ **Type:** `string[]` — **Default:** `[]`
491
+
492
+ Environment variable keys to inline into the client bundle at build time. The
493
+ same contract as `NEXT_PUBLIC_*` in Next, except which key is public is decided
494
+ by the config rather than by the name. `NODE_ENV` is always inlined.
495
+
496
+ Because the whole of `process.env` is defined as a single object, reading a key
497
+ that is not in the list returns `undefined` instead of crashing.
498
+
499
+ ```js
500
+ clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN_ORIGIN"]
501
+ ```
502
+
503
+ **Do not put secrets here** — the values sit in the bundle in plain text.
504
+
505
+ ## `headers()`
506
+
507
+ **Type:** `() => { source: string, headers: { key: string, value: string }[] }[]`
508
+ — **Default:** `[]`
509
+
510
+ Response headers by path pattern. The framework only writes long-lived cache
511
+ headers for static files; every other header (CSP, COOP, HSTS,
512
+ X-Frame-Options…) comes from here and takes precedence over the defaults.
513
+
514
+ **All** matching rules are applied (unlike redirects, it does not stop at the
515
+ first match), in order; if two rules write the same header, the later one wins.
516
+
517
+ Entries without a `key` or with an `undefined` `value` are skipped; a rule left
518
+ with no valid headers at all is not added.
519
+
520
+ ```js
521
+ async headers() {
522
+ return [
523
+ {
524
+ source: "/:path*",
525
+ headers: [
526
+ { key: "X-Frame-Options", value: "SAMEORIGIN" },
527
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
528
+ {
529
+ key: "Content-Security-Policy",
530
+ value: "default-src 'self'; img-src 'self' https://cdn.example.com data:",
531
+ },
532
+ ],
533
+ },
534
+ {
535
+ source: "/download/:path*",
536
+ headers: [{ key: "Cache-Control", value: "no-store" }],
537
+ },
538
+ ];
539
+ }
540
+ ```
541
+
542
+ ## `redirects()`
543
+
544
+ **Type:**
545
+ `() => { source: string, destination: string, permanent?: boolean, statusCode?: number }[]`
546
+ — **Default:** `[]`
547
+
548
+ | Field | Type | Meaning |
549
+ | --- | --- | --- |
550
+ | `source` | `string` | Pattern (syntax below) |
551
+ | `destination` | `string` | Target; `:param` placeholders are filled in |
552
+ | `permanent` | `boolean` | `true` → 308, otherwise 307 |
553
+ | `statusCode` | `number` | Explicit status code; overrides `permanent` |
554
+
555
+ The first matching rule wins and the query string is preserved. Details:
556
+ [03-routing.md](./03-routing.md).
557
+
558
+ ## `rewrites()`
559
+
560
+ **Type:** `() => Rule[] | { beforeFiles?: Rule[], afterFiles?: Rule[] }`
561
+ where `Rule = { source: string, destination: string }` — **Default:** `[]`
562
+
563
+ If an array is returned, all of it counts as `afterFiles`.
564
+
565
+ - `beforeFiles` runs even before static files.
566
+ - `afterFiles` runs after static has been tried, before the routes.
567
+ - Absolute target (`http://`/`https://`) → built-in reverse proxy.
568
+ - Relative target → only `req.url` changes.
569
+
570
+ Details: [03-routing.md](./03-routing.md).
571
+
572
+ ## `cache()`
573
+
574
+ **Type:**
575
+ `() => { html?: Record<string, number>, maxEntries?: number, data?: object, prewarm?: object }` —
576
+ **Default:**
577
+ `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
578
+
579
+ ### `cache().html`
580
+
581
+ A pattern → seconds mapping. A matching rule **overrides** the route's own
582
+ `revalidate` value. Negative or non-finite values are ignored; `0` means "no
583
+ caching".
584
+
585
+ The one exception is `route(fn, { private: true })`: on that route a matching
586
+ pattern is ignored. The lock is deliberately one-way — a mistake in the other
587
+ direction means one user's HTML is served to another.
588
+
589
+ ```js
590
+ html: {
591
+ "/": 60,
592
+ "/news/:slug": 300,
593
+ "/search": 0,
594
+ }
595
+ ```
596
+
597
+ ### `cache().maxEntries`
598
+
599
+ **Type:** `number` — **Default:** `500`
600
+
601
+ The entry limit of the HTML cache. Because an entry costs a hundred kilobytes,
602
+ raising this number burns through memory quickly; trying to solve a site with
603
+ tens of thousands of paths from here is the wrong layer — the right place is
604
+ `cache().data`.
605
+
606
+ ### `cache().data`
607
+
608
+ The upstream data cache (`withDataCache`). Details:
609
+ [06-caching.md](./06-caching.md).
610
+
611
+ | Field | Type | Default | Meaning |
612
+ | --- | --- | --- | --- |
613
+ | `maxEntries` | `number` | `10000` | The LRU entry limit. The limit is high because JSON is tens of times smaller than HTML. |
614
+ | `staleFactor` | `number` | `10` | For how many TTLs an entry stays usable after the TTL expired. `0` → no stale serving. |
615
+
616
+ ### `cache().prewarm`
617
+
618
+ | Field | Type | Default | Meaning |
619
+ | --- | --- | --- | --- |
620
+ | `enabled` | `boolean` | `true` | If `false`, no prewarming happens (can be overridden with `PREWARM=1`) |
621
+ | `max` | `number` | `400` | At most how many paths are prewarmed per pass |
622
+ | `concurrency` | `number` | prod 4, dev 2 | Number of parallel workers |
623
+ | `rps` | `number` | `0` | At most how many prewarm requests per second; `0` is unlimited. This is the setting that protects the upstream quota. |
624
+ | `delayMs` | `number` | prod 500, dev 3000 | Delay of the first pass after startup |
625
+ | `retryDelayMs` | `number` | `2000` | How long to wait before the retry pass |
626
+ | `intervalSeconds` | `number` | `0` | If greater than 0, the pass repeats periodically |
627
+ | `rotate` | `boolean` | `true` | If the list is longer than `max`, periodic passes continue where they left off |
628
+ | `priority` | `(string \| RegExp)[]` | `[]` | Warm-up order; matching paths are taken first on every pass |
629
+
630
+ `priority` accepts two forms: the pattern syntax used everywhere in the config,
631
+ and a plain `RegExp`. Whatever is written first is warmed first.
632
+
633
+ ```js
634
+ prewarm: {
635
+ max: 500,
636
+ rps: 4,
637
+ intervalSeconds: 300,
638
+ priority: [
639
+ "/", // the home page
640
+ "/markets/:path*", // the whole markets section
641
+ /-comments$/, // a rule the pattern syntax does not cover
642
+ ],
643
+ }
644
+ ```
645
+
646
+ Each numeric field can be overridden by an environment variable of the same
647
+ name; env takes precedence. Details: [06-caching.md](./06-caching.md).
648
+
649
+ ## `hooks`
650
+
651
+ **Type:** `Record<string, Function>` — **Default:** `{}`
652
+
653
+ All optional, all may be `async`. If a hook throws, the framework falls back to
654
+ its own default and warns — the page does not go down.
655
+
656
+ | Hook | Signature | What it returns | Document |
657
+ | --- | --- | --- | --- |
658
+ | `metadata` | `(page) => object` | Metadata default for every page; the controller's `metadata` is layered on top | [04](./04-rendering.md) |
659
+ | `layoutContext` | `({ pathname, metadata }) => object` | Layout locals; `lang`, `structuredData`, `extraHead` and `bodyClass` get special treatment | [04](./04-rendering.md) |
660
+ | `notFound` | `() => object \| null` | 404 page definition; if `null`, the framework's error page | [03](./03-routing.md) |
661
+ | `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) |
662
+ | `prewarmPaths` | `() => string[]` | Paths to prewarm; if it is not defined, prewarming is never set up | [06](./06-caching.md) |
663
+
664
+ ```js
665
+ hooks: {
666
+ metadata() {
667
+ return { titleTemplate: "%s | Example", siteUrl: "https://example.com" };
668
+ },
669
+
670
+ async layoutContext({ pathname }) {
671
+ return { navigation: await getNavigation(), isHome: pathname === "/" };
672
+ },
673
+
674
+ notFound() {
675
+ return {
676
+ view: "pages/not-found",
677
+ metadata: { title: "Page not found", robots: { index: false } },
678
+ };
679
+ },
680
+
681
+ error({ status }) {
682
+ return {
683
+ view: "pages/error",
684
+ data: { status },
685
+ metadata: { title: "Something went wrong", robots: { index: false } },
686
+ };
687
+ },
688
+
689
+ async prewarmPaths() {
690
+ return ["/", ...(await getArticlePaths())];
691
+ },
692
+ }
693
+ ```
694
+
695
+ ## `source` pattern syntax
696
+
697
+ `headers()`, `redirects()`, `rewrites()` and `cache().html` all use the same
698
+ small compiler. This is not Next's full `path-to-regexp` surface; the subset
699
+ actually used in configuration was chosen deliberately, and an unrecognised
700
+ syntax is not silently accepted as a literal — it produces a warning.
701
+
702
+ | Pattern | Regex equivalent | Example match |
703
+ | --- | --- | --- |
704
+ | `/about` | exact match | `/about` |
705
+ | `/news/:slug` | `([^/]+)` — a single segment | `/news/abc` (✗ `/news/a/b`) |
706
+ | `/:path*` | `(.*)` — zero or more segments | `/`, `/a`, `/a/b/c` |
707
+ | `/blog/:path*` | wildcard sub-path; the leading `/` is optional | `/blog`, `/blog/`, `/blog/a/b` |
708
+ | `/:path*.svg` | wildcard + fixed suffix | `/ikon.svg`, `/a/b/c.svg` |
709
+ | `/tag-:slug` | a parameter in the middle of a segment | `/tag-finance` |
710
+
711
+ Rules:
712
+
713
+ - `source` **must start with `/`**; if it does not, the rule is ignored and a
714
+ warning is printed.
715
+ - The parameter name must match the pattern `[A-Za-z_][A-Za-z0-9_]*`.
716
+ - A pattern always matches **from start to end** (`^…$`); use `:path*` for
717
+ prefix matching.
718
+ - `:path*` also captures zero segments and the `/` immediately before it is
719
+ optional: `/account/:path*` covers the section's root path (`/account`) too.
720
+ Otherwise a rule that wanted to close off a whole section was skipping
721
+ precisely its landing page.
722
+ - Every character other than parameters is treated as a literal and escaped for
723
+ the regex — `.` really means a dot.
724
+ - Captured values are written into the same-named `:param`s in `destination`. A
725
+ placeholder with no counterpart is left as is.
726
+
727
+ ## Environment variables
728
+
729
+ Every variable the framework reads. If a `.env` file exists it is loaded
730
+ automatically by the CLI (`--env-file=.env`); if not, the flag is never passed
731
+ and no warning is printed.
732
+
733
+ | Variable | Who reads it | Default | Meaning |
734
+ | --- | --- | --- | --- |
735
+ | `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. |
736
+ | `PORT` | `startServer` | `3000` | Port to listen on |
737
+ | `HOST` | `startServer` | `0.0.0.0` | Interface to bind to |
738
+ | `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) |
739
+ | `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) |
740
+ | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
741
+ | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
742
+ | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 2 | Number of parallel workers |
743
+ | `PREWARM_RPS` | `prewarm` | `0` | At most how many prewarm requests per second; `0` is unlimited |
744
+ | `PREWARM_DELAY_MS` | `startPrewarm` | prod 500, dev 3000 | Delay of the first pass |
745
+ | `PREWARM_RETRY_DELAY_MS` | `prewarm` | `2000` | The wait before the retry pass |
746
+ | `PREWARM_INTERVAL_SECONDS` | `startPrewarm` | `0` | If greater than 0, a periodic pass |
747
+ | `JSKELET_VERBOSE` | `jskelet dev` | — | If `1`, all of the changed files are listed on restart |
748
+ | `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. |
749
+ | `JSKELET_CHILD` | `jskelet build` | — | Set by the dev script; suppresses the build banner and the "Ready" summary |
750
+ | `NO_COLOR` | `jskelet/log` | — | If set, colour is never used (it overrides `JSKELET_COLOR` too) |
751
+
752
+ Your application's own variables (API origin, tokens) are not read by the
753
+ framework; use them directly via `process.env`. Declare the ones that need to
754
+ reach the browser with `clientEnv`.
755
+
756
+ The numeric prewarm settings only accept **positive and finite** values; an
757
+ invalid value silently falls through to the next layer (config → code default).
758
+
759
+ ## Programmatic access
760
+
761
+ ```js
762
+ import { getConfig, loadConfig } from "jskelet";
763
+
764
+ await loadConfig(); // reads from the project root
765
+ await loadConfig({ root: "/baska/proje" }); // a different root
766
+ await loadConfig({ configFile: "jskelet.test.mjs" });
767
+ await loadConfig({ force: true }); // bypass the cache and re-read
768
+
769
+ const config = getConfig(); // the resolved config
770
+ ```
771
+
772
+ `loadConfig()` hits the cache on a second call in the same process: `jskelet
773
+ start` calls it through both `ensure-build` and `createApp`, and there is no
774
+ benefit in reading and logging the config twice.
775
+
776
+ If `getConfig()` is used without `loadConfig()` having been called, it
777
+ **throws**: a silently wrong path turns into problems that are hard to diagnose,
778
+ like "why is there no stylesheet".
779
+
780
+ In the resolved config the directories are available as absolute paths under
781
+ `config.dirs` (`views`, `public`, `client`, `routes`, `styles`, `generated`,
782
+ `assets`, `fonts`), the patterns are in compiled form, and `config.loaded` tells
783
+ you whether the file was actually read.
784
+
785
+ ## What's next
786
+
787
+ - The effect of the build-side fields: [08-build.md](./08-build.md)
788
+ - The dev flow and `DEV_TOKEN`: [09-dev-tools.md](./09-dev-tools.md)
789
+ - Using environment variables in deployment: [10-deployment.md](./10-deployment.md)