jskelet 0.1.3 → 0.1.5

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);
@@ -88,6 +88,20 @@ export const DEFAULT_PREWARM = {
88
88
  */
89
89
  export const DEFAULT_HTML_CACHE_MAX_ENTRIES = 500;
90
90
 
91
+ /**
92
+ * `notFound()` geçici bir upstream hatasına denk geldiğinde sayfanın kaç kez
93
+ * daha denenmesi gerektiği.
94
+ *
95
+ * Varsayılan tek deneme: maliyeti upstream'e binen ikinci bir istek turu, ama
96
+ * alternatifi var olan bir sayfayı 404 olarak servis etmek — arama motoru için
97
+ * geçici bir rate limit'in kalıcı kayba dönüşmesi. `attempts: 0` tekrarı
98
+ * kapatır ve doğrudan önbelleğe girmeyen 503'e düşer.
99
+ */
100
+ export const DEFAULT_TRANSIENT_RETRY = {
101
+ attempts: 1,
102
+ delayMs: 300,
103
+ };
104
+
91
105
  /**
92
106
  * Upstream veri önbelleği.
93
107
  *
@@ -38,6 +38,7 @@ import {
38
38
  DEFAULT_PREWARM_SKIP,
39
39
  DEFAULT_SECURITY,
40
40
  DEFAULT_STATIC,
41
+ DEFAULT_TRANSIENT_RETRY,
41
42
  } from "./defaults.js";
42
43
 
43
44
  /** Framework paketinin kökü — kendi şablonlarına ve varlıklarına erişir. */
@@ -68,6 +69,8 @@ const CONFIG_FILE = "jskelet.config.mjs";
68
69
  * @property {{ pattern: CompiledPattern, seconds: number }[]} html
69
70
  * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
70
71
  * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
72
+ * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
73
+ * @property {{ attempts: number, delayMs: number }} transientRetry
71
74
  * @property {Record<string, unknown>} prewarm
72
75
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
73
76
  * @property {Record<string, unknown>} brand
@@ -206,7 +209,9 @@ function normalizePriority(raw) {
206
209
  /**
207
210
  * @param {unknown} raw
208
211
  * @returns {{ html: ResolvedConfig["html"], htmlMaxEntries: number,
209
- * data: Record<string, unknown>, prewarm: Record<string, unknown>,
212
+ * data: Record<string, unknown>, trackUpstream: boolean,
213
+ * transientRetry: { attempts: number, delayMs: number },
214
+ * prewarm: Record<string, unknown>,
210
215
  * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
211
216
  */
212
217
  function normalizeCache(raw) {
@@ -230,6 +235,13 @@ function normalizeCache(raw) {
230
235
  ? Math.floor(maxEntries)
231
236
  : DEFAULT_HTML_CACHE_MAX_ENTRIES,
232
237
  data: { ...DEFAULT_DATA_CACHE, ...(raw?.data ?? {}) },
238
+ // Otomatik upstream izleme kapatılabilir olmalı: `fetch`i kendisi saran
239
+ // bir uygulama (ölçüm, retry, circuit breaker) çakışma yaşayabilir.
240
+ trackUpstream: raw?.trackUpstream !== false,
241
+ transientRetry:
242
+ raw?.transientRetry === false
243
+ ? { attempts: 0, delayMs: 0 }
244
+ : { ...DEFAULT_TRANSIENT_RETRY, ...(raw?.transientRetry ?? {}) },
233
245
  // Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
234
246
  // ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
235
247
  prewarm,
@@ -440,8 +452,15 @@ export async function loadConfig(options = {}) {
440
452
  section("cache"),
441
453
  ]);
442
454
 
443
- const { html, htmlMaxEntries, data, prewarm, prewarmPriority } =
444
- normalizeCache(cache);
455
+ const {
456
+ html,
457
+ htmlMaxEntries,
458
+ data,
459
+ trackUpstream,
460
+ transientRetry,
461
+ prewarm,
462
+ prewarmPriority,
463
+ } = normalizeCache(cache);
445
464
  const dirs = resolveDirs(root, source.paths);
446
465
  const brand = { ...DEFAULT_BRAND, ...(source.brand ?? {}) };
447
466
 
@@ -455,6 +474,8 @@ export async function loadConfig(options = {}) {
455
474
  html,
456
475
  htmlMaxEntries,
457
476
  data,
477
+ trackUpstream,
478
+ transientRetry,
458
479
  prewarm,
459
480
  prewarmPriority,
460
481
  brand,
@@ -34,6 +34,7 @@ import { registerRoutes } from "./router.js";
34
34
  import { renderNotFound } from "./render.js";
35
35
  import { renderStatusPage, statusFromError } from "./status-page.js";
36
36
  import { startPrewarm } from "./prewarm.js";
37
+ import { trackUpstreamFetch } from "./upstream-tracking.js";
37
38
  import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
38
39
 
39
40
  /**
@@ -45,6 +46,11 @@ export async function createApp(options = {}) {
45
46
  await loadConfig(options);
46
47
  const config = getConfig();
47
48
 
49
+ // Upstream hatalarının izlenmesi route'lardan önce kurulmalı: sarmalayıcı
50
+ // yalnızca render bağlamı içindeki `fetch` çağrılarına bakar, ama bağlamın
51
+ // ilk kurulduğu istek de kapsanmalı.
52
+ if (config.trackUpstream) trackUpstreamFetch();
53
+
48
54
  const app = express();
49
55
 
50
56
  app.disable("x-powered-by");
@@ -162,7 +168,14 @@ export async function startServer(options = {}) {
162
168
  });
163
169
 
164
170
  return new Promise((resolve) => {
165
- const server = app.listen(port, host, () => {
171
+ const server = app.listen(port, host, async () => {
172
+ // Dev panelinin canlı kanalı: el sıkışma `upgrade` olayında geçtiği
173
+ // için middleware zincirine değil, doğrudan sunucuya bağlanır.
174
+ if (process.env.NODE_ENV === "development") {
175
+ const { attachDevSocket } = await import("./dev/devtools.js");
176
+ attachDevSocket(server);
177
+ }
178
+
166
179
  // Bu satırın biçimi sözleşme: `jskelet dev` sunucunun hazır olduğunu
167
180
  // buradan anlar ve özet satırını ona göre basar.
168
181
  console.log(
@@ -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,75 @@ 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) ve ısıtma ilerlemesi bir olay
240
+ * üretmiyor; onlar için düşük frekanslı bir kalp atışı gerekiyor. Isıtma
241
+ * sürerken sayaç akıcı görünsün diye sıklaşır.
242
+ *
243
+ * Bağlı panel yokken hiçbir şey hesaplanmaz.
244
+ */
245
+ function startHeartbeat() {
246
+ let tick = 0;
247
+
248
+ const timer = setInterval(() => {
249
+ if (!socketCount()) return;
250
+
251
+ tick += 1;
252
+ if (!prewarmProgress.active && tick % 4 !== 0) return;
253
+
254
+ broadcastSocket(statsPayload());
255
+ }, 1000);
256
+
257
+ timer.unref?.();
258
+ }
259
+
188
260
  /* ------------------------------------------------------------ live reload */
189
261
 
190
262
  /** @type {Set<import('express').Response>} */
@@ -198,8 +270,14 @@ function send(res, payload) {
198
270
  res.write(`data: ${JSON.stringify(payload)}\n\n`);
199
271
  }
200
272
 
201
- /** @param {object} payload */
273
+ /**
274
+ * Canlı yenileme olayları. Panel normalde WebSocket üzerinden dinler; SSE
275
+ * yalnızca soket kurulamadığında devreye giren yedek yol.
276
+ *
277
+ * @param {object} payload
278
+ */
202
279
  function broadcast(payload) {
280
+ broadcastSocket(payload);
203
281
  for (const client of clients) send(client, payload);
204
282
  }
205
283
 
@@ -285,22 +363,10 @@ function router() {
285
363
  req.on("close", () => clients.delete(res));
286
364
  });
287
365
 
366
+ // WebSocket kurulamadığında panelin düştüğü yedek uç.
288
367
  api.get("/stats", (req, res) => {
289
- const usage = process.memoryUsage();
290
368
  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
- });
369
+ res.json(statsPayload());
304
370
  });
305
371
 
306
372
  // Detaylı rapor: kendi sayfası, script'i ve veri ucu.
@@ -364,6 +430,7 @@ function router() {
364
430
  errors.length = 0;
365
431
  requests.length = 0;
366
432
  persist();
433
+ pushStats();
367
434
  res.json({ ok: true });
368
435
  });
