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