jskelet 0.2.2 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -1,315 +1,368 @@
1
- # Changelog
2
-
3
- All notable changes to this project are documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
- While the project is on `0.x`, minor releases may contain breaking changes; each
7
- one is listed under a **Breaking** heading.
8
-
9
- ## [Unreleased]
10
-
11
- ### Added
12
-
13
- - A cache admin panel at `/_jskelet/cache`, turned on with
14
- `cache().panel: { enabled: true }` or `JSKELET_CACHE_PANEL=1`. It lists what
15
- the in-process tier holds (key, size, status, remaining TTL, dependency count,
16
- precompressed bodies for HTML; key and TTL for data), reports whether the
17
- Redis tier is connected or bypassed, and runs the operations you would
18
- otherwise hand-write an admin route for: targeted invalidation with an
19
- optional hard mode, dropping a single entry, clearing either cache, unlinking
20
- the shared keys and triggering a prewarm pass. Unlike the dev overlay it does
21
- not look at `NODE_ENV`, because "why is this page stale" is a production
22
- question — but nothing is mounted until it is explicitly enabled, so the path
23
- does not exist by default. Access is a 32-character password regenerated on
24
- every process start and printed once to the server log; there is no persistent
25
- secret to leak and a deploy revokes old access on its own. The password is
26
- never accepted in a query string, three failed attempts ban the IP for 24
27
- hours, and every banned or unauthorised response is a `404` rather than a 401
28
- that would confirm the panel exists. The panel is excluded from indexing,
29
- prewarming and navigation speculation.
30
- - `dropHtmlCacheKey()` and `dropDataCacheKey()` drop one exact cache key.
31
- `invalidateHtmlCache()` matches a path pattern and takes down every query
32
- variant of a path, which is the right default for a webhook but wrong when you
33
- want `/list?page=2` gone and `/list?page=3` left hot.
34
-
35
- - An adaptive per-host rate limit for upstream calls, `cache().upstream`. It sits
36
- in the `fetch` wrapper rather than in the prewarm pass, because what spends the
37
- quota is the API call, not the page: one render may make one call or twenty, so
38
- `prewarm.rps` could never bound the real thing. A token bucket caps the average
39
- rate, a concurrency limit caps the calls in flight, and `rate` is treated as a
40
- ceiling that the limiter pulls down on its own — a 429 or 503 halves the rate,
41
- `Retry-After` stops the bucket for exactly as long as the upstream asked, and
42
- clean windows climb back one step at a time. A host that returns
43
- `breakerFailures` rate limits in a row is bypassed for `breakerCooldownMs`,
44
- which stops the worst waste: because a 429 counts as transient, the HTML
45
- produced by a throttled call is never stored, so a pass in that state spends
46
- quota and keeps nothing. Only 429 and 503 penalise the rate; a 400 or 500 is
47
- not a quota problem. Off by default — set `rate` to turn it on.
48
- - `getUpstreamLimiterStatus()` reports the current rate, calls in flight, 429
49
- count and breaker state per host. The dev panel's Server tab shows the same.
50
- - `getDataCacheStats()` counts how the data cache was used: fresh hits, stale
51
- hits, misses, coalesced concurrent reads, values promoted from the shared tier
52
- and — the only number that reaches the quota — real producer runs. A prewarm
53
- pass now prints its own share of that (`12 upstream calls for 430 data reads
54
- (97% from the data cache)`), which is what tells you whether the fix is a
55
- longer TTL or a rate limit. The dev report has a Data cache card for it.
56
-
57
- - The dev overlay header now shows the installed JSkelet version next to the
58
- title, labelled `latest` when it matches npm and `outdated` with the newer
59
- version when it does not, so you can tell at a glance which version the
60
- project runs without opening the Server tab.
61
- - An optional Redis tier behind both caches, turned on with
62
- `cache().redis: { enabled: true, url }` and `npm install ioredis`. The
63
- in-process cache stays primary and every request still reads it; Redis only
64
- does the two things a single process cannot. An instance that has never seen a
65
- path finds the HTML another replica already produced, so a fresh container or a
66
- post-deploy replacement does not re-render and re-fetch everything from
67
- scratch. And `invalidateHtmlCache()`, `clearHtmlCache()` and
68
- `clearDataCache()` now reach every replica over pub/sub instead of only the one
69
- that received the webhook — until now the others waited out the TTL and a
70
- visitor saw old or new content depending on where they landed. Keys live under
71
- `_jskelet:{namespace}:{buildId}:…`, where the build id makes HTML from a
72
- previous deploy expire on its own rather than pointing at asset files that no
73
- longer exist. Personalised (`storable: false`), degraded and non-200 responses
74
- are never shared. If `ioredis` is missing, Redis is unreachable or it goes down
75
- mid-flight, a warning is printed and the site keeps serving from memory.
76
- - `getRedisStatus()` reports whether the shared tier is connected, which key
77
- prefix and build id it is using, and how many command failures there have
78
- been — usable from a healthcheck endpoint. The same summary appears in the dev
79
- panel report.
80
- - Servers started with `startServer()` now shut down on `SIGTERM`/`SIGINT`
81
- instead of being killed: the listener is closed and the Redis connection is
82
- drained so in-flight writes are not cut mid-command.
83
- - Targeted HTML invalidation: `invalidateHtmlCache(target, { hard })` takes a
84
- path, the config pattern syntax (`/news/:slug`), a regular expression or a list
85
- of them, and returns how many entries were affected. By default it **stales**
86
- the entries rather than deleting them, so a webhook that touches hundreds of
87
- pages does not turn into hundreds of cold renders at the worst possible moment:
88
- visitors keep getting the old HTML while the refresh runs in the background,
89
- once per key. Matching is done against the path, so every query variant of a
90
- page is covered by one call, and a render already in flight when the purge
91
- arrives is not stored.
92
- - `clearDataCache()` now refreshes the HTML too. The `withDataCache` keys read
93
- during a render are recorded, so dropping `news:abc` stales every page that
94
- actually read it — the article, the home page listing it and the tag page —
95
- without the application declaring any tags. Turn it off with
96
- `cache().trackDependencies: false`; `getHtmlCacheEntries()` reports the
97
- dependency count per page as `deps`.
98
- - Invalidated paths go to the front of the next prewarm pass, so an updated page
99
- is refreshed without waiting for a visitor, while still respecting the `rps`
100
- limit. The pass summary counts them separately.
101
- - An upstream data cache: `withDataCache(key, ttlSeconds, producer)` and the
102
- `dataCache(fn, { key, revalidate })` wrapper, with `clearDataCache(prefix?)`,
103
- `getDataCacheSize()` and `getDataCacheEntries()` alongside them. It keeps JSON
104
- rather than HTML, so its default limit is 10,000 entries: a long-tail page that
105
- was never prewarmed still renders without touching the API. Concurrent reads of
106
- the same key collapse into one upstream request, an expired entry is served
107
- immediately while it refreshes in the background, a failing producer falls back
108
- to the stale value, and empty answers (`null`/`undefined`) are not stored
109
- unless `storeEmpty: true` is passed.
110
- - `cache().prewarm.priority` decides the warm-up order and accepts both the
111
- config pattern syntax (`/news/:slug`) and plain regular expressions. Matching
112
- paths are warmed on every pass.
113
- - Drip prewarming for large sites: `cache().prewarm.rps` (also `PREWARM_RPS`)
114
- caps requests per second regardless of parallelism, and `rotate` (on by
115
- default) makes periodic passes continue through the queue where the previous
116
- one stopped instead of re-warming the same first slice. A pass is skipped while
117
- the previous one is still running.
118
- - `cache().prewarm.retryDelayMs` (also `PREWARM_RETRY_DELAY_MS`) waits before the
119
- retry pass, since rate limit windows are measured in seconds.
120
- - `cache().maxEntries` configures the HTML cache limit, which used to be a fixed
121
- 500.
122
- - Transient upstream failures are now detected without any application code:
123
- `globalThis.fetch` is wrapped during startup and `429`, `5xx` and network
124
- errors raised inside a render are reported on their own, so rate limits stop
125
- turning existing pages into 404s even when the data layer never calls
126
- `reportUpstreamFailure()`. Requests outside a render and requests to the
127
- server itself are ignored, deterministic answers such as `404` are not
128
- reported, and the wrapper can be turned off with `cache().trackUpstream:
129
- false`.
130
- - `cache().transientRetry` (`{ attempts: 1, delayMs: 300 }` by default) retries a
131
- page that called `notFound()` while upstream was failing. Each attempt runs in
132
- a fresh upstream and per-request cache scope, so a page whose data arrives on
133
- the second try is served and cached as usual instead of degrading to an error.
134
- - The dev report now includes the data cache entry count under `cache.data`.
135
-
136
- ### Changed
137
-
138
- - The prewarm retry pass no longer retries permanent failures. A `400`, `403` or
139
- `404` does not get better on the second try, so those paths are dropped from
140
- the retry round and counted as `N not retried (permanent)` in the summary. The
141
- wait before the round now also honours the upstream rate limit: if a
142
- `Retry-After` or an open circuit breaker is holding calls back, the pass waits
143
- that out instead of retrying into the same 429.
144
- - Errors and warnings raised during a prewarm pass are no longer logged one per
145
- page. Request errors and the per-page render warnings (`was produced with
146
- missing data`, `returned notFound() while upstream is failing`, `could not be
147
- produced`) are counted while the pass runs and printed as a single summary
148
- block afterwards, grouped by message with the most frequent kinds first, so a
149
- failing upstream can no longer bury the "warmed N/M pages" line under hundreds
150
- of near-identical lines. Real traffic logs as before, and the dev tools panel
151
- still shows the per-path detail.
152
- - The marketing example's changelog page is now a timeline: releases are laid out
153
- along a rail with a sticky version column, each change group gets its own card
154
- with a coloured rule and item count, and a row of version chips at the top
155
- jumps straight to a release.
156
- - The dev tools panel is now fed over a WebSocket (`<devBasePath>/ws`) instead of
157
- polling `/stats` every two seconds. The server pushes statistics as they change
158
- and sends live reload and CSS hot-swap events over the same connection, so an
159
- open tab no longer keeps hitting the server while the panel is closed. No new
160
- dependency is involved; if the socket cannot be opened, the panel falls back to
161
- the previous SSE plus polling path.
162
- - The server now binds to `::` instead of `0.0.0.0` when no `HOST` is given, so a
163
- single dual-stack socket answers both IPv6 and IPv4. Browsers resolve
164
- `localhost` to `::1` first and, unlike ordinary requests, a WebSocket handshake
165
- does not fall back to IPv4 — which made the dev panel's live channel fail on an
166
- IPv4-only socket. Where IPv6 is unavailable the bind falls back to `0.0.0.0`.
167
- - Prewarming no longer holds up the rest of the dev server. In development it now
168
- runs with a single worker and a default limit of 4 requests per second
169
- (`prewarm.rps` / `PREWARM_RPS` still override it), so page requests and the dev
170
- panel stay responsive while a warm-up round is going on. Production behaviour
171
- is unchanged.
172
- - `notFound()` is no longer served as a 404 when a transient upstream failure
173
- (`429`, `5xx`, network error) happened during the same render. The page is
174
- retried first and, if upstream is still failing, responds with an uncached
175
- `503` and `Retry-After`. A temporary rate limit is no longer frozen into "this
176
- page does not exist" for the whole TTL. A retry that gets a clean answer saying
177
- the page is gone still returns a normal 404.
178
- - Responses produced with missing data are no longer offered to shared caches:
179
- a `degraded` render is sent with `private, no-store` instead of
180
- `public, s-maxage=…`. The `X-JSkelet-Cache` diagnostic header is still written.
181
- - The prewarm summary distinguishes paths left for the next pass
182
- (`700 deferred to the next pass`) from paths dropped entirely
183
- (`700 over the limit`).
184
- - The changelog page of the marketing example is generated from the project's
185
- `CHANGELOG.md` instead of a hand-written list, and shows the version published
186
- on npm next to the installed one.
187
- - The marketing example reads its markdown (documentation and changelog) from
188
- the repository over GitHub's raw endpoint, falling back to the installed
189
- package when the network is unavailable, so a deployment that ships without
190
- `node_modules` can still serve the docs. In development the local file wins
191
- and nothing is cached. The branch is overridable with `DOCS_REF`.
192
-
193
- ### Fixed
194
-
195
- - The dev panel's WebSocket handshake was answered with a `Sec-WebSocket-Accept`
196
- value derived from a mistyped protocol constant. Browsers verify that value and
197
- closed the connection immediately with "Incorrect 'Sec-WebSocket-Accept' header
198
- value", so the panel silently fell back to polling.
199
-
200
- ## [0.1.2] - 2026-08-30
201
-
202
- ### Added
203
-
204
- - `route(fn, { private: true })` for pages that depend on the visitor. The HTML
205
- cache is bypassed, `cache.html` patterns can no longer turn caching on for
206
- that route, and the response is sent with `private, no-store`, `Vary: Cookie`
207
- and no ETag.
208
- - A runtime guard against identity leaks: when a cacheable route reads
209
- `Cookie`, `Authorization` or a session field, the rendered HTML is never
210
- stored. In development the request fails with an explanation, in production it
211
- is served with `no-store` and logged.
212
- - `fragment()` for layout-less partial responses, with `no-store` and cache
213
- bypass built in.
214
- - CSRF protection. Cross-site state-changing requests are rejected based on
215
- `Origin` and `Sec-Fetch-Site`; requests carrying neither header still pass, so
216
- webhooks keep working. An optional double-submit token layer is enabled with
217
- `security.csrf.token` and rendered into forms by the new `csrfField()` helper.
218
- - Signed cookie helpers under `jskelet/cookies`: `parseCookies()`,
219
- `setCookie()`, `clearCookie()`, `setSignedCookie()`, `getSignedCookie()`,
220
- `randomToken()` and `safeEqual()`. Defaults are `HttpOnly`, `SameSite=Lax` and
221
- `Secure` outside development.
222
- - A `security` configuration section: `trustProxy`, `cookieSecret` and `csrf`.
223
- - `seeOther()` for the post/redirect/get flow, which needs 303 rather than the
224
- method-preserving 307 that `redirect()` sends.
225
- - Island cleanup. A `mount()` function may return a teardown callback; it is now
226
- stored and called by the new `unmount(root)` export when the subtree leaves
227
- the DOM.
228
- - Client helpers for partial updates: `swap()` and `startSwapLinks()` for
229
- fetching and replacing a region, `enhanceForm()` and `startForms()` for
230
- submitting forms without a full page load while keeping the no-JavaScript
231
- path working.
232
- - A fourth example, `examples/dashboard`: sign-in with a signed cookie session,
233
- a private page, a paginated table fragment, a CSRF-protected mutation and an
234
- island with cleanup, covered by its own `smoke.mjs`.
235
- - An npm version badge in the `README`, linking to the package page.
236
- - An English edition of the documentation under `docs/en/`, mirroring every
237
- chapter of the Turkish `docs/`.
238
- - The dev overlay now compares the installed version against the `latest` tag on
239
- npm: the Server tab shows the version, marks an `update` chip when a newer
240
- release exists and offers the upgrade command. The lookup is cached for six
241
- hours, never blocks the server and can be turned off with
242
- `JSKELET_VERSION_CHECK=0`.
243
-
244
- ### Changed
245
-
246
- - `trust proxy` is now configurable through `security.trustProxy` instead of
247
- being always on. The default is unchanged, but a server exposed directly to
248
- the internet should turn it off: while it is on, a client can forge its own
249
- `X-Forwarded-For` and rate limiting or audit logs see the wrong address.
250
- - Every message the framework prints is now English: config, router, render,
251
- cache, prewarm, asset and build warnings, CLI output, the project `jskelet
252
- init` scaffolds, and the devtools overlay and report interfaces. Visitor-facing
253
- status pages still follow `brand.lang` and keep their Turkish translations.
254
- - The dev overlay and report now show the current JSkelet logo, served with a
255
- cacheable response instead of being re-fetched on every navigation.
256
-
257
- ### Fixed
258
-
259
- - A page rendered without `revalidate` used to be sent with no `Cache-Control`
260
- at all, while still carrying a strong ETag. HTTP treats such a response as
261
- heuristically cacheable, so an intermediate proxy or the browser's back button
262
- could store a response meant for a single visitor. Dynamic pages now send
263
- `private, no-store` and no ETag.
264
- - A redirect thrown from a route that reads the session is no longer cacheable
265
- either; a stored "you need to sign in" redirect used to follow the visitor
266
- even after signing in.
267
-
268
- ## [0.1.1] - 2026-08-30
269
-
270
- ### Added
271
-
272
- - English `README`, plus `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`,
273
- a `LICENSE` file, issue and pull request templates, and a CI workflow.
274
- - An English-first `examples/marketing` with a Turkish translation, serving the
275
- package documentation under `/docs` and reading its version, dependencies and
276
- bundle sizes from the installed package.
277
-
278
- ### Changed
279
-
280
- - The install instructions point at the npm package instead of the git
281
- repository.
282
-
283
- ### Fixed
284
-
285
- - No more white flash between pages: the page background moved onto the root
286
- element, so it applies before the body paints. Reduced-motion preferences now
287
- switch off the decorative animations as well, not just page transitions.
288
-
289
- ## [0.1.0] - 2026-08-30
290
-
291
- Initial release.
292
-
293
- ### Added
294
-
295
- - Express 5 server with EJS rendering: `createApp()`, `startServer()`,
296
- `route()`, `renderPage()`, `renderView()`, `renderNotFound()`.
297
- - In-process HTML TTL cache with stale-while-revalidate, plus prewarm at boot.
298
- - Island runtime with visibility, eager and idle hydration strategies, a small
299
- cross-island store, and DOM helpers.
300
- - Configuration through `jskelet.config.mjs`: `brand`, `paths`, `navigation`,
301
- `icons`, `fonts`, `clientEnv`, `redirects()`, `rewrites()`, `headers()`,
302
- `cache()` and `hooks`.
303
- - Build pipeline: fonts, SVG sprite from used icons, Tailwind v4 CSS, esbuild
304
- bundles with code splitting, webp variants, hashed output and brotli/gzip
305
- precompression.
306
- - Dev server with watch build, CSS hot-swap, automatic restart and a devtools
307
- overlay (requests, errors, upstream calls, cache dump, Web Vitals).
308
- - CLI: `jskelet dev`, `jskelet build`, `jskelet start`, `jskelet init`.
309
- - Documentation under `docs/` and three examples: `minimal`, `blog`,
310
- `marketing`.
311
-
312
- [Unreleased]: https://github.com/ayberkenis/jskelet/compare/v0.1.2...HEAD
313
- [0.1.2]: https://github.com/ayberkenis/jskelet/compare/v0.1.1...v0.1.2
314
- [0.1.1]: https://github.com/ayberkenis/jskelet/compare/v0.1.0...v0.1.1
315
- [0.1.0]: https://github.com/ayberkenis/jskelet/releases/tag/v0.1.0
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ While the project is on `0.x`, minor releases may contain breaking changes; each
7
+ one is listed under a **Breaking** heading.
8
+
9
+ ## [Unreleased]
10
+
11
+ ### Added
12
+
13
+ - `cache().query`, a pattern → allowlist mapping that decides which query
14
+ parameters belong to the HTML cache key. An allowlist caches one entry per
15
+ distinct value of the listed parameters and ignores the rest, so every
16
+ `?utm_source=…` variant of a path shares one copy; `true` puts the whole query
17
+ in the key and `[]` ignores it entirely. Parameters enter the key sorted, so
18
+ `?a=1&b=2` and `?b=2&a=1` are one entry.
19
+ - A cache admin panel at `/_jskelet/cache`, turned on with
20
+ `cache().panel: { enabled: true }` or `JSKELET_CACHE_PANEL=1`. It lists what
21
+ the in-process tier holds (key, size, status, remaining TTL, dependency count,
22
+ precompressed bodies for HTML; key and TTL for data), reports whether the
23
+ Redis tier is connected or bypassed, and runs the operations you would
24
+ otherwise hand-write an admin route for: targeted invalidation with an
25
+ optional hard mode, dropping a single entry, clearing either cache, unlinking
26
+ the shared keys and triggering a prewarm pass. Unlike the dev overlay it does
27
+ not look at `NODE_ENV`, because "why is this page stale" is a production
28
+ question — but nothing is mounted until it is explicitly enabled, so the path
29
+ does not exist by default. Access is a 32-character password regenerated on
30
+ every process start and printed once to the server log; there is no persistent
31
+ secret to leak and a deploy revokes old access on its own. The password is
32
+ never accepted in a query string, three failed attempts ban the IP for 24
33
+ hours, and every banned or unauthorised response is a `404` rather than a 401
34
+ that would confirm the panel exists. The panel is excluded from indexing,
35
+ prewarming and navigation speculation.
36
+ - Cloudflare cache management, from the panel and from code. Set
37
+ `JSKELET_CLOUDFLARE_KEY` and `JSKELET_CLOUDFLARE_ZONE_ID` (or
38
+ `cache().cloudflare`) and the panel gains the CDN tier next to the origin one:
39
+ purge everything, purge every URL currently held in memory with one button or
40
+ a single row with `cf purge`, purge by prefix, host or cache tag, toggle
41
+ development mode, cache level, browser cache TTL, query string sorting,
42
+ Always Online, Tiered Cache, Regional Tiered Cache and Cache Reserve, clear
43
+ Cache Reserve, and read the cache hit ratio. This matters because
44
+ `invalidateHtmlCache()` refreshes the origin while the copy your visitors get
45
+ keeps being served from the edge until its TTL expires. The same surface is
46
+ exported as `purgeCloudflare()`, `toCloudflareUrls()`,
47
+ `fetchCloudflareOverview()`, `fetchCacheAnalytics()`, `fetchPathEdges()` and
48
+ `getCloudflareStatus()`; none of them throw, so a CDN outage returns
49
+ `{ ok: false, error }` instead of breaking a publish flow. Long purge lists
50
+ are batched at Cloudflare's 100-keys-per-request limit and sent sequentially
51
+ to stay inside the rate limit. The token is read from the environment, is
52
+ never returned in a response, and only cache related zone settings can be
53
+ changed.
54
+ - An edge breakdown for a single path: `fetchPathEdges()` reports which
55
+ Cloudflare colos served it from cache and which went to the origin. This is
56
+ observation, not inventory — Cloudflare has no endpoint that lists which
57
+ edges currently hold a URL, and no way to warm an edge you pick, so the panel
58
+ says as much rather than implying otherwise.
59
+ - `getRedisDetails()` reports where the shared tier actually points — address,
60
+ TLS, database, namespace, which kinds are shared and whether the purge channel
61
+ is subscribed — because "connected" alone does not explain a Redis that shares
62
+ nothing because of a wrong namespace. The password is never part of the
63
+ output. `inspectRedis()` counts the keys per kind plus `DBSIZE` and
64
+ `used_memory`; it runs a `SCAN`, so the panel keeps it behind its own button
65
+ instead of the refresh loop. When Redis is off, the panel explains what a
66
+ shared tier would buy and shows the memory and disk state of the host instead,
67
+ which is the number that decides whether `maxEntries` is too high.
68
+ - `dropHtmlCacheKey()` and `dropDataCacheKey()` drop one exact cache key.
69
+ `invalidateHtmlCache()` matches a path pattern and takes down every query
70
+ variant of a path, which is the right default for a webhook but wrong when you
71
+ want `/list?page=2` gone and `/list?page=3` left hot.
72
+
73
+ - An adaptive per-host rate limit for upstream calls, `cache().upstream`. It sits
74
+ in the `fetch` wrapper rather than in the prewarm pass, because what spends the
75
+ quota is the API call, not the page: one render may make one call or twenty, so
76
+ `prewarm.rps` could never bound the real thing. A token bucket caps the average
77
+ rate, a concurrency limit caps the calls in flight, and `rate` is treated as a
78
+ ceiling that the limiter pulls down on its own — a 429 or 503 halves the rate,
79
+ `Retry-After` stops the bucket for exactly as long as the upstream asked, and
80
+ clean windows climb back one step at a time. A host that returns
81
+ `breakerFailures` rate limits in a row is bypassed for `breakerCooldownMs`,
82
+ which stops the worst waste: because a 429 counts as transient, the HTML
83
+ produced by a throttled call is never stored, so a pass in that state spends
84
+ quota and keeps nothing. Only 429 and 503 penalise the rate; a 400 or 500 is
85
+ not a quota problem. Off by default — set `rate` to turn it on.
86
+ - `getUpstreamLimiterStatus()` reports the current rate, calls in flight, 429
87
+ count and breaker state per host. The dev panel's Server tab shows the same.
88
+ - `getDataCacheStats()` counts how the data cache was used: fresh hits, stale
89
+ hits, misses, coalesced concurrent reads, values promoted from the shared tier
90
+ and — the only number that reaches the quota — real producer runs. A prewarm
91
+ pass now prints its own share of that (`12 upstream calls for 430 data reads
92
+ (97% from the data cache)`), which is what tells you whether the fix is a
93
+ longer TTL or a rate limit. The dev report has a Data cache card for it.
94
+
95
+ - The dev overlay header now shows the installed JSkelet version next to the
96
+ title, labelled `latest` when it matches npm and `outdated` with the newer
97
+ version when it does not, so you can tell at a glance which version the
98
+ project runs without opening the Server tab.
99
+ - An optional Redis tier behind both caches, turned on with
100
+ `cache().redis: { enabled: true, url }` and `npm install ioredis`. The
101
+ in-process cache stays primary and every request still reads it; Redis only
102
+ does the two things a single process cannot. An instance that has never seen a
103
+ path finds the HTML another replica already produced, so a fresh container or a
104
+ post-deploy replacement does not re-render and re-fetch everything from
105
+ scratch. And `invalidateHtmlCache()`, `clearHtmlCache()` and
106
+ `clearDataCache()` now reach every replica over pub/sub instead of only the one
107
+ that received the webhook — until now the others waited out the TTL and a
108
+ visitor saw old or new content depending on where they landed. Keys live under
109
+ `_jskelet:{namespace}:{buildId}:…`, where the build id makes HTML from a
110
+ previous deploy expire on its own rather than pointing at asset files that no
111
+ longer exist. Personalised (`storable: false`), degraded and non-200 responses
112
+ are never shared. If `ioredis` is missing, Redis is unreachable or it goes down
113
+ mid-flight, a warning is printed and the site keeps serving from memory.
114
+ - `getRedisStatus()` reports whether the shared tier is connected, which key
115
+ prefix and build id it is using, and how many command failures there have
116
+ been — usable from a healthcheck endpoint. The same summary appears in the dev
117
+ panel report.
118
+ - Servers started with `startServer()` now shut down on `SIGTERM`/`SIGINT`
119
+ instead of being killed: the listener is closed and the Redis connection is
120
+ drained so in-flight writes are not cut mid-command.
121
+ - Targeted HTML invalidation: `invalidateHtmlCache(target, { hard })` takes a
122
+ path, the config pattern syntax (`/news/:slug`), a regular expression or a list
123
+ of them, and returns how many entries were affected. By default it **stales**
124
+ the entries rather than deleting them, so a webhook that touches hundreds of
125
+ pages does not turn into hundreds of cold renders at the worst possible moment:
126
+ visitors keep getting the old HTML while the refresh runs in the background,
127
+ once per key. Matching is done against the path, so every query variant of a
128
+ page is covered by one call, and a render already in flight when the purge
129
+ arrives is not stored.
130
+ - `clearDataCache()` now refreshes the HTML too. The `withDataCache` keys read
131
+ during a render are recorded, so dropping `news:abc` stales every page that
132
+ actually read it — the article, the home page listing it and the tag page —
133
+ without the application declaring any tags. Turn it off with
134
+ `cache().trackDependencies: false`; `getHtmlCacheEntries()` reports the
135
+ dependency count per page as `deps`.
136
+ - Invalidated paths go to the front of the next prewarm pass, so an updated page
137
+ is refreshed without waiting for a visitor, while still respecting the `rps`
138
+ limit. The pass summary counts them separately.
139
+ - An upstream data cache: `withDataCache(key, ttlSeconds, producer)` and the
140
+ `dataCache(fn, { key, revalidate })` wrapper, with `clearDataCache(prefix?)`,
141
+ `getDataCacheSize()` and `getDataCacheEntries()` alongside them. It keeps JSON
142
+ rather than HTML, so its default limit is 10,000 entries: a long-tail page that
143
+ was never prewarmed still renders without touching the API. Concurrent reads of
144
+ the same key collapse into one upstream request, an expired entry is served
145
+ immediately while it refreshes in the background, a failing producer falls back
146
+ to the stale value, and empty answers (`null`/`undefined`) are not stored
147
+ unless `storeEmpty: true` is passed.
148
+ - `cache().prewarm.priority` decides the warm-up order and accepts both the
149
+ config pattern syntax (`/news/:slug`) and plain regular expressions. Matching
150
+ paths are warmed on every pass.
151
+ - Drip prewarming for large sites: `cache().prewarm.rps` (also `PREWARM_RPS`)
152
+ caps requests per second regardless of parallelism, and `rotate` (on by
153
+ default) makes periodic passes continue through the queue where the previous
154
+ one stopped instead of re-warming the same first slice. A pass is skipped while
155
+ the previous one is still running.
156
+ - `cache().prewarm.retryDelayMs` (also `PREWARM_RETRY_DELAY_MS`) waits before the
157
+ retry pass, since rate limit windows are measured in seconds.
158
+ - `cache().maxEntries` configures the HTML cache limit, which used to be a fixed 500.
159
+ - Transient upstream failures are now detected without any application code:
160
+ `globalThis.fetch` is wrapped during startup and `429`, `5xx` and network
161
+ errors raised inside a render are reported on their own, so rate limits stop
162
+ turning existing pages into 404s even when the data layer never calls
163
+ `reportUpstreamFailure()`. Requests outside a render and requests to the
164
+ server itself are ignored, deterministic answers such as `404` are not
165
+ reported, and the wrapper can be turned off with `cache().trackUpstream:
166
+ false`.
167
+ - `cache().transientRetry` (`{ attempts: 1, delayMs: 300 }` by default) retries a
168
+ page that called `notFound()` while upstream was failing. Each attempt runs in
169
+ a fresh upstream and per-request cache scope, so a page whose data arrives on
170
+ the second try is served and cached as usual instead of degrading to an error.
171
+ - The dev report now includes the data cache entry count under `cache.data`.
172
+
173
+ ### Changed
174
+
175
+ - The release history page in `examples/marketing` now shows one release at a
176
+ time: the newest one is expanded and older releases collapse to a single
177
+ header row with their date, status and change count. Every release used to be
178
+ printed open in a two-column grid, which made the page an unreadable wall as
179
+ soon as a few versions piled up. Version links and the quick-jump strip still
180
+ work, and they open the collapsed release they point at.
181
+ - The prewarm retry pass no longer retries permanent failures. A `400`, `403` or
182
+ `404` does not get better on the second try, so those paths are dropped from
183
+ the retry round and counted as `N not retried (permanent)` in the summary. The
184
+ wait before the round now also honours the upstream rate limit: if a
185
+ `Retry-After` or an open circuit breaker is holding calls back, the pass waits
186
+ that out instead of retrying into the same 429.
187
+ - Errors and warnings raised during a prewarm pass are no longer logged one per
188
+ page. Request errors and the per-page render warnings (`was produced with
189
+ missing data`, `returned notFound() while upstream is failing`, `could not be
190
+ produced`) are counted while the pass runs and printed as a single summary
191
+ block afterwards, grouped by message with the most frequent kinds first, so a
192
+ failing upstream can no longer bury the "warmed N/M pages" line under hundreds
193
+ of near-identical lines. Real traffic logs as before, and the dev tools panel
194
+ still shows the per-path detail.
195
+ - The marketing example's changelog page is now a timeline: releases are laid out
196
+ along a rail with a sticky version column, each change group gets its own card
197
+ with a coloured rule and item count, and a row of version chips at the top
198
+ jumps straight to a release.
199
+ - The dev tools panel is now fed over a WebSocket (`<devBasePath>/ws`) instead of
200
+ polling `/stats` every two seconds. The server pushes statistics as they change
201
+ and sends live reload and CSS hot-swap events over the same connection, so an
202
+ open tab no longer keeps hitting the server while the panel is closed. No new
203
+ dependency is involved; if the socket cannot be opened, the panel falls back to
204
+ the previous SSE plus polling path.
205
+ - The server now binds to `::` instead of `0.0.0.0` when no `HOST` is given, so a
206
+ single dual-stack socket answers both IPv6 and IPv4. Browsers resolve
207
+ `localhost` to `::1` first and, unlike ordinary requests, a WebSocket handshake
208
+ does not fall back to IPv4 — which made the dev panel's live channel fail on an
209
+ IPv4-only socket. Where IPv6 is unavailable the bind falls back to `0.0.0.0`.
210
+ - Prewarming no longer holds up the rest of the dev server. In development it now
211
+ runs with a single worker and a default limit of 4 requests per second
212
+ (`prewarm.rps` / `PREWARM_RPS` still override it), so page requests and the dev
213
+ panel stay responsive while a warm-up round is going on. Production behaviour
214
+ is unchanged.
215
+ - `notFound()` is no longer served as a 404 when a transient upstream failure
216
+ (`429`, `5xx`, network error) happened during the same render. The page is
217
+ retried first and, if upstream is still failing, responds with an uncached
218
+ `503` and `Retry-After`. A temporary rate limit is no longer frozen into "this
219
+ page does not exist" for the whole TTL. A retry that gets a clean answer saying
220
+ the page is gone still returns a normal 404.
221
+ - Responses produced with missing data are no longer offered to shared caches:
222
+ a `degraded` render is sent with `private, no-store` instead of
223
+ `public, s-maxage=…`. The `X-JSkelet-Cache` diagnostic header is still written.
224
+ - The prewarm summary distinguishes paths left for the next pass
225
+ (`700 deferred to the next pass`) from paths dropped entirely
226
+ (`700 over the limit`).
227
+ - The changelog page of the marketing example is generated from the project's
228
+ `CHANGELOG.md` instead of a hand-written list, and shows the version published
229
+ on npm next to the installed one.
230
+ - The marketing example reads its markdown (documentation and changelog) from
231
+ the repository over GitHub's raw endpoint, falling back to the installed
232
+ package when the network is unavailable, so a deployment that ships without
233
+ `node_modules` can still serve the docs. In development the local file wins
234
+ and nothing is cached. The branch is overridable with `DOCS_REF`.
235
+
236
+ ### Fixed
237
+
238
+ - The dev panel's WebSocket handshake was answered with a `Sec-WebSocket-Accept`
239
+ value derived from a mistyped protocol constant. Browsers verify that value and
240
+ closed the connection immediately with "Incorrect 'Sec-WebSocket-Accept' header
241
+ value", so the panel silently fell back to polling.
242
+
243
+ ### Breaking
244
+
245
+ - A request that carries a query parameter is now dynamic by default: it is not
246
+ written to the HTML cache and the response is sent with `private, no-store`,
247
+ even when a `cache().html` pattern covers the path. Every query variant used
248
+ to become its own cache entry, which let campaign parameters
249
+ (`?utm_source=…`) mint unbounded keys and evict real pages from a 500-entry
250
+ store. Pages whose output genuinely depends on the query keep their cache by
251
+ listing the relevant parameters under `cache().query`.
252
+
253
+ ## [0.1.2] - 2026-08-30
254
+
255
+ ### Added
256
+
257
+ - `route(fn, { private: true })` for pages that depend on the visitor. The HTML
258
+ cache is bypassed, `cache.html` patterns can no longer turn caching on for
259
+ that route, and the response is sent with `private, no-store`, `Vary: Cookie`
260
+ and no ETag.
261
+ - A runtime guard against identity leaks: when a cacheable route reads
262
+ `Cookie`, `Authorization` or a session field, the rendered HTML is never
263
+ stored. In development the request fails with an explanation, in production it
264
+ is served with `no-store` and logged.
265
+ - `fragment()` for layout-less partial responses, with `no-store` and cache
266
+ bypass built in.
267
+ - CSRF protection. Cross-site state-changing requests are rejected based on
268
+ `Origin` and `Sec-Fetch-Site`; requests carrying neither header still pass, so
269
+ webhooks keep working. An optional double-submit token layer is enabled with
270
+ `security.csrf.token` and rendered into forms by the new `csrfField()` helper.
271
+ - Signed cookie helpers under `jskelet/cookies`: `parseCookies()`,
272
+ `setCookie()`, `clearCookie()`, `setSignedCookie()`, `getSignedCookie()`,
273
+ `randomToken()` and `safeEqual()`. Defaults are `HttpOnly`, `SameSite=Lax` and
274
+ `Secure` outside development.
275
+ - A `security` configuration section: `trustProxy`, `cookieSecret` and `csrf`.
276
+ - `seeOther()` for the post/redirect/get flow, which needs 303 rather than the
277
+ method-preserving 307 that `redirect()` sends.
278
+ - Island cleanup. A `mount()` function may return a teardown callback; it is now
279
+ stored and called by the new `unmount(root)` export when the subtree leaves
280
+ the DOM.
281
+ - Client helpers for partial updates: `swap()` and `startSwapLinks()` for
282
+ fetching and replacing a region, `enhanceForm()` and `startForms()` for
283
+ submitting forms without a full page load while keeping the no-JavaScript
284
+ path working.
285
+ - A fourth example, `examples/dashboard`: sign-in with a signed cookie session,
286
+ a private page, a paginated table fragment, a CSRF-protected mutation and an
287
+ island with cleanup, covered by its own `smoke.mjs`.
288
+ - An npm version badge in the `README`, linking to the package page.
289
+ - An English edition of the documentation under `docs/en/`, mirroring every
290
+ chapter of the Turkish `docs/`.
291
+ - The dev overlay now compares the installed version against the `latest` tag on
292
+ npm: the Server tab shows the version, marks an `update` chip when a newer
293
+ release exists and offers the upgrade command. The lookup is cached for six
294
+ hours, never blocks the server and can be turned off with
295
+ `JSKELET_VERSION_CHECK=0`.
296
+
297
+ ### Changed
298
+
299
+ - `trust proxy` is now configurable through `security.trustProxy` instead of
300
+ being always on. The default is unchanged, but a server exposed directly to
301
+ the internet should turn it off: while it is on, a client can forge its own
302
+ `X-Forwarded-For` and rate limiting or audit logs see the wrong address.
303
+ - Every message the framework prints is now English: config, router, render,
304
+ cache, prewarm, asset and build warnings, CLI output, the project `jskelet
305
+ init` scaffolds, and the devtools overlay and report interfaces. Visitor-facing
306
+ status pages still follow `brand.lang` and keep their Turkish translations.
307
+ - The dev overlay and report now show the current JSkelet logo, served with a
308
+ cacheable response instead of being re-fetched on every navigation.
309
+
310
+ ### Fixed
311
+
312
+ - A page rendered without `revalidate` used to be sent with no `Cache-Control`
313
+ at all, while still carrying a strong ETag. HTTP treats such a response as
314
+ heuristically cacheable, so an intermediate proxy or the browser's back button
315
+ could store a response meant for a single visitor. Dynamic pages now send
316
+ `private, no-store` and no ETag.
317
+ - A redirect thrown from a route that reads the session is no longer cacheable
318
+ either; a stored "you need to sign in" redirect used to follow the visitor
319
+ even after signing in.
320
+
321
+ ## [0.1.1] - 2026-08-30
322
+
323
+ ### Added
324
+
325
+ - English `README`, plus `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`,
326
+ a `LICENSE` file, issue and pull request templates, and a CI workflow.
327
+ - An English-first `examples/marketing` with a Turkish translation, serving the
328
+ package documentation under `/docs` and reading its version, dependencies and
329
+ bundle sizes from the installed package.
330
+
331
+ ### Changed
332
+
333
+ - The install instructions point at the npm package instead of the git
334
+ repository.
335
+
336
+ ### Fixed
337
+
338
+ - No more white flash between pages: the page background moved onto the root
339
+ element, so it applies before the body paints. Reduced-motion preferences now
340
+ switch off the decorative animations as well, not just page transitions.
341
+
342
+ ## [0.1.0] - 2026-08-30
343
+
344
+ Initial release.
345
+
346
+ ### Added
347
+
348
+ - Express 5 server with EJS rendering: `createApp()`, `startServer()`,
349
+ `route()`, `renderPage()`, `renderView()`, `renderNotFound()`.
350
+ - In-process HTML TTL cache with stale-while-revalidate, plus prewarm at boot.
351
+ - Island runtime with visibility, eager and idle hydration strategies, a small
352
+ cross-island store, and DOM helpers.
353
+ - Configuration through `jskelet.config.mjs`: `brand`, `paths`, `navigation`,
354
+ `icons`, `fonts`, `clientEnv`, `redirects()`, `rewrites()`, `headers()`,
355
+ `cache()` and `hooks`.
356
+ - Build pipeline: fonts, SVG sprite from used icons, Tailwind v4 CSS, esbuild
357
+ bundles with code splitting, webp variants, hashed output and brotli/gzip
358
+ precompression.
359
+ - Dev server with watch build, CSS hot-swap, automatic restart and a devtools
360
+ overlay (requests, errors, upstream calls, cache dump, Web Vitals).
361
+ - CLI: `jskelet dev`, `jskelet build`, `jskelet start`, `jskelet init`.
362
+ - Documentation under `docs/` and three examples: `minimal`, `blog`,
363
+ `marketing`.
364
+
365
+ [Unreleased]: https://github.com/ayberkenis/jskelet/compare/v0.1.2...HEAD
366
+ [0.1.2]: https://github.com/ayberkenis/jskelet/compare/v0.1.1...v0.1.2
367
+ [0.1.1]: https://github.com/ayberkenis/jskelet/compare/v0.1.0...v0.1.1
368
+ [0.1.0]: https://github.com/ayberkenis/jskelet/releases/tag/v0.1.0