jskelet 0.1.4 → 0.1.6

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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  const BASE = "/__jskelet/dev";
14
+ /** Soket kurulamadığında düşülen yedek yoklama sıklığı. */
14
15
  const POLL_MS = 2000;
15
16
  const MAX_ERRORS = 100;
16
17
 
@@ -386,50 +387,112 @@ let restarts = 0;
386
387
  /* -------------------------------------------------------- canlı yenileme */
387
388
 
388
389
  /**
389
- * Sunucu olay akışı. Amaç titremeyi bitirmek: CSS değiştiğinde sayfa
390
- * yenilenmez, yalnızca stylesheet yeni sürümle takas edilir. Sunucu yeniden
391
- * başladığında (boot kimliği değişince) tek sefer tam yenileme yapılır;
392
- * overlay durumu sekme belleğinde durduğu için panel açık kalmaya devam eder.
390
+ * Sunucudan gelen her şey tek bir WebSocket üzerinden akar: istatistikler,
391
+ * CSS takası ve yeniden başlatma bildirimi. Eskiden istatistikler iki saniyede
392
+ * bir çekiliyordu; her açık sekme, panel kapalıyken bile sunucuya sürekli
393
+ * istek atıyordu.
394
+ *
395
+ * CSS değiştiğinde sayfa yenilenmez, yalnızca stylesheet yeni sürümle takas
396
+ * edilir. Sunucu yeniden başladığında (boot kimliği değişince) tek sefer tam
397
+ * yenileme yapılır; overlay durumu sekme belleğinde durduğu için panel açık
398
+ * kalmaya devam eder.
393
399
  */
