jskelet 0.4.5 → 0.4.7
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 +9 -0
- package/docs/06-cache.md +64 -10
- package/docs/07-yapilandirma.md +24 -1
- package/docs/en/06-caching.md +65 -13
- package/docs/en/07-configuration.md +24 -1
- package/package.json +1 -1
- package/src/config/defaults.js +40 -1
- package/src/config/index.js +106 -1
- package/src/index.js +1 -1
- package/src/server/html-cache.js +14 -0
- package/src/server/image-optimizer.js +2 -1
- package/src/server/prewarm.js +268 -19
- package/src/server/render.js +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,11 @@ one is listed under a **Breaking** heading.
|
|
|
10
10
|
|
|
11
11
|
### Added
|
|
12
12
|
|
|
13
|
+
- Visit-driven HTML prewarm (`cache().prewarm.onVisit`): after each public
|
|
14
|
+
cacheable page response, same-origin links in the HTML are warmed in the
|
|
15
|
+
background (document order, `perPage` cap). Mutually exclusive with classic
|
|
16
|
+
prewarm (`max` / `priority` / `rotate` / `hooks.prewarmPaths`, etc.) — mixing
|
|
17
|
+
them fails at config load.
|
|
13
18
|
- Runtime remote image optimizer: set `images.remote.allowHosts` to proxy
|
|
14
19
|
allowlisted http(s) images through `/_jskelet/image?url=&w=&q=` as resized
|
|
15
20
|
webp (disk cache under `.jskelet/image-cache/`). `image()` rewrites matching
|
|
@@ -28,6 +33,10 @@ one is listed under a **Breaking** heading.
|
|
|
28
33
|
|
|
29
34
|
### Fixed
|
|
30
35
|
|
|
36
|
+
- Remote image optimizer cache hits no longer 404. Disk cache lives under
|
|
37
|
+
`.jskelet/image-cache/`; Express `sendFile` ignores dotfiles by default, so
|
|
38
|
+
the file was written but the response still failed. `sendCached` now passes
|
|
39
|
+
`dotfiles: "allow"`.
|
|
31
40
|
- Cloudflare analytics in the cache panel no longer asks for an open-ended
|
|
32
41
|
window. Queries used only `datetime_geq`, so Cloudflare closed the range at
|
|
33
42
|
query time and a default 24h lookback became `1d` plus network delay — Free
|
package/docs/06-cache.md
CHANGED
|
@@ -1052,19 +1052,68 @@ yönlenen gerçek bir istekle o edge'in önbelleğine giriyor; sunucudan
|
|
|
1052
1052
|
cache'lememe kararının en sık sebepleri, ve bu panelde `dynamic` olarak
|
|
1053
1053
|
görünür.
|
|
1054
1054
|
|
|
1055
|
-
## Prewarm — açılışta ısıtma
|
|
1055
|
+
## Prewarm — açılışta veya ziyarette ısıtma
|
|
1056
1056
|
|
|
1057
1057
|
Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz: önbellek
|
|
1058
|
-
süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
tazelenir.
|
|
1058
|
+
süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca (klasik mod) ya
|
|
1059
|
+
da trafik geldikçe (`onVisit`) yapılır. Kazanç aynı — tıklanan / komşu sayfa
|
|
1060
|
+
soğuk render'ı beklemez — fakat veri dondurulmaz; her girdi route'un
|
|
1061
|
+
`revalidate` süresiyle yaşlanır ve stale-while-revalidate ile arkada tazelenir.
|
|
1062
1062
|
|
|
1063
1063
|
Isıtma **gerçek HTTP istekleriyle** yapılır (`http://127.0.0.1:<port>`), çünkü
|
|
1064
1064
|
cache anahtarı, sıkıştırma ve middleware zinciri normal trafikle bire bir aynı
|
|
1065
1065
|
olsun.
|
|
1066
1066
|
|
|
1067
|
-
|
|
1067
|
+
İki mod **karşılıklı dışlayıcıdır**. `cache().prewarm.onVisit` açıksa klasik
|
|
1068
|
+
alanlar (`max`, `priority`, `rotate`, `intervalSeconds`, …) ve
|
|
1069
|
+
`hooks.prewarmPaths` birlikte verilemez — config yüklenirken hata fırlar.
|
|
1070
|
+
Tersi de geçerli: klasik liste ısıtması kullanıyorsanız `onVisit` yazmayın.
|
|
1071
|
+
|
|
1072
|
+
### Mod: `onVisit` — ziyaret edilen sayfanın linkleri
|
|
1073
|
+
|
|
1074
|
+
Bir kullanıcı herkese açık, önbelleklenebilir bir sayfayı (`public` HTML, 200)
|
|
1075
|
+
aldığında framework yanıt HTML'indeki aynı-origin `<a href>` yollarını (üstten
|
|
1076
|
+
alta, `perPage` kadar) kuyruğa alır ve arka planda ısıtır. Bir sonraki tıklama
|
|
1077
|
+
veya aynı sayfaya gelen başka ziyaretçi çoğu zaman `HIT` görür.
|
|
1078
|
+
|
|
1079
|
+
```js
|
|
1080
|
+
// jskelet.config.mjs
|
|
1081
|
+
export default {
|
|
1082
|
+
async cache() {
|
|
1083
|
+
return {
|
|
1084
|
+
html: { "/": 60, "/haber/:slug": 300 },
|
|
1085
|
+
prewarm: {
|
|
1086
|
+
onVisit: {
|
|
1087
|
+
perPage: 20, // sayfa başına en fazla link
|
|
1088
|
+
concurrency: 2, // opsiyonel
|
|
1089
|
+
rps: 4, // opsiyonel; 0 = sınırsız
|
|
1090
|
+
},
|
|
1091
|
+
},
|
|
1092
|
+
};
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
`onVisit: true` de yeterlidir (varsayılan `perPage: 20`).
|
|
1098
|
+
|
|
1099
|
+
Kurallar:
|
|
1100
|
+
|
|
1101
|
+
- Yalnızca `route()` ile giden **public + cache'lenebilir** 200 HTML tetikler;
|
|
1102
|
+
`private`, degraded veya `no-store` yanıtlar link çıkarmaz.
|
|
1103
|
+
- Isıtma isteğinin kendi UA'sı (`brand.prewarmUserAgent`) tetiklemez — sonsuz
|
|
1104
|
+
crawl olmaz.
|
|
1105
|
+
- Zaten taze olan yollar kuyruğa girmez.
|
|
1106
|
+
- `nofollow`, `target="_blank"`, `data-no-prefetch`, `prewarmSkip` ve
|
|
1107
|
+
`navigation.exclude` Speculation Rules ile aynı muafiyetleri paylaşır.
|
|
1108
|
+
- Query string ısıtılmaz (varsayılan cache politikası query'yi dinamik sayar).
|
|
1109
|
+
- Açılışta otomatik tur yoktur; ilk ziyaretçi o sayfa için hâlâ MISS
|
|
1110
|
+
ödeyebilir. Kritik yolları deploy öncesi sıcak tutmak istiyorsanız klasik
|
|
1111
|
+
modu veya readiness + seed tercih edin.
|
|
1112
|
+
- `PREWARM=0` onVisit'i de kapatır. `PREWARM_MAX` / `PREWARM_INTERVAL_SECONDS`
|
|
1113
|
+
/ `PREWARM_DELAY_MS` / `PREWARM_RETRY_DELAY_MS` onVisit ile birlikte
|
|
1114
|
+
kullanılamaz (hata).
|
|
1115
|
+
|
|
1116
|
+
### Mod: klasik — `hooks.prewarmPaths()`
|
|
1068
1117
|
|
|
1069
1118
|
Hangi yolların ısıtılacağını uygulama bildirir; genelde sitemap üreten
|
|
1070
1119
|
fonksiyonun aynısıdır.
|
|
@@ -1089,9 +1138,10 @@ Kurallar:
|
|
|
1089
1138
|
`/api/`, `/_fragment/`, `/__jskelet/`. Oturuma bağlı sayfalar ısıtılmamalı.
|
|
1090
1139
|
- Tekilleştirme **sırayı korur**: `priority` verilmediğinde uygulamanın verdiği
|
|
1091
1140
|
sıra anlamlıdır — en önemli sayfaları başa koyun.
|
|
1092
|
-
- Bu hook tanımlı değilse ısıtma hiç kurulmaz; zamanlayıcı bile açılmaz.
|
|
1141
|
+
- Bu hook tanımlı değilse klasik ısıtma hiç kurulmaz; zamanlayıcı bile açılmaz.
|
|
1142
|
+
(`onVisit` modunda hook **yasaktır**, yukarıya bakın.)
|
|
1093
1143
|
|
|
1094
|
-
### Tur mantığı
|
|
1144
|
+
### Tur mantığı (klasik)
|
|
1095
1145
|
|
|
1096
1146
|
1. Liste toplanır. `max`'tan (varsayılan 400) uzunsa bir dilim seçilir:
|
|
1097
1147
|
`priority` eşleşenler **her turda** başa alınır, kalan yerler kuyruktan
|
|
@@ -1272,8 +1322,12 @@ turunun gerçekten `MISS` → önbellek doldurup doldurmadığını buradan gör
|
|
|
1272
1322
|
fazla `revalidate` + bir tazeleme turudur.
|
|
1273
1323
|
- **Önbellek şişiyor.** Query parametreleri anahtara girdiği için kampanya
|
|
1274
1324
|
parametreleri girdi çoğaltıyor olabilir.
|
|
1275
|
-
- **Isıtma hiç çalışmıyor.** `hooks.prewarmPaths` tanımlı değil,
|
|
1276
|
-
ayarlı ya da `cache().prewarm.enabled === false`.
|
|
1325
|
+
- **Isıtma hiç çalışmıyor.** Klasik modda `hooks.prewarmPaths` tanımlı değil,
|
|
1326
|
+
`PREWARM=0` ayarlı ya da `cache().prewarm.enabled === false`. `onVisit`
|
|
1327
|
+
modunda `listen` sonrası logda `onVisit mode` satırını ve public cache'li
|
|
1328
|
+
bir sayfa gezildiğini doğrulayın.
|
|
1329
|
+
- **Config `onVisit` + `max` / `prewarmPaths` ile düşüyor.** İki mod karşılıklı
|
|
1330
|
+
dışlayıcı; yalnızca birini kullanın.
|
|
1277
1331
|
- **Isıtma turu API'yi 429'a sokuyor.** `rps` verilmemiş. `concurrency`
|
|
1278
1332
|
düşürmek yeterli değil; kotayı koruyan ayar toplam hız. Kalıcı çözüm veri
|
|
1279
1333
|
önbelleği: ikinci turdan sonra ısıtma upstream'e gitmez.
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -882,6 +882,11 @@ tam cevabı olmadığı — [06-cache.md](./06-cache.md) içinde.
|
|
|
882
882
|
|
|
883
883
|
### `cache().prewarm`
|
|
884
884
|
|
|
885
|
+
İki mod: **klasik** (liste + açılış turu) veya **`onVisit`** (ziyaret edilen
|
|
886
|
+
sayfadaki linkler). Birlikte verilemez — config yüklenirken hata.
|
|
887
|
+
|
|
888
|
+
#### Klasik alanlar
|
|
889
|
+
|
|
885
890
|
| Alan | Tip | Varsayılan | Anlamı |
|
|
886
891
|
| --- | --- | --- | --- |
|
|
887
892
|
| `enabled` | `boolean` | `true` | `false` ise ısıtma yapılmaz (`PREWARM=1` ile ezilebilir) |
|
|
@@ -910,6 +915,24 @@ prewarm: {
|
|
|
910
915
|
}
|
|
911
916
|
```
|
|
912
917
|
|
|
918
|
+
#### `onVisit`
|
|
919
|
+
|
|
920
|
+
| Alan | Tip | Varsayılan | Anlamı |
|
|
921
|
+
| --- | --- | --- | --- |
|
|
922
|
+
| `onVisit` | `true \| false \| object` | kapalı | Ziyaret tabanlı ısıtma |
|
|
923
|
+
| `onVisit.perPage` | `number` | `20` | Sayfa başına üstten alta en fazla link |
|
|
924
|
+
| `onVisit.concurrency` | `number` | klasik ile aynı | Paralel işçi |
|
|
925
|
+
| `onVisit.rps` | `number` | klasik ile aynı | Saniyedeki tavan; `0` sınırsız |
|
|
926
|
+
|
|
927
|
+
```js
|
|
928
|
+
prewarm: {
|
|
929
|
+
onVisit: { perPage: 20, rps: 4 },
|
|
930
|
+
}
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
`hooks.prewarmPaths` ve klasik alanlar (`max`, `priority`, …) `onVisit` ile
|
|
934
|
+
**yasaktır**. Ayrıntı: [06-cache.md](./06-cache.md).
|
|
935
|
+
|
|
913
936
|
Sayısal alanların her biri aynı adı taşıyan ortam değişkeniyle ezilebilir; env
|
|
914
937
|
önceliklidir. Ayrıntı: [06-cache.md](./06-cache.md).
|
|
915
938
|
|
|
@@ -926,7 +949,7 @@ varsayılanına döner ve uyarır — sayfa düşmez.
|
|
|
926
949
|
| `layoutContext` | `({ pathname, metadata }) => object` | Layout local'leri; `lang`, `structuredData`, `extraHead`, `bodyClass` özel yorumlanır | [04](./04-render-ve-sablonlar.md) |
|
|
927
950
|
| `notFound` | `() => object \| null` | 404 sayfa tanımı; `null` ise framework'ün hata sayfası | [03](./03-routing.md) |
|
|
928
951
|
| `error` | `({ status, error }) => object \| string \| null` | 404 dışındaki hata sayfaları (ve `notFound` yoksa 404); sayfa tanımı ya da doğrudan HTML | [03](./03-routing.md) |
|
|
929
|
-
| `prewarmPaths` | `() => string[]` |
|
|
952
|
+
| `prewarmPaths` | `() => string[]` | Klasik ısıtmada ısıtılacak yollar; tanımlı değilse klasik tur kurulmaz. `onVisit` ile birlikte **yasak** | [06](./06-cache.md) |
|
|
930
953
|
|
|
931
954
|
```js
|
|
932
955
|
hooks: {
|
package/docs/en/06-caching.md
CHANGED
|
@@ -1051,20 +1051,69 @@ before anything else: `Cache-Control: private`, `Set-Cookie` and query string
|
|
|
1051
1051
|
settings are the most common reasons an edge decides not to cache, and they
|
|
1052
1052
|
show up as `dynamic` in this panel.
|
|
1053
1053
|
|
|
1054
|
-
## Prewarm — warming
|
|
1054
|
+
## Prewarm — warming at startup or on visit
|
|
1055
1055
|
|
|
1056
1056
|
The equivalent of Next's build-time prerender, except the output is not written
|
|
1057
|
-
to disk: since the cache lives in process memory, the warm-up
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
stale-while-revalidate.
|
|
1057
|
+
to disk: since the cache lives in process memory, the warm-up happens when the
|
|
1058
|
+
process comes up (classic mode) or as traffic arrives (`onVisit`). The gain is
|
|
1059
|
+
the same — the clicked / neighbouring page does not wait for a cold render —
|
|
1060
|
+
but the data is not frozen; every entry ages with the route's `revalidate` and
|
|
1061
|
+
is refreshed in the background with stale-while-revalidate.
|
|
1062
1062
|
|
|
1063
1063
|
The warm-up is done with **real HTTP requests**
|
|
1064
1064
|
(`http://127.0.0.1:<port>`), so that the cache key, the compression and the
|
|
1065
1065
|
middleware chain are exactly the same as with normal traffic.
|
|
1066
1066
|
|
|
1067
|
-
|
|
1067
|
+
The two modes are **mutually exclusive**. If `cache().prewarm.onVisit` is on,
|
|
1068
|
+
classic fields (`max`, `priority`, `rotate`, `intervalSeconds`, …) and
|
|
1069
|
+
`hooks.prewarmPaths` must not be set together — config load throws. The reverse
|
|
1070
|
+
holds too: if you use classic list warming, do not set `onVisit`.
|
|
1071
|
+
|
|
1072
|
+
### Mode: `onVisit` — links from the page just visited
|
|
1073
|
+
|
|
1074
|
+
When a user receives a public, cacheable page (`public` HTML, 200), the
|
|
1075
|
+
framework takes same-origin `<a href>` paths from the response HTML (top to
|
|
1076
|
+
bottom, up to `perPage`) and warms them in the background. The next click, or
|
|
1077
|
+
another visitor to the same neighbourhood, usually sees a `HIT`.
|
|
1078
|
+
|
|
1079
|
+
```js
|
|
1080
|
+
// jskelet.config.mjs
|
|
1081
|
+
export default {
|
|
1082
|
+
async cache() {
|
|
1083
|
+
return {
|
|
1084
|
+
html: { "/": 60, "/news/:slug": 300 },
|
|
1085
|
+
prewarm: {
|
|
1086
|
+
onVisit: {
|
|
1087
|
+
perPage: 20, // at most this many links per page
|
|
1088
|
+
concurrency: 2, // optional
|
|
1089
|
+
rps: 4, // optional; 0 = unlimited
|
|
1090
|
+
},
|
|
1091
|
+
},
|
|
1092
|
+
};
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
`onVisit: true` is enough (default `perPage: 20`).
|
|
1098
|
+
|
|
1099
|
+
Rules:
|
|
1100
|
+
|
|
1101
|
+
- Only **public + cacheable** 200 HTML from `route()` triggers it; `private`,
|
|
1102
|
+
degraded or `no-store` responses do not extract links.
|
|
1103
|
+
- The warmer's own UA (`brand.prewarmUserAgent`) does not trigger — no crawl
|
|
1104
|
+
loop.
|
|
1105
|
+
- Paths that are already fresh are not enqueued.
|
|
1106
|
+
- `nofollow`, `target="_blank"`, `data-no-prefetch`, `prewarmSkip` and
|
|
1107
|
+
`navigation.exclude` share the same exemptions as Speculation Rules.
|
|
1108
|
+
- Query strings are not warmed (default cache policy treats query as dynamic).
|
|
1109
|
+
- There is no automatic startup pass; the first visitor to a page may still pay
|
|
1110
|
+
a MISS. If you need critical paths hot before traffic, prefer classic mode or
|
|
1111
|
+
readiness + a seed.
|
|
1112
|
+
- `PREWARM=0` turns onVisit off too. `PREWARM_MAX` / `PREWARM_INTERVAL_SECONDS`
|
|
1113
|
+
/ `PREWARM_DELAY_MS` / `PREWARM_RETRY_DELAY_MS` cannot be used with onVisit
|
|
1114
|
+
(error).
|
|
1115
|
+
|
|
1116
|
+
### Mode: classic — `hooks.prewarmPaths()`
|
|
1068
1117
|
|
|
1069
1118
|
The application declares which paths get warmed; usually it is the very same
|
|
1070
1119
|
function that produces the sitemap.
|
|
@@ -1090,11 +1139,10 @@ Rules:
|
|
|
1090
1139
|
not be warmed.
|
|
1091
1140
|
- Deduplication **preserves order**: when no `priority` is given, the order the
|
|
1092
1141
|
application provides is meaningful — put the most important pages first.
|
|
1093
|
-
- If this hook is not defined the warm-up is never set up; not even the
|
|
1094
|
-
is started.
|
|
1095
|
-
|
|
1096
|
-
### Round logic
|
|
1142
|
+
- If this hook is not defined the classic warm-up is never set up; not even the
|
|
1143
|
+
timer is started. (In `onVisit` mode the hook is **forbidden** — see above.)
|
|
1097
1144
|
|
|
1145
|
+
### Round logic (classic)
|
|
1098
1146
|
1. The list is collected. If it is longer than `max` (400 by default) a slice is
|
|
1099
1147
|
selected: the paths matching `priority` are taken first **on every round**,
|
|
1100
1148
|
and the remaining slots are filled from the queue.
|
|
@@ -1277,8 +1325,12 @@ filled the cache.
|
|
|
1277
1325
|
lag is at most `revalidate` + one refresh round.
|
|
1278
1326
|
- **The cache is bloating.** Because query parameters go into the key, campaign
|
|
1279
1327
|
parameters may be multiplying entries.
|
|
1280
|
-
- **The warm-up never runs.** `hooks.prewarmPaths` is not
|
|
1281
|
-
is set, or `cache().prewarm.enabled === false`.
|
|
1328
|
+
- **The warm-up never runs.** In classic mode `hooks.prewarmPaths` is not
|
|
1329
|
+
defined, `PREWARM=0` is set, or `cache().prewarm.enabled === false`. In
|
|
1330
|
+
`onVisit` mode check the `onVisit mode` log line after `listen` and that a
|
|
1331
|
+
public cacheable page was visited.
|
|
1332
|
+
- **Config fails with `onVisit` + `max` / `prewarmPaths`.** The two modes are
|
|
1333
|
+
mutually exclusive; use only one.
|
|
1282
1334
|
- **The warm-up round pushes the API into 429.** No `rps` was given. Lowering
|
|
1283
1335
|
`concurrency` is not enough; the setting that protects the quota is the total
|
|
1284
1336
|
rate. The lasting fix is the data cache: after the second round the warm-up
|
|
@@ -902,6 +902,11 @@ edges hold this page" has no exact answer — is in
|
|
|
902
902
|
|
|
903
903
|
### `cache().prewarm`
|
|
904
904
|
|
|
905
|
+
Two modes: **classic** (list + startup pass) or **`onVisit`** (links from the
|
|
906
|
+
page just visited). They cannot be combined — config load throws.
|
|
907
|
+
|
|
908
|
+
#### Classic fields
|
|
909
|
+
|
|
905
910
|
| Field | Type | Default | Meaning |
|
|
906
911
|
| --- | --- | --- | --- |
|
|
907
912
|
| `enabled` | `boolean` | `true` | If `false`, no prewarming happens (can be overridden with `PREWARM=1`) |
|
|
@@ -930,6 +935,24 @@ prewarm: {
|
|
|
930
935
|
}
|
|
931
936
|
```
|
|
932
937
|
|
|
938
|
+
#### `onVisit`
|
|
939
|
+
|
|
940
|
+
| Field | Type | Default | Meaning |
|
|
941
|
+
| --- | --- | --- | --- |
|
|
942
|
+
| `onVisit` | `true \| false \| object` | off | Visit-driven warming |
|
|
943
|
+
| `onVisit.perPage` | `number` | `20` | At most how many links per page (top to bottom) |
|
|
944
|
+
| `onVisit.concurrency` | `number` | same as classic | Parallel workers |
|
|
945
|
+
| `onVisit.rps` | `number` | same as classic | Requests per second cap; `0` unlimited |
|
|
946
|
+
|
|
947
|
+
```js
|
|
948
|
+
prewarm: {
|
|
949
|
+
onVisit: { perPage: 20, rps: 4 },
|
|
950
|
+
}
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
`hooks.prewarmPaths` and classic fields (`max`, `priority`, …) are **forbidden**
|
|
954
|
+
with `onVisit`. Details: [06-caching.md](./06-caching.md).
|
|
955
|
+
|
|
933
956
|
Each numeric field can be overridden by an environment variable of the same
|
|
934
957
|
name; env takes precedence. Details: [06-caching.md](./06-caching.md).
|
|
935
958
|
|
|
@@ -946,7 +969,7 @@ its own default and warns — the page does not go down.
|
|
|
946
969
|
| `layoutContext` | `({ pathname, metadata }) => object` | Layout locals; `lang`, `structuredData`, `extraHead` and `bodyClass` get special treatment | [04](./04-rendering.md) |
|
|
947
970
|
| `notFound` | `() => object \| null` | 404 page definition; if `null`, the framework's error page | [03](./03-routing.md) |
|
|
948
971
|
| `error` | `({ status, error }) => object \| string \| null` | Error pages other than 404 (and 404 when there is no `notFound`); a page definition or HTML directly | [03](./03-routing.md) |
|
|
949
|
-
| `prewarmPaths` | `() => string[]` | Paths
|
|
972
|
+
| `prewarmPaths` | `() => string[]` | Paths for classic prewarm; if omitted, the classic pass is never set up. **Forbidden** with `onVisit` | [06](./06-caching.md) |
|
|
950
973
|
|
|
951
974
|
```js
|
|
952
975
|
hooks: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/config/defaults.js
CHANGED
|
@@ -50,7 +50,11 @@ export const DEFAULT_DEV_GATE_BYPASS = [
|
|
|
50
50
|
"/favicon.ico",
|
|
51
51
|
];
|
|
52
52
|
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Klasik (liste tabanlı) prewarm ayarları. `onVisit` modu bunlarla birlikte
|
|
55
|
+
* kullanılamaz — ya açılış/`prewarmPaths` turu, ya ziyaret edilen sayfadaki
|
|
56
|
+
* linkleri ısıtma.
|
|
57
|
+
*/
|
|
54
58
|
export const DEFAULT_PREWARM = {
|
|
55
59
|
enabled: true,
|
|
56
60
|
max: 400,
|
|
@@ -85,6 +89,41 @@ export const DEFAULT_PREWARM = {
|
|
|
85
89
|
priority: [],
|
|
86
90
|
};
|
|
87
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Ziyaret tabanlı ısıtma. Bir sayfa servis edilince HTML'deki aynı-origin
|
|
94
|
+
* linkler kuyruğa alınır; bir sonraki tıklama (veya başka ziyaretçi) çoğu
|
|
95
|
+
* zaman HIT görür. Klasik `prewarm` alanlarıyla karşılıklı dışlayıcıdır.
|
|
96
|
+
*/
|
|
97
|
+
export const DEFAULT_PREWARM_ON_VISIT = {
|
|
98
|
+
enabled: false,
|
|
99
|
+
/** Sayfa başına üstten alta en fazla kaç link kuyruğa alınır. */
|
|
100
|
+
perPage: 20,
|
|
101
|
+
/**
|
|
102
|
+
* Paralel işçi; `null` → klasik prewarm ile aynı varsayılan
|
|
103
|
+
* (prod 4 / dev 1) `startPrewarm` içinde çözülür.
|
|
104
|
+
* @type {number | null}
|
|
105
|
+
*/
|
|
106
|
+
concurrency: null,
|
|
107
|
+
/**
|
|
108
|
+
* Saniyedeki istek tavanı; `null` → klasik ile aynı (prod 0 / dev 4).
|
|
109
|
+
* @type {number | null}
|
|
110
|
+
*/
|
|
111
|
+
rps: null,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** `onVisit` açıkken `cache().prewarm` kökünde yasak olan klasik alanlar. */
|
|
115
|
+
export const CLASSIC_PREWARM_KEYS = [
|
|
116
|
+
"enabled",
|
|
117
|
+
"max",
|
|
118
|
+
"intervalSeconds",
|
|
119
|
+
"concurrency",
|
|
120
|
+
"delayMs",
|
|
121
|
+
"rps",
|
|
122
|
+
"retryDelayMs",
|
|
123
|
+
"rotate",
|
|
124
|
+
"priority",
|
|
125
|
+
];
|
|
126
|
+
|
|
88
127
|
/**
|
|
89
128
|
* HTML önbelleğinin girdi sınırı. 500 girdi ortalama bir sayfa boyutunda
|
|
90
129
|
* yaklaşık 100-200 MB tutar; uzun kuyruklu siteler bunu yükseltmek yerine
|
package/src/config/index.js
CHANGED
|
@@ -42,7 +42,9 @@ import {
|
|
|
42
42
|
DEFAULT_NAVIGATION,
|
|
43
43
|
DEFAULT_NAVIGATION_EXCLUDE,
|
|
44
44
|
DEFAULT_PREWARM,
|
|
45
|
+
DEFAULT_PREWARM_ON_VISIT,
|
|
45
46
|
DEFAULT_PREWARM_SKIP,
|
|
47
|
+
CLASSIC_PREWARM_KEYS,
|
|
46
48
|
DEFAULT_REDIS,
|
|
47
49
|
DEFAULT_SECURITY,
|
|
48
50
|
DEFAULT_STATIC,
|
|
@@ -655,7 +657,7 @@ function normalizeCache(raw) {
|
|
|
655
657
|
html.push({ pattern, seconds: value });
|
|
656
658
|
}
|
|
657
659
|
|
|
658
|
-
const prewarm =
|
|
660
|
+
const prewarm = normalizePrewarm(raw?.prewarm);
|
|
659
661
|
const queryRules = normalizeQueryRules(raw?.query);
|
|
660
662
|
const maxEntries = Number(raw?.maxEntries);
|
|
661
663
|
|
|
@@ -688,6 +690,98 @@ function normalizeCache(raw) {
|
|
|
688
690
|
};
|
|
689
691
|
}
|
|
690
692
|
|
|
693
|
+
/**
|
|
694
|
+
* @param {unknown} raw
|
|
695
|
+
* @returns {{ enabled: boolean } & typeof DEFAULT_PREWARM_ON_VISIT}
|
|
696
|
+
*/
|
|
697
|
+
function normalizeOnVisit(raw) {
|
|
698
|
+
if (raw === true) {
|
|
699
|
+
return { ...DEFAULT_PREWARM_ON_VISIT, enabled: true };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (raw == null || raw === false) {
|
|
703
|
+
return { ...DEFAULT_PREWARM_ON_VISIT, enabled: false };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
707
|
+
throw new Error(
|
|
708
|
+
"[config] cache().prewarm.onVisit must be true, false, or an object",
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const source = /** @type {Record<string, unknown>} */ (raw);
|
|
713
|
+
const perPage = Number(source.perPage);
|
|
714
|
+
const concurrency = Number(source.concurrency);
|
|
715
|
+
const rps = Number(source.rps);
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
...DEFAULT_PREWARM_ON_VISIT,
|
|
719
|
+
enabled: source.enabled !== false,
|
|
720
|
+
perPage:
|
|
721
|
+
Number.isFinite(perPage) && perPage > 0
|
|
722
|
+
? Math.floor(perPage)
|
|
723
|
+
: DEFAULT_PREWARM_ON_VISIT.perPage,
|
|
724
|
+
concurrency:
|
|
725
|
+
Number.isFinite(concurrency) && concurrency > 0
|
|
726
|
+
? Math.floor(concurrency)
|
|
727
|
+
: null,
|
|
728
|
+
rps: Number.isFinite(rps) && rps >= 0 ? rps : null,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Klasik liste ısıtması ile `onVisit` karşılıklı dışlayıcıdır. İkisini birden
|
|
734
|
+
* yazmak sessizce yanlış moda düşmesin diye yüklemede hata verir.
|
|
735
|
+
*
|
|
736
|
+
* @param {unknown} raw
|
|
737
|
+
* @returns {Record<string, unknown>}
|
|
738
|
+
*/
|
|
739
|
+
function normalizePrewarm(raw) {
|
|
740
|
+
const source =
|
|
741
|
+
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
742
|
+
? /** @type {Record<string, unknown>} */ ({ ...raw })
|
|
743
|
+
: {};
|
|
744
|
+
|
|
745
|
+
const onVisit = normalizeOnVisit(source.onVisit);
|
|
746
|
+
|
|
747
|
+
if (onVisit.enabled) {
|
|
748
|
+
const conflicts = Object.keys(source).filter(
|
|
749
|
+
(key) => key !== "onVisit" && CLASSIC_PREWARM_KEYS.includes(key),
|
|
750
|
+
);
|
|
751
|
+
if (conflicts.length) {
|
|
752
|
+
throw new Error(
|
|
753
|
+
"[config] cache().prewarm.onVisit cannot be combined with classic " +
|
|
754
|
+
`prewarm settings (${conflicts.join(", ")}). Use either onVisit or ` +
|
|
755
|
+
"classic settings (max, priority, rotate, …), not both.",
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const unknown = Object.keys(source).filter((key) => key !== "onVisit");
|
|
760
|
+
if (unknown.length) {
|
|
761
|
+
throw new Error(
|
|
762
|
+
"[config] cache().prewarm.onVisit cannot be combined with " +
|
|
763
|
+
`${unknown.join(", ")}. On-visit mode only accepts the onVisit object.`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
...DEFAULT_PREWARM,
|
|
769
|
+
enabled: true,
|
|
770
|
+
onVisit,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Klasik mod: `onVisit: false` yazılmış olabilir; diğer alanlar varsayılanlarla
|
|
775
|
+
// birleşir. `onVisit` anahtarı çözülmüş nesnede her zaman durur.
|
|
776
|
+
const classic = { ...source };
|
|
777
|
+
delete classic.onVisit;
|
|
778
|
+
return {
|
|
779
|
+
...DEFAULT_PREWARM,
|
|
780
|
+
...classic,
|
|
781
|
+
onVisit,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
691
785
|
/** Speculation Rules'un tanıdığı eagerness değerleri. */
|
|
692
786
|
const EAGERNESS = new Set(["conservative", "moderate", "eager"]);
|
|
693
787
|
|
|
@@ -1043,6 +1137,17 @@ export async function loadConfig(options = {}) {
|
|
|
1043
1137
|
clientEnv: source.clientEnv ?? [],
|
|
1044
1138
|
};
|
|
1045
1139
|
|
|
1140
|
+
if (
|
|
1141
|
+
config.prewarm?.onVisit?.enabled &&
|
|
1142
|
+
typeof config.hooks?.prewarmPaths === "function"
|
|
1143
|
+
) {
|
|
1144
|
+
throw new Error(
|
|
1145
|
+
"[config] hooks.prewarmPaths() cannot be used with cache().prewarm.onVisit. " +
|
|
1146
|
+
"On-visit mode warms links from each response; classic mode uses prewarmPaths. " +
|
|
1147
|
+
"Choose one.",
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1046
1151
|
// Dev'de build ve sunucu ayrı alt süreçler; üçü de aynı özeti basınca satır
|
|
1047
1152
|
// banner'ın ve build bloğunun arasına üç kez giriyor. Özeti dış süreç basar.
|
|
1048
1153
|
if (loaded && !process.env.JSKELET_CHILD) {
|
package/src/index.js
CHANGED
|
@@ -77,7 +77,7 @@ export {
|
|
|
77
77
|
} from "./server/cloudflare.js";
|
|
78
78
|
// Upstream hız freninin host başına durumu: healthcheck ve teşhis uçları için.
|
|
79
79
|
export { getUpstreamLimiterStatus } from "./server/upstream-limiter.js";
|
|
80
|
-
export { prewarm, prewarmProgress } from "./server/prewarm.js";
|
|
80
|
+
export { prewarm, prewarmProgress, extractSameOriginLinks, isOnVisitPrewarm } from "./server/prewarm.js";
|
|
81
81
|
export { createProxy } from "./server/middleware/upstream-proxy.js";
|
|
82
82
|
export { getConfig, loadConfig } from "./config/index.js";
|
|
83
83
|
export { attrs, cn, cx, esc, jsonScript } from "./views/helpers/html.js";
|
package/src/server/html-cache.js
CHANGED
|
@@ -815,3 +815,17 @@ export function getHtmlCacheEntries() {
|
|
|
815
815
|
deps: entry.deps.size,
|
|
816
816
|
}));
|
|
817
817
|
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Yol (query'siz) için taze bir HTML girdisi var mı? Ziyaret ısıtması yalnızca
|
|
821
|
+
* soğuk / bayat hedefleri kuyruğa alır; HIT'leri yeniden çekmez.
|
|
822
|
+
*
|
|
823
|
+
* @param {string} pathname
|
|
824
|
+
* @returns {boolean}
|
|
825
|
+
*/
|
|
826
|
+
export function isHtmlCacheFresh(pathname) {
|
|
827
|
+
if (typeof pathname !== "string" || !pathname.startsWith("/")) return false;
|
|
828
|
+
const entry = store.get(pathname);
|
|
829
|
+
if (!entry) return false;
|
|
830
|
+
return Date.now() < entry.expiresAt;
|
|
831
|
+
}
|
|
@@ -309,7 +309,8 @@ function sendCached(res, filePath, maxAge) {
|
|
|
309
309
|
`public, max-age=${maxAge}, stale-while-revalidate=${Math.min(maxAge, 86400)}`,
|
|
310
310
|
);
|
|
311
311
|
res.setHeader("Vary", "Accept");
|
|
312
|
-
|
|
312
|
+
// Cache dir is under .jskelet/; Express send ignores dotfiles by default.
|
|
313
|
+
res.sendFile(path.resolve(filePath), { dotfiles: "allow" });
|
|
313
314
|
}
|
|
314
315
|
|
|
315
316
|
/**
|
package/src/server/prewarm.js
CHANGED
|
@@ -1,31 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sunucu açılışında sayfaları önden render edip HTML
|
|
2
|
+
* Sunucu açılışında veya ziyaret sırasında sayfaları önden render edip HTML
|
|
3
|
+
* cache'ini doldurur.
|
|
4
|
+
*
|
|
5
|
+
* İki karşılıklı dışlayan mod:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Klasik** — `hooks.prewarmPaths()` listesi, açılış/`intervalSeconds`
|
|
8
|
+
* turları, `priority` / `rotate` / `max`.
|
|
9
|
+
* 2. **onVisit** — herkese açık bir sayfa servis edilince HTML'deki
|
|
10
|
+
* aynı-origin linkler kuyruğa alınır; tıklanabilir komşular ısınır.
|
|
3
11
|
*
|
|
4
12
|
* Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz:
|
|
5
13
|
* HTML cache süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca
|
|
6
|
-
*
|
|
7
|
-
* dondurulmaz: her girdi route'un `revalidate` süresiyle yaşlanır ve
|
|
8
|
-
* stale-while-revalidate ile arkada tazelenir.
|
|
14
|
+
* (veya trafik geldikçe) yapılır.
|
|
9
15
|
*
|
|
10
16
|
* Isıtma gerçek HTTP istekleriyle yapılır: cache anahtarı, sıkıştırma ve
|
|
11
|
-
* middleware zinciri normal trafikle bire bir aynı olsun.
|
|
12
|
-
* ısıtılacağını uygulama `hooks.prewarmPaths()` ile bildirir; genelde
|
|
13
|
-
* sitemap üreten fonksiyonun aynısıdır.
|
|
14
|
-
*
|
|
15
|
-
* On binlerce yolluk bir sitede tur bir "damla damla" tarayıcıya dönüşür:
|
|
16
|
-
* `priority` desenleri her turda başa alınır, geri kalan kuyruk turlar
|
|
17
|
-
* arasında kaldığı yerden devam eder (`rotate`) ve `rps` toplam hızı upstream
|
|
18
|
-
* kotasının altında tutar. Amaç, kimse gelmese bile hiçbir sayfanın soğuk
|
|
19
|
-
* kalmaması — ama bunu API'yi düşürmeden yapmak.
|
|
17
|
+
* middleware zinciri normal trafikle bire bir aynı olsun.
|
|
20
18
|
*/
|
|
21
19
|
import process from "node:process";
|
|
22
20
|
import { getConfig, hook } from "../config/index.js";
|
|
23
21
|
import { getRequestContext } from "../http/request-context.js";
|
|
24
|
-
import { takeInvalidatedPaths } from "./html-cache.js";
|
|
22
|
+
import { isHtmlCacheFresh, takeInvalidatedPaths } from "./html-cache.js";
|
|
25
23
|
import { getDataCacheStats } from "./data-cache.js";
|
|
26
24
|
import { isTransientStatus } from "./upstream-tracking.js";
|
|
27
25
|
import { upstreamCooldownMs } from "./upstream-limiter.js";
|
|
28
26
|
|
|
27
|
+
/** Klasik turu yöneten env'ler; `onVisit` ile birlikte yasak. */
|
|
28
|
+
const CLASSIC_PREWARM_ENV = [
|
|
29
|
+
"PREWARM_MAX",
|
|
30
|
+
"PREWARM_INTERVAL_SECONDS",
|
|
31
|
+
"PREWARM_DELAY_MS",
|
|
32
|
+
"PREWARM_RETRY_DELAY_MS",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* onVisit turu sürerken concurrency/rps. Klasik moda dokunmaz (`null`).
|
|
37
|
+
* @type {{ concurrency: number | null, rps: number | null }}
|
|
38
|
+
*/
|
|
39
|
+
const visitWarmSettings = { concurrency: null, rps: null };
|
|
40
|
+
|
|
29
41
|
/**
|
|
30
42
|
* Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
|
|
31
43
|
* üretimde kimse okumazsa da maliyeti bir nesnedir.
|
|
@@ -127,6 +139,15 @@ function num(value, fallback) {
|
|
|
127
139
|
* @returns {number}
|
|
128
140
|
*/
|
|
129
141
|
function setting(envKey, configKey, fallback) {
|
|
142
|
+
if (
|
|
143
|
+
visitWarmSettings.concurrency != null &&
|
|
144
|
+
configKey === "concurrency"
|
|
145
|
+
) {
|
|
146
|
+
return visitWarmSettings.concurrency;
|
|
147
|
+
}
|
|
148
|
+
if (visitWarmSettings.rps != null && configKey === "rps") {
|
|
149
|
+
return visitWarmSettings.rps;
|
|
150
|
+
}
|
|
130
151
|
return num(process.env[envKey], num(getConfig().prewarm?.[configKey], fallback));
|
|
131
152
|
}
|
|
132
153
|
|
|
@@ -421,13 +442,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
421
442
|
|
|
422
443
|
// Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
|
|
423
444
|
// için yarışmasın.
|
|
424
|
-
const concurrency = setting(
|
|
445
|
+
const concurrency = setting(
|
|
446
|
+
"PREWARM_CONCURRENCY",
|
|
447
|
+
"concurrency",
|
|
448
|
+
isDev ? 1 : 4,
|
|
449
|
+
);
|
|
425
450
|
|
|
426
451
|
// Render tek bir olay döngüsünde çalışıyor: aralıksız bir tur, geliştirme
|
|
427
452
|
// sırasında sayfa isteklerini ve dev panelinin kanalını arkasında bekletiyor.
|
|
428
453
|
// Dev'de varsayılan bir hız freni bu yüzden var; üretimde ısıtma bir kez
|
|
429
454
|
// olup bittiği için fren yalnızca istenirse (`prewarm.rps`) devreye girer.
|
|
430
|
-
|
|
455
|
+
// onVisit turlarında `visitWarmSettings.rps` `setting()` üzerinden iner.
|
|
456
|
+
const rps = setting("PREWARM_RPS", "rps", isDev ? 4 : 0);
|
|
431
457
|
const pace = createPacer(rps);
|
|
432
458
|
|
|
433
459
|
const all = only?.length ? only : await collectPaths();
|
|
@@ -554,8 +580,213 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
554
580
|
}
|
|
555
581
|
|
|
556
582
|
/**
|
|
557
|
-
*
|
|
558
|
-
*
|
|
583
|
+
* Speculation Rules `href_matches` benzeri dışlama: tam yol veya `/*` öneki.
|
|
584
|
+
*
|
|
585
|
+
* @param {string} pathname
|
|
586
|
+
* @param {string[]} patterns
|
|
587
|
+
* @returns {boolean}
|
|
588
|
+
*/
|
|
589
|
+
function matchesHrefExclude(pathname, patterns) {
|
|
590
|
+
for (const pattern of patterns) {
|
|
591
|
+
if (typeof pattern !== "string" || !pattern) continue;
|
|
592
|
+
if (pattern.endsWith("/*")) {
|
|
593
|
+
const base = pattern.slice(0, -2);
|
|
594
|
+
if (pathname === base || pathname.startsWith(`${base}/`)) return true;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (pathname === pattern) return true;
|
|
598
|
+
}
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* HTML içindeki aynı-origin `<a href>` yollarını DOM sırasıyla (üstten alta)
|
|
604
|
+
* toplar. Speculation Rules ile aynı muafiyetler: `nofollow`, `_blank`,
|
|
605
|
+
* `data-no-prefetch`, `prewarmSkip`, `navigation.exclude`.
|
|
606
|
+
*
|
|
607
|
+
* @param {string} html
|
|
608
|
+
* @param {{ limit?: number, basePath?: string }} [options]
|
|
609
|
+
* @returns {string[]}
|
|
610
|
+
*/
|
|
611
|
+
export function extractSameOriginLinks(html, options = {}) {
|
|
612
|
+
if (typeof html !== "string" || !html) return [];
|
|
613
|
+
|
|
614
|
+
const limit = Math.max(1, Math.floor(Number(options.limit) || 20));
|
|
615
|
+
const { prewarmSkip, navigation } = getConfig();
|
|
616
|
+
const exclude = navigation?.exclude ?? [];
|
|
617
|
+
|
|
618
|
+
/** @type {string[]} */
|
|
619
|
+
const links = [];
|
|
620
|
+
const seen = new Set();
|
|
621
|
+
const tagRe = /<a\b([^>]*)>/gi;
|
|
622
|
+
let match;
|
|
623
|
+
|
|
624
|
+
while ((match = tagRe.exec(html)) !== null && links.length < limit) {
|
|
625
|
+
const attrs = match[1];
|
|
626
|
+
if (/\btarget\s*=\s*(?:"_blank"|'_blank'|_blank)(?=[\s>]|$)/i.test(attrs)) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (/\bdata-no-prefetch\b/i.test(attrs)) continue;
|
|
630
|
+
|
|
631
|
+
const relMatch = attrs.match(
|
|
632
|
+
/\brel\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i,
|
|
633
|
+
);
|
|
634
|
+
const rel = relMatch?.[1] ?? relMatch?.[2] ?? relMatch?.[3] ?? "";
|
|
635
|
+
if (/\bnofollow\b/i.test(rel)) continue;
|
|
636
|
+
|
|
637
|
+
const hrefMatch = attrs.match(
|
|
638
|
+
/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i,
|
|
639
|
+
);
|
|
640
|
+
const href = hrefMatch?.[1] ?? hrefMatch?.[2] ?? hrefMatch?.[3];
|
|
641
|
+
if (!href) continue;
|
|
642
|
+
|
|
643
|
+
const path = normalizeWarmPath(href, options.basePath);
|
|
644
|
+
if (!path) continue;
|
|
645
|
+
if (prewarmSkip.some((prefix) => path.startsWith(prefix))) continue;
|
|
646
|
+
if (matchesHrefExclude(path, exclude)) continue;
|
|
647
|
+
if (seen.has(path)) continue;
|
|
648
|
+
|
|
649
|
+
seen.add(path);
|
|
650
|
+
links.push(path);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return links;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* @param {string} href
|
|
658
|
+
* @param {string} [basePath] Ziyaret edilen sayfa; göreli href çözümü için.
|
|
659
|
+
* @returns {string | null}
|
|
660
|
+
*/
|
|
661
|
+
function normalizeWarmPath(href, basePath = "/") {
|
|
662
|
+
const trimmed = href.trim();
|
|
663
|
+
if (!trimmed || trimmed.startsWith("#")) return null;
|
|
664
|
+
if (/^(mailto|tel|javascript|data):/i.test(trimmed)) return null;
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
const basePathname = basePath.startsWith("/") ? basePath : `/${basePath}`;
|
|
668
|
+
const url = new URL(trimmed, `https://warm.invalid${basePathname}`);
|
|
669
|
+
// Dış origin veya `https://…` mutlak linkler elenir; yalnızca site-içi
|
|
670
|
+
// path / göreli href ısınır.
|
|
671
|
+
if (url.origin !== "https://warm.invalid") return null;
|
|
672
|
+
return url.pathname || "/";
|
|
673
|
+
} catch {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** @type {string | null} */
|
|
679
|
+
let visitOrigin = null;
|
|
680
|
+
/** @type {string[]} */
|
|
681
|
+
const visitPending = [];
|
|
682
|
+
/** @type {Set<string>} */
|
|
683
|
+
const visitQueued = new Set();
|
|
684
|
+
let visitDraining = false;
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Ziyaret ısıtması açık mı? `PREWARM=0` her iki modu da keser.
|
|
688
|
+
*
|
|
689
|
+
* @returns {boolean}
|
|
690
|
+
*/
|
|
691
|
+
export function isOnVisitPrewarm() {
|
|
692
|
+
if (process.env.PREWARM === "0") return false;
|
|
693
|
+
try {
|
|
694
|
+
return getConfig().prewarm?.onVisit?.enabled === true;
|
|
695
|
+
} catch {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Yanıt gövdesindeki linkleri soğuksa kuyruğa alır. İstek yolunu bloklamamak
|
|
702
|
+
* için `route()` bunu `queueMicrotask` ile çağırır.
|
|
703
|
+
*
|
|
704
|
+
* @param {string} html
|
|
705
|
+
* @param {{ path: string, req?: { get?: (name: string) => string | undefined,
|
|
706
|
+
* headers?: Record<string, unknown> } }} context
|
|
707
|
+
* @returns {void}
|
|
708
|
+
*/
|
|
709
|
+
export function noteVisitWarm(html, context) {
|
|
710
|
+
if (!isOnVisitPrewarm() || !visitOrigin) return;
|
|
711
|
+
if (!context?.path || typeof html !== "string") return;
|
|
712
|
+
|
|
713
|
+
const ua =
|
|
714
|
+
context.req?.get?.("user-agent") ??
|
|
715
|
+
/** @type {string | undefined} */ (context.req?.headers?.["user-agent"]);
|
|
716
|
+
if (ua && ua === getConfig().brand.prewarmUserAgent) return;
|
|
717
|
+
|
|
718
|
+
const perPage = Number(getConfig().prewarm?.onVisit?.perPage) || 20;
|
|
719
|
+
const links = extractSameOriginLinks(html, {
|
|
720
|
+
limit: perPage,
|
|
721
|
+
basePath: context.path,
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
for (const path of links) {
|
|
725
|
+
if (path === context.path) continue;
|
|
726
|
+
if (isHtmlCacheFresh(path)) continue;
|
|
727
|
+
if (visitQueued.has(path)) continue;
|
|
728
|
+
visitQueued.add(path);
|
|
729
|
+
visitPending.push(path);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
enqueueInvalidatedForVisit();
|
|
733
|
+
if (visitPending.length) void drainVisitWarm();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Invalidate edilmiş yolları da kuyruğa alır (klasik turdaki
|
|
738
|
+
* `takeInvalidatedPaths` karşılığı).
|
|
739
|
+
*
|
|
740
|
+
* @returns {void}
|
|
741
|
+
*/
|
|
742
|
+
function enqueueInvalidatedForVisit() {
|
|
743
|
+
for (const path of takeInvalidatedPaths()) {
|
|
744
|
+
if (visitQueued.has(path)) continue;
|
|
745
|
+
visitQueued.add(path);
|
|
746
|
+
visitPending.push(path);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function drainVisitWarm() {
|
|
751
|
+
if (visitDraining || !visitOrigin) return;
|
|
752
|
+
visitDraining = true;
|
|
753
|
+
|
|
754
|
+
const onVisit = getConfig().prewarm?.onVisit ?? {};
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
while (visitPending.length) {
|
|
758
|
+
enqueueInvalidatedForVisit();
|
|
759
|
+
|
|
760
|
+
const batch = visitPending.splice(0, 32);
|
|
761
|
+
for (const path of batch) visitQueued.delete(path);
|
|
762
|
+
|
|
763
|
+
const cold = batch.filter((path) => !isHtmlCacheFresh(path));
|
|
764
|
+
if (!cold.length) continue;
|
|
765
|
+
|
|
766
|
+
// Klasik `prewarm()` turunu yeniden kullan: retry, progress, UA aynı.
|
|
767
|
+
// onVisit.concurrency / rps verilmişse `visitWarmSettings` ile iner;
|
|
768
|
+
// yoksa `prewarm()` kendi (env → config → isDev) zincirine düşer.
|
|
769
|
+
visitWarmSettings.concurrency = onVisit.concurrency;
|
|
770
|
+
visitWarmSettings.rps = onVisit.rps;
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
await prewarm({ origin: visitOrigin, paths: cold, quiet: true });
|
|
774
|
+
} catch (error) {
|
|
775
|
+
console.error("[prewarm] onVisit warm failed", error);
|
|
776
|
+
} finally {
|
|
777
|
+
visitWarmSettings.concurrency = null;
|
|
778
|
+
visitWarmSettings.rps = null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} finally {
|
|
782
|
+
visitDraining = false;
|
|
783
|
+
if (visitPending.length) void drainVisitWarm();
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır.
|
|
789
|
+
* `onVisit` modunda zamanlayıcı yok: yalnızca origin kaydı ve kuyruk.
|
|
559
790
|
*
|
|
560
791
|
* @param {{ port: number }} options
|
|
561
792
|
* @returns {void}
|
|
@@ -563,12 +794,30 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
563
794
|
export function startPrewarm({ port }) {
|
|
564
795
|
const config = getConfig();
|
|
565
796
|
if (process.env.PREWARM === "0") return;
|
|
797
|
+
|
|
798
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
799
|
+
|
|
800
|
+
if (config.prewarm?.onVisit?.enabled) {
|
|
801
|
+
const classicEnv = CLASSIC_PREWARM_ENV.filter((key) => process.env[key]);
|
|
802
|
+
if (classicEnv.length) {
|
|
803
|
+
throw new Error(
|
|
804
|
+
"[prewarm] onVisit mode cannot be used with " +
|
|
805
|
+
`${classicEnv.join(", ")}. Those env vars belong to classic prewarm.`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
visitOrigin = origin;
|
|
810
|
+
console.log(
|
|
811
|
+
`[prewarm] onVisit mode — warming links from each public page response`,
|
|
812
|
+
);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
566
816
|
if (process.env.PREWARM !== "1" && config.prewarm?.enabled === false) return;
|
|
567
817
|
// Isıtacak yol bildirmeyen bir projede zamanlayıcı kurmanın anlamı yok.
|
|
568
818
|
if (typeof config.hooks?.prewarmPaths !== "function") return;
|
|
569
819
|
|
|
570
820
|
const isDev = process.env.NODE_ENV === "development";
|
|
571
|
-
const origin = `http://127.0.0.1:${port}`;
|
|
572
821
|
|
|
573
822
|
// Hız frenli bir tur `intervalSeconds`'tan uzun sürebilir; üst üste binen
|
|
574
823
|
// turlar `prewarmProgress`'i bozar ve upstream'e iki kat yük bindirir.
|
package/src/server/render.js
CHANGED
|
@@ -41,7 +41,7 @@ import * as html from "../views/helpers/html.js";
|
|
|
41
41
|
import * as tags from "../views/helpers/tags.js";
|
|
42
42
|
import { loadComponents } from "../views/components/loader.js";
|
|
43
43
|
import { renderStatusPage } from "./status-page.js";
|
|
44
|
-
import { suppressForPrewarm } from "./prewarm.js";
|
|
44
|
+
import { suppressForPrewarm, noteVisitWarm } from "./prewarm.js";
|
|
45
45
|
import {
|
|
46
46
|
ensureTemplatesCompiled,
|
|
47
47
|
getComponentDirs,
|
|
@@ -395,6 +395,17 @@ export function route(controller, options = {}) {
|
|
|
395
395
|
// ETag kişiye özel HTML için kullanıcıya özgü bir doğrulayıcıdır ve
|
|
396
396
|
// `no-store` ile birlikte hiçbir işe yaramaz; üretilmesi engellenir.
|
|
397
397
|
await sendHtml(req, res, result.html, result.encoded, { etag: publicCache });
|
|
398
|
+
|
|
399
|
+
// onVisit: yanıt gittikten sonra linkleri kuyruğa al — TTFB'yi şişirmez.
|
|
400
|
+
// Yalnızca herkese açık, önbelleklenebilir 200 HTML; private / degraded
|
|
401
|
+
// sayfadaki linkler kişiye özel veya eksik olabilir.
|
|
402
|
+
if (publicCache && result.status === 200 && result.html) {
|
|
403
|
+
const pagePath = req.path;
|
|
404
|
+
const pageHtml = result.html;
|
|
405
|
+
queueMicrotask(() => {
|
|
406
|
+
noteVisitWarm(pageHtml, { path: pagePath, req });
|
|
407
|
+
});
|
|
408
|
+
}
|
|
398
409
|
} catch (error) {
|
|
399
410
|
if (isRedirectError(error)) {
|
|
400
411
|
// Oturuma bağlı bir yönlendirme de kişiye özeldir: "giriş yapmalısın"
|