jskelet 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,16 @@ one is listed under a **Breaking** heading.
10
10
 
11
11
  ### Added
12
12
 
13
+ - Dynamic Open Graph images (Next.js `ImageResponse` / `opengraph-image`):
14
+ `ogImage`, `sendOgImage`, `ogHandler`, and `ImageResponse` turn card fields or
15
+ raw SVG into PNG when `sharp` is installed (SVG fallback otherwise). Wired in
16
+ `examples/blog` as `/og/blog/:slug.png` and `metadata.openGraph.image`.
17
+ - Early HTML cache refresh before TTL expiry: the last successful produce time
18
+ (`produceMs`) sets a lead window (`min(max(produceMs×2, 250ms), ttl/2)`). A
19
+ still-fresh `HIT` in that window revalidates in the background; idle entries
20
+ are soft-staled by a sweeper and drained over HTTP even without classic
21
+ `prewarmPaths` (`PREWARM=0` disables both). In-flight refreshes no longer drop
22
+ the entry when `staleUntil` elapses.
13
23
  - Route-level stylesheets: put files in `styles/pages/*.css` and load them from
14
24
  the controller with `styles: ["home.css"]` (same contract as island
15
25
  `entries`). The layout emits them after global `app.css`; dev hot-swaps any
@@ -35,6 +35,10 @@ gerekmez:
35
35
  | `redirect` | `jskelet` → `redirect` |
36
36
  | `permanentRedirect` | `jskelet` → `permanentRedirect` |
37
37
  | `seeOther` | `jskelet` → `seeOther` |
38
+ | `ogHandler` | `jskelet` → `ogHandler` |
39
+ | `ogImage` | `jskelet` → `ogImage` |
40
+ | `sendOgImage` | `jskelet` → `sendOgImage` |
41
+ | `ImageResponse` | `jskelet` → `ImageResponse` |
38
42
 
39
43
  İstersen doğrudan import da edebilirsin; `api` yalnızca kolaylık:
40
44
 
@@ -511,6 +511,64 @@ return {
511
511
  `renderHeadMeta(metadata)` fonksiyonu dışa açıktır; layout dışında (ör. bir
512
512
  fragment ya da e-posta) aynı etiketleri üretmek gerekirse kullanılabilir.
513
513
 
514
+ ## Dinamik OG görselleri
515
+
516
+ Next.js `ImageResponse` / `opengraph-image.tsx` karşılığı. JSX yok: kart
517
+ alanları (`title`, `description`, `siteName`, renkler) ya da ham `svg` verilir.
518
+ `sharp` (opsiyonel peer) kuruluysa PNG, yoksa SVG döner. Sosyal kazıyıcıların
519
+ çoğu PNG beklediği için prod'da `sharp` önerilir.
520
+
521
+ HTML değil görsel döndüğü için `route()` kullanılmaz — `ogHandler` düz bir
522
+ Express handler üretir. `notFound()` ve `null` dönüşü 404 olur.
523
+
524
+ ```js
525
+ // routes/35-og.mjs
526
+ export default function register(app, { ogHandler, notFound }) {
527
+ app.get(
528
+ "/og/blog/:slug.png",
529
+ ogHandler(async ({ params }) => {
530
+ const post = getPost(params.slug);
531
+ if (!post) notFound();
532
+ return {
533
+ title: post.title,
534
+ description: post.excerpt,
535
+ siteName: "Blog",
536
+ };
537
+ }),
538
+ );
539
+ }
540
+ ```
541
+
542
+ Sayfa metadata'sında mutlak URL ve boyut verin:
543
+
544
+ ```js
545
+ openGraph: {
546
+ type: "article",
547
+ image: `${SITE_URL}/og/blog/${post.slug}.png`,
548
+ imageWidth: 1200,
549
+ imageHeight: 630,
550
+ },
551
+ ```
552
+
553
+ Ham SVG veya Next benzeri sınıf:
554
+
555
+ ```js
556
+ import { ImageResponse, sendOgImage, OG_SIZE } from "jskelet";
557
+
558
+ app.get("/og/custom.png", async (req, res) => {
559
+ const image = new ImageResponse(
560
+ `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">…</svg>`,
561
+ OG_SIZE,
562
+ );
563
+ await image.send(res);
564
+ // veya: await sendOgImage(res, { title: "…", format: "svg" });
565
+ });
566
+ ```
567
+
568
+ Varsayılan `Cache-Control`:
569
+ `public, max-age=0, s-maxage=86400, stale-while-revalidate=604800`.
570
+ `cacheControl` seçeneğiyle ezilir. Çalışan örnek: `examples/blog/routes/35-og.mjs`.
571
+
514
572
  ## Hook'lar
515
573
 
516
574
  Hook'lar `jskelet.config.mjs` → `hooks` altında tanımlanır. Hepsi opsiyonel,
package/docs/06-cache.md CHANGED
@@ -142,15 +142,29 @@ Girdi yapısı:
142
142
  ```
143
143
  expiresAt = now + ttl
144
144
  staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
145
+ produceMs = son başarılı üretimin süresi (ms)
145
146
  ```
146
147
 
147
148
  Okuma davranışı:
148
149
 
149
150
  | Durum | Yanıt | Arka plan |
150
151
  | --- | --- | --- |
151
- | `now < expiresAt` | Önbellekteki HTML, `HIT` | — |
152
+ | `now < expiresAt - leadMs` | Önbellekteki HTML, `HIT` | — |
153
+ | `expiresAt - leadMs ≤ now < expiresAt` | Önbellekteki HTML, `HIT` | **Erken tazeleme** başlar |
152
154
  | `expiresAt ≤ now < staleUntil` | Önbellekteki HTML **anında**, `STALE` | Tazeleme başlatılır |
153
- | `now ≥ staleUntil` | Girdi silinir, taze render, `MISS` | — |
155
+ | `now ≥ staleUntil` | Girdi silinir, taze render, `MISS` (uçuştaki tazeleme varken silinmez) | — |
156
+
157
+ `leadMs` sayfanın load süresini hesaba katar:
158
+
159
+ ```
160
+ leadMs = min(max(produceMs * 2, 250ms), ttl / 2)
161
+ ```
162
+
163
+ Böylece yavaş bir sayfa TTL dolduğu anda hâlâ soğuk render'a düşmez: taze
164
+ HTML çoğu zaman `expiresAt` gelmeden yazılmış olur. Trafik yoksa bir sweeper
165
+ aynı pencerede girdiyi soft-bayatlatır ve ısıtma kuyruğuna alır; `startPrewarm`
166
+ ( `PREWARM=0` değilse) kuyruğu HTTP ile boşaltır — klasik `prewarmPaths`
167
+ olmasa da.
154
168
 
155
169
  Stale penceresinde tazelemenin hatası isteği etkilemez: eski HTML pencere
156
170
  boyunca geçerli kalır ve hata yalnızca loglanır
@@ -622,7 +622,9 @@ Ayrıntı: [03-routing.md](./03-routing.md).
622
622
 
623
623
  Desen → saniye eşlemesi. Eşleşen kural, route'un kendi `revalidate` değerini
624
624
  **ezer**. Negatif ya da sonlu olmayan değerler yok sayılır; `0` "önbellekleme"
625
- anlamına gelir.
625
+ anlamına gelir. TTL dolmadan önce framework, son render süresine göre erken
626
+ arka plan tazelemesi başlatır (ayrı bir config alanı yok; ayrıntı
627
+ [06-cache.md](./06-cache.md)).
626
628
 
627
629
  ```js
