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 +368 -261
- package/docs/03-routing.md +4 -0
- package/docs/06-cache.md +340 -12
- package/docs/07-yapilandirma.md +131 -2
- package/docs/en/03-routing.md +4 -0
- package/docs/en/06-caching.md +347 -14
- package/docs/en/07-configuration.md +135 -2
- package/package.json +1 -1
- package/src/client/cache-panel/login.html +71 -0
- package/src/client/cache-panel/panel.css +740 -0
- package/src/client/cache-panel/panel.html +307 -0
- package/src/client/cache-panel/panel.js +808 -0
- package/src/client/devtools/overlay.js +85 -0
- package/src/client/devtools/report.js +13 -0
- package/src/config/defaults.js +98 -2
- package/src/config/index.js +185 -3
- package/src/index.js +21 -1
- package/src/log.mjs +25 -0
- package/src/server/cache-panel.js +738 -0
- package/src/server/cloudflare.js +595 -0
- package/src/server/create-app.js +16 -0
- package/src/server/data-cache.js +92 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/html-cache.js +36 -0
- package/src/server/prewarm.js +94 -15
- package/src/server/redis.js +108 -0
- package/src/server/render.js +74 -2
- package/src/server/upstream-limiter.js +376 -0
- package/src/server/upstream-tracking.js +25 -0
- package/src/version.mjs +9 -0
package/docs/en/06-caching.md
CHANGED
|
@@ -117,18 +117,31 @@ The cache also only kicks in for `GET` requests.
|
|
|
117
117
|
## The cache key
|
|
118
118
|
|
|
119
119
|
```
|
|
120
|
-
`${
|
|
120
|
+
`${path}?${the allowed query parameters, sorted}`
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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.
|
|
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>
|