jskelet 0.2.5 → 0.3.0

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 (106) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +403 -383
  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 +293 -287
  7. package/docs/03-routing.md +486 -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 +1231 -1209
  11. package/docs/07-yapilandirma.md +44 -21
  12. package/docs/08-build.md +366 -366
  13. package/docs/09-dev-araclari.md +335 -335
  14. package/docs/10-dagitim.md +329 -329
  15. package/docs/11-tasima.md +1 -0
  16. package/docs/12-panel-ve-oturum.md +384 -384
  17. package/docs/README.md +105 -105
  18. package/docs/en/01-getting-started.md +292 -292
  19. package/docs/en/02-architecture.md +311 -305
  20. package/docs/en/03-routing.md +503 -497
  21. package/docs/en/04-rendering.md +504 -504
  22. package/docs/en/05-islands.md +492 -492
  23. package/docs/en/06-caching.md +1197 -1198
  24. package/docs/en/07-configuration.md +1009 -986
  25. package/docs/en/08-build.md +383 -383
  26. package/docs/en/09-dev-tools.md +342 -342
  27. package/docs/en/10-deployment.md +332 -332
  28. package/docs/en/11-migration.md +360 -359
  29. package/docs/en/12-dashboards-and-sessions.md +392 -392
  30. package/docs/en/README.md +112 -112
  31. package/package.json +102 -102
  32. package/src/build/ensure-build.mjs +15 -15
  33. package/src/build/paths.mjs +143 -143
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +268 -268
  36. package/src/build/tasks/css.mjs +124 -124
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +224 -224
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/client/{cache-panel → admin}/i18n.js +756 -670
  42. package/src/client/{cache-panel → admin}/login.html +74 -74
  43. package/src/client/{cache-panel → admin}/panel.css +804 -756
  44. package/src/client/admin/panel.html +486 -0
  45. package/src/client/{cache-panel → admin}/panel.js +1242 -915
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +725 -725
  48. package/src/client/dom.js +95 -95
  49. package/src/client/form.js +192 -192
  50. package/src/client/index.js +35 -35
  51. package/src/client/registry.js +297 -297
  52. package/src/client/safe-image.js +91 -91
  53. package/src/client/store.js +36 -36
  54. package/src/client/swap.js +188 -188
  55. package/src/config/defaults.js +16 -7
  56. package/src/config/index.js +38 -22
  57. package/src/config/pattern.js +107 -107
  58. package/src/http/control-flow.js +71 -71
  59. package/src/http/cookies.js +257 -257
  60. package/src/http/request-cache.js +46 -46
  61. package/src/http/request-context.js +162 -162
  62. package/src/index.js +83 -83
  63. package/src/init.mjs +221 -221
  64. package/src/log.mjs +58 -0
  65. package/src/runtime/alias-hooks.mjs +119 -119
  66. package/src/runtime/register.mjs +4 -4
  67. package/src/server/admin/actions.js +229 -0
  68. package/src/server/admin/auth.js +125 -0
  69. package/src/server/admin/event-log.js +151 -0
  70. package/src/server/admin/gate.js +209 -0
  71. package/src/server/admin/inventory.js +188 -0
  72. package/src/server/admin/mount.js +56 -0
  73. package/src/server/admin/router.js +216 -0
  74. package/src/server/admin/snapshot.js +126 -0
  75. package/src/server/assets.js +147 -147
  76. package/src/server/cache-deps.js +42 -42
  77. package/src/server/cloudflare.js +607 -607
  78. package/src/server/create-app.js +295 -291
  79. package/src/server/data-cache.js +462 -462
  80. package/src/server/dev/report.js +369 -369
  81. package/src/server/dev/socket.js +170 -170
  82. package/src/server/dev/version-check.mjs +139 -139
  83. package/src/server/html-cache.js +817 -817
  84. package/src/server/metadata.js +102 -102
  85. package/src/server/middleware/compression.js +205 -205
  86. package/src/server/middleware/csrf.js +134 -134
  87. package/src/server/middleware/dev-gate.js +62 -62
  88. package/src/server/middleware/headers.js +37 -37
  89. package/src/server/middleware/redirects.js +32 -32
  90. package/src/server/middleware/static-precompressed.js +100 -100
  91. package/src/server/middleware/trailing-slash.js +53 -0
  92. package/src/server/middleware/upstream-proxy.js +141 -141
  93. package/src/server/prewarm.js +601 -601
  94. package/src/server/redis.js +569 -569
  95. package/src/server/router.js +128 -128
  96. package/src/server/status-page.js +164 -164
  97. package/src/server/upstream-limiter.js +376 -376
  98. package/src/server/upstream-tracking.js +166 -166
  99. package/src/start.mjs +7 -7
  100. package/src/templates/layout.ejs +44 -44
  101. package/src/version.mjs +31 -31
  102. package/src/views/components/loader.js +85 -85
  103. package/src/views/helpers/html.js +102 -102
  104. package/src/views/helpers/tags.js +245 -245
  105. package/src/client/cache-panel/panel.html +0 -308
  106. package/src/server/cache-panel.js +0 -759