628
630
  html: {
package/docs/11-tasima.md CHANGED
@@ -40,6 +40,7 @@ alt kümesine benzetildi — `next.config` sözdizimi, Metadata API, `notFound()
40
40
  | `loading.js` / Suspense | — | Sunucu HTML'i tam; iskelet gerekmiyor |
41
41
  | Streaming SSR | — | Yanıt tek parça |
42
42
  | `generateMetadata()` | Controller `metadata` + `hooks.metadata()` | Aynı alan adları ([04](./04-render-ve-sablonlar.md)) |
43
+ | `opengraph-image.tsx` / `ImageResponse` | `ogHandler` + `ImageResponse` / `sendOgImage` | SVG veya kart alanları → PNG (`sharp`); [04](./04-render-ve-sablonlar.md) |
43
44
  | `generateStaticParams()` | `hooks.prewarmPaths()` | Build zamanı değil, açılış zamanı ısıtma |
44
45
  | Route Handlers (`route.js`) | Düz Express handler'ı | `app.get/post(...)` |
45
46
  | Middleware (`middleware.ts`) | Express middleware + config `rewrites`/`headers`/`redirects` | `app.use(...)` |
@@ -68,6 +69,7 @@ başlığı elle okuyun.
68
69
  | `next/link` | `link({ href, text })` — `jskelet/tags` | `title` otomatik, dış bağlantıya `rel`/`target` otomatik |
69
70
  | `next/link` prefetch'i | `navigation: { prefetch, prerender }` | Speculation Rules; client runtime'ı yok ([07](./07-yapilandirma.md)) |
70
71
  | `next/image` | `image({ src, alt, priority })` — `jskelet/tags` | `srcset` build manifest'inden |
72
+ | `next/og` `ImageResponse` | `ImageResponse` / `ogHandler` — `jskelet` | JSX yok; SVG veya `title`/`description` kartı |
71
73
  | `next/font/google` | `fonts: [{ family, weights }]` | Self-host woff2, commit edilir |
72
74
  | `@phosphor-icons/react` | `icon({ name, weight })` — `jskelet/tags` | Build zamanı SVG sprite |
73
75
  | `react-dom` preconnect/preload | `preconnect: [...]` + `headHints()` | ([04](./04-render-ve-sablonlar.md)) |
package/docs/README.md CHANGED
@@ -57,8 +57,9 @@ npm --prefix examples/minimal run dev
57
57
 
58
58
  **`examples/blog/`** — dinamik route (`/blog/:slug`), etiket sayfaları,
59
59
  `redirects`/`rewrites`/`headers`/`cache` yapılandırmasının tamamı, fragment ile
60
- gelen sekme panelleri, form gönderimi, prewarm, `robots.txt`/`sitemap.xml`/`rss.xml`
61
- ve dört island (tema, sekme, arama, form).
60
+ gelen sekme panelleri, form gönderimi, prewarm, `robots.txt`/`sitemap.xml`/`rss.xml`,
61
+ dinamik OG görselleri (`/og/blog/:slug.png`) ve dört island (tema, sekme, arama,
62
+ form).
62
63
 
63
64
  ```bash
64
65
  npm --prefix examples/blog install
@@ -36,6 +36,10 @@ don't have to import things one by one in every file:
36
36
  | `redirect` | `jskelet` → `redirect` |
37
37
  | `permanentRedirect` | `jskelet` → `permanentRedirect` |
38
38
  | `seeOther` | `jskelet` → `seeOther` |
39
+ | `ogHandler` | `jskelet` → `ogHandler` |
40
+ | `ogImage` | `jskelet` → `ogImage` |
41
+ | `sendOgImage` | `jskelet` → `sendOgImage` |
42
+ | `ImageResponse` | `jskelet` → `ImageResponse` |
39
43
 
40
44
  You can also import directly if you prefer; `api` is only a convenience:
41
45
 
@@ -522,6 +522,64 @@ The `renderHeadMeta(metadata)` function is exported; it can be used when you
522
522
  need to produce the same tags outside the layout (for example in a fragment or
523
523
  an email).
524
524
 
525
+ ## Dynamic OG images
526
+
527
+ Counterpart to Next.js `ImageResponse` / `opengraph-image.tsx`. There is no JSX:
528
+ pass card fields (`title`, `description`, `siteName`, colours) or a raw `svg`.
529
+ With the optional `sharp` peer installed the response is PNG; otherwise SVG.
530
+ Most social scrapers expect PNG, so install `sharp` in production.
531
+
532
+ Because the response is an image, not HTML, do not use `route()` — `ogHandler`
533
+ returns a plain Express handler. `notFound()` and a `null` return yield 404.
534
+
535
+ ```js
536
+ // routes/35-og.mjs
537
+ export default function register(app, { ogHandler, notFound }) {
538
+ app.get(
539
+ "/og/blog/:slug.png",
540
+ ogHandler(async ({ params }) => {
541
+ const post = getPost(params.slug);
542
+ if (!post) notFound();
543
+ return {
544
+ title: post.title,
545
+ description: post.excerpt,
546
+ siteName: "Blog",
547
+ };
548
+ }),
549
+ );
550
+ }
551
+ ```
552
+
553
+ Point page metadata at the absolute URL and size:
554
+
555
+ ```js
556
+ openGraph: {
557
+ type: "article",
558
+ image: `${SITE_URL}/og/blog/${post.slug}.png`,
559
+ imageWidth: 1200,
560
+ imageHeight: 630,
561
+ },
562
+ ```
563
+
564
+ Raw SVG or a Next-like class:
565
+
566
+ ```js
567
+ import { ImageResponse, sendOgImage, OG_SIZE } from "jskelet";
568
+
569
+ app.get("/og/custom.png", async (req, res) => {
570
+ const image = new ImageResponse(
571
+ `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">…</svg>`,
572
+ OG_SIZE,
573
+ );
574
+ await image.send(res);
575
+ // or: await sendOgImage(res, { title: "…", format: "svg" });
576
+ });
577
+ ```
578
+
579
+ Default `Cache-Control`:
580
+ `public, max-age=0, s-maxage=86400, stale-while-revalidate=604800`.
581
+ Override with `cacheControl`. Working example: `examples/blog/routes/35-og.mjs`.
582
+
525
583
  ## Hooks
526
584
 
527
585
  Hooks are defined in `jskelet.config.mjs` under `hooks`. They are all optional
@@ -150,15 +150,29 @@ The entry structure:
150
150
  ```
