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 +10 -0
- package/docs/03-routing.md +4 -0
- package/docs/04-render-ve-sablonlar.md +58 -0
- package/docs/06-cache.md +16 -2
- package/docs/07-yapilandirma.md +3 -1
- package/docs/11-tasima.md +2 -0
- package/docs/README.md +3 -2
- package/docs/en/03-routing.md +4 -0
- package/docs/en/04-rendering.md +58 -0
- package/docs/en/06-caching.md +16 -2
- package/docs/en/07-configuration.md +3 -1
- package/docs/en/11-migration.md +2 -0
- package/docs/en/README.md +2 -2
- package/package.json +1 -1
- package/src/index.js +10 -0
- package/src/server/html-cache.js +144 -9
- package/src/server/og-image.js +356 -0
- package/src/server/prewarm.js +57 -3
- package/src/server/router.js +8 -2
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
|
package/docs/03-routing.md
CHANGED
|
@@ -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
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -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,
|
|
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
|
package/docs/en/03-routing.md
CHANGED
|
@@ -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
|
|
package/docs/en/04-rendering.md
CHANGED
|
@@ -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
|
package/docs/en/06-caching.md
CHANGED
|
@@ -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
|
package/docs/en/11-migration.md
CHANGED
|
@@ -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
|
|
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.
|
|
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 {
|
package/src/server/html-cache.js
CHANGED
|
@@ -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
|
-
|
|
195
|
-
|
|
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
|
|
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
|
-
|
|
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, "&")
|
|
61
|
+
.replace(/</g, "<")
|
|
62
|
+
.replace(/>/g, ">")
|
|
63
|
+
.replace(/"/g, """)
|
|
64
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/src/server/prewarm.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/server/router.js
CHANGED
|
@@ -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
|
|
18
|
-
* böylece route dosyaları framework'ten tek tek
|
|
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
|
/**
|