@@ -1,305 +1,311 @@
1
- # 02 — Architecture and the reasoning behind the decisions
2
-
3
- This document does not explain how JSkelet works, but **why it works this way**.
4
- The path a request takes from the server to the browser, why the island model is
5
- tied to visibility, why the HTML is produced in full, why the cache lives in
6
- process memory and why the middleware order must not be shuffled — that is all
7
- here. Most of the reasoning comes from the measurement notes in the headers of
8
- the source files; for the APIs themselves see documents
9
- [03](./03-routing.md), [04](./04-rendering.md), [05](./05-islands.md) and
10
- [06](./06-caching.md).
11
-
12
- ## The basic premise
13
-
14
- On a news or content site, almost everything the visitor sees is already ready
15
- on the server. Interaction, on the other hand, is scattered point by point: a
16
- search box, a drawer, a chart, a comment form. With that profile, rebuilding the
17
- whole page on the client (hydration) is the biggest cost you pay, and in return
18
- the visitor gains nothing.
19
-
20
- JSkelet puts this observation at the centre of the architecture:
21
-
22
- 1. **The server HTML is complete.** Even if JS never runs, the page can be read,
23
- navigated and indexed.
24
- 2. **JS only adds behaviour.** Every interactive piece is attached as an
25
- independent "island", with its own module, at its own time.
26
- 3. **Page production is cached.** There is no point in producing the same HTML
27
- again on every request; a memory cache with a TTL takes the place of ISR.
28
-
29
- ## The path of a request
30
-
31
- ```
32
- Request
33
- ├─ rewrites(beforeFiles) config → proxy or a change to req.url
34
- ├─ compression brotli/gzip negotiation (quality 5)
35
- ├─ headers static cache + config headers()
36
- ├─ devGate if DEV_TOKEN is set, 404 without a token
37
- ├─ redirects config redirects(), first match wins
38
- ├─ staticPrecompressed .br/.gz copies produced at build (quality 11)
39
- ├─ express.static files under public/
40
- ├─ (dev) devtools only when NODE_ENV=development
41
- ├─ body parsers urlencoded 64kb + json 256kb
42
- ├─ rewrites(afterFiles) after static has been tried
43
- ├─ routes
44
- │ └─ route(controller)
45
- │ └─ withHtmlCache TTL + stale-while-revalidate
46
- │ └─ withUpstreamTracking
47
- │ └─ withRequestCache
48
- │ └─ controller → renderPage → EJS
49
- ├─ 404 → hooks.notFound()
50
- └─ error handling redirect/notFound + 500 fallback
51
- ```
52
-
53
- ## Why the middleware order is this order
54
-
55
- The real value of the `src/server/create-app.js` file is the order; every
56
- position has a reason, and moving things around leads to silent breakage.
57
-
58
- - **`rewrites(beforeFiles)` comes even before static files.** Otherwise a rule
59
- that moves the `/assets/x.js` path somewhere else would never take effect,
60
- because `express.static` answers the request first.
61
- - **`compression` before static.** If it came after, static files would never be
62
- compressed.
63
- - **`headers` → `devGate` → `redirects`.** The gate's 404 must come before the
64
- redirects: an environment that has not gone live should not leak even its
65
- redirect rules to the outside.
66
- - **`staticPrecompressed` before `express.static`.** If there are `.br`/`.gz`
67
- copies produced at build time, those are served (brotli quality 11);
68
- otherwise the request falls through to the `static` below it and the
69
- middleware compresses on the fly (quality 5). Recompressing a hashed,
70
- `immutable` file on every request is wasted CPU.
71
- - **Body parsers after static.** Image requests should not pay the cost of body
72
- parsing.
73
- - **`rewrites(afterFiles)` after static has been tried and before pages.** The
74
- equivalent of Next.js's two-phase rewrite semantics.
75
- - **404 and error handling last.** The error handler also catches the
76
- `notFound`/`redirect` control flow, because those can be thrown outside a
77
- controller as well (e.g. inside a middleware).
78
-
79
- The framework turns off `x-powered-by` and writes a brandable header in its
80
- place, sets `etag` to `strong` and enables `trust proxy`. `trust proxy` is
81
- required for the correct protocol and client IP behind a reverse proxy
82
- ([10-deployment.md](./10-deployment.md)).
83
-
84
- ## The island model: why visibility-based hydration
85
-
86
- `src/client/registry.js` hands every `[data-island]` element to an
87
- `IntersectionObserver` (`rootMargin: "200px 0px"`). Elements on screen are
88
- triggered on the very first observation; those off screen are **never
89
- downloaded** until they are scrolled to. Heavy modules like the chart library on
90
- the home page thus drop out of the initial load entirely.
91
-
92
- There are three behaviours, all controlled from the HTML:
93
-
94
- - **Default:** tied to visibility.
95
- - **`data-island-eager`:** independent of visibility, attaches immediately. For
96
- global behaviours like the header or the cookie banner.
97
- - **`data-island-idle`:** even if it is visible, it waits until `load` has
98
- completed and the main thread is free. So that heavy modules that are visible
99
- in the first viewport but not critical (e.g. a mini chart that pulls in a
100
- chart library) do not compete with LCP.
101
-
102
- Two further details came out of measurement:
103
-
104
- - **The attaching work is deferred to idle time** (`requestIdleCallback`,
105
- `timeout: 500`). If many islands that become visible at once turn into a
106
- single long task, TBT and INP suffer.
107
- - **Elements with no layout box are attached directly.** A `hidden`
108
- drawer/dialog has no layout box and `IntersectionObserver` will never report
109
- it; that is why `hydrate()` reads the measurements in one pass
110
- (`getClientRects().length`) and, instead of handing boxless elements to the
111
- observer, attaches them immediately.
112
-
113
- One consequence of this: **image error handling is not an island.** An
114
- image-heavy page can have 80+ `<img>` elements, and attaching a separate island
115
- to each one (observer + dynamic import + mount) is a serious hydration cost just
116
- for the possibility of an error. `startSafeImages()` instead installs a single
117
- capture-phase listener on the document ([05-islands.md](./05-islands.md)).
118
-
119
- ## Why the server HTML is complete
120
-
121
- The layout and the page template produce the entirety of the content the visitor
122
- will see. There is no "show a skeleton, then fill it in" pattern on the client
123
- side. This buys three things:
124
-
125
- 1. **SEO:** the crawler does not have to wait for JS.
126
- 2. **LCP:** the largest contentful element arrives in the first HTML response;
127
- downloading, parsing and executing JS is not on the LCP path.
128
- 3. **CLS:** because content is not injected later, the layout does not shift.
129
-
130
- The same principle is applied on the `<head>` side too. The layout prints
131
- resource hints (`preconnect`, LCP `preload`) at the **very beginning** of the
132
- `<head>`; delaying those writes straight to LCP.
133
-
134
- ### Why a single, render-blocking stylesheet
135
-
136
- No separate "critical CSS" is produced. In measurement, because the inline
137
- critical CSS did not fully cover the first viewport, the page reflowed once the
138
- sheet arrived (CLS 0.307 on a list page) and the same ~27 KB was repeated in
139
- every HTML response. Leaving a single compressed sheet render-blocking is both
140
- faster and free of CLS; on the second visit it already comes from the
141
- `immutable` cache.
142
-
143
- The same logic applies to icons: instead of a separate request per icon, an SVG
144
- sprite is produced at build time from only the symbols actually used in the
145
- source. Shipping the whole Phosphor set is 1500+ icons, that is several
146
- megabytes; the scan typically keeps the sprite at 10-30 symbols
147
- ([08-build.md](./08-build.md)).
148
-
149
- ## Cache strategy: in-memory TTL instead of ISR
150
-
151
- `src/server/html-cache.js` keeps an LRU HTML cache with a TTL, keyed by route +
152
- query (at most 500 entries). When the TTL expires the entry is not thrown away
153
- immediately: within the `stale` window the old HTML returns instantly and the
154
- refresh runs in the background (stale-while-revalidate, `STALE_FACTOR = 1`, i.e.
155
- the stale window is as long as the TTL).
156
-
157
- The gain: after the first warm-up no request ever waits for a render. The price:
158
- the data in the HTML can be at most `revalidate + one refresh round` behind.
159
- That price is acceptable, because live fields such as prices are updated on the
160
- client from a WebSocket and the lag is not visible on screen.
161
-
162
- The decision not to write to disk is deliberate. The equivalent of Next's
163
- build-time prerender is prewarm, but the output is not written to disk: because
164
- the cache lives in process memory, the warm-up is done when the process comes
165
- up. The gain is the same — the first visitor does not wait for a cold render —
166
- but the data is not frozen; every entry ages with the route's `revalidate`
167
- duration ([06-caching.md](./06-caching.md)).
168
-
169
- ### Keeping the compressed body in the cache
170
-
171
- Every cached entry stores the brotli/gzip output alongside the HTML (the
172
- `encoded` map shares its lifetime with the HTML). The same page is not
173
- re-brotli'd on every request. Because `Content-Encoding` is set inside `route()`
174
- on this path, the compression middleware does not kick in.
175
-
176
- ### Why transient and permanent upstream errors are handled differently
177
-
178
- If an upstream went down during the render, the output contains incomplete data,
179
- and such HTML is **not** written to the cache: the next request tries again.
180
-
181
- But this only applies to *transient* errors (network errors, 408, 425, 429 and
182
- all 5xx). Deterministic answers like 400/403/404 do not get better by retrying;
183
- turning off the cache because of them would mean rendering the page from scratch
184
- on every visit — the content comes back in the same incomplete state anyway, the
185
- visitor merely pays the render time. That is why permanent errors are only
186
- logged and do not block the cache.
187
-
188
- The direction in which this information reaches the framework is also
189
- deliberately inverted: the framework does not know the data layer, the data
190
- layer notifies the framework (`reportUpstreamFailure()`). If nobody ever calls
191
- it, the cost is an empty array.
192
-
193
- ### The nesting order of the three scopes
194
-
195
- `route()` sets up this order:
196
-
197
- ```
198
- withHtmlCache( withUpstreamTracking( withRequestCache( controller ) ) )
199
- ```
200
-
201
- The order matters: **the per-request cache must be innermost** so that two calls
202
- within the same render collapse into a single upstream request; **upstream
203
- tracking must be inside the HTML cache** so that output produced with incomplete
204
- data is not written to the cache.
205
-
206
- ## Fault tolerance: no single gap takes the site down
207
-
208
- There is a principle repeated throughout the framework: missing configuration or
209
- missing build output produces a degraded but working page instead of an error.
210
-
211
- - **If the config file is missing or unreadable**, a warning is printed and the
212
- server comes up with the defaults. A broken edit must not make the site
213
- impossible to open. In the same way, if one of the
214
- `headers()`/`redirects()`/`rewrites()`/`cache()` sections throws, only that
215
- section is ignored.
216
- - **If hooks throw**, the framework falls back to its own default and warns.
217
- - **If the build did not run**, `asset()` returns `/assets/<name>`, `hasAsset()`
218
- is false and the layout does not print the stylesheet/script tags at all. When
219
- `jskelet build` is forgotten you see an unstyled but working page instead of
220
- an error.
221
- - **A broken route module in dev** prints a warning and is skipped; **in
222
- production it throws.** Going live with a half-built route table means pages
223
- that silently return 404.
224
- - **If the 404 render blows up too**, a minimal, template-free HTML is returned;
225
- the visitor should not see an empty response.
226
- - **A single request error does not take the process down:**
227
- `unhandledRejection` and `uncaughtException` are logged and the process stays
228
- up. On a news site, an error on a single page must not take the whole site
229
- down.
230
-
231
- ## Why there is no file-system-based routing
232
-
233
- Order matters. If a single-segment catch-all such as `/:slug` is registered
234
- before the `/about` route, "about" is mistaken for a slug. Making the order
235
- visible instead of hiding it in file names makes diagnosis easier: either you
236
- give an explicit list via `jskelet.config.mjs` → `routes`, or you have the
237
- `routes/` directory scanned alphabetically and put a numeric prefix on the file
238
- names (`10-pages.js`, `50-blog.js`, `99-catch-all.js`). Details:
239
- [03-routing.md](./03-routing.md).
240
-
241
- ## Why a single source of truth for config
242
-
243
- `src/config/index.js` normalizes the project root, the directory paths, the
244
- branding, the hooks and the rules. Other modules do not compute paths, they call
245
- `getConfig()`. The reason is concrete: once the framework lives inside
246
- `node_modules/`, every file that tries to find the root by counting `../..`
247
- breaks. For the same reason there is a single mutation point on the build side
248
- too (`initBuildPaths()`).
249
-
250
- If `getConfig()` is used without `loadConfig()` having been called, it throws
251
- instead of assuming an empty project root: a silently wrong path turns into
252
- hard-to-diagnose problems like "why is there no stylesheet".
253
-
254
- ## Why this dependency list
255
-
256
- There are four runtime dependencies: `express`, `ejs`, `esbuild`,
257
- `tailwind-merge`. Everything else (Tailwind, PostCSS, lightningcss, sharp, the
258
- Phosphor icons) is an **optional peer dependency**, and if it is absent the
259
- corresponding build step is skipped.
260
-
261
- Two decisions deserve a separate explanation:
262
-
263
- - **`node:zlib` instead of the `compression` package.** The package does not
264
- support brotli and brings a seven-deep dependency tree; doing the brotli +
265
- gzip negotiation by hand is enough. Brotli is preferred: on the home page HTML
266
- it is ~35% smaller than gzip.
267
- - **`tailwind-merge` stays at runtime.** Class computation is done only on the
268
- server, it never enters the client bundle, so it has no effect on page weight.
269
- A hand-written group table, on the other hand, produced visual regressions
270
- because it mixed up width/colour pairs like `border-2` +
271
- `border-transparent` and dropped classes.
272
-
273
- Optional packages are resolved from the **application's** `node_modules`, not
274
- from the framework's own. If the framework is installed via a `file:` or
275
- workspace link, a plain `import "postcss"` looks in the framework's tree — not
276
- in the application's.
277
-
278
- ## Why alias and extension hooks
279
-
280
- `node --import jskelet/register` does two things:
281
-
282
- 1. It resolves the `compilerOptions.paths` aliases in `jsconfig.json` /
283
- `tsconfig.json` (`@/lib/x` → `<root>/lib/x`). Because the editor and the
284
- runtime are fed from the same file, the two do not drift apart.
285
- 2. It adds extensions to extensionless relative imports (`./cache` →
286
- `./cache.js`). Node ESM does not do this, and it is the most common breaking
287
- point in code migrated from a bundler.
288
-
289
- The `@/` resolution on the esbuild side mimics the same behaviour, so that
290
- modules under `lib/` can use the same import style both on the server and in the
291
- browser.
292
-
293
- `--import` expects a module **specifier**, not a file path. On Windows an
294
- absolute path like `H:\...` is mistaken for a URL with the `h:` scheme and
295
- rejected; that is why the framework uses `pathToFileURL(...).href` everywhere.
296
- For the same reason the config, the route modules and the components are
297
- imported with a `file://` URL too.
298
-
299
- ## What's next
300
-
301
- - The route and controller contract: [03-routing.md](./03-routing.md)
302
- - The template layer and metadata: [04-rendering.md](./04-rendering.md)
303
- - The island runtime API: [05-islands.md](./05-islands.md)
304
- - Cache settings and prewarm: [06-caching.md](./06-caching.md)
305
- - The inner workings of the dev flow: [09-dev-tools.md](./09-dev-tools.md)
1
+ # 02 — Architecture and the reasoning behind the decisions
2
+
3
+ This document does not explain how JSkelet works, but **why it works this way**.
4
+ The path a request takes from the server to the browser, why the island model is
5
+ tied to visibility, why the HTML is produced in full, why the cache lives in
6
+ process memory and why the middleware order must not be shuffled — that is all
7
+ here. Most of the reasoning comes from the measurement notes in the headers of
8
+ the source files; for the APIs themselves see documents
9
+ [03](./03-routing.md), [04](./04-rendering.md), [05](./05-islands.md) and
10
+ [06](./06-caching.md).
11
+
12
+ ## The basic premise
13
+
14
+ On a news or content site, almost everything the visitor sees is already ready
15
+ on the server. Interaction, on the other hand, is scattered point by point: a
16
+ search box, a drawer, a chart, a comment form. With that profile, rebuilding the
17
+ whole page on the client (hydration) is the biggest cost you pay, and in return
18
+ the visitor gains nothing.
19
+
20
+ JSkelet puts this observation at the centre of the architecture:
21
+
22
+ 1. **The server HTML is complete.** Even if JS never runs, the page can be read,
23
+ navigated and indexed.
24
+ 2. **JS only adds behaviour.** Every interactive piece is attached as an
25
+ independent "island", with its own module, at its own time.
26
+ 3. **Page production is cached.** There is no point in producing the same HTML
27
+ again on every request; a memory cache with a TTL takes the place of ISR.
28
+
29
+ ## The path of a request
30
+
31
+ ```
32
+ Request
33
+ ├─ rewrites(beforeFiles) config → proxy or a change to req.url
34
+ ├─ compression brotli/gzip negotiation (quality 5)
35
+ ├─ headers static cache + config headers()
36
+ ├─ devGate if DEV_TOKEN is set, 404 without a token
37
+ ├─ redirects config redirects(), first match wins
38
+ ├─ trailingSlash 308 when config trailingSlash is true
39
+ ├─ staticPrecompressed .br/.gz copies produced at build (quality 11)
40
+ ├─ express.static files under public/
41
+ ├─ (dev) devtools only when NODE_ENV=development
42
+ ├─ body parsers urlencoded 64kb + json 256kb
43
+ ├─ rewrites(afterFiles) after static has been tried
44
+ ├─ routes
45
+ │ └─ route(controller)
46
+ │ └─ withHtmlCache TTL + stale-while-revalidate
47
+ │ └─ withUpstreamTracking
48
+ │ └─ withRequestCache
49
+ │ └─ controller → renderPage → EJS
50
+ ├─ 404 → hooks.notFound()
51
+ └─ error handling redirect/notFound + 500 fallback
52
+ ```
53
+
54
+ ## Why the middleware order is this order
55
+
56
+ The real value of the `src/server/create-app.js` file is the order; every
57
+ position has a reason, and moving things around leads to silent breakage.
58
+
59
+ - **`rewrites(beforeFiles)` comes even before static files.** Otherwise a rule
60
+ that moves the `/assets/x.js` path somewhere else would never take effect,
61
+ because `express.static` answers the request first.
62
+ - **`compression` before static.** If it came after, static files would never be
63
+ compressed.
64
+ - **`headers` → `devGate` → `redirects` → `trailingSlash`.** The gate's 404 must
65
+ come before the redirects: an environment that has not gone live should not
66
+ leak even its redirect rules to the outside. `trailingSlash` sits after config
67
+ redirects so explicit rules see the requested path first; the canonical slash
68
+ form is enforced as a second step.
69
+ - **`staticPrecompressed` before `express.static`.** If there are `.br`/`.gz`
70
+ copies produced at build time, those are served (brotli quality 11);
71
+ otherwise the request falls through to the `static` below it and the
72
+ middleware compresses on the fly (quality 5). Recompressing a hashed,
73
+ `immutable` file on every request is wasted CPU.
74
+ - **Admin panel** (when `admin().enabled` / `JSKELET_ADMIN`): after static,
75
+ before body parsers and routes. Carries its own body parsers so the app
76
+ cannot shadow the path. When off, the module is never loaded.
77
+ - **Body parsers after static.** Image requests should not pay the cost of body
78
+ parsing.
79
+ - **`rewrites(afterFiles)` after static has been tried and before pages.** The
80
+ equivalent of Next.js's two-phase rewrite semantics.
81
+ - **404 and error handling last.** The error handler also catches the
82
+ `notFound`/`redirect` control flow, because those can be thrown outside a
83
+ controller as well (e.g. inside a middleware).
84
+
85
+ The framework turns off `x-powered-by` and writes a brandable header in its
86
+ place, sets `etag` to `strong` and enables `trust proxy`. `trust proxy` is
87
+ required for the correct protocol and client IP behind a reverse proxy
88
+ ([10-deployment.md](./10-deployment.md)).
89
+
90
+ ## The island model: why visibility-based hydration
91
+
92
+ `src/client/registry.js` hands every `[data-island]` element to an
93
+ `IntersectionObserver` (`rootMargin: "200px 0px"`). Elements on screen are
94
+ triggered on the very first observation; those off screen are **never
95
+ downloaded** until they are scrolled to. Heavy modules like the chart library on
96
+ the home page thus drop out of the initial load entirely.
97
+
98
+ There are three behaviours, all controlled from the HTML:
99
+
100
+ - **Default:** tied to visibility.
101
+ - **`data-island-eager`:** independent of visibility, attaches immediately. For
102
+ global behaviours like the header or the cookie banner.
103
+ - **`data-island-idle`:** even if it is visible, it waits until `load` has
104
+ completed and the main thread is free. So that heavy modules that are visible
105
+ in the first viewport but not critical (e.g. a mini chart that pulls in a
106
+ chart library) do not compete with LCP.
107
+
108
+ Two further details came out of measurement:
109
+
110
+ - **The attaching work is deferred to idle time** (`requestIdleCallback`,
111
+ `timeout: 500`). If many islands that become visible at once turn into a
112
+ single long task, TBT and INP suffer.
113
+ - **Elements with no layout box are attached directly.** A `hidden`
114
+ drawer/dialog has no layout box and `IntersectionObserver` will never report
115
+ it; that is why `hydrate()` reads the measurements in one pass
116
+ (`getClientRects().length`) and, instead of handing boxless elements to the
117
+ observer, attaches them immediately.
118
+
119
+ One consequence of this: **image error handling is not an island.** An
120
+ image-heavy page can have 80+ `<img>` elements, and attaching a separate island
121
+ to each one (observer + dynamic import + mount) is a serious hydration cost just
122
+ for the possibility of an error. `startSafeImages()` instead installs a single
123
+ capture-phase listener on the document ([05-islands.md](./05-islands.md)).
124
+
125
+ ## Why the server HTML is complete
126
+
127
+ The layout and the page template produce the entirety of the content the visitor
128
+ will see. There is no "show a skeleton, then fill it in" pattern on the client
129
+ side. This buys three things:
130
+
131
+ 1. **SEO:** the crawler does not have to wait for JS.
132
+ 2. **LCP:** the largest contentful element arrives in the first HTML response;
133
+ downloading, parsing and executing JS is not on the LCP path.
134
+ 3. **CLS:** because content is not injected later, the layout does not shift.
135
+
136
+ The same principle is applied on the `<head>` side too. The layout prints
137
+ resource hints (`preconnect`, LCP `preload`) at the **very beginning** of the
138
+ `<head>`; delaying those writes straight to LCP.
139
+
140
+ ### Why a single, render-blocking stylesheet
141
+
142
+ No separate "critical CSS" is produced. In measurement, because the inline
143
+ critical CSS did not fully cover the first viewport, the page reflowed once the
144
+ sheet arrived (CLS 0.307 on a list page) and the same ~27 KB was repeated in
145
+ every HTML response. Leaving a single compressed sheet render-blocking is both
146
+ faster and free of CLS; on the second visit it already comes from the
147
+ `immutable` cache.
148
+
149
+ The same logic applies to icons: instead of a separate request per icon, an SVG
150
+ sprite is produced at build time from only the symbols actually used in the
151
+ source. Shipping the whole Phosphor set is 1500+ icons, that is several
152
+ megabytes; the scan typically keeps the sprite at 10-30 symbols
153
+ ([08-build.md](./08-build.md)).
154
+
155
+ ## Cache strategy: in-memory TTL instead of ISR
156
+
157
+ `src/server/html-cache.js` keeps an LRU HTML cache with a TTL, keyed by route +
158
+ query (at most 500 entries). When the TTL expires the entry is not thrown away
159
+ immediately: within the `stale` window the old HTML returns instantly and the
160
+ refresh runs in the background (stale-while-revalidate, `STALE_FACTOR = 1`, i.e.
161
+ the stale window is as long as the TTL).
162
+
163
+ The gain: after the first warm-up no request ever waits for a render. The price:
164
+ the data in the HTML can be at most `revalidate + one refresh round` behind.
165
+ That price is acceptable, because live fields such as prices are updated on the
166
+ client from a WebSocket and the lag is not visible on screen.
167
+
168
+ The decision not to write to disk is deliberate. The equivalent of Next's
169
+ build-time prerender is prewarm, but the output is not written to disk: because
170
+ the cache lives in process memory, the warm-up is done when the process comes
171
+ up. The gain is the same — the first visitor does not wait for a cold render —
172
+ but the data is not frozen; every entry ages with the route's `revalidate`
173
+ duration ([06-caching.md](./06-caching.md)).
174
+
175
+ ### Keeping the compressed body in the cache
176
+
177
+ Every cached entry stores the brotli/gzip output alongside the HTML (the
178
+ `encoded` map shares its lifetime with the HTML). The same page is not
179
+ re-brotli'd on every request. Because `Content-Encoding` is set inside `route()`
180
+ on this path, the compression middleware does not kick in.
181
+
182
+ ### Why transient and permanent upstream errors are handled differently
183
+
184
+ If an upstream went down during the render, the output contains incomplete data,
185
+ and such HTML is **not** written to the cache: the next request tries again.
186
+
187
+ But this only applies to *transient* errors (network errors, 408, 425, 429 and
188
+ all 5xx). Deterministic answers like 400/403/404 do not get better by retrying;
189
+ turning off the cache because of them would mean rendering the page from scratch
190
+ on every visit — the content comes back in the same incomplete state anyway, the
191
+ visitor merely pays the render time. That is why permanent errors are only
192
+ logged and do not block the cache.
193
+
194
+ The direction in which this information reaches the framework is also
195
+ deliberately inverted: the framework does not know the data layer, the data
196
+ layer notifies the framework (`reportUpstreamFailure()`). If nobody ever calls
197
+ it, the cost is an empty array.
198
+
199
+ ### The nesting order of the three scopes
200
+
201
+ `route()` sets up this order:
202
+
203
+ ```
204
+ withHtmlCache( withUpstreamTracking( withRequestCache( controller ) ) )
205
+ ```
206
+
207
+ The order matters: **the per-request cache must be innermost** so that two calls
208
+ within the same render collapse into a single upstream request; **upstream
209
+ tracking must be inside the HTML cache** so that output produced with incomplete
210
+ data is not written to the cache.
211
+
212
+ ## Fault tolerance: no single gap takes the site down
213
+
214
+ There is a principle repeated throughout the framework: missing configuration or
215
+ missing build output produces a degraded but working page instead of an error.
216
+
217
+ - **If the config file is missing or unreadable**, a warning is printed and the
218
+ server comes up with the defaults. A broken edit must not make the site
219
+ impossible to open. In the same way, if one of the
220
+ `headers()`/`redirects()`/`rewrites()`/`cache()` sections throws, only that
221
+ section is ignored.
222
+ - **If hooks throw**, the framework falls back to its own default and warns.
223
+ - **If the build did not run**, `asset()` returns `/assets/<name>`, `hasAsset()`
224
+ is false and the layout does not print the stylesheet/script tags at all. When
225
+ `jskelet build` is forgotten you see an unstyled but working page instead of
226
+ an error.
227
+ - **A broken route module in dev** prints a warning and is skipped; **in
228
+ production it throws.** Going live with a half-built route table means pages
229
+ that silently return 404.
230
+ - **If the 404 render blows up too**, a minimal, template-free HTML is returned;
231
+ the visitor should not see an empty response.
232
+ - **A single request error does not take the process down:**
233
+ `unhandledRejection` and `uncaughtException` are logged and the process stays
234
+ up. On a news site, an error on a single page must not take the whole site
235
+ down.
236
+
237
+ ## Why there is no file-system-based routing
238
+
239
+ Order matters. If a single-segment catch-all such as `/:slug` is registered
240
+ before the `/about` route, "about" is mistaken for a slug. Making the order
241
+ visible instead of hiding it in file names makes diagnosis easier: either you
242
+ give an explicit list via `jskelet.config.mjs` → `routes`, or you have the
243
+ `routes/` directory scanned alphabetically and put a numeric prefix on the file
244
+ names (`10-pages.js`, `50-blog.js`, `99-catch-all.js`). Details:
245
+ [03-routing.md](./03-routing.md).
246
+
247
+ ## Why a single source of truth for config
248
+
249
+ `src/config/index.js` normalizes the project root, the directory paths, the
250
+ branding, the hooks and the rules. Other modules do not compute paths, they call
251
+ `getConfig()`. The reason is concrete: once the framework lives inside
252
+ `node_modules/`, every file that tries to find the root by counting `../..`
253
+ breaks. For the same reason there is a single mutation point on the build side
254
+ too (`initBuildPaths()`).
255
+
256
+ If `getConfig()` is used without `loadConfig()` having been called, it throws
257
+ instead of assuming an empty project root: a silently wrong path turns into
258
+ hard-to-diagnose problems like "why is there no stylesheet".
259
+
260
+ ## Why this dependency list
261
+
262
+ There are four runtime dependencies: `express`, `ejs`, `esbuild`,
263
+ `tailwind-merge`. Everything else (Tailwind, PostCSS, lightningcss, sharp, the
264
+ Phosphor icons) is an **optional peer dependency**, and if it is absent the
265
+ corresponding build step is skipped.
266
+
267
+ Two decisions deserve a separate explanation:
268
+
269
+ - **`node:zlib` instead of the `compression` package.** The package does not
270
+ support brotli and brings a seven-deep dependency tree; doing the brotli +
271
+ gzip negotiation by hand is enough. Brotli is preferred: on the home page HTML
272
+ it is ~35% smaller than gzip.
273
+ - **`tailwind-merge` stays at runtime.** Class computation is done only on the
274
+ server, it never enters the client bundle, so it has no effect on page weight.
275
+ A hand-written group table, on the other hand, produced visual regressions
276
+ because it mixed up width/colour pairs like `border-2` +
277
+ `border-transparent` and dropped classes.
278
+
279
+ Optional packages are resolved from the **application's** `node_modules`, not
280
+ from the framework's own. If the framework is installed via a `file:` or
281
+ workspace link, a plain `import "postcss"` looks in the framework's tree — not
282
+ in the application's.
283
+
284
+ ## Why alias and extension hooks
285
+
286
+ `node --import jskelet/register` does two things:
287
+
288
+ 1. It resolves the `compilerOptions.paths` aliases in `jsconfig.json` /
289
+ `tsconfig.json` (`@/lib/x` → `<root>/lib/x`). Because the editor and the
290
+ runtime are fed from the same file, the two do not drift apart.
291
+ 2. It adds extensions to extensionless relative imports (`./cache` →
292
+ `./cache.js`). Node ESM does not do this, and it is the most common breaking
293
+ point in code migrated from a bundler.
294
+
295
+ The `@/` resolution on the esbuild side mimics the same behaviour, so that
296
+ modules under `lib/` can use the same import style both on the server and in the
297
+ browser.
298
+
299
+ `--import` expects a module **specifier**, not a file path. On Windows an
300
+ absolute path like `H:\...` is mistaken for a URL with the `h:` scheme and
301
+ rejected; that is why the framework uses `pathToFileURL(...).href` everywhere.
302
+ For the same reason the config, the route modules and the components are
303
+ imported with a `file://` URL too.
304
+
305
+ ## What's next
306
+
307
+ - The route and controller contract: [03-routing.md](./03-routing.md)
308
+ - The template layer and metadata: [04-rendering.md](./04-rendering.md)
309
+ - The island runtime API: [05-islands.md](./05-islands.md)
310
+ - Cache settings and prewarm: [06-caching.md](./06-caching.md)
311
+ - The inner workings of the dev flow: [09-dev-tools.md](./09-dev-tools.md)