jskelet 0.1.4 → 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.
package/CHANGELOG.md CHANGED
@@ -47,6 +47,12 @@ one is listed under a **Breaking** heading.
47
47
 
48
48
  ### Changed
49
49
 
50
+ - The dev tools panel is now fed over a WebSocket (`<devBasePath>/ws`) instead of
51
+ polling `/stats` every two seconds. The server pushes statistics as they change
52
+ and sends live reload and CSS hot-swap events over the same connection, so an
53
+ open tab no longer keeps hitting the server while the panel is closed. No new
54
+ dependency is involved; if the socket cannot be opened, the panel falls back to
55
+ the previous SSE plus polling path.
50
56
  - `notFound()` is no longer served as a 404 when a transient upstream failure
51
57
  (`429`, `5xx`, network error) happened during the same render. The page is
52
58
  retried first and, if upstream is still failing, responds with an uncached
@@ -111,9 +111,9 @@ Restart satırı değişen dosyayı ya da sayısını gösterir:
111
111
 
112
112
  ## CSS hot-swap ve tam yenileme
113
113
 
114
- Dev sunucusu `.jskelet/manifest.json` dosyasını izler ve bir SSE kanalı
115
- (`<devBasePath>/events`) üzerinden tarayıcıya olay yayınlar. Manifest her build
116
- turunda yeniden yazıldığı için değişiklik tespiti manifest üzerinden yapılır.
114
+ Dev sunucusu `.jskelet/manifest.json` dosyasını izler ve olayları canlı kanal
115
+ (`<devBasePath>/ws`) üzerinden tarayıcıya yayınlar. Manifest her build turunda
116
+ yeniden yazıldığı için değişiklik tespiti manifest üzerinden yapılır.
117
117
 
118
118
  | Değişen | Davranış |
119
119
  | --- | --- |
@@ -131,6 +131,27 @@ Sunucu yeniden başladığında overlay bunu **boot kimliğinden** anlar: her s
131
131
  kendine özgü bir `boot` değeri yayınlar, overlay değişikliği görüp "restarted"
132
132
  bilgisini gösterir ve kendi durumunu sıfırlamaz.
133
133
 
