jskelet 0.4.6 → 0.4.8

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.
@@ -1,31 +1,43 @@
1
1
  /**
2
- * Sunucu açılışında sayfaları önden render edip HTML cache'ini doldurur.
2
+ * Sunucu açılışında veya ziyaret sırasında sayfaları önden render edip HTML
3
+ * cache'ini doldurur.
4
+ *
5
+ * İki karşılıklı dışlayan mod:
6
+ *
7
+ * 1. **Klasik** — `hooks.prewarmPaths()` listesi, açılış/`intervalSeconds`
8
+ * turları, `priority` / `rotate` / `max`.
9
+ * 2. **onVisit** — herkese açık bir sayfa servis edilince HTML'deki
10
+ * aynı-origin linkler kuyruğa alınır; tıklanabilir komşular ısınır.
3
11
  *
4
12
  * Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz:
5
13
  * HTML cache süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca
6
- * yapılır. Kazanç aynı — ilk ziyaretçi soğuk render'ı beklemez — fakat veri
7
- * dondurulmaz: her girdi route'un `revalidate` süresiyle yaşlanır ve
8
- * stale-while-revalidate ile arkada tazelenir.
14
+ * (veya trafik geldikçe) yapılır.
9
15
  *
10
16
  * Isıtma gerçek HTTP istekleriyle yapılır: cache anahtarı, sıkıştırma ve
11
- * middleware zinciri normal trafikle bire bir aynı olsun. Hangi yolların
12
- * ısıtılacağını uygulama `hooks.prewarmPaths()` ile bildirir; genelde
13
- * sitemap üreten fonksiyonun aynısıdır.
14
- *
15
- * On binlerce yolluk bir sitede tur bir "damla damla" tarayıcıya dönüşür:
16
- * `priority` desenleri her turda başa alınır, geri kalan kuyruk turlar
17
- * arasında kaldığı yerden devam eder (`rotate`) ve `rps` toplam hızı upstream
18
- * kotasının altında tutar. Amaç, kimse gelmese bile hiçbir sayfanın soğuk
19
- * kalmaması — ama bunu API'yi düşürmeden yapmak.
17
+ * middleware zinciri normal trafikle bire bir aynı olsun.
20
18
  */
21
19
  import process from "node:process";
22
20
  import { getConfig, hook } from "../config/index.js";
23
21
  import { getRequestContext } from "../http/request-context.js";
24
- import { takeInvalidatedPaths } from "./html-cache.js";
22
+ import { isHtmlCacheFresh, takeInvalidatedPaths } from "./html-cache.js";
25
23
  import { getDataCacheStats } from "./data-cache.js";
26
24
  import { isTransientStatus } from "./upstream-tracking.js";
27
25
  import { upstreamCooldownMs } from "./upstream-limiter.js";
28
26
 
27
+ /** Klasik turu yöneten env'ler; `onVisit` ile birlikte yasak. */
28
+ const CLASSIC_PREWARM_ENV = [
29
+ "PREWARM_MAX",
30
+ "PREWARM_INTERVAL_SECONDS",
31
+ "PREWARM_DELAY_MS",
32
+ "PREWARM_RETRY_DELAY_MS",
33
+ ];
34
+
35
+ /**
36
+ * onVisit turu sürerken concurrency/rps. Klasik moda dokunmaz (`null`).
37
+ * @type {{ concurrency: number | null, rps: number | null }}
38
+ */
39
+ const visitWarmSettings = { concurrency: null, rps: null };
40
+
29
41
  /**
30
42
  * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
31
43
  * üretimde kimse okumazsa da maliyeti bir nesnedir.
@@ -127,6 +139,15 @@ function num(value, fallback) {
127
139
  * @returns {number}
128
140
  */
129
141
  function setting(envKey, configKey, fallback) {
142
+ if (
143
+ visitWarmSettings.concurrency != null &&
144
+ configKey === "concurrency"
145
+ ) {
146
+ return visitWarmSettings.concurrency;
147
+ }
148
+ if (visitWarmSettings.rps != null && configKey === "rps") {
149
+ return visitWarmSettings.rps;
150
+ }
130
151
  return num(process.env[envKey], num(getConfig().prewarm?.[configKey], fallback));
131
152
  }
132
153
 
@@ -421,13 +442,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
421
442
 
422
443
  // Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
423
444
  // için yarışmasın.
424
- const concurrency = setting("PREWARM_CONCURRENCY", "concurrency", isDev ? 1 : 4);
445
+ const concurrency = setting(
446
+ "PREWARM_CONCURRENCY",
447
+ "concurrency",
448
+ isDev ? 1 : 4,
449
+ );
425
450
 
