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.
@@ -117,18 +117,31 @@ The cache also only kicks in for `GET` requests.
117
117
  ## The cache key
118
118
 
119
119
  ```
120
- `${req.path}?${new URLSearchParams(query).toString()}`
120
+ `${path}?${the allowed query parameters, sorted}`
121
121
  ```
122
122
 
123
- So the path **and all query parameters** are part of the key. `/list?page=2`
124
- and `/list?page=3` are separate entries.
123
+ For a request without a query the key is just the path. **A request that carries
124
+ a query parameter is dynamic by default**: it never enters the cache and is sent
125
+ with `private, no-store`. Caching every variant of a path mints an unbounded
126
+ number of keys (`?utm_source=…` and friends), and in a 500-entry store LRU then
127
+ evicts the real pages in favour of campaign variants.
125
128
 
126
- The practical consequence: a page that does not depend on the query string
127
- produces a separate entry for every combination when it is called with
128
- different campaign parameters (`?utm_source=…`). Stripping such parameters at
129
- the reverse proxy layer, or turning off the cache (by not supplying
130
- `revalidate`), is a reasonable precaution; by default the store holds at most
131
- 500 entries and evicts the oldest with LRU.
129
+ Which parameter actually changes the output is declared by the application, in
130
+ `jskelet.config.mjs` → `cache().query`:
131
+
132
+ ```js
133
+ cache: () => ({
134
+ html: { "/list": 60 },
135
+ query: { "/list": ["page"] },
136
+ }),
137
+ ```
138
+
139
+ Now `/list?page=2` and `/list?page=3` are separate entries, while
140
+ `/list?page=2&utm_source=x` shares the `?page=2` copy: a parameter outside the
141
+ list never reaches the key. A pattern mapped to `true` puts every parameter in
142
+ the key (careful: nothing but `maxEntries` then bounds the entry count), and one
143
+ mapped to `[]` ignores the query entirely. Details:
144
+ [07-configuration.md](./07-configuration.md).
132
145
 
133
146
  ## Stale-while-revalidate
134
147
 