134
+ ## Canlı kanal
135
+
136
+ Overlay'e giden her şey — istatistikler, live reload ve CSS hot-swap olayları —
137
+ tek bir WebSocket üzerinden gelir (`<devBasePath>/ws`). Panel eskiden
138
+ istatistikleri iki saniyede bir çekiyordu; açık her sekme, panel kapalıyken bile
139
+ sunucuya sürekli istek atıyordu. Artık sunucu değişiklik oldukça iter: bir istek
140
+ ya da hata kaydedildiğinde (120 ms birleştirilerek), ısıtma sürerken saniyede
141
+ bir, geri kalan zamanda yalnızca uptime/bellek tazelensin diye dört saniyede bir.
142
+ Bağlı panel yoksa hiçbir şey hesaplanmaz.
143
+
144
+ El sıkışma HTTP `upgrade` olayında geçtiği ve o olay middleware zincirine hiç
145
+ uğramadığı için kanal `listen` sonrası doğrudan sunucuya bağlanır
146
+ (`attachDevSocket`). Sunucu tarafı `ws` gibi bir bağımlılık kullanmaz: yalnızca
147
+ sunucu→istemci metin çerçevesi yazmak ve istemcinin ping/close çerçevelerini
148
+ yanıtlamak gerekiyor.
149
+
150
+ Soket hiç açılamazsa (araya giren bir proxy WebSocket'i geçirmiyor olabilir)
151
+ overlay eski yola düşer: `/events` SSE akışı + `/stats` yoklaması. Soket kurulup
152
+ sonra düşerse — yani sunucu yeniden başlıyorsa — yarım saniyede bir yeniden
153
+ bağlanır ve gösterge bu sırada "bağlantı yok" der.
154
+
134
155
  ## Devtools overlay
135
156
 
136
157
  Sağ altta yüzen bir baloncuk; `Alt+D` ile açılır, `Esc` ya da karartma alanına
@@ -236,8 +257,9 @@ Rapor katmanı yalnızca development'ta yüklenir, üretim çıktısına hiç gi
236
257
  | --- | --- | --- |
237
258
  | `/overlay.js` | GET | Overlay script'i |
238
259
  | `/logo.png` | GET | Overlay logosu |
239
- | `/events` | GET | SSE: live reload ve CSS hot-swap olayları |
240
- | `/stats` | GET | Anlık istatistikler (overlay 2 saniyede bir çeker) |
260
+ | `/ws` | GET (upgrade) | Canlı kanal: istatistikler, live reload ve CSS hot-swap olayları |
261
+ | `/events` | GET | SSE: yalnızca WebSocket kurulamazsa kullanılan yedek olay akışı |
262
+ | `/stats` | GET | Anlık istatistikler; aynı yedek yolun veri ucu |
241
263
  | `/report` | GET | Rapor sayfası (HTML) |
242
264
  | `/report.js` | GET | Rapor sayfasının script'i |
243
265
  | `/report/data` | GET | Raporun tek veri kaynağı (JSON) |
@@ -116,7 +116,7 @@ If `JSKELET_VERBOSE=1` is set, all files are listed when more than one changed.
116
116
  ## CSS hot-swap and full reload
117
117
 
118
118
  The dev server watches `.jskelet/manifest.json` and broadcasts events to the
119
- browser over an SSE channel (`<devBasePath>/events`). Since the manifest is
119
+ browser over the live channel (`<devBasePath>/ws`). Since the manifest is
120
120
  rewritten on every build round, change detection is done through the manifest.
121
121
 
122
122
  | What changed | Behavior |
@@ -134,6 +134,28 @@ When the server restarts, the overlay figures it out from the **boot id**: every
134
134
  process broadcasts a unique `boot` value, the overlay sees the change, shows the
135
135
  "restarted" note and does not reset its own state.
136
136
 
137
+ ## The live channel
138
+
139
+ Everything the overlay shows — statistics, live reload and CSS hot-swap events —
140
+ arrives over a single WebSocket (`<devBasePath>/ws`). The panel used to poll for
141
+ statistics every two seconds, so every open tab kept hitting the server even
142
+ while the panel was closed. Now the server pushes as things change: when a
143
+ request or an error is recorded (coalesced over 120 ms), once per second while
144
+ prewarming runs, and every four seconds otherwise so uptime and memory stay
145
+ fresh. Nothing is computed when no panel is connected.
146
+
147
+ The handshake happens on the HTTP `upgrade` event, and that event never reaches
148
+ the middleware chain, so the channel is attached straight to the server after
149
+ `listen` (`attachDevSocket`). The server side pulls in no dependency such as
150
+ `ws`: all it needs is to write server-to-client text frames and to answer the
151
+ client's ping/close frames.
152
+
153
+ If the socket cannot be opened at all (a proxy in between may not pass WebSocket
154
+ through), the overlay falls back to the old path: the `/events` SSE stream plus
155
+ polling `/stats`. If the socket opens and later drops — that is, the server is
156
+ restarting — it reconnects every half second and the indicator reads
157
+ "server restarting…" in the meantime.
158
+
137
159
  ## Devtools overlay
138
160
 
139
161
  A floating bubble in the bottom right; opened with `Alt+D`, closed with `Esc` or
@@ -240,8 +262,9 @@ Under `brand.devBasePath` (default `/__jskelet/dev`):
240
262
  | --- | --- | --- |
241
263
  | `/overlay.js` | GET | The overlay script |
242
264
  | `/logo.png` | GET | The overlay logo |
243
- | `/events` | GET | SSE: live reload and CSS hot-swap events |
244
- | `/stats` | GET | Current statistics (the overlay polls every 2 seconds) |
265
+ | `/ws` | GET (upgrade) | Live channel: statistics, live reload and CSS hot-swap events |
266
+ | `/events` | GET | SSE: the fallback event stream, used only when WebSocket cannot be established |
267
+ | `/stats` | GET | Current statistics; the data endpoint of that same fallback |
245
268
  | `/report` | GET | The report page (HTML) |
246
269
  | `/report.js` | GET | The report page's script |
247
270
  | `/report/data` | GET | The report's single data source (JSON) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A framework that feels like no framework: Express 5 + EJS server rendering, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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);
@@ -168,7 +168,14 @@ export async function startServer(options = {}) {
168
168
  });
169
169
 
170
170
  return new Promise((resolve) => {
171
- 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
+
172
179
  // Bu satırın biçimi sözleşme: `jskelet dev` sunucunun hazır olduğunu
173
180
  // buradan anlar ve özet satırını ona göre basar.
174
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
+ }