151
151
  expiresAt = now + ttl
152
152
  staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
153
+ produceMs = duration of the last successful produce (ms)
153
154
  ```
154
155
 
155
156
  Read behaviour:
156
157
 
157
158
  | State | Response | Background |
158
159
  | --- | --- | --- |
159
- | `now < expiresAt` | The cached HTML, `HIT` | — |
160
+ | `now < expiresAt - leadMs` | The cached HTML, `HIT` | — |
161
+ | `expiresAt - leadMs ≤ now < expiresAt` | The cached HTML, `HIT` | **Early refresh** starts |
160
162
  | `expiresAt ≤ now < staleUntil` | The cached HTML **immediately**, `STALE` | A refresh is started |
161
- | `now ≥ staleUntil` | The entry is deleted, fresh render, `MISS` | — |
163
+ | `now ≥ staleUntil` | The entry is deleted, fresh render, `MISS` (not while a refresh is in flight) | — |
164
+
165
+ `leadMs` accounts for the page’s load time:
166
+
167
+ ```
168
+ leadMs = min(max(produceMs * 2, 250ms), ttl / 2)
169
+ ```
170
+
171
+ So a slow page does not fall back to a cold render the moment TTL ends: fresh
172
+ HTML is usually written before `expiresAt`. With no traffic, a sweeper
173
+ soft-stales the entry in the same window and queues it for warming;
174
+ `startPrewarm` (unless `PREWARM=0`) drains that queue over HTTP — even when
175
+ classic `prewarmPaths` is absent.
162
176
 
163
177
  A failure of the refresh inside the stale window does not affect the request:
164
178
  the old HTML stays valid for the whole window and the error is only logged
@@ -635,7 +635,9 @@ Details: [03-routing.md](./03-routing.md).
635
635
 
636
636
  A pattern → seconds mapping. A matching rule **overrides** the route's own
637
637
  `revalidate` value. Negative or non-finite values are ignored; `0` means "no
638
- caching".
638
+ caching". Before TTL ends the framework starts an early background refresh
639
+ based on the last render duration (no separate config field; see
640
+ [06-caching.md](./06-caching.md)).
639
641
 
640
642
  The one exception is `route(fn, { private: true })`: on that route a matching
641
643
  pattern is ignored. The lock is deliberately one-way — a mistake in the other
@@ -41,6 +41,7 @@ will feel familiar. The *reasons* behind the differences are in
41
41
  | `loading.js` / Suspense | — | The server HTML is complete; no skeleton needed |
42
42
  | Streaming SSR | — | The response is a single chunk |
43
43
  | `generateMetadata()` | Controller `metadata` + `hooks.metadata()` | Same field names ([04](./04-rendering.md)) |
44
+ | `opengraph-image.tsx` / `ImageResponse` | `ogHandler` + `ImageResponse` / `sendOgImage` | SVG or card fields → PNG (`sharp`); [04](./04-rendering.md) |
44
45
  | `generateStaticParams()` | `hooks.prewarmPaths()` | Warming at startup time, not build time |
45
46
  | Route Handlers (`route.js`) | A plain Express handler | `app.get/post(...)` |
46
47
  | Middleware (`middleware.ts`) | Express middleware + config `rewrites`/`headers`/`redirects` | `app.use(...)` |
@@ -69,6 +70,7 @@ header manually.
69
70
  | `next/link` | `link({ href, text })` — `jskelet/tags` | `title` automatic, `rel`/`target` automatic for external links |
70
71
  | `next/link` prefetching | `navigation: { prefetch, prerender }` | Speculation Rules; no client runtime ([07](./07-configuration.md)) |
71
72
  | `next/image` | `image({ src, alt, priority })` — `jskelet/tags` | `srcset` from the build manifest |
73
+ | `next/og` `ImageResponse` | `ImageResponse` / `ogHandler` — `jskelet` | No JSX; SVG or `title`/`description` card |
72
74
  | `next/font/google` | `fonts: [{ family, weights }]` | Self-hosted woff2, committed |
73
75
  | `@phosphor-icons/react` | `icon({ name, weight })` — `jskelet/tags` | Build-time SVG sprite |
74
76
  | `react-dom` preconnect/preload | `preconnect: [...]` + `headHints()` | ([04](./04-rendering.md)) |
package/docs/en/README.md CHANGED
@@ -62,8 +62,8 @@ npm --prefix examples/minimal run dev
62
62
  **`examples/blog/`** — a dynamic route (`/blog/:slug`), tag pages, the whole of
63
63
  the `redirects`/`rewrites`/`headers`/`cache` configuration, tab panels arriving
64
64
  as fragments, form submission, prewarm,
65
- `robots.txt`/`sitemap.xml`/`rss.xml` and four islands (theme, tabs, search,
66
- form).
65
+ `robots.txt`/`sitemap.xml`/`rss.xml`, dynamic OG images (`/og/blog/:slug.png`)
66
+ and four islands (theme, tabs, search, form).
67
67
 
68
68
  ```bash
69
69
  npm --prefix examples/blog install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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/index.js CHANGED
