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.
@@ -13,6 +13,7 @@ import { createHash } from "node:crypto";
13
13
  import express from "express";
14
14
  import * as log from "../../log.mjs";
15
15
  import { FRAMEWORK_ROOT, getConfig } from "../../config/index.js";
16
+ import { getRequestContext } from "../../http/request-context.js";
16
17
  import { prewarm, prewarmProgress } from "../prewarm.js";
17
18
  import { clearHtmlCache } from "../html-cache.js";
18
19
  import {
@@ -55,7 +56,21 @@ const STATE_FILE = path.join(
55
56
  /** @type {{ id: number, method: string, url: string, status: number, ms: number, cache: string | null, at: number }[]} */
56
57
  let requests = [];
57
58
 
58
- /** @type {{ id: number, level: string, message: string, stack: string | null, url: string | null, at: number }[]} */
59
+ /**
60
+ * @typedef {{
61
+ * id: number,
62
+ * level: string,
63
+ * message: string,
64
+ * stack: string | null,
65
+ * url: string | null,
66
+ * page: string | null,
67
+ * island: string | null,
68
+ * details: unknown,
69
+ * at: number,
70
+ * }} ServerError
71
+ */
72
+
73
+ /** @type {ServerError[]} */
59
74
  let errors = [];
60
75
 
61
76
  let nextId = 1;
@@ -103,7 +118,13 @@ function trim(list) {
103
118
  /**
104
119
  * @param {string} level
105
120
  * @param {string} message
106
- * @param {{ stack?: string | null, url?: string | null }} [extra]
121
+ * @param {{
122
+ * stack?: string | null,
123
+ * url?: string | null,
124
+ * page?: string | null,
125
+ * island?: string | null,
126
+ * details?: unknown,
127
+ * }} [extra]
107
128
  */
108
129
  export function recordServerError(level, message, extra = {}) {
109
130
  errors.push({
@@ -112,6 +133,9 @@ export function recordServerError(level, message, extra = {}) {
112
133
  message,
113
134
  stack: extra.stack ?? null,
114
135
  url: extra.url ?? null,
136
+ page: extra.page ?? null,
137
+ island: extra.island ?? null,
138
+ details: extra.details ?? null,
115
139
  at: Date.now(),
116
140
  });
117
141
  trim(errors);
@@ -121,21 +145,49 @@ export function recordServerError(level, message, extra = {}) {
121
145
 
122
146
  /**
123
147
  * `console.error` / `console.warn` çıktısını da overlay'e taşır: sunucudaki
124
- * uyarılar terminalde kaybolmasın.
148
+ * uyarılar terminalde kaybolmasın. Render bağlamındaysa sayfa yolu da yazılır.
125
149
  */
126
150
  function patchConsole() {
127
151
  for (const level of /** @type {const} */ (["error", "warn"])) {
128
152
  const original = console[level].bind(console);
129
153
  console[level] = (...args) => {
130
154
  const error = args.find((arg) => arg instanceof Error);
155
+ const page = currentPage();
131
156
  recordServerError(level, args.map(format).join(" "), {
132
157
  stack: error?.stack ?? null,
158
+ page,
159
+ url: page,
160
+ details: extractDetails(args),
133
161
  });
134
162
  original(...args);
135
163
  };
136
164
  }
137
165
  }
138
166
 
167
+ /** @returns {string | null} */
168
+ function currentPage() {
169
+ return getRequestContext()?.pathname ?? null;
170
+ }
171
+
172
+ /**
173
+ * console argümanlarından yapılandırılmış bir `details` alanı ayıklar.
174
+ * Uygulama `console.error("msg", { details: {...} })` yazdığında overlay
175
+ * nesneyi `[object Object]` yerine açılabilir JSON olarak görsün.
176
+ *
177
+ * @param {unknown[]} args
178
+ * @returns {unknown}
179
+ */
180
+ function extractDetails(args) {
181
+ for (const arg of args) {
182
+ if (!arg || typeof arg !== "object" || arg instanceof Error) continue;
183
+ const record = /** @type {Record<string, unknown>} */ (arg);
184
+ if ("details" in record) return record.details;
185
+ if ("detail" in record) return record.detail;
186
+ return record;
187
+ }
188
+ return null;
189
+ }
190
+
139
191
  /**
140
192
  * @param {unknown} value
141
193
  * @returns {string}
@@ -144,12 +196,73 @@ function format(value) {
144
196
  if (typeof value === "string") return value;
145
197
  if (value instanceof Error) return `${value.name}: ${value.message}`;
146
198
  try {
147
- return JSON.stringify(value);
199
+ return JSON.stringify(value, (_key, nested) => {
200
+ // Döngüsel referanslarda stringify zaten fırlar; burada yalnızca
201
+ // Error örneklerini okunabilir kılmak yeterli.
202
+ if (nested instanceof Error) {
203
+ return { name: nested.name, message: nested.message };
204
+ }
205
+ return nested;
206
+ });
148
207
  } catch {
149
208
  return String(value);
150
209
  }
151
210
  }
152
211
 
212
+ /**
213
+ * Upstream `fetch` başarısızlığını overlay hata listesine yazar.
214
+ *
215
+ * @param {{
216
+ * url: string,
217
+ * method: string,
218
+ * status: number,
219
+ * ms: number,
220
+ * bytes: number,
221
+ * error: string | null,
222
+ * page: string | null,
223
+ * details: unknown,
224
+ * }} call
225
+ */
226
+ function recordApiFailure(call) {
227
+ // Aynı SSR turunda hem fetch sarmalayıcısı hem uygulama logger'ı aynı
228
+ // hatayı basabiliyor; kısa pencerede tekilleştir.
229
+ const last = errors.at(-1);
230
+ if (
231
+ last &&
232
+ last.url === call.url &&
233
+ last.page === call.page &&
234
+ Date.now() - last.at < 2000
235
+ ) {
236
+ // İlk kayıtta details yoksa sonrakinin gövdesini birleştir.
237
+ if (last.details == null && call.details != null) {
238
+ last.details = call.details;
239
+ if (call.error && !last.message.includes(call.error)) {
240
+ last.message = `${call.method} ${shortApiPath(call.url)} → ${call.error}`;
241
+ }
242
+ persist();
243
+ pushStats();
244
+ }
245
+ return;
246
+ }
247
+
248
+ recordServerError("error", `${call.method} ${shortApiPath(call.url)} → ${call.error ?? call.status}`, {
249
+ url: call.url,
250
+ page: call.page,
251
+ details: call.details,
252
+ stack: null,
253
+ });
254
+ }
255
+
256
+ /** @param {string} url */
257
+ function shortApiPath(url) {
258
+ try {
259
+ const parsed = new URL(url);
260
+ return parsed.pathname + parsed.search;
261
+ } catch {
262
+ return url;
263
+ }
264
+ }
265
+
153
266
  /** Her HTML isteğinin süresini ve cache durumunu kaydeder. */
154
267
  function timing() {
155
268
  /** @type {import('express').RequestHandler} */
@@ -448,7 +561,7 @@ export function mountDevtools(app) {
448
561
 
449
562
  restore();
450
563
  patchConsole();
451
- trackServerFetch();
564
+ trackServerFetch({ onFailure: recordApiFailure });
452
565
  watchManifest();
453
566
  startVersionCheck();
454
567
  startHeartbeat();
@@ -18,6 +18,7 @@ import { getRedisStatus } from "../redis.js";
18
18
  import { getUpstreamLimiterStatus } from "../upstream-limiter.js";
19
19
  import { prewarmProgress } from "../prewarm.js";
20
20
  import { getConfig } from "../../config/index.js";
21
+ import { getRequestContext } from "../../http/request-context.js";
21
22
 
22
23
  /** Yollar config'ten: framework paket içine taşındığında `../..` sayan her hesap bozulur. */
23
24
  const ROOT = getConfig().root;
@@ -78,61 +79,176 @@ export function clearPageReports() {
78
79
  /* --------------------------------------------------- sunucu tarafı API çağrıları */
79
80
 
80
81
  /**
81
- * @type {{ url: string, host: string, method: string, status: number, ms: number,
82
- * bytes: number, at: number, error: string | null }[]}
82
+ * @typedef {{
83
+ * url: string,
84
+ * host: string,
85
+ * method: string,
86
+ * status: number,
87
+ * ms: number,
88
+ * bytes: number,
89
+ * at: number,
90
+ * error: string | null,
91
+ * page: string | null,
92
+ * details: unknown,
93
+ * }} ServerApiCall
83
94
  */
95
+
96
+ /** @type {ServerApiCall[]} */
84
97
  const serverApiCalls = [];
85
98
  const MAX_API_CALLS = 300;
86
99
 
100
+ /** Başarısız çağrı gövdesinin overlay'e taşınacak üst sınırı. */
101
+ const DETAILS_MAX = 4_000;
102
+
103
+ /**
104
+ * @typedef {{
105
+ * url: string,
106
+ * method: string,
107
+ * status: number,
108
+ * ms: number,
109
+ * bytes: number,
110
+ * error: string | null,
111
+ * page: string | null,
112
+ * details: unknown,
113
+ * }} ApiFailure
114
+ */
115
+
87
116
  /**
88
117
  * SSR sırasında yapılan dış çağrıları ölçer. `globalThis.fetch` sarılır;
89
118
  * yalnızca dev'de çağrıldığı için üretim yolu dokunulmaz kalır.
119
+ *
120
+ * Başarısız cevaplar (4xx/5xx ya da ağ) isteğe bağlı `onFailure` ile
121
+ * overlay hata günlüğüne de düşer — uygulama kendi logger'ıyla stderr'e
122
+ * yazsa bile panel "hangi sayfa hangi API" bilgisini görsün.
123
+ *
124
+ * @param {{ onFailure?: (call: ApiFailure) => void }} [options]
90
125
  */
91
- export function trackServerFetch() {
126
+ export function trackServerFetch(options = {}) {
92
127
  const original = globalThis.fetch;
93
- if (original.__jskeletWrapped) return;
128
+ if (/** @type {any} */ (original).__jskeletWrapped) return;
129
+
130
+ const { onFailure } = options;
94
131
 
95
132
  /** @type {typeof fetch} */
96
133
  const wrapped = async (input, init) => {
97
134
  const url = typeof input === "string" ? input : (input?.url ?? String(input));
135
+ const method = String(init?.method ?? "GET").toUpperCase();
98
136
  const started = Date.now();
137
+ const page = currentPage();
99
138
 
100
139
  // Kendi sunucumuza yapılan istekler (ısıtma, sağlık kontrolü) API sayılmaz.
101
- const isSelf = /^https?:\/\/(127\.0\.0\.1|localhost)/i.test(url);
140
+ const isSelf = /^https?:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:|\/|$)/i.test(url);
102
141
 
103
142
  try {
104
143
  const response = await original(input, init);
105
144
  if (!isSelf) {
106
- push({
145
+ const details = response.ok
146
+ ? null
147
+ : await readFailureDetails(response);
148
+ const error = response.ok ? null : summarizeFailure(response.status, details);
149
+ const call = {
107
150
  url,
108
- method: init?.method ?? "GET",
151
+ method,
109
152
  status: response.status,
110
153
  ms: Date.now() - started,
111
154
  bytes: Number(response.headers.get("content-length") ?? 0),
112
- error: response.ok ? null : `HTTP ${response.status}`,
113
- });
155
+ error,
156
+ page,
157
+ details,
158
+ };
159
+ push(call);
160
+ if (error) onFailure?.(call);
114
161
  }
115
162
  return response;
116
163
  } catch (error) {
117
164
  if (!isSelf) {
118
- push({
165
+ const message = error instanceof Error ? error.message : String(error);
166
+ const call = {
119
167
  url,
120
- method: init?.method ?? "GET",
168
+ method,
121
169
  status: 0,
122
170
  ms: Date.now() - started,
123
171
  bytes: 0,
124
- error: error instanceof Error ? error.message : String(error),
125
- });
172
+ error: message,
173
+ page,
174
+ details: null,
175
+ };
176
+ push(call);
177
+ onFailure?.(call);
126
178
  }
127
179
  throw error;
128
180
  }
129
181
  };
130
182
 
131
- wrapped.__jskeletWrapped = true;
183
+ /** @type {any} */ (wrapped).__jskeletWrapped = true;
132
184
  globalThis.fetch = wrapped;
133
185
  }
134
186
 
135
- /** @param {{ url: string, method: string, status: number, ms: number, bytes: number, error: string | null }} call */
187
+ /** @returns {string | null} */
188
+ function currentPage() {
189
+ return getRequestContext()?.pathname ?? null;
190
+ }
191
+
192
+ /**
193
+ * @param {Response} response
194
+ * @returns {Promise<unknown>}
195
+ */
196
+ async function readFailureDetails(response) {
197
+ try {
198
+ const text = (await response.clone().text()).slice(0, DETAILS_MAX);
199
+ if (!text) return null;
200
+ try {
201
+ return JSON.parse(text);
202
+ } catch {
203
+ return text;
204
+ }
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Overlay başlığı: durum + varsa API'nin kendi doğrulama mesajı.
212
+ *
213
+ * @param {number} status
214
+ * @param {unknown} details
215
+ * @returns {string}
216
+ */
217
+ function summarizeFailure(status, details) {
218
+ const label = `HTTP ${status}`;
219
+ const detail = failureMessage(details);
220
+ return detail ? `${label}: ${detail}` : label;
221
+ }
222
+
223
+ /**
224
+ * Upstream gövdesinden kısa, okunabilir bir cümle çıkarır. `[object Object]`
225
+ * basmamak için nesneleri bilinçli dolaşır.
226
+ *
227
+ * @param {unknown} details
228
+ * @returns {string | null}
229
+ */
230
+ function failureMessage(details) {
231
+ if (details == null) return null;
232
+ if (typeof details === "string") return details.slice(0, 280);
233
+ if (typeof details !== "object") return String(details);
234
+
235
+ const record = /** @type {Record<string, unknown>} */ (details);
236
+ for (const key of ["details", "detail", "message", "error", "title"]) {
237
+ const value = record[key];
238
+ if (typeof value === "string" && value.trim()) return value.slice(0, 280);
239
+ if (value && typeof value === "object") {
240
+ const nested = failureMessage(value);
241
+ if (nested) return nested;
242
+ }
243
+ }
244
+ try {
245
+ return JSON.stringify(details).slice(0, 280);
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ /** @param {Omit<ServerApiCall, "host" | "at">} call */
136
252
  function push(call) {
137
253
  let host = "—";
138
254
  try {
@@ -815,3 +815,17 @@ export function getHtmlCacheEntries() {
815
815
  deps: entry.deps.size,
816
816
  }));
817
817
  }
818
+
819
+ /**
820
+ * Yol (query'siz) için taze bir HTML girdisi var mı? Ziyaret ısıtması yalnızca
821
+ * soğuk / bayat hedefleri kuyruğa alır; HIT'leri yeniden çekmez.
822
+ *
823
+ * @param {string} pathname
824
+ * @returns {boolean}
825
+ */
826
+ export function isHtmlCacheFresh(pathname) {
827
+ if (typeof pathname !== "string" || !pathname.startsWith("/")) return false;
828
+ const entry = store.get(pathname);
829
+ if (!entry) return false;
830
+ return Date.now() < entry.expiresAt;
831
+ }