426
451
  // Render tek bir olay döngüsünde çalışıyor: aralıksız bir tur, geliştirme
427
452
  // sırasında sayfa isteklerini ve dev panelinin kanalını arkasında bekletiyor.
428
453
  // Dev'de varsayılan bir hız freni bu yüzden var; üretimde ısıtma bir kez
429
454
  // olup bittiği için fren yalnızca istenirse (`prewarm.rps`) devreye girer.
430
- const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, isDev ? 4 : 0));
455
+ // onVisit turlarında `visitWarmSettings.rps` `setting()` üzerinden iner.
456
+ const rps = setting("PREWARM_RPS", "rps", isDev ? 4 : 0);
431
457
  const pace = createPacer(rps);
432
458
 
433
459
  const all = only?.length ? only : await collectPaths();
@@ -554,8 +580,213 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
554
580
  }
555
581
 
556
582
  /**
557
- * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır; isteğe
558
- * bağlı olarak periyodik tekrarlar. Hiçbir hata süreci düşürmez.
583
+ * Speculation Rules `href_matches` benzeri dışlama: tam yol veya `/*` öneki.
584
+ *
585
+ * @param {string} pathname
586
+ * @param {string[]} patterns
587
+ * @returns {boolean}
588
+ */
589
+ function matchesHrefExclude(pathname, patterns) {
590
+ for (const pattern of patterns) {
591
+ if (typeof pattern !== "string" || !pattern) continue;
592
+ if (pattern.endsWith("/*")) {
593
+ const base = pattern.slice(0, -2);
594
+ if (pathname === base || pathname.startsWith(`${base}/`)) return true;
595
+ continue;
596
+ }
597
+ if (pathname === pattern) return true;
598
+ }
599
+ return false;
600
+ }
601
+
602
+ /**
603
+ * HTML içindeki aynı-origin `<a href>` yollarını DOM sırasıyla (üstten alta)
604
+ * toplar. Speculation Rules ile aynı muafiyetler: `nofollow`, `_blank`,
605
+ * `data-no-prefetch`, `prewarmSkip`, `navigation.exclude`.
606
+ *
607
+ * @param {string} html
608
+ * @param {{ limit?: number, basePath?: string }} [options]
609
+ * @returns {string[]}
610
+ */
611
+ export function extractSameOriginLinks(html, options = {}) {
612
+ if (typeof html !== "string" || !html) return [];
613
+
614
+ const limit = Math.max(1, Math.floor(Number(options.limit) || 20));
615
+ const { prewarmSkip, navigation } = getConfig();
616
+ const exclude = navigation?.exclude ?? [];
617
+
618
+ /** @type {string[]} */
619
+ const links = [];
620
+ const seen = new Set();
621
+ const tagRe = /<a\b([^>]*)>/gi;
622
+ let match;
623
+
624
+ while ((match = tagRe.exec(html)) !== null && links.length < limit) {
625
+ const attrs = match[1];
626
+ if (/\btarget\s*=\s*(?:"_blank"|'_blank'|_blank)(?=[\s>]|$)/i.test(attrs)) {
627
+ continue;
628
+ }
629
+ if (/\bdata-no-prefetch\b/i.test(attrs)) continue;
630
+
631
+ const relMatch = attrs.match(
632
+ /\brel\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i,
633
+ );
634
+ const rel = relMatch?.[1] ?? relMatch?.[2] ?? relMatch?.[3] ?? "";
635
+ if (/\bnofollow\b/i.test(rel)) continue;
636
+
637
+ const hrefMatch = attrs.match(
638
+ /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i,
639
+ );
640
+ const href = hrefMatch?.[1] ?? hrefMatch?.[2] ?? hrefMatch?.[3];
641
+ if (!href) continue;
642
+
643
+ const path = normalizeWarmPath(href, options.basePath);
644
+ if (!path) continue;
645
+ if (prewarmSkip.some((prefix) => path.startsWith(prefix))) continue;
646
+ if (matchesHrefExclude(path, exclude)) continue;
647
+ if (seen.has(path)) continue;
648
+
649
+ seen.add(path);
650
+ links.push(path);
651
+ }
652
+
653
+ return links;
654
+ }
655
+
656
+ /**
657
+ * @param {string} href
658
+ * @param {string} [basePath] Ziyaret edilen sayfa; göreli href çözümü için.
659
+ * @returns {string | null}
660
+ */
661
+ function normalizeWarmPath(href, basePath = "/") {
662
+ const trimmed = href.trim();
663
+ if (!trimmed || trimmed.startsWith("#")) return null;
664
+ if (/^(mailto|tel|javascript|data):/i.test(trimmed)) return null;
665
+
666
+ try {
667
+ const basePathname = basePath.startsWith("/") ? basePath : `/${basePath}`;
668
+ const url = new URL(trimmed, `https://warm.invalid${basePathname}`);
669
+ // Dış origin veya `https://…` mutlak linkler elenir; yalnızca site-içi
670
+ // path / göreli href ısınır.
671
+ if (url.origin !== "https://warm.invalid") return null;
672
+ return url.pathname || "/";
673
+ } catch {
674
+ return null;
675
+ }
676
+ }
677
+
678
+ /** @type {string | null} */
679
+ let visitOrigin = null;
680
+ /** @type {string[]} */
681
+ const visitPending = [];
682
+ /** @type {Set<string>} */
683
+ const visitQueued = new Set();
684
+ let visitDraining = false;
685
+
686
+ /**
687
+ * Ziyaret ısıtması açık mı? `PREWARM=0` her iki modu da keser.
688
+ *
689
+ * @returns {boolean}
690
+ */
691
+ export function isOnVisitPrewarm() {
692
+ if (process.env.PREWARM === "0") return false;
693
+ try {
694
+ return getConfig().prewarm?.onVisit?.enabled === true;
695
+ } catch {
696
+ return false;
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Yanıt gövdesindeki linkleri soğuksa kuyruğa alır. İstek yolunu bloklamamak
702
+ * için `route()` bunu `queueMicrotask` ile çağırır.
703
+ *
704
+ * @param {string} html
705
+ * @param {{ path: string, req?: { get?: (name: string) => string | undefined,
706
+ * headers?: Record<string, unknown> } }} context
707
+ * @returns {void}
708
+ */
709
+ export function noteVisitWarm(html, context) {
710
+ if (!isOnVisitPrewarm() || !visitOrigin) return;
711
+ if (!context?.path || typeof html !== "string") return;
712
+
713
+ const ua =
714
+ context.req?.get?.("user-agent") ??
715
+ /** @type {string | undefined} */ (context.req?.headers?.["user-agent"]);
716
+ if (ua && ua === getConfig().brand.prewarmUserAgent) return;
717
+
718
+ const perPage = Number(getConfig().prewarm?.onVisit?.perPage) || 20;
719
+ const links = extractSameOriginLinks(html, {
720
+ limit: perPage,
721
+ basePath: context.path,
722
+ });
723
+
724
+ for (const path of links) {
725
+ if (path === context.path) continue;
726
+ if (isHtmlCacheFresh(path)) continue;
727
+ if (visitQueued.has(path)) continue;
728
+ visitQueued.add(path);
729
+ visitPending.push(path);
730
+ }
731
+
732
+ enqueueInvalidatedForVisit();
733
+ if (visitPending.length) void drainVisitWarm();
734
+ }
735
+
736
+ /**
737
+ * Invalidate edilmiş yolları da kuyruğa alır (klasik turdaki
738
+ * `takeInvalidatedPaths` karşılığı).
739
+ *
740
+ * @returns {void}
741
+ */
742
+ function enqueueInvalidatedForVisit() {
743
+ for (const path of takeInvalidatedPaths()) {
744
+ if (visitQueued.has(path)) continue;
745
+ visitQueued.add(path);
746
+ visitPending.push(path);
747
+ }
748
+ }
749
+
750
+ async function drainVisitWarm() {
751
+ if (visitDraining || !visitOrigin) return;
752
+ visitDraining = true;
753
+
754
+ const onVisit = getConfig().prewarm?.onVisit ?? {};
755
+
756
+ try {
757
+ while (visitPending.length) {
758
+ enqueueInvalidatedForVisit();
759
+
760
+ const batch = visitPending.splice(0, 32);
761
+ for (const path of batch) visitQueued.delete(path);
762
+
763
+ const cold = batch.filter((path) => !isHtmlCacheFresh(path));
764
+ if (!cold.length) continue;
765
+
766
+ // Klasik `prewarm()` turunu yeniden kullan: retry, progress, UA aynı.
767
+ // onVisit.concurrency / rps verilmişse `visitWarmSettings` ile iner;
768
+ // yoksa `prewarm()` kendi (env → config → isDev) zincirine düşer.
769
+ visitWarmSettings.concurrency = onVisit.concurrency;
770
+ visitWarmSettings.rps = onVisit.rps;
771
+
772
+ try {
773
+ await prewarm({ origin: visitOrigin, paths: cold, quiet: true });
774
+ } catch (error) {
775
+ console.error("[prewarm] onVisit warm failed", error);
776
+ } finally {
777
+ visitWarmSettings.concurrency = null;
778
+ visitWarmSettings.rps = null;
779
+ }
780
+ }
781
+ } finally {
782
+ visitDraining = false;
783
+ if (visitPending.length) void drainVisitWarm();
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır.
789
+ * `onVisit` modunda zamanlayıcı yok: yalnızca origin kaydı ve kuyruk.
559
790
  *
560
791
  * @param {{ port: number }} options
561
792
  * @returns {void}
@@ -563,12 +794,30 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
563
794
  export function startPrewarm({ port }) {
564
795
  const config = getConfig();
565
796
  if (process.env.PREWARM === "0") return;
797
+
798
+ const origin = `http://127.0.0.1:${port}`;
799
+
800
+ if (config.prewarm?.onVisit?.enabled) {
801
+ const classicEnv = CLASSIC_PREWARM_ENV.filter((key) => process.env[key]);
802
+ if (classicEnv.length) {
803
+ throw new Error(
804
+ "[prewarm] onVisit mode cannot be used with " +
805
+ `${classicEnv.join(", ")}. Those env vars belong to classic prewarm.`,
806
+ );
807
+ }
808
+
809
+ visitOrigin = origin;
810
+ console.log(
811
+ `[prewarm] onVisit mode — warming links from each public page response`,
812
+ );
813
+ return;
814
+ }
815
+
566
816
  if (process.env.PREWARM !== "1" && config.prewarm?.enabled === false) return;
567
817
  // Isıtacak yol bildirmeyen bir projede zamanlayıcı kurmanın anlamı yok.
568
818
  if (typeof config.hooks?.prewarmPaths !== "function") return;
569
819
 
570
820
  const isDev = process.env.NODE_ENV === "development";
571
- const origin = `http://127.0.0.1:${port}`;
572
821
 
573
822
  // Hız frenli bir tur `intervalSeconds`'tan uzun sürebilir; üst üste binen
574
823
  // turlar `prewarmProgress`'i bozar ve upstream'e iki kat yük bindirir.
@@ -41,7 +41,7 @@ import * as html from "../views/helpers/html.js";
41
41
  import * as tags from "../views/helpers/tags.js";
42
42
  import { loadComponents } from "../views/components/loader.js";
43
43
  import { renderStatusPage } from "./status-page.js";
44
- import { suppressForPrewarm } from "./prewarm.js";
44
+ import { suppressForPrewarm, noteVisitWarm } from "./prewarm.js";
45
45
  import {
46
46
  ensureTemplatesCompiled,
47
47
  getComponentDirs,
@@ -304,7 +304,11 @@ export function route(controller, options = {}) {
304
304
  const isPrivate = options.private === true;
305
305
 
306
306
  return async (req, res, next) => {
307
- const context = createRequestContext({ private: isPrivate, res });
307
+ const context = createRequestContext({
308
+ private: isPrivate,
309
+ res,
310
+ pathname: req.path,
311
+ });
308
312
  const ctx = {
309
313
  params: req.params ?? {},
310
314
  query: req.query ?? {},
@@ -395,6 +399,17 @@ export function route(controller, options = {}) {
395
399
  // ETag kişiye özel HTML için kullanıcıya özgü bir doğrulayıcıdır ve
396
400
  // `no-store` ile birlikte hiçbir işe yaramaz; üretilmesi engellenir.
397
401
  await sendHtml(req, res, result.html, result.encoded, { etag: publicCache });
402
+
403
+ // onVisit: yanıt gittikten sonra linkleri kuyruğa al — TTFB'yi şişirmez.
404
+ // Yalnızca herkese açık, önbelleklenebilir 200 HTML; private / degraded
405
+ // sayfadaki linkler kişiye özel veya eksik olabilir.
406
+ if (publicCache && result.status === 200 && result.html) {
407
+ const pagePath = req.path;
408
+ const pageHtml = result.html;
409
+ queueMicrotask(() => {
410
+ noteVisitWarm(pageHtml, { path: pagePath, req });
411
+ });
412
+ }
398
413
  } catch (error) {
399
414
  if (isRedirectError(error)) {
400
415
  // Oturuma bağlı bir yönlendirme de kişiye özeldir: "giriş yapmalısın"
@@ -431,7 +446,11 @@ export function route(controller, options = {}) {
431
446
  */
432
447
  export function fragment(controller) {
433
448
  return async (req, res, next) => {
434
- const context = createRequestContext({ private: true, res });
449
+ const context = createRequestContext({
450
+ private: true,
451
+ res,
452
+ pathname: req.path,
453
+ });
435
454
  const ctx = {
436
455
  params: req.params ?? {},
437
456
  query: req.query ?? {},