jskelet 0.2.2 → 0.2.4

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