394
- function connectEvents() {
395
- const source = new EventSource(`${BASE}/events`);
400
+ function connectSocket() {
401
+ let socket;
402
+ try {
403
+ socket = new WebSocket(
404
+ `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${BASE}/ws`,
405
+ );
406
+ } catch {
407
+ startFallback();
408
+ return;
409
+ }
396
410
 
397
- source.addEventListener("message", (event) => {
398
- /** @type {{ type: string, boot?: string, href?: string }} */
399
- const payload = JSON.parse(event.data);
411
+ // Soket hiç açılamazsa (proxy WebSocket'i geçirmiyor olabilir) eski
412
+ // SSE + yoklama yoluna düşülür; dev akışı bir ara katman yüzünden körelmesin.
413
+ let opened = false;
400
414
 
401
- if (payload.type === "hello") {
402
- offline = false;
403
- const previous = sessionStorage.getItem(BOOT_KEY);
404
- sessionStorage.setItem(BOOT_KEY, payload.boot);
415
+ socket.addEventListener("open", () => {
416
+ opened = true;
417
+ });
405
418
 
406
- if (previous && previous !== payload.boot) {
407
- restarts += 1;
408
- location.reload();
409
- return;
410
- }
419
+ socket.addEventListener("message", (event) => {
420
+ handleServerMessage(JSON.parse(event.data));
421
+ });
411
422
 
412
- bootId = payload.boot;
413
- render();
423
+ socket.addEventListener("close", () => {
424
+ if (!opened) {
425
+ startFallback();
414
426
  return;
415
427
  }
416
428
 
417
- if (payload.type === "css") {
418
- swapStylesheet(payload.href);
429
+ // Sunucu yeniden başlıyor: gösterge "bağlantı yok"a döner ve kısa aralıkla
430
+ // yeniden denenir. Açılışta gelen `hello` yeniden başlatmayı bildirir.
431
+ if (!offline) {
432
+ offline = true;
433
+ render();
434
+ }
435
+ setTimeout(connectSocket, 500);
436
+ });
437
+ }
438
+
439
+ /**
440
+ * Hem soketten hem yedek SSE akışından gelen paketler burada işlenir.
441
+ * @param {{ type: string, boot?: string, href?: string }} payload
442
+ */
443
+ function handleServerMessage(payload) {
444
+ if (payload.type === "stats") {
445
+ applyStats(payload);
446
+ return;
447
+ }
448
+
449
+ if (payload.type === "hello") {
450
+ offline = false;
451
+ const previous = sessionStorage.getItem(BOOT_KEY);
452
+ sessionStorage.setItem(BOOT_KEY, payload.boot);
453
+
454
+ if (previous && previous !== payload.boot) {
455
+ restarts += 1;
456
+ location.reload();
419
457
  return;
420
458
  }
421
459
 
422
- if (payload.type === "reload") location.reload();
423
- });
460
+ bootId = payload.boot;
461
+ render();
462
+ return;
463
+ }
464
+
465
+ if (payload.type === "css") {
466
+ swapStylesheet(payload.href);
467
+ return;
468
+ }
469
+
470
+ if (payload.type === "reload") location.reload();
471
+ }
472
+
473
+ /**
474
+ * WebSocket kurulamadığında eski yol: SSE + periyodik yoklama. Bir kez
475
+ * başlatılır.
476
+ */
477
+ let fallbackStarted = false;
424
478
 
479
+ function startFallback() {
480
+ if (fallbackStarted) return;
481
+ fallbackStarted = true;
482
+
483
+ const source = new EventSource(`${BASE}/events`);
484
+ source.addEventListener("message", (event) =>
485
+ handleServerMessage(JSON.parse(event.data)),
486
+ );
425
487
  source.addEventListener("error", () => {
426
- // Sunucu yeniden başlarken bağlantı düşer; EventSource kendi kendine
427
- // yeniden bağlanır, biz yalnızca göstergeyi güncelleriz.
428
488
  if (!offline) {
429
489
  offline = true;
430
490
  render();
431
491
  }
432
492
  });
493
+
494
+ setInterval(pollServer, POLL_MS);
495
+ pollServer();
433
496
  }
434
497
 
435
498
  /**
@@ -446,24 +509,30 @@ function swapStylesheet(href) {
446
509
  current.after(next);
447
510
  }
448
511
 
512
+ /**
513
+ * Sunucudan gelen istatistik paketini panele işler.
514
+ * @param {object} stats
515
+ */
516
+ function applyStats(stats) {
517
+ // Süreç kimliği değiştiyse sunucu yeniden başlamıştır. Overlay kapanmaz,
518
+ // yalnızca sayacı artar; günlükler sunucuda kalıcı olduğu için de silinmez.
519
+ if (bootId && stats.boot !== bootId) restarts += 1;
520
+ bootId = stats.boot ?? bootId;
521
+
522
+ offline = false;
523
+ serverStats = stats;
524
+ render();
525
+ }
526
+
527
+ /** Yalnızca yedek yolda kullanılır; canlı veri soketten gelir. */
449
528
  async function pollServer() {
529
+ if (!fallbackStarted) return;
530
+
450
531
  try {
451
532
  const response = await fetch(`${BASE}/stats`, { cache: "no-store" });
452
533
  if (!response.ok) return;
453
534
 
454
- const stats = await response.json();
455
-
456
- // Süreç kimliği değiştiyse sunucu yeniden başlamıştır. Overlay kapanmaz,
457
- // yalnızca sayacı artar; günlükler sunucuda kalıcı olduğu için de silinmez.
458
- if (bootId && stats.boot !== bootId) restarts += 1;
459
- bootId = stats.boot ?? bootId;
460
-
461
- offline = false;
462
- serverStats = stats;
463
- render();
464
-
465
- // Isıtma sürerken sayaç akıcı görünsün diye yoklama sıklaşır.
466
- if (stats.prewarm?.active) setTimeout(pollServer, 600);
535
+ applyStats(await response.json());
467
536
  } catch {
468
537
  // Yeniden başlatma penceresi: eldeki veriler korunur, yalnızca durum
469
538
  // göstergesi "bağlantı yok"a döner.
@@ -1763,13 +1832,9 @@ function start() {
1763
1832
  bind(ensureRoot());
1764
1833
  render();
1765
1834
 
1766
- connectEvents();
1767
-
1768
- setInterval(() => {
1769
- // Panel kapalıyken de rozet güncel kalsın diye sunucu yine yoklanır.
1770
- pollServer();
1771
- }, POLL_MS);
1772
- pollServer();
1835
+ // Panel kapalıyken de rozet güncel kalsın diye kanal her zaman açılır;
1836
+ // maliyeti tek bir bağlantı ve yalnızca değişiklik oldukça gelen paketler.
1837
+ connectSocket();
1773
1838
 
1774
1839
  // Ölçümler oturmadan gönderilmesin; sonra sekmeden ayrılırken güncellenir.
1775
1840
  setTimeout(() => sendPageReport(), 3000);
@@ -70,6 +70,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
70
70
  * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
71
71
  * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
72
72
  * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
73
+ * @property {boolean} trackDependencies Render'ın okuduğu veri anahtarları kaydedilsin mi.
73
74
  * @property {{ attempts: number, delayMs: number }} transientRetry
74
75
  * @property {Record<string, unknown>} prewarm
75
76
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
@@ -210,6 +211,7 @@ function normalizePriority(raw) {
210
211
  * @param {unknown} raw
211
212
  * @returns {{ html: ResolvedConfig["html"], htmlMaxEntries: number,
212
213
  * data: Record<string, unknown>, trackUpstream: boolean,
214
+ * trackDependencies: boolean,
213
215
  * transientRetry: { attempts: number, delayMs: number },
214
216
  * prewarm: Record<string, unknown>,
215
217
  * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
@@ -238,6 +240,10 @@ function normalizeCache(raw) {
238
240
  // Otomatik upstream izleme kapatılabilir olmalı: `fetch`i kendisi saran
239
241
  // bir uygulama (ölçüm, retry, circuit breaker) çakışma yaşayabilir.
240
242
  trackUpstream: raw?.trackUpstream !== false,
243
+ // Hangi sayfanın hangi veri anahtarını okuduğu kaydedilsin mi.
244
+ // `withDataCache` kullanmayan bir uygulamada kaydedilecek bir şey yok;
245
+ // kapatmak bağlam kurma maliyetini de kaldırır.
246
+ trackDependencies: raw?.trackDependencies !== false,
241
247
  transientRetry:
242
248
  raw?.transientRetry === false
243
249
  ? { attempts: 0, delayMs: 0 }
@@ -457,6 +463,7 @@ export async function loadConfig(options = {}) {
457
463
  htmlMaxEntries,
458
464
  data,
459
465
  trackUpstream,
466
+ trackDependencies,
460
467
  transientRetry,
461
468
  prewarm,
462
469
  prewarmPriority,
@@ -475,6 +482,7 @@ export async function loadConfig(options = {}) {
475
482
  htmlMaxEntries,
476
483
  data,
477
484
  trackUpstream,
485
+ trackDependencies,
478
486
  transientRetry,
479
487
  prewarm,
480
488
  prewarmPriority,
package/src/index.js CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  clearHtmlCache,
44
44
  getHtmlCacheEntries,
45
45
  getHtmlCacheSize,
46
+ invalidateHtmlCache,
46
47
  withHtmlCache,
47
48
  } from "./server/html-cache.js";
48
49
  export {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Bir render'ın hangi veri anahtarlarını okuduğunu kaydeder.
3
+ *
4
+ * `request-cache.js` ile aynı desen: `AsyncLocalStorage`, bağlam yoksa her şey
5
+ * sessizce devre dışı. Script'ten, cron'dan ya da istek dışı bir yerden yapılan
6
+ * `withDataCache` çağrıları hiçbir şeye yazılmaz.
7
+ *
8
+ * Neden gerekli: HTML önbelleğinin elinde "bu sayfa şu içerikten etkilenir"
9
+ * bilgisi yoktu, dolayısıyla bir içerik güncellendiğinde tek seçenek TTL'i
10
+ * beklemek ya da tüm önbelleği boşaltmaktı. Bağımlılığı uygulamanın elle
11
+ * bildirmesi (tag'lemek) ise en sık yapılan hatayı davet ediyor: detay
12
+ * sayfasını işaretleyip aynı içeriği listeleyen ana sayfayı unutmak.
13
+ *
14
+ * Burada bildirim yok, **gözlem** var: render sırasında fiilen okunan anahtarlar
15
+ * kaydedilir. Ana sayfa o veriyi okuduysa listede olur, okumadıysa olmaz.
16
+ */
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+
19
+ /** @type {AsyncLocalStorage<Set<string>>} */
20
+ const storage = new AsyncLocalStorage();
21
+
22
+ /**
23
+ * `run`'ı, içindeki `recordDependency()` çağrılarının `deps`'e yazacağı bir
24
+ * bağlamda çalıştırır.
25
+ *
26
+ * @template T
27
+ * @param {Set<string>} deps
28
+ * @param {() => T} run
29
+ * @returns {T}
30
+ */
31
+ export function collectDependencies(deps, run) {
32
+ return storage.run(deps, run);
33
+ }
34
+
35
+ /**
36
+ * Bu render'ın bir veri anahtarını okuduğunu bildirir. Bağlam yoksa no-op.
37
+ *
38
+ * @param {string} key
39
+ */
40
+ export function recordDependency(key) {
41
+ storage.getStore()?.add(key);
42
+ }
@@ -167,6 +167,15 @@ export async function startServer(options = {}) {
167
167
  console.error("[uncaughtException]", error);
168
168
  });
169
169
 
170
+ // Dev panelinin canlı kanalı: el sıkışma `upgrade` olayında geçtiği için
171
+ // middleware zincirine değil, doğrudan sunucuya bağlanır. Modül `listen`den
172
+ // önce yüklenir; dinleme başladıktan sonra beklenen bir `await` kalırsa ilk
173
+ // upgrade isteği dinleyici yokken gelip reddedilebiliyor.
174
+ const attachDevSocket =
175
+ process.env.NODE_ENV === "development"
176
+ ? (await import("./dev/devtools.js")).attachDevSocket
177
+ : null;
178
+
170
179
  return new Promise((resolve) => {
171
180
  const server = app.listen(port, host, () => {
172
181
  // Bu satırın biçimi sözleşme: `jskelet dev` sunucunun hazır olduğunu
@@ -177,5 +186,7 @@ export async function startServer(options = {}) {
177
186
  startPrewarm({ port });
178
187
  resolve(server);
179
188
  });
189
+
190
+ attachDevSocket?.(server);
180
191
  });
181
192
  }
@@ -19,6 +19,8 @@
19
19
  */
20
20
  import { getConfig } from "../config/index.js";
21
21
  import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
22
+ import { recordDependency } from "./cache-deps.js";
23
+ import { invalidateHtmlByDependency } from "./html-cache.js";
22
24
 
23
25
  /**
24
26
  * @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
@@ -144,6 +146,10 @@ function refresh(key, ttlSeconds, producer, options) {
144
146
  export async function withDataCache(key, ttlSeconds, producer, options = {}) {
145
147
  if (!ttlSeconds) return producer();
146
148
 
149
+ // Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
150
+ // sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
151
+ recordDependency(key);
152
+
147
153
  const hit = read(key);
148
154
 
149
155
  if (hit) {
@@ -202,24 +208,31 @@ export function dataCache(fn, options) {
202
208
  * Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
203
209
  * "bu haber güncellendi" bilgisi geldiğinde kullanılır.
204
210
  *
211
+ * Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
212
+ * uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
213
+ * gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
214
+ *
205
215
  * @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
206
216
  * @returns {number} Silinen girdi sayısı.
207
217
  */
208
218
  export function clearDataCache(prefix) {
219
+ /** @type {string[]} */
220
+ const removed = [];
221
+
209
222
  if (prefix === undefined) {
210
- const size = store.size;
223
+ removed.push(...store.keys());
211
224
  store.clear();
212
- return size;
213
- }
214
-
215
- let removed = 0;
216
- for (const key of store.keys()) {
217
- if (key.startsWith(prefix)) {
218
- store.delete(key);
219
- removed += 1;
225
+ } else {
226
+ for (const key of store.keys()) {
227
+ if (key.startsWith(prefix)) {
228
+ store.delete(key);
229
+ removed.push(key);
230
+ }
220
231
  }
221
232
  }
222
- return removed;
233
+
234
+ if (removed.length) invalidateHtmlByDependency(removed);
235
+ return removed.length;
223
236
  }
224
237
 
225
238
  /** @returns {number} */
@@ -22,6 +22,7 @@ import {
22
22
  trackServerFetch,
23
23
  } from "./report.js";
24
24
  import { startVersionCheck, versionStatus } from "./version-check.mjs";
25
+ import { broadcastSocket, socketCount, upgradeToSocket } from "./socket.js";
25
26
 
26
27
  /** Overlay dosyaları framework paketinden servis edilir, uygulamadan değil. */
27
28
  const DEVTOOLS_DIR = path.join(FRAMEWORK_ROOT, "src", "client", "devtools");
@@ -115,6 +116,7 @@ export function recordServerError(level, message, extra = {}) {
115
116
  });
116
117
  trim(errors);
117
118
  persist();
119
+ pushStats();
118
120
  }
119
121
 
120
122
  /**
@@ -177,6 +179,7 @@ function timing() {
177
179
  requests.push(entry);
178
180
  trim(requests);
179
181
  persist();
182
+ pushStats();
180
183
  // Terminalde canlı istek satırı.
181
184
  log.http(entry);
182
185
  });
@@ -185,6 +188,70 @@ function timing() {
185
188
  };
186
189
  }
187
190
 
191
+ /* ----------------------------------------------------------- istatistikler */
192
+
193
+ /**
194
+ * Overlay'in gösterdiği her şey tek pakette. `GET /stats` ve WebSocket aynı
195
+ * gövdeyi kullanır ki panel hangi yoldan beslenirse beslensin aynı şeyi
196
+ * görsün.
197
+ *
198
+ * @returns {object}
199
+ */
200
+ function statsPayload() {
201
+ const usage = process.memoryUsage();
202
+
203
+ return {
204
+ type: "stats",
205
+ pid: process.pid,
206
+ // Overlay yeniden başlatmayı bu kimlikten anlar; kendi durumunu
207
+ // sıfırlamadan yalnızca "restarted" bilgisini gösterir.
208
+ boot: BOOT_ID,
209
+ uptime: process.uptime(),
210
+ node: process.version,
211
+ version: versionStatus(),
212
+ memory: { rss: usage.rss, heapUsed: usage.heapUsed },
213
+ prewarm: { ...prewarmProgress },
214
+ requests: requests.slice(-25).reverse(),
215
+ errors: errors.slice(-25).reverse(),
216
+ };
217
+ }
218
+
219
+ /** @type {NodeJS.Timeout | null} */
220
+ let statsTimer = null;
221
+
222
+ /**
223
+ * Değişiklikleri panele iter. Bir sayfa yüklemesi arka arkaya birçok kayıt
224
+ * üretiyor (istek + uyarılar); paket başına bir çerçeve yerine kısa bir
225
+ * sessizlikten sonra tek çerçeve gönderilir.
226
+ */
227
+ function pushStats() {
228
+ if (statsTimer || !socketCount()) return;
229
+
230
+ statsTimer = setTimeout(() => {
231
+ statsTimer = null;
232
+ broadcastSocket(statsPayload());
233
+ }, 120);
234
+
235
+ statsTimer.unref?.();
236
+ }
237
+
238
+ /**
239
+ * Zamana bağlı alanlar (uptime, bellek, ısıtma sayacı) bir olay üretmiyor;
240
+ * onlar için sabit bir kalp atışı var. Bilinçli olarak ısıtmadan bağımsız:
241
+ * kanalın temposu bir arka plan işine göre değişirse panel de o işin ritmine
242
+ * bağlanmış olur.
243
+ *
244
+ * Bağlı panel yokken hiçbir şey hesaplanmaz.
245
+ */
246
+ function startHeartbeat() {
247
+ const timer = setInterval(() => {
248
+ if (!socketCount()) return;
249
+ broadcastSocket(statsPayload());
250
+ }, 2000);
251
+
252
+ timer.unref?.();
253
+ }
254
+
188
255
  /* ------------------------------------------------------------ live reload */
189
256
 
190
257
  /** @type {Set<import('express').Response>} */
@@ -198,8 +265,14 @@ function send(res, payload) {
198
265
  res.write(`data: ${JSON.stringify(payload)}\n\n`);
199
266
  }
200
267
 
201
- /** @param {object} payload */
268
+ /**
269
+ * Canlı yenileme olayları. Panel normalde WebSocket üzerinden dinler; SSE
270
+ * yalnızca soket kurulamadığında devreye giren yedek yol.
271
+ *
272
+ * @param {object} payload
273
+ */
202
274
  function broadcast(payload) {
275
+ broadcastSocket(payload);
203
276
  for (const client of clients) send(client, payload);
204
277
  }
205
278
 
@@ -285,22 +358,10 @@ function router() {
285
358
  req.on("close", () => clients.delete(res));
286
359
  });
287
360
 
361
+ // WebSocket kurulamadığında panelin düştüğü yedek uç.
288
362
  api.get("/stats", (req, res) => {
289
- const usage = process.memoryUsage();
290
363
  res.setHeader("Cache-Control", "no-store");
291
- res.json({
292
- pid: process.pid,
293
- // Overlay yeniden başlatmayı bu kimlikten anlar; kendi durumunu
294
- // sıfırlamadan yalnızca "restarted" bilgisini gösterir.
295
- boot: BOOT_ID,
296
- uptime: process.uptime(),
297
- node: process.version,
298
- version: versionStatus(),
299
- memory: { rss: usage.rss, heapUsed: usage.heapUsed },
300
- prewarm: { ...prewarmProgress },
301
- requests: requests.slice(-25).reverse(),
302
- errors: errors.slice(-25).reverse(),
303
- });
364
+ res.json(statsPayload());
304
365
  });
305
366
 
306
367
  // Detaylı rapor: kendi sayfası, script'i ve veri ucu.
@@ -364,6 +425,7 @@ function router() {
364
425
  errors.length = 0;
365
426
  requests.length = 0;
366
427
  persist();
428
+ pushStats();
367
429
  res.json({ ok: true });
368
430
  });
369
431
 
@@ -382,6 +444,30 @@ export function mountDevtools(app) {
382
444
  trackServerFetch();
383
445
  watchManifest();
384
446
  startVersionCheck();
447
+ startHeartbeat();
385
448
  app.use(timing());
386
449
  app.use(brand.devBasePath, router());
387
450
  }
451
+
452
+ /**
453
+ * Canlı kanalı HTTP sunucusuna bağlar.
454
+ *
455
+ * Express uygulamasına takılamıyor: WebSocket el sıkışması `upgrade` olayında
456
+ * geçiyor ve o olay middleware zincirine hiç uğramıyor. Bu yüzden `listen`
457
+ * sonrası ayrı bir adım.
458
+ *
459
+ * @param {import('node:http').Server} server
460
+ */
461
+ export function attachDevSocket(server) {
462
+ const endpoint = `${getConfig().brand.devBasePath}/ws`;
463
+
464
+ server.on("upgrade", (req, socket) => {
465
+ // Uygulamanın kendi WebSocket uçları olabilir; yalnızca bizimkini alırız.
466
+ if ((req.url ?? "").split("?")[0] !== endpoint) return;
467
+
468
+ upgradeToSocket(req, socket, (send) => {
469
+ send({ type: "hello", boot: BOOT_ID });
470
+ send(statsPayload());
471
+ });
472
+ });
473
+ }