369
436
 
@@ -382,6 +449,30 @@ export function mountDevtools(app) {
382
449
  trackServerFetch();
383
450
  watchManifest();
384
451
  startVersionCheck();
452
+ startHeartbeat();
385
453
  app.use(timing());
386
454
  app.use(brand.devBasePath, router());
387
455
  }
456
+
457
+ /**
458
+ * Canlı kanalı HTTP sunucusuna bağlar.
459
+ *
460
+ * Express uygulamasına takılamıyor: WebSocket el sıkışması `upgrade` olayında
461
+ * geçiyor ve o olay middleware zincirine hiç uğramıyor. Bu yüzden `listen`
462
+ * sonrası ayrı bir adım.
463
+ *
464
+ * @param {import('node:http').Server} server
465
+ */
466
+ export function attachDevSocket(server) {
467
+ const endpoint = `${getConfig().brand.devBasePath}/ws`;
468
+
469
+ server.on("upgrade", (req, socket) => {
470
+ // Uygulamanın kendi WebSocket uçları olabilir; yalnızca bizimkini alırız.
471
+ if ((req.url ?? "").split("?")[0] !== endpoint) return;
472
+
473
+ upgradeToSocket(req, socket, (send) => {
474
+ send({ type: "hello", boot: BOOT_ID });
475
+ send(statsPayload());
476
+ });
477
+ });
478
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Dev araçlarının canlı kanalı: küçük bir WebSocket sunucusu.
3
+ *
4
+ * Overlay eskiden istatistikleri iki saniyede bir `GET /stats` ile çekiyordu;
5
+ * panel kapalıyken bile her sekme sürekli istek atıyor, dev sunucusunun istek
6
+ * günlüğünü de kirletiyordu. Artık sunucu değişiklik oldukça (istek, hata,
7
+ * ısıtma ilerlemesi, CSS/JS değişimi) tek bağlantı üzerinden itiyor.
8
+ *
9
+ * Bağımlılık eklemiyoruz: yalnızca sunucu→istemci metin çerçevesi yazmak ve
10
+ * istemcinin ping/close çerçevelerini anlamak gerekiyor; bu da RFC 6455'in
11
+ * küçük bir alt kümesi. `permessage-deflate` yok, parça (fragment) yok —
12
+ * gönderdiğimiz paketler için ikisi de gereksiz.
13
+ */
14
+ import { createHash } from "node:crypto";
15
+
16
+ /** RFC 6455'te sabitlenmiş el sıkışma tuzu. */
17
+ const GUID = "258EAFA5-E914-47DA-95CA-5AB0DC85B39A";
18
+
19
+ /** @type {Set<import('node:net').Socket>} */
20
+ const sockets = new Set();
21
+
22
+ /** @param {string} key */
23
+ function accept(key) {
24
+ return createHash("sha1")
25
+ .update(key + GUID)
26
+ .digest("base64");
27
+ }
28
+
29
+ /**
30
+ * Tek parçalı metin çerçevesi. Maskeleme yalnızca istemci→sunucu yönünde
31
+ * zorunlu olduğu için burada yok.
32
+ *
33
+ * @param {string} text
34
+ * @returns {Buffer}
35
+ */
36
+ function frame(text) {
37
+ const payload = Buffer.from(text, "utf8");
38
+ const length = payload.length;
39
+
40
+ /** @type {Buffer} */
41
+ let header;
42
+ if (length < 126) {
43
+ header = Buffer.from([0x81, length]);
44
+ } else if (length < 65536) {
45
+ header = Buffer.alloc(4);
46
+ header[0] = 0x81;
47
+ header[1] = 126;
48
+ header.writeUInt16BE(length, 2);
49
+ } else {
50
+ header = Buffer.alloc(10);
51
+ header[0] = 0x81;
52
+ header[1] = 127;
53
+ header.writeBigUInt64BE(BigInt(length), 2);
54
+ }
55
+
56
+ return Buffer.concat([header, payload]);
57
+ }
58
+
59
+ /**
60
+ * İstemciden gelen çerçeveleri ayıklar. İçerik bizi ilgilendirmiyor; amaç
61
+ * kapanışı ve ping'i doğru yanıtlamak, aksi hâlde tarayıcı bağlantıyı
62
+ * "protokol hatası" sayıp kapatıyor.
63
+ *
64
+ * @param {import('node:net').Socket} socket
65
+ */
66
+ function readFrames(socket) {
67
+ let buffer = Buffer.alloc(0);
68
+
69
+ socket.on("data", (chunk) => {
70
+ buffer = Buffer.concat([buffer, chunk]);
71
+
72
+ while (buffer.length >= 2) {
73
+ const opcode = buffer[0] & 0x0f;
74
+ const masked = (buffer[1] & 0x80) !== 0;
75
+ let length = buffer[1] & 0x7f;
76
+ let offset = 2;
77
+
78
+ if (length === 126) {
79
+ if (buffer.length < offset + 2) return;
80
+ length = buffer.readUInt16BE(offset);
81
+ offset += 2;
82
+ } else if (length === 127) {
83
+ if (buffer.length < offset + 8) return;
84
+ length = Number(buffer.readBigUInt64BE(offset));
85
+ offset += 8;
86
+ }
87
+
88
+ if (masked) offset += 4;
89
+ if (buffer.length < offset + length) return;
90
+
91
+ buffer = buffer.subarray(offset + length);
92
+
93
+ // 0x8 close, 0x9 ping. Veri çerçeveleri yok sayılır: istemcinin bize
94
+ // söyleyeceği bir şey yok, kanal tek yönlü kullanılıyor.
95
+ if (opcode === 0x8) {
96
+ socket.end(Buffer.from([0x88, 0x00]));
97
+ return;
98
+ }
99
+ if (opcode === 0x9) socket.write(Buffer.from([0x8a, 0x00]));
100
+ }
101
+ });
102
+ }
103
+
104
+ /**
105
+ * HTTP upgrade isteğini WebSocket bağlantısına çevirir.
106
+ *
107
+ * @param {import('node:http').IncomingMessage} req
108
+ * @param {import('node:net').Socket} socket
109
+ * @param {(send: (payload: object) => void) => void} onOpen
110
+ * Bağlantı kurulunca çağrılır; ilk paketi göndermek için kullanılır.
111
+ */
112
+ export function upgradeToSocket(req, socket, onOpen) {
113
+ const key = req.headers["sec-websocket-key"];
114
+ if (!key) {
115
+ socket.destroy();
116
+ return;
117
+ }
118
+
119
+ socket.write(
120
+ "HTTP/1.1 101 Switching Protocols\r\n" +
121
+ "Upgrade: websocket\r\n" +
122
+ "Connection: Upgrade\r\n" +
123
+ `Sec-WebSocket-Accept: ${accept(String(key))}\r\n\r\n`,
124
+ );
125
+
126
+ // Küçük paketleri geciktirmenin anlamı yok; panel anlık görünmeli.
127
+ socket.setNoDelay(true);
128
+ // Bağlantı hataları (sekme kapanması, ağ) süreci düşürmemeli.
129
+ socket.on("error", () => socket.destroy());
130
+ socket.on("close", () => sockets.delete(socket));
131
+
132
+ readFrames(socket);
133
+ sockets.add(socket);
134
+
135
+ onOpen((payload) => {
136
+ if (!socket.destroyed) socket.write(frame(JSON.stringify(payload)));
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Bağlı tüm panellere gönderir.
142
+ * @param {object} payload
143
+ */
144
+ export function broadcastSocket(payload) {
145
+ if (!sockets.size) return;
146
+
147
+ const packet = frame(JSON.stringify(payload));
148
+ for (const socket of sockets) {
149
+ if (socket.destroyed) sockets.delete(socket);
150
+ else socket.write(packet);
151
+ }
152
+ }
153
+
154
+ /** @returns {number} açık panel sayısı */
155
+ export function socketCount() {
156
+ return sockets.size;
157
+ }