@@ -38,6 +38,16 @@ export {
38
38
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
39
39
  export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
40
40
  export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
41
+ export {
42
+ ImageResponse,
43
+ OG_SIZE,
44
+ buildOgSvg,
45
+ escapeXml,
46
+ ogHandler,
47
+ ogImage,
48
+ sendOgImage,
49
+ wrapText,
50
+ } from "./server/og-image.js";
41
51
  export { headHints } from "./server/head-hints.js";
42
52
  export { renderHeadMeta } from "./server/metadata.js";
43
53
  export {
@@ -7,6 +7,11 @@
7
7
  * tazeleme turu` kadar geride olabilir. Fiyat gibi canlı alanlar istemcide
8
8
  * WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
9
9
  *
10
+ * TTL dolmadan önce de tazelenir (**erken tazeleme**): son başarılı üretimin
11
+ * süresi (`produceMs`) kadar önden arka plan refresh başlar, böylece yavaş
12
+ * bir sayfa TTL anında hâlâ soğuk render'a düşmez. Trafik yoksa sweeper
13
+ * girdiyi soft-bayatlatır ve ısıtma kuyruğuna alır.
14
+ *
10
15
  * TTL'in yanında ikinci bir tazelik kaynağı daha var: **hedefli
11
16
  * invalidation**. Bir içerik güncellendiğinde tüm önbelleği boşaltmak
12
17
  * (`clearHtmlCache()`) o an sıcak olan her sayfayı soğuk render'a çevirir;
@@ -50,9 +55,12 @@ import {
50
55
  * `encoded` haritası yanıt yolunda (`sendHtml`) doluyor, yani yazma anında
51
56
  * boş; `storeEncoded` açıkken harita büyüdüğünde girdi yeniden paylaşılır.
52
57
  *
58
+ * `produceMs`: son başarılı üretimin süresi. Erken tazeleme penceresi bundan
59
+ * türetilir; Redis'ten gelen kopyada yoksa varsayılan kullanılır.
60
+ *
53
61
  * @typedef {{ html: string, status: number, expiresAt: number,
54
62
  * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string>,
55
- * storedAt: number, sharedEncodings: number }} HtmlEntry
63
+ * storedAt: number, sharedEncodings: number, produceMs: number }} HtmlEntry
56
64
  */
57
65
 
58
66
  /**
@@ -94,12 +102,27 @@ function trackDependencies() {
94
102
  */
95
103
  const STALE_FACTOR = 1;
96
104
 
105
+ /**
106
+ * Redis'ten gelen veya süresi bilinmeyen girdiler için erken tazeleme lead'i.
107
+ * Ölçülmüş `produceMs` yokken aşırı iyimser (0) kalmamak için.
108
+ */
109
+ const DEFAULT_PRODUCE_MS = 500;
110
+
111
+ /** Erken tazelemenin alt sınırı — çok hızlı sayfalar da TTL'den önce ısınsın. */
112
+ const EARLY_REFRESH_MIN_MS = 250;
113
+
114
+ /** Trafiksiz girdileri erken pencerede soft-bayatlatma aralığı. */
115
+ const EARLY_SWEEP_INTERVAL_MS = 1000;
116
+
97
117
  /** @type {Map<string, HtmlEntry>} */
98
118
  const store = new Map();
99
119
 
100
120
  /** @type {Map<string, Promise<{ html: string, status: number }>>} */
101
121
  const inflight = new Map();
102
122
 
123
+ /** @type {ReturnType<typeof setInterval> | null} */
124
+ let earlySweepTimer = null;
125
+
103
126
  /**
104
127
  * Uçuştaki her tazelemenin kimliği. Bir girdi tazelenirken invalidate
105
128
  * edilirse o tazelemenin sonucu **artık geçersizdir**: render, purge'den önce
@@ -148,6 +171,54 @@ const purgedDeps = new Map();
148
171
 
149
172
  const MAX_PURGED_DEPS = 1000;
150
173
 
174
+ /**
175
+ * Erken tazeleme lead'i: son render süresinin 2 katı (en az 250 ms), TTL'in
176
+ * yarısından fazla olamaz — kısa TTL'lerde sürekli refresh döngüsü olmasın.
177
+ *
178
+ * @param {number} produceMs
179
+ * @param {number} ttlMs
180
+ * @returns {number}
181
+ */
182
+ export function earlyRefreshLeadMs(produceMs, ttlMs) {
183
+ const measured =
184
+ Number.isFinite(produceMs) && produceMs > 0 ? produceMs : DEFAULT_PRODUCE_MS;
185
+ const lead = Math.max(measured * 2, EARLY_REFRESH_MIN_MS);
186
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) return lead;
187
+ return Math.min(lead, ttlMs / 2);
188
+ }
189
+
190
+ /**
191
+ * @param {HtmlEntry} entry
192
+ * @returns {number}
193
+ */
194
+ function entryTtlMs(entry) {
195
+ // Soft-bayatlatılmış girdide expiresAt 0; orijinal TTL storedAt farkından
196
+ // okunamaz. O durumda produceMs üzerinden güvenli bir üst sınır yeter.
197
+ if (entry.expiresAt > entry.storedAt) return entry.expiresAt - entry.storedAt;
198
+ return Math.max(entry.produceMs * 4, EARLY_REFRESH_MIN_MS * 2);
199
+ }
200
+
201
+ /**
202
+ * @param {HtmlEntry} entry
203
+ * @param {number} [now]
204
+ * @returns {boolean}
205
+ */
206
+ function isEarly(entry, now = Date.now()) {
207
+ if (now >= entry.expiresAt) return false;
208
+ const lead = earlyRefreshLeadMs(entry.produceMs, entryTtlMs(entry));
209
+ return now >= entry.expiresAt - lead;
210
+ }
211
+
212
+ /**
213
+ * @param {unknown} value
214
+ * @returns {number}
215
+ */
216
+ function normalizeProduceMs(value) {
217
+ const n = Number(value);
218
+ if (Number.isFinite(n) && n >= 0) return Math.round(n);
219
+ return DEFAULT_PRODUCE_MS;
220
+ }
221
+
151
222
  /**
152
223
  * Girdiyi ters indeksten söker. Bu adım atlanırsa indeks, düşen girdilerin
153
224
  * anahtarlarını tutmaya devam eder ve sessizce sızar.
@@ -183,7 +254,7 @@ function drop(key) {
183
254
  /**
184
255
  * @param {string} key
185
256
  * @returns {{ html: string, status: number, encoded: Map<string, Buffer>,
186
- * stale: boolean } | null}
257
+ * stale: boolean, early: boolean } | null}
187
258
  */
188
259
  function read(key) {
189
260
  const entry = store.get(key);
@@ -191,8 +262,12 @@ function read(key) {
191
262
 
192
263
  const now = Date.now();
193
264
  if (now >= entry.staleUntil) {
194
- drop(key);
195
- return null;
265
+ // Uçuştaki tazeleme bitene kadar girdiyi tut: yavaş upstream'de
266
+ // staleUntil dolup MISS'e düşmek erken tazelemenin amacını bozar.
267
+ if (!inflight.has(key)) {
268
+ drop(key);
269
+ return null;
270
+ }
196
271
  }
197
272
 
198
273
  // LRU: erişilen girdiyi sona taşı.
@@ -206,11 +281,13 @@ function read(key) {
206
281
  share(key, entry);
207
282
  }
208
283
 
284
+ const stale = now >= entry.expiresAt;
209
285
  return {
210
286
  html: entry.html,
211
287
  status: entry.status,
212
288
  encoded: entry.encoded,
213
- stale: now >= entry.expiresAt,
289
+ stale,
290
+ early: !stale && isEarly(entry, now),
214
291
  };
215
292
  }
216
293
 
@@ -234,6 +311,7 @@ function share(key, entry) {
234
311
  storedAt: entry.storedAt,
235
312
  expiresAt: entry.expiresAt,
236
313
  staleUntil: entry.staleUntil,
314
+ produceMs: entry.produceMs,
237
315
  deps: [...entry.deps],
238
316
  };
239
317
 
@@ -298,6 +376,7 @@ async function readShared(key) {
298
376
  deps,
299
377
  storedAt,
300
378
  sharedEncodings: encoded.size,
379
+ produceMs: normalizeProduceMs(payload.produceMs),
301
380
  };
302
381
  }
303
382
 
@@ -306,8 +385,9 @@ async function readShared(key) {
306
385
  * @param {{ html: string, status: number }} value
307
386
  * @param {number} ttlSeconds
308
387
  * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
388
+ * @param {number} [produceMs] Son üretimin süresi (ms).
309
389
  */
310
- function write(key, value, ttlSeconds, deps = null) {
390
+ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
311
391
  const now = Date.now();
312
392
 
313
393
  /** @type {HtmlEntry} */
@@ -322,6 +402,7 @@ function write(key, value, ttlSeconds, deps = null) {
322
402
  deps: deps ?? new Set(),
323
403
  storedAt: now,
324
404
  sharedEncodings: 0,
405
+ produceMs: normalizeProduceMs(produceMs),
325
406
  };
326
407
 
327
408
  install(key, entry);
@@ -385,6 +466,9 @@ function refresh(key, ttlSeconds, producer) {
385
466
  const pending = inflight.get(key);
386
467
  if (pending) return pending;
387
468
 
469
+ // Tazeleme sürerken staleUntil dolmasın: drop → MISS yolu kapanır.
470
+ extendStaleWhileRefreshing(key);
471
+
388
472
  const token = {};
389
473
  tokens.set(key, token);
390
474
 
@@ -397,6 +481,17 @@ function refresh(key, ttlSeconds, producer) {
397
481
  return task;
398
482
  }
399
483
 
484
+ /**
485
+ * @param {string} key
486
+ */
487
+ function extendStaleWhileRefreshing(key) {
488
+ const entry = store.get(key);
489
+ if (!entry) return;
490
+ const lead = earlyRefreshLeadMs(entry.produceMs, entryTtlMs(entry));
491
+ const floor = Date.now() + lead;
492
+ if (entry.staleUntil < floor) entry.staleUntil = floor;
493
+ }
494
+
400
495
  /**
401
496
  * @param {string} key
402
497
  * @param {number} ttlSeconds
@@ -436,7 +531,7 @@ async function produce(key, ttlSeconds, producer, token) {
436
531
  // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
437
532
  const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
438
533
  if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
439
- write(key, value, ttlSeconds, deps);
534
+ write(key, value, ttlSeconds, deps, Date.now() - startedAt);
440
535
  }
441
536
 
442
537
  return value;
@@ -447,7 +542,7 @@ async function produce(key, ttlSeconds, producer, token) {
447
542
  * @param {number} ttlSeconds 0 → cache yok
448
543
  * @param {() => Promise<{ html: string, status: number }>} producer
449
544
  * @returns {Promise<{ html: string, status: number, cached: boolean,
450
- * stale?: boolean, encoded?: Map<string, Buffer> }>}
545
+ * stale?: boolean, early?: boolean, encoded?: Map<string, Buffer> }>}
451
546
  */
452
547
  export async function withHtmlCache(key, ttlSeconds, producer) {
453
548
  if (!ttlSeconds) {
@@ -460,7 +555,8 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
460
555
  if (hit) {
461
556
  // Süresi geçmiş girdi anında döner; tazeleme arkada yürür ve hatası
462
557
  // isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
463
- if (hit.stale) {
558
+ // Erken pencerede de aynı: hâlâ HIT, ama TTL dolmadan taze HTML yazılsın.
559
+ if (hit.stale || hit.early) {
464
560
  invalidated.delete(key);
465
561
  void refresh(key, ttlSeconds, producer).catch((error) => {
466
562
  console.error(`[html-cache] background refresh failed: ${key}`, error);
@@ -829,3 +925,42 @@ export function isHtmlCacheFresh(pathname) {
829
925
  if (!entry) return false;
830
926
  return Date.now() < entry.expiresAt;
831
927
  }
928
+
929
+ /**
930
+ * Erken tazeleme penceresine girmiş (veya TTL'i dolmuş) trafiksiz girdileri
931
+ * soft-bayatlatır ve ısıtma kuyruğuna alır. HTTP ısıtması producer'sız
932
+ * çalıştığı için soft-bayat şart: taze HIT yenileme tetiklemez.
933
+ *
934
+ * @returns {number} İşaretlenen girdi sayısı.
935
+ */
936
+ export function sweepEarlyExpiry() {
937
+ const now = Date.now();
938
+ let marked = 0;
939
+
940
+ for (const [key, entry] of store) {
941
+ if (inflight.has(key)) continue;
942
+ // Zaten soft-bayat / kuyrukta — her saniye yeniden ekleme.
943
+ if (entry.expiresAt === 0) continue;
944
+ if (now < entry.expiresAt && !isEarly(entry, now)) continue;
945
+
946
+ entry.expiresAt = 0;
947
+ if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
948
+ marked += 1;
949
+ }
950
+
951
+ return marked;
952
+ }
953
+
954
+ /**
955
+ * Trafiksiz sayfaların TTL öncesi soft-bayatlatılması. `startPrewarm` açar;
956
+ * `PREWARM=0` iken hiç kurulmaz. `unref` — süreç kapanışını geciktirmez.
957
+ *
958
+ * @returns {void}
959
+ */
960
+ export function startEarlyExpirySweep() {
961
+ if (earlySweepTimer) return;
962
+ earlySweepTimer = setInterval(() => {
963
+ sweepEarlyExpiry();
964
+ }, EARLY_SWEEP_INTERVAL_MS);
965
+ earlySweepTimer.unref();
966
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Dinamik Open Graph görselleri — Next.js `ImageResponse` /
3
+ * `opengraph-image.tsx` karşılığı.
4
+ *
5
+ * JSX yok: ya hazır kart alanları (`title`, `description`, `siteName`) ya da
6
+ * ham `svg` verilir. sharp (opsiyonel peer) varsa PNG üretilir; yoksa SVG
7
+ * döner. Sosyal kazıyıcıların çoğu PNG beklediği için prod'da sharp önerilir.
8
+ *
9
+ * Domain bilgisi taşınmaz — metin, renk ve SVG uygulama tarafındandır.
10
+ */
11
+ import { tryImportFromApp } from "../build/resolve-peer.mjs";
12
+ import { getConfig } from "../config/index.js";
13
+ import { isNotFoundError } from "../http/control-flow.js";
14
+
15
+ /** @type {((input: Buffer, opts?: object) => import('sharp').Sharp) | null | undefined} */
16
+ let sharpModule;
17
+
18
+ /** Sosyal kartlar için yaygın boyut (Facebook / X / LinkedIn). */
19
+ export const OG_SIZE = Object.freeze({ width: 1200, height: 630 });
20
+
21
+ const DEFAULT_CACHE =
22
+ "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800";
23
+
24
+ /**
25
+ * @typedef {object} OgCardOptions
26
+ * @property {string} [title]
27
+ * @property {string} [description]
28
+ * @property {string} [siteName]
29
+ * @property {string} [background] Düz SVG rengi (`#0f172a`)
30
+ * @property {string} [color] Ana metin rengi
31
+ * @property {string} [mutedColor] Açıklama / site adı
32
+ * @property {string} [accent] Sol şerit rengi
33
+ */
34
+
35
+ /**
36
+ * @typedef {OgCardOptions & {
37
+ * svg?: string,
38
+ * width?: number,
39
+ * height?: number,
40
+ * format?: 'png' | 'svg',
41
+ * cacheControl?: string,
42
+ * }} OgImageOptions
43
+ */
44
+
45
+ /**
46
+ * @typedef {object} OgImageResult
47
+ * @property {Buffer} body
48
+ * @property {string} contentType
49
+ * @property {number} width
50
+ * @property {number} height
51
+ */
52
+
53
+ /**
54
+ * XML metin kaçışı — kullanıcı başlığı SVG'ye gömülür.
55
+ * @param {unknown} value
56
+ * @returns {string}
57
+ */
58
+ export function escapeXml(value) {
59
+ return String(value ?? "")
60
+ .replace(/&/g, "&amp;")
61
+ .replace(/</g, "&lt;")
62
+ .replace(/>/g, "&gt;")
63
+ .replace(/"/g, "&quot;")
64
+ .replace(/'/g, "&apos;");
65
+ }
66
+
67
+ /**
68
+ * Kelime sınırında satır kır. Uzun kelime kesilir; taşan içerik son satırda `…`.
69
+ * @param {string} text
70
+ * @param {number} maxChars
71
+ * @param {number} maxLines
72
+ * @returns {string[]}
73
+ */
74
+ export function wrapText(text, maxChars, maxLines) {
75
+ const words = String(text ?? "")
76
+ .trim()
77
+ .split(/\s+/)
78
+ .filter(Boolean);
79
+ if (!words.length || maxLines < 1 || maxChars < 1) return [];
80
+
81
+ /** @type {string[]} */
82
+ const lines = [];
83
+ let current = "";
84
+ let overflow = false;
85
+
86
+ const pushCurrent = () => {
87
+ if (!current) return;
88
+ lines.push(current);
89
+ current = "";
90
+ };
91
+
92
+ for (const word of words) {
93
+ if (lines.length >= maxLines) {
94
+ overflow = true;
95
+ break;
96
+ }
97
+
98
+ const candidate = current ? `${current} ${word}` : word;
99
+ if (candidate.length <= maxChars) {
100
+ current = candidate;
101
+ continue;
102
+ }
103
+
104
+ pushCurrent();
105
+ if (lines.length >= maxLines) {
106
+ overflow = true;
107
+ break;
108
+ }
109
+
110
+ if (word.length <= maxChars) {
111
+ current = word;
112
+ continue;
113
+ }
114
+
115
+ let rest = word;
116
+ while (rest.length > maxChars) {
117
+ if (lines.length >= maxLines) {
118
+ overflow = true;
119
+ rest = "";
120
+ break;
121
+ }
122
+ lines.push(rest.slice(0, maxChars));
123
+ rest = rest.slice(maxChars);
124
+ }
125
+ current = rest;
126
+ }
127
+
128
+ if (current && lines.length < maxLines) {
129
+ lines.push(current);
130
+ } else if (current) {
131
+ overflow = true;
132
+ }
133
+
134
+ if (overflow && lines.length) {
135
+ const last = lines[lines.length - 1];
136
+ const base = last.endsWith("…") ? last.slice(0, -1) : last;
137
+ const trimmed = base.slice(0, Math.max(1, maxChars - 1));
138
+ lines[lines.length - 1] = `${trimmed}…`;
139
+ }
140
+
141
+ return lines;
142
+ }
143
+
144
+ /**
145
+ * Hazır kart SVG'si. Uygulama kendi SVG'sini vermek isterse `svg` kullanır.
146
+ * @param {OgCardOptions & { width?: number, height?: number }} options
147
+ * @returns {string}
148
+ */
149
+ export function buildOgSvg(options = {}) {
150
+ const width = options.width ?? OG_SIZE.width;
151
+ const height = options.height ?? OG_SIZE.height;
152
+ const background = options.background ?? "#0f172a";
153
+ const color = options.color ?? "#f8fafc";
154
+ const muted = options.mutedColor ?? "#94a3b8";
155
+ const accent = options.accent ?? "#38bdf8";
156
+
157
+ const titleLines = wrapText(options.title ?? "", 28, 3);
158
+ const descLines = wrapText(options.description ?? "", 52, 2);
159
+ const siteName = options.siteName ? escapeXml(options.siteName) : "";
160
+
161
+ const titleTs = titleLines
162
+ .map((line, i) => {
163
+ const dy = i === 0 ? 0 : 72;
164
+ return `<tspan x="80" dy="${dy}">${escapeXml(line)}</tspan>`;
165
+ })
166
+ .join("");
167
+
168
+ const descTs = descLines
169
+ .map((line, i) => {
170
+ const dy = i === 0 ? 0 : 40;
171
+ return `<tspan x="80" dy="${dy}">${escapeXml(line)}</tspan>`;
172
+ })
173
+ .join("");
174
+
175
+ const titleY = 200;
176
+ const descY = titleY + Math.max(titleLines.length, 1) * 72 + 36;
177
+
178
+ return (
179
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
180
+ `<rect width="100%" height="100%" fill="${escapeXml(background)}"/>` +
181
+ `<rect x="0" y="0" width="14" height="${height}" fill="${escapeXml(accent)}"/>` +
182
+ (titleTs
183
+ ? `<text x="80" y="${titleY}" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="64" font-weight="700" fill="${escapeXml(color)}">${titleTs}</text>`
184
+ : "") +
185
+ (descTs
186
+ ? `<text x="80" y="${descY}" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="30" font-weight="400" fill="${escapeXml(muted)}">${descTs}</text>`
187
+ : "") +
188
+ (siteName
189
+ ? `<text x="80" y="${height - 64}" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="24" font-weight="600" fill="${escapeXml(muted)}">${siteName}</text>`
190
+ : "") +
191
+ `</svg>`
192
+ );
193
+ }
194
+
195
+ /**
196
+ * @returns {Promise<((input: Buffer, opts?: object) => import('sharp').Sharp) | null>}
197
+ */
198
+ async function loadSharp() {
199
+ if (sharpModule !== undefined) return sharpModule;
200
+
201
+ /** @type {any} */
202
+ let mod = null;
203
+ try {
204
+ mod = await tryImportFromApp(getConfig().root, "sharp");
205
+ } catch {
206
+ // Config yoksa (birim test) doğrudan çözümle.
207
+ try {
208
+ mod = await import("sharp");
209
+ } catch {
210
+ mod = null;
211
+ }
212
+ }
213
+
214
+ sharpModule = mod?.default ?? mod ?? null;
215
+ return sharpModule;
216
+ }
217
+
218
+ /**
219
+ * SVG veya kart alanlarından PNG/SVG gövde üretir.
220
+ * @param {OgImageOptions} [options]
221
+ * @returns {Promise<OgImageResult>}
222
+ */
223
+ export async function ogImage(options = {}) {
224
+ const width = options.width ?? OG_SIZE.width;
225
+ const height = options.height ?? OG_SIZE.height;
226
+ const svg =
227
+ typeof options.svg === "string" && options.svg.trim()
228
+ ? options.svg
229
+ : buildOgSvg({ ...options, width, height });
230
+
231
+ const preferSvg = options.format === "svg";
232
+ const sharp = preferSvg ? null : await loadSharp();
233
+
234
+ if (!sharp) {
235
+ return {
236
+ body: Buffer.from(svg, "utf8"),
237
+ contentType: "image/svg+xml; charset=utf-8",
238
+ width,
239
+ height,
240
+ };
241
+ }
242
+
243
+ const body = await sharp(Buffer.from(svg, "utf8"))
244
+ .resize(width, height, { fit: "fill" })
245
+ .png()
246
+ .toBuffer();
247
+
248
+ return {
249
+ body,
250
+ contentType: "image/png",
251
+ width,
252
+ height,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Express yanıtına OG görseli basar.
258
+ * @param {import('express').Response} res
259
+ * @param {OgImageOptions} [options]
260
+ * @returns {Promise<OgImageResult>}
261
+ */
262
+ export async function sendOgImage(res, options = {}) {
263
+ const result = await ogImage(options);
264
+ const cacheControl = options.cacheControl ?? DEFAULT_CACHE;
265
+
266
+ res.status(200);
267
+ res.setHeader("Content-Type", result.contentType);
268
+ res.setHeader("Cache-Control", cacheControl);
269
+ res.setHeader("Content-Length", String(result.body.length));
270
+ // Kazıyıcılar ve CDN'ler için boyut ipucu (meta ile de verilir).
271
+ res.setHeader("X-Og-Width", String(result.width));
272
+ res.setHeader("X-Og-Height", String(result.height));
273
+ res.end(result.body);
274
+ return result;
275
+ }
276
+
277
+ /**
278
+ * Next `opengraph-image` route handler'ına yakın Express sarmalayıcı.
279
+ *
280
+ * Factory `null` dönerse veya `notFound()` fırlatırsa 404.
281
+ *
282
+ * @param {(ctx: { params: Record<string, string>, query: import('express').Request['query'], req: import('express').Request }) =>
283
+ * OgImageOptions | null | Promise<OgImageOptions | null>} factory
284
+ * @param {OgImageOptions} [defaults] Her istekte birleşen varsayılanlar
285
+ * @returns {import('express').RequestHandler}
286
+ */
287
+ export function ogHandler(factory, defaults = {}) {
288
+ return async (req, res, next) => {
289
+ try {
290
+ const result = await factory({
291
+ params: req.params ?? {},
292
+ query: req.query,
293
+ req,
294
+ });
295
+ if (result == null) {
296
+ res.status(404).end();
297
+ return;
298
+ }
299
+ await sendOgImage(res, { ...defaults, ...result });
300
+ } catch (error) {
301
+ if (isNotFoundError(error)) {
302
+ res.status(404).end();
303
+ return;
304
+ }
305
+ next(error);
306
+ }
307
+ };
308
+ }
309
+
310
+ /**
311
+ * Next.js `new ImageResponse(...)` DX'si. JSX yok — ilk argüman SVG string
312
+ * veya kart alanları nesnesi.
313
+ *
314
+ * @example
315
+ * ```js
316
+ * return new ImageResponse(
317
+ * { title: post.title, description: post.excerpt, siteName: "Blog" },
318
+ * { width: 1200, height: 630 },
319
+ * );
320
+ * // handler içinde: await image.send(res)
321
+ * ```
322
+ */
323
+ export class ImageResponse {
324
+ /** @type {OgImageOptions} */
325
+ #options;
326
+
327
+ /**
328
+ * @param {string | OgCardOptions} element
329
+ * @param {Omit<OgImageOptions, keyof OgCardOptions | 'svg'> & { width?: number, height?: number }} [init]
330
+ */
331
+ constructor(element, init = {}) {
332
+ if (typeof element === "string") {
333
+ this.#options = { ...init, svg: element };
334
+ } else {
335
+ this.#options = { ...element, ...init };
336
+ }
337
+ }
338
+
339
+ /** @returns {OgImageOptions} */
340
+ get options() {
341
+ return this.#options;
342
+ }
343
+
344
+ /** @returns {Promise<OgImageResult>} */
345
+ async buffer() {
346
+ return ogImage(this.#options);
347
+ }
348
+
349
+ /**
350
+ * @param {import('express').Response} res
351
+ * @returns {Promise<OgImageResult>}
352
+ */
353
+ async send(res) {
354
+ return sendOgImage(res, this.#options);
355
+ }
356
+ }
@@ -19,7 +19,7 @@
19
19
  import process from "node:process";
20
20
  import { getConfig, hook } from "../config/index.js";
21
21
  import { getRequestContext } from "../http/request-context.js";
22
- import { isHtmlCacheFresh, takeInvalidatedPaths } from "./html-cache.js";
22
+ import { isHtmlCacheFresh, takeInvalidatedPaths, startEarlyExpirySweep } from "./html-cache.js";
23
23
  import { getDataCacheStats } from "./data-cache.js";
24
24
  import { isTransientStatus } from "./upstream-tracking.js";
25
25
  import { upstreamCooldownMs } from "./upstream-limiter.js";
@@ -786,7 +786,8 @@ async function drainVisitWarm() {
786
786
 
787
787
  /**
788
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.
789
+ * `onVisit` modunda klasik zamanlayıcı yok; yine de erken-TTL invalidation
790
+ * drain'i çalışır — soft-bayatlayan sweeper'ın kuyruğu boşalmasın.
790
791
  *
791
792
  * @param {{ port: number }} options
792
793
  * @returns {void}
@@ -797,6 +798,11 @@ export function startPrewarm({ port }) {
797
798
 
798
799
  const origin = `http://127.0.0.1:${port}`;
799
800
 
801
+ // Klasik `prewarmPaths` olmasa da TTL öncesi soft-bayatlayan girdiler
802
+ // HTTP ile ısıtılsın. `PREWARM=0` yukarıda her şeyi keser.
803
+ startEarlyExpirySweep();
804
+ startExpiryWarmDrain(origin);
805
+
800
806
  if (config.prewarm?.onVisit?.enabled) {
801
807
  const classicEnv = CLASSIC_PREWARM_ENV.filter((key) => process.env[key]);
802
808
  if (classicEnv.length) {
@@ -814,7 +820,8 @@ export function startPrewarm({ port }) {
814
820
  }
815
821
 
816
822
  if (process.env.PREWARM !== "1" && config.prewarm?.enabled === false) return;
817
- // Isıtacak yol bildirmeyen bir projede zamanlayıcı kurmanın anlamı yok.
823
+ // Isıtacak yol bildirmeyen bir projede klasik tur zamanlayıcısı gerekmez;
824
+ // expiry drain yine de yukarıda kuruldu.
818
825
  if (typeof config.hooks?.prewarmPaths !== "function") return;
819
826
 
820
827
  const isDev = process.env.NODE_ENV === "development";
@@ -848,3 +855,50 @@ export function startPrewarm({ port }) {
848
855
  const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
849
856
  if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
850
857
  }
858
+
859
+ /** @type {string | null} */
860
+ let expiryOrigin = null;
861
+
862
+ /** @type {boolean} */
863
+ let expiryDraining = false;
864
+
865
+ /** @type {ReturnType<typeof setInterval> | null} */
866
+ let expiryDrainTimer = null;
867
+
868
+ /**
869
+ * Soft-bayat / invalidate kuyruğunu periyodik boşaltır. Klasik tur ve onVisit
870
+ * aynı kuyruğu da okur; bu drain `prewarmPaths` yokken de çalışır.
871
+ *
872
+ * @param {string} origin
873
+ * @returns {void}
874
+ */
875
+ function startExpiryWarmDrain(origin) {
876
+ expiryOrigin = origin;
877
+ if (expiryDrainTimer) return;
878
+ expiryDrainTimer = setInterval(() => {
879
+ void drainExpiryWarm();
880
+ }, 1000);
881
+ expiryDrainTimer.unref();
882
+ }
883
+
884
+ /**
885
+ * @returns {Promise<void>}
886
+ */
887
+ async function drainExpiryWarm() {
888
+ if (expiryDraining || !expiryOrigin) return;
889
+
890
+ const paths = takeInvalidatedPaths();
891
+ if (!paths.length) return;
892
+
893
+ expiryDraining = true;
894
+ try {
895
+ const cold = paths.filter((path) => !isHtmlCacheFresh(path));
896
+ if (!cold.length) return;
897
+
898
+ await prewarm({ origin: expiryOrigin, paths: cold, quiet: true });
899
+ } catch (error) {
900
+ console.error("[prewarm] expiry warm failed", error);
901
+ } finally {
902
+ expiryDraining = false;
903
+ }
904
+ }
@@ -14,8 +14,9 @@
14
14
  *
15
15
  * Modül sözleşmesi: default export ya da `register` adlı named export,
16
16
  * `(app, api) => void | Promise<void>` imzasıyla. `api` içinde `route`,
17
- * `fragment`, `renderView`, `renderPage` ve `notFound`/`redirect` hazır gelir,
18
- * böylece route dosyaları framework'ten tek tek import yapmak zorunda kalmaz.
17
+ * `fragment`, `renderView`, `renderPage`, `notFound`/`redirect` ve
18
+ * `ogHandler` hazır gelir, böylece route dosyaları framework'ten tek tek
19
+ * import yapmak zorunda kalmaz.
19
20
  */
20
21
  import fs from "node:fs";
21
22
  import path from "node:path";
@@ -29,6 +30,7 @@ import {
29
30
  redirect,
30
31
  seeOther,
31
32
  } from "../http/control-flow.js";
33
+ import { ImageResponse, ogHandler, ogImage, sendOgImage } from "./og-image.js";
32
34
 
33
35
  const isDev = process.env.NODE_ENV === "development";
34
36
 
@@ -96,6 +98,10 @@ const api = {
96
98
  redirect,
97
99
  permanentRedirect,
98
100
  seeOther,
101
+ ogHandler,
102
+ ogImage,
103
+ sendOgImage,
104
+ ImageResponse,
99
105
  };
100
106
 
101
107
  /**