jskelet 0.2.1 → 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,261 +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
- - An optional Redis tier behind both caches, turned on with
14
- `cache().redis: { enabled: true, url }` and `npm install ioredis`. The
15
- in-process cache stays primary and every request still reads it; Redis only
16
- does the two things a single process cannot. An instance that has never seen a
17
- path finds the HTML another replica already produced, so a fresh container or a
18
- post-deploy replacement does not re-render and re-fetch everything from
19
- scratch. And `invalidateHtmlCache()`, `clearHtmlCache()` and
20
- `clearDataCache()` now reach every replica over pub/sub instead of only the one
21
- that received the webhook — until now the others waited out the TTL and a
22
- visitor saw old or new content depending on where they landed. Keys live under
23
- `_jskelet:{namespace}:{buildId}:…`, where the build id makes HTML from a
24
- previous deploy expire on its own rather than pointing at asset files that no
25
- longer exist. Personalised (`storable: false`), degraded and non-200 responses
26
- are never shared. If `ioredis` is missing, Redis is unreachable or it goes down
27
- mid-flight, a warning is printed and the site keeps serving from memory.
28
- - `getRedisStatus()` reports whether the shared tier is connected, which key
29
- prefix and build id it is using, and how many command failures there have
30
- been — usable from a healthcheck endpoint. The same summary appears in the dev
31
- panel report.
32
- - Servers started with `startServer()` now shut down on `SIGTERM`/`SIGINT`
33
- instead of being killed: the listener is closed and the Redis connection is
34
- drained so in-flight writes are not cut mid-command.
35
- - Targeted HTML invalidation: `invalidateHtmlCache(target, { hard })` takes a
36
- path, the config pattern syntax (`/news/:slug`), a regular expression or a list
37
- of them, and returns how many entries were affected. By default it **stales**
38
- the entries rather than deleting them, so a webhook that touches hundreds of
39
- pages does not turn into hundreds of cold renders at the worst possible moment:
40
- visitors keep getting the old HTML while the refresh runs in the background,
41
- once per key. Matching is done against the path, so every query variant of a
42
- page is covered by one call, and a render already in flight when the purge
43
- arrives is not stored.
44
- - `clearDataCache()` now refreshes the HTML too. The `withDataCache` keys read
45
- during a render are recorded, so dropping `news:abc` stales every page that
46
- actually read it — the article, the home page listing it and the tag page —
47
- without the application declaring any tags. Turn it off with
48
- `cache().trackDependencies: false`; `getHtmlCacheEntries()` reports the
49
- dependency count per page as `deps`.
50
- - Invalidated paths go to the front of the next prewarm pass, so an updated page
51
- is refreshed without waiting for a visitor, while still respecting the `rps`
52
- limit. The pass summary counts them separately.
53
- - An upstream data cache: `withDataCache(key, ttlSeconds, producer)` and the
54
- `dataCache(fn, { key, revalidate })` wrapper, with `clearDataCache(prefix?)`,
55
- `getDataCacheSize()` and `getDataCacheEntries()` alongside them. It keeps JSON
56
- rather than HTML, so its default limit is 10,000 entries: a long-tail page that
57
- was never prewarmed still renders without touching the API. Concurrent reads of
58
- the same key collapse into one upstream request, an expired entry is served
59
- immediately while it refreshes in the background, a failing producer falls back
60
- to the stale value, and empty answers (`null`/`undefined`) are not stored
61
- unless `storeEmpty: true` is passed.
62
- - `cache().prewarm.priority` decides the warm-up order and accepts both the
63
- config pattern syntax (`/news/:slug`) and plain regular expressions. Matching
64
- paths are warmed on every pass.
65
- - Drip prewarming for large sites: `cache().prewarm.rps` (also `PREWARM_RPS`)
66
- caps requests per second regardless of parallelism, and `rotate` (on by
67
- default) makes periodic passes continue through the queue where the previous
68
- one stopped instead of re-warming the same first slice. A pass is skipped while
69
- the previous one is still running.
70
- - `cache().prewarm.retryDelayMs` (also `PREWARM_RETRY_DELAY_MS`) waits before the
71
- retry pass, since rate limit windows are measured in seconds.
72
- - `cache().maxEntries` configures the HTML cache limit, which used to be a fixed
73
- 500.
74
- - Transient upstream failures are now detected without any application code:
75
- `globalThis.fetch` is wrapped during startup and `429`, `5xx` and network
76
- errors raised inside a render are reported on their own, so rate limits stop
77
- turning existing pages into 404s even when the data layer never calls
78
- `reportUpstreamFailure()`. Requests outside a render and requests to the
79
- server itself are ignored, deterministic answers such as `404` are not
80
- reported, and the wrapper can be turned off with `cache().trackUpstream:
81
- false`.
82
- - `cache().transientRetry` (`{ attempts: 1, delayMs: 300 }` by default) retries a
83
- page that called `notFound()` while upstream was failing. Each attempt runs in
84
- a fresh upstream and per-request cache scope, so a page whose data arrives on
85
- the second try is served and cached as usual instead of degrading to an error.
86
- - The dev report now includes the data cache entry count under `cache.data`.
87
-
88
- ### Changed
89
-
90
- - Errors and warnings raised during a prewarm pass are no longer logged one per
91
- page. Request errors and the per-page render warnings (`was produced with
92
- missing data`, `returned notFound() while upstream is failing`, `could not be
93
- produced`) are counted while the pass runs and printed as a single summary
94
- block afterwards, grouped by message with the most frequent kinds first, so a
95
- failing upstream can no longer bury the "warmed N/M pages" line under hundreds
96
- of near-identical lines. Real traffic logs as before, and the dev tools panel
97
- still shows the per-path detail.
98
- - The marketing example's changelog page is now a timeline: releases are laid out
99
- along a rail with a sticky version column, each change group gets its own card
100
- with a coloured rule and item count, and a row of version chips at the top
101
- jumps straight to a release.
102
- - The dev tools panel is now fed over a WebSocket (`<devBasePath>/ws`) instead of
103
- polling `/stats` every two seconds. The server pushes statistics as they change
104
- and sends live reload and CSS hot-swap events over the same connection, so an
105
- open tab no longer keeps hitting the server while the panel is closed. No new
106
- dependency is involved; if the socket cannot be opened, the panel falls back to
107
- the previous SSE plus polling path.
108
- - The server now binds to `::` instead of `0.0.0.0` when no `HOST` is given, so a
109
- single dual-stack socket answers both IPv6 and IPv4. Browsers resolve
110
- `localhost` to `::1` first and, unlike ordinary requests, a WebSocket handshake
111
- does not fall back to IPv4 — which made the dev panel's live channel fail on an
112
- IPv4-only socket. Where IPv6 is unavailable the bind falls back to `0.0.0.0`.
113
- - Prewarming no longer holds up the rest of the dev server. In development it now
114
- runs with a single worker and a default limit of 4 requests per second
115
- (`prewarm.rps` / `PREWARM_RPS` still override it), so page requests and the dev
116
- panel stay responsive while a warm-up round is going on. Production behaviour
117
- is unchanged.
118
- - `notFound()` is no longer served as a 404 when a transient upstream failure
119
- (`429`, `5xx`, network error) happened during the same render. The page is
120
- retried first and, if upstream is still failing, responds with an uncached
121
- `503` and `Retry-After`. A temporary rate limit is no longer frozen into "this
122
- page does not exist" for the whole TTL. A retry that gets a clean answer saying
123
- the page is gone still returns a normal 404.
124
- - Responses produced with missing data are no longer offered to shared caches:
125
- a `degraded` render is sent with `private, no-store` instead of
126
- `public, s-maxage=…`. The `X-JSkelet-Cache` diagnostic header is still written.
127
- - The prewarm summary distinguishes paths left for the next pass
128
- (`700 deferred to the next pass`) from paths dropped entirely
129
- (`700 over the limit`).
130
- - The changelog page of the marketing example is generated from the project's
131
- `CHANGELOG.md` instead of a hand-written list, and shows the version published
132
- on npm next to the installed one.
133
- - The marketing example reads its markdown (documentation and changelog) from
134
- the repository over GitHub's raw endpoint, falling back to the installed
135
- package when the network is unavailable, so a deployment that ships without
136
- `node_modules` can still serve the docs. In development the local file wins
137
- and nothing is cached. The branch is overridable with `DOCS_REF`.
138
-
139
- ### Fixed
140
-
141
- - The dev panel's WebSocket handshake was answered with a `Sec-WebSocket-Accept`
142
- value derived from a mistyped protocol constant. Browsers verify that value and
143
- closed the connection immediately with "Incorrect 'Sec-WebSocket-Accept' header
144
- value", so the panel silently fell back to polling.
145
-
146
- ## [0.1.2] - 2026-08-30
147
-
148
- ### Added
149
-
150
- - `route(fn, { private: true })` for pages that depend on the visitor. The HTML
151
- cache is bypassed, `cache.html` patterns can no longer turn caching on for
152
- that route, and the response is sent with `private, no-store`, `Vary: Cookie`
153
- and no ETag.
154
- - A runtime guard against identity leaks: when a cacheable route reads
155
- `Cookie`, `Authorization` or a session field, the rendered HTML is never
156
- stored. In development the request fails with an explanation, in production it
157
- is served with `no-store` and logged.
158
- - `fragment()` for layout-less partial responses, with `no-store` and cache
159
- bypass built in.
160
- - CSRF protection. Cross-site state-changing requests are rejected based on
161
- `Origin` and `Sec-Fetch-Site`; requests carrying neither header still pass, so
162
- webhooks keep working. An optional double-submit token layer is enabled with
163
- `security.csrf.token` and rendered into forms by the new `csrfField()` helper.
164
- - Signed cookie helpers under `jskelet/cookies`: `parseCookies()`,
165
- `setCookie()`, `clearCookie()`, `setSignedCookie()`, `getSignedCookie()`,
166
- `randomToken()` and `safeEqual()`. Defaults are `HttpOnly`, `SameSite=Lax` and
167
- `Secure` outside development.
168
- - A `security` configuration section: `trustProxy`, `cookieSecret` and `csrf`.
169
- - `seeOther()` for the post/redirect/get flow, which needs 303 rather than the
170
- method-preserving 307 that `redirect()` sends.
171
- - Island cleanup. A `mount()` function may return a teardown callback; it is now
172
- stored and called by the new `unmount(root)` export when the subtree leaves
173
- the DOM.
174
- - Client helpers for partial updates: `swap()` and `startSwapLinks()` for
175
- fetching and replacing a region, `enhanceForm()` and `startForms()` for
176
- submitting forms without a full page load while keeping the no-JavaScript
177
- path working.
178
- - A fourth example, `examples/dashboard`: sign-in with a signed cookie session,
179
- a private page, a paginated table fragment, a CSRF-protected mutation and an
180
- island with cleanup, covered by its own `smoke.mjs`.
181
- - An npm version badge in the `README`, linking to the package page.
182
- - An English edition of the documentation under `docs/en/`, mirroring every
183
- chapter of the Turkish `docs/`.
184
- - The dev overlay now compares the installed version against the `latest` tag on
185
- npm: the Server tab shows the version, marks an `update` chip when a newer
186
- release exists and offers the upgrade command. The lookup is cached for six
187
- hours, never blocks the server and can be turned off with
188
- `JSKELET_VERSION_CHECK=0`.
189
-
190
- ### Changed
191
-
192
- - `trust proxy` is now configurable through `security.trustProxy` instead of
193
- being always on. The default is unchanged, but a server exposed directly to
194
- the internet should turn it off: while it is on, a client can forge its own
195
- `X-Forwarded-For` and rate limiting or audit logs see the wrong address.
196
- - Every message the framework prints is now English: config, router, render,
197
- cache, prewarm, asset and build warnings, CLI output, the project `jskelet
198
- init` scaffolds, and the devtools overlay and report interfaces. Visitor-facing
199
- status pages still follow `brand.lang` and keep their Turkish translations.
200
- - The dev overlay and report now show the current JSkelet logo, served with a
201
- cacheable response instead of being re-fetched on every navigation.
202
-
203
- ### Fixed
204
-
205
- - A page rendered without `revalidate` used to be sent with no `Cache-Control`
206
- at all, while still carrying a strong ETag. HTTP treats such a response as
207
- heuristically cacheable, so an intermediate proxy or the browser's back button
208
- could store a response meant for a single visitor. Dynamic pages now send
209
- `private, no-store` and no ETag.
210
- - A redirect thrown from a route that reads the session is no longer cacheable
211
- either; a stored "you need to sign in" redirect used to follow the visitor
212
- even after signing in.
213
-
214
- ## [0.1.1] - 2026-08-30
215
-
216
- ### Added
217
-
218
- - English `README`, plus `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`,
219
- a `LICENSE` file, issue and pull request templates, and a CI workflow.
220
- - An English-first `examples/marketing` with a Turkish translation, serving the
221
- package documentation under `/docs` and reading its version, dependencies and
222
- bundle sizes from the installed package.
223
-
224
- ### Changed
225
-
226
- - The install instructions point at the npm package instead of the git
227
- repository.
228
-
229
- ### Fixed
230
-
231
- - No more white flash between pages: the page background moved onto the root
232
- element, so it applies before the body paints. Reduced-motion preferences now
233
- switch off the decorative animations as well, not just page transitions.
234
-
235
- ## [0.1.0] - 2026-08-30
236
-
237
- Initial release.
238
-
239
- ### Added
240
-
241
- - Express 5 server with EJS rendering: `createApp()`, `startServer()`,
242
- `route()`, `renderPage()`, `renderView()`, `renderNotFound()`.
243
- - In-process HTML TTL cache with stale-while-revalidate, plus prewarm at boot.
244
- - Island runtime with visibility, eager and idle hydration strategies, a small
245
- cross-island store, and DOM helpers.
246
- - Configuration through `jskelet.config.mjs`: `brand`, `paths`, `navigation`,
247
- `icons`, `fonts`, `clientEnv`, `redirects()`, `rewrites()`, `headers()`,
248
- `cache()` and `hooks`.
249
- - Build pipeline: fonts, SVG sprite from used icons, Tailwind v4 CSS, esbuild
250
- bundles with code splitting, webp variants, hashed output and brotli/gzip
251
- precompression.
252
- - Dev server with watch build, CSS hot-swap, automatic restart and a devtools
253
- overlay (requests, errors, upstream calls, cache dump, Web Vitals).
254
- - CLI: `jskelet dev`, `jskelet build`, `jskelet start`, `jskelet init`.
255
- - Documentation under `docs/` and three examples: `minimal`, `blog`,
256
- `marketing`.
257
-
258
- [Unreleased]: https://github.com/ayberkenis/jskelet/compare/v0.1.2...HEAD
259
- [0.1.2]: https://github.com/ayberkenis/jskelet/compare/v0.1.1...v0.1.2
260
- [0.1.1]: https://github.com/ayberkenis/jskelet/compare/v0.1.0...v0.1.1
261
- [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