@@ -441,6 +454,98 @@ cache: {
441
454
  `transientRetry: false` (or `attempts: 0`) disables the retry and falls straight
442
455
  through to the 503.
443
456
 
457
+ ## Upstream rate limit: `cache().upstream`
458
+
459
+ Everything above describes what happens **after** a 429 arrives. This section is
460
+ about not getting one in the first place.
461
+
462
+ The brake sits inside the `trackUpstreamFetch()` wrapper, that is, where the
463
+ real `fetch` call goes out. The prewarm pass's `prewarm.rps` cannot do this job:
464
+ it counts **page** requests to our own server, but one page render may make one
465
+ API call or twenty. What binds the quota is the number of calls, not the number
466
+ of pages — and with the brake here, prewarming and real traffic spend the same
467
+ budget.
468
+
469
+ Off by default: unless `rate` is given, no request ever waits and the cost is a
470
+ single branch.
471
+
472
+ ```js
473
+ // jskelet.config.mjs
474
+ cache: () => ({
475
+ upstream: {
476
+ rate: 10, // ceiling in calls per second, per host
477
+ burst: 20, // tolerance for short bursts
478
+ concurrency: 8, // calls in flight at once
479
+ hosts: {
480
+ // Endpoints with a different quota get their own settings.
481
+ "api.example.com": { rate: 3, concurrency: 2 },
482
+ },
483
+ },
484
+ }),
485
+ ```
486
+
487
+ ### Three mechanisms, three different limits
488
+
489
+ | Mechanism | What it bounds | Settings |
490
+ | --- | --- | --- |
491
+ | Token bucket | Average rate (calls per second) | `rate`, `burst` |
492
+ | Concurrency | Instantaneous pressure (calls in flight) | `concurrency` |
493
+ | AIMD | What the right rate actually is | `minRate`, `increaseStep`, `increaseIntervalMs`, `decreaseIntervalMs` |
494
+
495
+ The third one is the real idea. A fixed rate is always either too slow or too
496
+ fast: nobody can write the true quota limit into a config file, and it changes
497
+ during the day anyway. So `rate` is treated as a **ceiling** and the actual rate
498
+ moves with what the upstream says:
499
+
500
+ - **429 or 503** → the rate is halved (multiplicative decrease). If the response
501
+ carries `Retry-After`, the bucket stops entirely for that long — the upstream
502
+ is already telling you how long to wait.
503
+ - **Every clean window** → the rate climbs by `increaseStep` (additive
504
+ increase), up to the `rate` ceiling.
505
+
506
+ Decreasing multiplicatively and increasing additively is deliberate. The other
507
+ way round would earn a fresh 429 every window.
508
+
509
+ ### Circuit breaker
510
+
511
+ A host that returns `breakerFailures` (default 5) rate limits in a row is
512
+ bypassed entirely for `breakerCooldownMs`: the call is not made at all and is
513
+ reported straight away as a transient failure.
514
+
515
+ It looks harsh, but the asymmetry demands it: because a 429 counts as transient,
516
+ the HTML produced by that call is **not stored**. So a pass that hit the rate
517
+ limit spends quota and stores nothing in return — and the next pass finds the
518
+ same page cold and tries again. The breaker stops that burn.
519
+
520
+ ```
521
+ [upstream] api.example.com: 5 consecutive rate limits — bypassing for 10000ms (rate is now 1.2/s)
522
+ ```
523
+
524
+ Only 429 and 503 count. A `400`/`404` is not a quota problem and neither is a
525
+ `500`: slowing down does not fix them, it only makes the site slower.
526
+
527
+ ### Seeing the state
528
+
529
+ `getUpstreamLimiterStatus()` returns the current rate, calls in flight and
530
+ counters per host; the dev panel's **Server** tab prints the same thing. During
531
+ a 429 storm, tuning without knowing "what rate is it down to right now" is
532
+ guesswork.
533
+
534
+ ```js
535
+ import { getUpstreamLimiterStatus } from "jskelet";
536
+
537
+ // [{ host: "api.example.com", rate: 2.5, maxRate: 10, concurrency: 8,
538
+ // active: 3, throttled: 12, rejected: 40, bypassed: false, blockedMs: 0 }]
539
+ ```
540
+
541
+ ### Before turning it on
542
+
543
+ The rate limit is a last resort. If hundreds of pages fetch the same upstream
544
+ response, the real fix is keeping the
545
+ [`withDataCache`](#cross-request-data-cache-withdatacache) TTL longer than the
546
+ pass interval: a 400-page pass then makes one call for a shared endpoint. The
547
+ brake slows those calls down, it does not reduce their number.
548
+
444
549
  ## Managing the cache
445
550
 
446
551
  `jskelet` exports these functions:
@@ -664,8 +769,220 @@ you the circuit breaker is open and `errors` is the total command failure count.
664
769
  The same summary is in the dev panel report
665
770
  ([09-dev-tools.md](./09-dev-tools.md)).
666
771
 
772
+ Two more diagnostic surfaces:
773
+
774
+ | Call | What it tells you |
775
+ | --- | --- |
776
+ | `getRedisDetails()` | **Where** the connection points: address, TLS, database, `namespace`, which kinds are shared, whether the purge channel is subscribed. The password is never returned — a connection URL may carry one. |
777
+ | `inspectRedis()` | What is actually in the shared tier: keys per kind, `DBSIZE` and `used_memory`. It runs a `SCAN`, so **never call it on the request path**; in the admin panel it sits behind its own button. |
778
+
667
779
  The full list of settings: [07-configuration.md](./07-configuration.md).
668
780
 
781
+ ## The admin panel
782
+
783
+ Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
784
+ endpoints above, the framework ships a panel. It is deliberately separate from
785
+ the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
786
+ panel does not look at the environment — "why is this page stale", "did the
787
+ webhook purge land", "is Redis actually connected" are production questions.
788
+
789
+ ```js
790
+ // jskelet.config.mjs
791
+ export default {
792
+ cache() {
793
+ return {
794
+ html: { "/news/:slug": 300 },
795
+ panel: { enabled: process.env.CACHE_PANEL === "1" },
796
+ };
797
+ },
798
+ };
799
+ ```
800
+
801
+ Without `enabled` **nothing is mounted**: the path does not exist, the module is
802
+ never loaded and it costs the production process nothing. The environment
803
+ variable (`JSKELET_CACHE_PANEL=1`) overrides the config, because the panel is
804
+ usually opened once during an incident and editing the config file and
805
+ redeploying is the last thing you want at that moment.
806
+
807
+ When the panel is on, the server log prints the password:
808
+
809
+ ```
810
+ [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
811
+ ```
812
+
813
+ ### Access and hardening
814
+
815
+ - **The password is regenerated on every process start** (32 hex characters) and
816
+ only ever appears in the log. There is no persistent secret to leak: leaking
817
+ one means handing out the right to flush the cache, and a deploy should revoke
818
+ old access on its own.
819
+ - **The password is not accepted in the query string,** so access logs, browser
820
+ history and the `Referer` header never carry it. Sign-in goes through the form.
821
+ - **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
822
+ Requests without a session count just like a wrong password; a successful
823
+ sign-in resets the counter.
824
+ - **Banned and unauthorised requests get a `404`.** A 401 or 403 confirms the
825
+ panel exists; a 404 behaves as if it never did. The rest of the site is
826
+ untouched.
827
+ - **Nothing is indexable:** every response carries `X-Robots-Tag: noindex,
828
+ nofollow, noarchive, nosnippet`, `Cache-Control: no-store` and
829
+ `Referrer-Policy: no-referrer`. The path is also exempt from prewarming and
830
+ from navigation speculation.
831
+ - Actions require an `X-JSkelet-Cache-Panel` header — a header a cross-site form
832
+ cannot send, which is the panel's own CSRF brake.
833
+ - Sessions and ban counters live in process memory; persisting them to disk
834
+ would be the wrong trade for a panel whose password changes on every restart.
835
+
836
+ ### What the panel shows
837
+
838
+ | Area | Contents |
839
+ | --- | --- |
840
+ | Top bar | Version, environment, pid, uptime, RSS |
841
+ | Cards | HTML entry count and limit, HTML bytes in memory, stale entry count, data entry count, Redis state (`connected` / `bypassed` / `off`), prewarm progress |
842
+ | Shared tier | **Where** the connection points (address, TLS, database), the key prefix and `namespace`, the `buildId`, which kinds are shared, the state of compressed bodies and the purge broadcast, the command timeout and the error count. When it is off, a Redis recommendation with an install snippet takes its place. |
843
+ | Cloudflare | Zone, plan, cache related zone settings, how long development mode has left, Tiered Cache / Cache Reserve state and the cache hit ratio. When no zone is connected, a setup snippet takes its place. |
844
+ | Host | The machine's memory usage and how full the disk holding the project is |
845
+ | Entry list | HTML: path (opens in a new tab), fresh/stale, size, status code, remaining TTL, dependency count, precompressed bodies. Data: key (click to copy), fresh/stale, remaining TTL |
846
+
847
+ The list is **filtered by key** and the filter runs on the server: a data cache
848
+ can hold tens of thousands of keys. At most 500 rows come back per request and
849
+ the counter in the heading says how many matches were cut. HTML bodies and
850
+ cached values are **never returned** — the panel's job is to show state, not to
851
+ export content.
852
+
853
+ ### What you can do from it
854
+
855
+ | Action | Equivalent call |
856
+ | --- | --- |
857
+ | Invalidate (target + `hard`) | `invalidateHtmlCache(target, { hard })` |
858
+ | `drop` a single row | `dropHtmlCacheKey(key)` / `dropDataCacheKey(key)` |
859
+ | Clear HTML cache | `clearHtmlCache()` |
860
+ | Clear data cache (optional prefix) | `clearDataCache(prefix)` |
861
+ | Drop shared keys | Scans and unlinks the `html` or `data` namespace in Redis |
862
+ | Count keys in Redis | `inspectRedis()` — keys per kind, `DBSIZE` and `used_memory` |
863
+ | Prewarm | `prewarm()` — the pass runs in the background, progress shows in the card |
864
+ | Cloudflare purge (everything / URLs held here / prefix / host / tag) | `purgeCloudflare()` |
865
+ | Change a Cloudflare setting or feature | Zone settings and Tiered Cache / Cache Reserve |
866
+
867
+ Each one propagates to the shared tier as well: clearing a single replica's
868
+ cache is what produces the "I cleared it and it is still old" question in a
869
+ clustered setup.
870
+
871
+ Dropping a single row is not the same as `invalidateHtmlCache()`: that one
872
+ matches a path pattern and takes down **every** query variant of a path, while
873
+ `dropHtmlCacheKey()` takes the exact key — `/list?page=2` goes and
874
+ `/list?page=3` stays hot.
875
+
876
+ ## The CDN tier: Cloudflare
877
+
878
+ Everything above is the **origin** cache. With Cloudflare in front, the HTML
879
+ your visitors get usually never reaches you: the copy at the edge is served
880
+ until its TTL runs out. That is why `invalidateHtmlCache()` alone does not fix
881
+ "I updated the page but the old one still shows" — the origin refreshes, the
882
+ edge keeps waiting.
883
+
884
+ JSkelet lets you drive both tiers from the same place.
885
+
886
+ ### Setup
887
+
888
+ The token is a secret, so it goes in the environment, not in a config file:
889
+
890
+ ```bash
891
+ JSKELET_CLOUDFLARE_KEY=... # API token
892
+ JSKELET_CLOUDFLARE_ZONE_ID=... # zone identifier
893
+ JSKELET_CLOUDFLARE_HOSTNAME=example.com # optional
894
+ ```
895
+
896
+ Which permissions the token needs depends on what you want to do: `Zone.Cache
897
+ Purge` to purge, `Zone.Zone Settings` to change settings, `Zone.Analytics`
898
+ (read) for the hit ratio and the edge breakdown. A purge-only token still opens
899
+ the panel; the settings sections just report an error.
900
+
901
+ The zone id and site name are not secrets, so they can also come from
902
+ `jskelet.config.mjs`. The environment always wins:
903
+
904
+ ```js
905
+ cache: {
906
+ cloudflare: {
907
+ zoneId: "…",
908
+ hostname: "example.com", // purging wants absolute URLs; this turns paths into them
909
+ analyticsHours: 24,
910
+ },
911
+ }
912
+ ```
913
+
914
+ Without `hostname`, purge URLs are derived from the origin the panel was opened
915
+ on. If you reach the panel over an internal address (`http://10.0.0.4:3000`),
916
+ that address means nothing to Cloudflare — there, `hostname` is required.
917
+
918
+ ### What you can do
919
+
920
+ Whatever Cloudflare's cache surface offers is in the panel:
921
+
922
+ | Action | Note |
923
+ | --- | --- |
924
+ | Purge everything | The whole zone. The bluntest tool; warming back up is expensive |
925
+ | Purge by URL | Every page currently held in memory with one button, or `cf purge` per row |
926
+ | Purge by prefix / host / tag | Available on all plans now; 100 keys per request |
927
+ | Development mode | Bypasses the edge cache for three hours, then turns itself off |
928
+ | Cache level, browser cache TTL, query string sorting, Always Online | Zone settings |
929
+ | Tiered Cache, Regional Tiered Cache, Cache Reserve | Plan dependent; shows "unavailable" where the plan lacks it |
930
+ | Clear Cache Reserve | Separate from purging: `purge_everything` drops the edges, the persistent copy in R2 stays |
931
+
932
+ Long URL lists are split into batches of 100 keys and sent **sequentially**.
933
+ Sending them in parallel means half the batch rejected on the Free plan, where
934
+ purging is limited to five requests per minute.
935
+
936
+ The same surface from code:
937
+
938
+ ```js
939
+ import { invalidateHtmlCache, purgeCloudflare, toCloudflareUrls } from "jskelet";
940
+
941
+ export async function onPostPublished(slug) {
942
+ const paths = ["/", `/blog/${slug}`];
943
+
944
+ invalidateHtmlCache(paths); // origin
945
+ await purgeCloudflare({ files: toCloudflareUrls(paths) }); // edge
946
+ }
947
+ ```
948
+
949
+ Nothing in this module throws: with no token, on a Cloudflare 403 or when the
950
+ network drops, the result is `{ ok: false, error }`. A CDN outage should not
951
+ break your publishing flow.
952
+
953
+ ### "How many edges hold this page?" — what can and cannot be asked
954
+
955
+ There is no Cloudflare endpoint that lists the **inventory** of an object.
956
+ Hundreds of cities run independent caches and none of them will answer "do you
957
+ currently hold this URL". So the panel shows observation rather than inventory:
958
+ enter a path and the GraphQL analytics tell you which colo (IST, FRA, AMS…)
959
+ served it from cache and how often it went to the origin over the last N hours.
960
+
961
+ ```js
962
+ const report = await fetchPathEdges({ path: "/blog", hours: 24 });
963
+ // → { colos: [{ colo: "IST", hits: 7, misses: 2 }, …], hits, misses }
964
+ ```
965
+
966
+ Two limits to keep in mind while reading it: an edge that received no request
967
+ does not appear at all, even if it holds a copy; and the dataset is sampled, so
968
+ ratios are reliable while absolute counts are estimates.
969
+
970
+ There is also no way to **warm** an edge you pick. An object enters an edge
971
+ cache only through a real request routed there; you cannot tell Frankfurt from
972
+ your server to go cache something. Three things do work in practice:
973
+
974
+ - **Warm the origin** (`prewarm`): the edge that takes the first request finds
975
+ a ready response, so that request is not the slow one.
976
+ - **Tiered Cache**: edges do not go straight to the origin, they pull from an
977
+ upper tier — the first request in one city counts as warming for the others.
978
+ - **Cache Reserve**: a persistent copy in R2 for long-tail content, so requests
979
+ do not reach the origin when an edge evicts.
980
+
981
+ If your `hit` ratio is low, check whether the response is cacheable at all
982
+ before anything else: `Cache-Control: private`, `Set-Cookie` and query string
983
+ settings are the most common reasons an edge decides not to cache, and they
984
+ show up as `dynamic` in this panel.
985
+
669
986
  ## Prewarm — warming up at startup
670
987
 
671
988
  The equivalent of Next's build-time prerender, except the output is not written
@@ -720,13 +1037,29 @@ Rules:
720
1037
  parallelism. In dev, 4 requests per second apply by default: rendering runs
721
1038
  on a single event loop, so an unpaced round leaves page requests and the dev
722
1039
  panel's live channel waiting behind it.
723
- 4. **A single serial retry round** is performed for the failed paths after
724
- waiting `retryDelayMs` (`concurrency: 1`). The wait is deliberate: rate limit
725
- windows are on the order of seconds, so retrying immediately just earns the
726
- same 429.
727
- 5. A summary is logged:
1040
+ 4. **A single serial retry round** is performed for the paths that hit a
1041
+ **transient** failure (`concurrency: 1`). Permanent answers like `400`, `403`
1042
+ or `404` are not retried: a deterministic error does not get better on the
1043
+ second try and those calls spend quota for nothing. The summary shows them as
1044
+ `N not retried (permanent)`.
1045
+ 5. The wait before the retry is `retryDelayMs`, but when the rate limit is on and
1046
+ something is holding it back, that wins: retrying 2 seconds into a 10 second
1047
+ circuit breaker would just earn the same 429 up front.
1048
+ 6. A summary is logged:
728
1049
  `[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
729
1050
 
1051
+ Then comes how much the pass actually touched the upstream:
1052
+
1053
+ ```text
1054
+ [prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
1055
+ ```
1056
+
1057
+ This is the one line that tells you which way to turn the knob. If the ratio is
1058
+ low the fix is not the rate limit but a longer `withDataCache` TTL — the brake
1059
+ slows calls down, it does not reduce their number. The same counters are
1060
+ available through `getDataCacheStats()` and on the dev report's **Data cache**
1061
+ card.
1062
+
730
1063
  Request errors and the per-page render warnings (`was produced with missing
731
1064
  data`, `returned notFound() while upstream is failing`, `could not be
732
1065
  produced`) raised during the pass are not logged one by one. They are counted
@@ -119,6 +119,7 @@ export default {
119
119
  async cache() {
120
120
  return {
121
121
  html: { "/": 60, "/news/:slug": 300 },
122
+ query: { "/search": ["q", "page"] },
122
123
  maxEntries: 500,
123
124
  data: { maxEntries: 10000, staleFactor: 10 },
124
125
  prewarm: {
@@ -572,9 +573,9 @@ Details: [03-routing.md](./03-routing.md).
572
573
  ## `cache()`
573
574
 
574
575
  **Type:**
575
- `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, redis?: object, prewarm?: object }` —
576
+ `() => { html?: Record<string, number>, query?: Record<string, string[] | true>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
576
577
  **Default:**
577
- `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
578
+ `{ html: {}, query: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
578
579
 
579
580
  ### `cache().html`
580
581
 
@@ -594,6 +595,39 @@ html: {
594
595
  }
595
596
  ```
596
597
 
598
+ ### `cache().query`
599
+
600
+ A pattern → list of query parameters allowed into the cache key.
601
+
602
+ **By default a request that carries a query parameter is dynamic**: even when
603
+ `cache().html` covers that path, the response never enters the HTML cache and
604
+ is sent with `private, no-store`. The reason is simple — caching every variant
605
+ of a path mints an unbounded number of keys (`?utm_source=…` and friends), and
606
+ once the `maxEntries` limit is reached those keys evict the real pages. Only the
607
+ application knows which parameter actually changes the output.
608
+
609
+ ```js
610
+ query: {
611
+ "/search": ["q", "page"], // only these two belong to the key
612
+ "/products": ["category"],
613
+ "/report/:id": true, // every parameter belongs to the key
614
+ "/campaign": [], // the query is ignored entirely
615
+ }
616
+ ```
617
+
618
+ - **Allowlist** (`string[]`): the listed parameters become part of the key and
619
+ each distinct value gets its own entry. Parameters outside the list are
620
+ **ignored** — the page is still cached and every campaign variant shares one
621
+ copy.
622
+ - **`true`**: every parameter belongs to the key. Nothing but `maxEntries`
623
+ bounds the number of entries, so use it only where the value set is closed.
624
+ - **`[]`**: the query is not considered at all; every variant is served the HTML
625
+ of the query-less version.
626
+
627
+ Parameters are written into the key **sorted**, so `?a=1&b=2` and `?b=2&a=1`
628
+ share one entry. `route(fn, { private: true })` is unaffected by this section; a
629
+ private route is never cached under any condition.
630
+
597
631
  ### `cache().maxEntries`
598
632
 
599
633
  **Type:** `number` — **Default:** `500`
@@ -643,6 +677,39 @@ transient upstream failure. The point is that an existing page never turns into
643
677
  a 404; if the retries are exhausted the response is an uncached 503. `false` or
644
678
  `attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
645
679
 
680
+ ### `cache().upstream`
681
+
682
+ A per-host rate limit for the `fetch` calls that go to upstream APIs. Off by
683
+ default: unless `rate` is given, no request ever waits. `rate` is a ceiling; the
684
+ actual rate pulls itself down in response to 429s and climbs back step by step
685
+ during clean windows.
686
+
687
+ | Field | Type | Default | Meaning |
688
+ | --- | --- | --- | --- |
689
+ | `rate` | `number` | `0` | Maximum calls per second. `0` → brake disabled |
690
+ | `burst` | `number` | `0` | Bucket size; `0` → one second's budget of burst |
691
+ | `concurrency` | `number` | `8` | Calls allowed in flight at once |
692
+ | `minRate` | `number` | `0.5` | Floor of the decrease; the rate never goes below it |
693
+ | `increaseStep` | `number` | `1` | Step of the additive increase (calls/second) |
694
+ | `increaseIntervalMs` | `number` | `5000` | Increase period |
695
+ | `decreaseIntervalMs` | `number` | `1000` | Minimum time between two decreases |
696
+ | `breakerFailures` | `number` | `5` | Consecutive 429s after which the host is bypassed |
697
+ | `breakerCooldownMs` | `number` | `10000` | How long the bypass lasts |
698
+ | `hosts` | `Record<string, object>` | `{}` | Per-host overrides; same fields apply |
699
+
700
+ Only `429` and `503` penalise the rate: a `400`/`404`/`500` is not a quota
701
+ problem. Read the state with `getUpstreamLimiterStatus()` or from the dev
702
+ panel's **Server** tab. Details, and what to check before turning it on:
703
+ [06-caching.md](./06-caching.md).
704
+
705
+ ```js
706
+ upstream: {
707
+ rate: 10,
708
+ concurrency: 4,
709
+ hosts: { "api.example.com": { rate: 3 } },
710
+ }
711
+ ```
712
+
646
713
  ### `cache().redis`
647
714
 
648
715
  An optional Redis second tier (L2). The in-process cache stays primary; Redis
@@ -677,6 +744,68 @@ redis: {
677
744
  }
678
745
  ```
679
746
 
747
+ ### `cache().panel`
748
+
749
+ The cache admin panel. It shows the state of the in-process tier and the Redis
750
+ tier, and it triggers targeted invalidation, single-entry drops and prewarming.
751
+
752
+ It does not look at the environment: without `enabled` **nothing is mounted**
753
+ and the path does not exist. When it is on it also works in production — that is
754
+ where the real questions ("why is this page stale", "did the webhook purge
755
+ land") get asked.
756
+
757
+ | Field | Type | Default | Meaning |
758
+ | --- | --- | --- | --- |
759
+ | `enabled` | `boolean` | `false` | Only turns on when explicitly `true` (`JSKELET_CACHE_PANEL` overrides it) |
760
+ | `basePath` | `string` | `"/_jskelet/cache"` | Root of the panel |
761
+ | `banAttempts` | `number` | `3` | How many failed attempts ban an IP |
762
+ | `banHours` | `number` | `24` | How long the ban lasts |
763
+ | `sessionHours` | `number` | `12` | Lifetime of the session cookie |
764
+
765
+ The password is generated **on every process start** and only appears in the
766
+ server log:
767
+
768
+ ```
769
+ [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
770
+ ```
771
+
772
+ There is no persistent secret (no config field, no environment variable):
773
+ leaking one means handing out the right to flush the cache, and a deploy should
774
+ revoke old access on its own. Banned and unauthorised requests all get a `404`.
775
+ Usage and screens: [06-caching.md](./06-caching.md).
776
+
777
+ ### `cache().cloudflare`
778
+
779
+ The CDN tier. JSkelet's cache is the origin cache; the copy your visitors get
780
+ sits at the edge. With this section connected, the panel can purge the edge,
781
+ read and change cache related zone settings and show the cache hit ratio.
782
+
783
+ | Field | Type | Default | Meaning |
784
+ | --- | --- | --- | --- |
785
+ | `enabled` | `boolean` | `true` | Set `false` to keep the surface off even when a token is present in the environment |
786
+ | `zoneId` | `string \| null` | `null` | Zone identifier (`JSKELET_CLOUDFLARE_ZONE_ID` overrides it) |
787
+ | `apiToken` | `string \| null` | `null` | The token; **prefer the environment**, putting it here puts a secret in the repo |
788
+ | `hostname` | `string \| null` | `null` | Purging wants absolute URLs; paths are resolved against this name. Falls back to the origin the panel was opened on |
789
+ | `analyticsHours` | `number` | `24` | Analytics window, at most `72` |
790
+
791
+ Passing the token only through `JSKELET_CLOUDFLARE_KEY` keeps the config file
792
+ clean. Permissions follow what you intend to do: `Zone.Cache Purge` to purge,
793
+ `Zone.Zone Settings` for settings, `Zone.Analytics` (read) for the hit ratio.
794
+ The token is never returned in a panel response — only the fact that it came
795
+ from the environment.
796
+
797
+ With no zone connected the panel shows a setup snippet rather than a warning,
798
+ and if Cloudflare returns an error that section reports it while the rest of the
799
+ panel keeps working. What can actually be asked — in particular why "how many
800
+ edges hold this page" has no exact answer — is in
801
+ [06-caching.md](./06-caching.md).
802
+
803
+ ```js
804
+ panel: {
805
+ enabled: process.env.CACHE_PANEL === "1",
806
+ }
807
+ ```
808
+
680
809
  ### `cache().prewarm`
681
810
 
682
811
  | Field | Type | Default | Meaning |
@@ -801,6 +930,10 @@ and no warning is printed.
801
930
  | `HOST` | `startServer` | `::` | Interface to bind to. The default listens dual-stack (IPv6 + IPv4); it falls back to `0.0.0.0` where IPv6 is unavailable |
802
931
  | `JSKELET_SECRET` | `jskelet/cookies` | — | The signed cookie secret. Read when `security.cookieSecret` is not set; if neither exists, the signed cookie API throws. [12](./12-dashboards-and-sessions.md) |
803
932
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | If set, every request without a token gets a 404. Prewarming carries the token as a cookie. [09](./09-dev-tools.md) |
933
+ | `JSKELET_CACHE_PANEL` | `createApp` | — | When set, turns the cache panel on; `0` turns off a panel enabled in the config. The env wins because the panel is usually opened once during an incident. [06](./06-caching.md) |
934
+ | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache surface | — | API token. Until it is set, CDN purging and edge analytics stay off; it overrides `apiToken` in the config. The token is never returned in a response. [06](./06-caching.md) |
935
+ | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
936
+ | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
804
937
  | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
805
938
  | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
806
939
  | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Number of parallel workers |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "A framework that feels like no framework: Express 5 + EJS server rendering, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,71 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
7
+ <meta name="referrer" content="no-referrer" />
8
+ <title>Cache · JSkelet</title>
9
+ <link rel="icon" href="logo.png" />
10
+ <link rel="stylesheet" href="panel.css" />
11
+ </head>
12
+ <body class="login">
13
+ <form id="form" autocomplete="off">
14
+ <div class="brand">
15
+ <img src="logo.png" alt="" width="30" height="30" />
16
+ <span class="wordmark">JSkelet</span>
17
+ </div>
18
+ <h1>Cache panel</h1>
19
+ <p>
20
+ This run's password is printed in the server log
21
+ (<code>[cache-panel]</code>).
22
+ </p>
23
+ <input
24
+ id="password"
25
+ type="password"
26
+ name="password"
27
+ maxlength="64"
28
+ spellcheck="false"
29
+ autocomplete="off"
30
+ placeholder="32-character password"
31
+ autofocus
32
+ />
33
+ <button class="primary" type="submit">Unlock</button>
34
+ <p class="error" id="error"></p>
35
+ <p class="hint m0">
36
+ Three failed attempts block this address for 24 hours.
37
+ </p>
38
+ </form>
39
+
40
+ <script type="module">
41
+ const form = document.getElementById("form");
42
+ const input = document.getElementById("password");
43
+ const error = document.getElementById("error");
44
+
45
+ form.addEventListener("submit", async (event) => {
46
+ event.preventDefault();
47
+ error.textContent = "";
48
+
49
+ const response = await fetch("login", {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({ password: input.value }),
53
+ });
54
+
55
+ if (response.ok) {
56
+ location.reload();
57
+ return;
58
+ }
59
+
60
+ // Yasaklandıysa sunucu artık 404 dönüyor; kalan deneme sayısı
61
+ // bilinçli olarak bildirilmiyor.
62
+ error.textContent =
63
+ response.status === 404
64
+ ? "Too many attempts."
65
+ : "Invalid password.";
66
+ input.value = "";
67
+ input.focus();
68
+ });
69
+ </script>
70
+ </body>
71
+ </html>