jskelet 0.2.4 → 0.3.0

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