jskelet 0.4.7 → 0.5.0
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 +9 -0
- package/docs/02-mimari.md +8 -3
- package/docs/03-routing.md +2 -0
- package/docs/04-render-ve-sablonlar.md +11 -4
- package/docs/08-build.md +32 -4
- package/docs/09-dev-araclari.md +12 -6
- package/docs/en/02-architecture.md +8 -2
- package/docs/en/03-routing.md +2 -0
- package/docs/en/04-rendering.md +11 -4
- package/docs/en/08-build.md +37 -8
- package/docs/en/09-dev-tools.md +12 -7
- package/package.json +1 -1
- package/src/build/tasks/css.mjs +122 -13
- package/src/client/devtools/overlay.js +203 -9
- package/src/client/devtools/report.js +20 -0
- package/src/client/registry.js +8 -0
- package/src/http/request-context.js +4 -1
- package/src/server/dev/devtools.js +124 -7
- package/src/server/dev/report.js +131 -15
- package/src/server/render.js +14 -4
- package/src/templates/layout.ejs +10 -4
|
@@ -17,7 +17,7 @@ const BASE = "/__jskelet/dev";
|
|
|
17
17
|
const POLL_MS = 2000;
|
|
18
18
|
const MAX_ERRORS = 100;
|
|
19
19
|
|
|
20
|
-
/** @type {{ id: number, level: string, message: string, stack: string | null, source: string, at: number }[]} */
|
|
20
|
+
/** @type {{ id: number, level: string, message: string, stack: string | null, source: string, page?: string | null, island?: string | null, url?: string | null, details?: unknown, at: number }[]} */
|
|
21
21
|
const clientErrors = [];
|
|
22
22
|
let nextId = 1;
|
|
23
23
|
|
|
@@ -109,7 +109,14 @@ function loadState() {
|
|
|
109
109
|
/**
|
|
110
110
|
* @param {string} level
|
|
111
111
|
* @param {string} message
|
|
112
|
-
* @param {{
|
|
112
|
+
* @param {{
|
|
113
|
+
* stack?: string | null,
|
|
114
|
+
* source?: string,
|
|
115
|
+
* page?: string | null,
|
|
116
|
+
* island?: string | null,
|
|
117
|
+
* url?: string | null,
|
|
118
|
+
* details?: unknown,
|
|
119
|
+
* }} [extra]
|
|
113
120
|
*/
|
|
114
121
|
function pushError(level, message, extra = {}) {
|
|
115
122
|
clientErrors.unshift({
|
|
@@ -118,6 +125,10 @@ function pushError(level, message, extra = {}) {
|
|
|
118
125
|
message,
|
|
119
126
|
stack: extra.stack ?? null,
|
|
120
127
|
source: extra.source ?? "client",
|
|
128
|
+
page: extra.page ?? location.pathname,
|
|
129
|
+
island: extra.island ?? null,
|
|
130
|
+
url: extra.url ?? null,
|
|
131
|
+
details: extra.details ?? null,
|
|
121
132
|
at: Date.now(),
|
|
122
133
|
});
|
|
123
134
|
if (clientErrors.length > MAX_ERRORS) clientErrors.length = MAX_ERRORS;
|
|
@@ -318,6 +329,8 @@ const clientApi = [];
|
|
|
318
329
|
/**
|
|
319
330
|
* Tarayıcıdaki veri çağrıları. Panelin kendi uçları ve statik varlıklar
|
|
320
331
|
* sayılmaz; amaç sayfanın hangi API'lere kaç ms harcadığını görmek.
|
|
332
|
+
* 4xx/5xx ve ağ hataları Errors sekmesine de düşer — hangi sayfa / island
|
|
333
|
+
* bağlamı mümkün olduğunca doldurulur.
|
|
321
334
|
*/
|
|
322
335
|
function captureFetch() {
|
|
323
336
|
const original = window.fetch.bind(window);
|
|
@@ -326,13 +339,34 @@ function captureFetch() {
|
|
|
326
339
|
const url = typeof input === "string" ? input : (input?.url ?? String(input));
|
|
327
340
|
if (url.includes(BASE)) return original(input, init);
|
|
328
341
|
|
|
342
|
+
const method = String(init?.method ?? "GET").toUpperCase();
|
|
329
343
|
const started = performance.now();
|
|
330
344
|
try {
|
|
331
345
|
const response = await original(input, init);
|
|
332
346
|
record(url, performance.now() - started, response.status, response);
|
|
347
|
+
if (!response.ok) {
|
|
348
|
+
void reportFailedFetch({
|
|
349
|
+
url,
|
|
350
|
+
method,
|
|
351
|
+
status: response.status,
|
|
352
|
+
response,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
333
355
|
return response;
|
|
334
356
|
} catch (error) {
|
|
335
357
|
record(url, performance.now() - started, 0, null);
|
|
358
|
+
pushError(
|
|
359
|
+
"error",
|
|
360
|
+
`${method} ${shortUrl(url)} → network error`,
|
|
361
|
+
{
|
|
362
|
+
source: "fetch",
|
|
363
|
+
url,
|
|
364
|
+
page: location.pathname + location.search,
|
|
365
|
+
island: inferIsland(),
|
|
366
|
+
stack: error instanceof Error ? error.stack : null,
|
|
367
|
+
details: error instanceof Error ? error.message : String(error),
|
|
368
|
+
},
|
|
369
|
+
);
|
|
336
370
|
throw error;
|
|
337
371
|
}
|
|
338
372
|
};
|
|
@@ -355,6 +389,99 @@ function captureFetch() {
|
|
|
355
389
|
}
|
|
356
390
|
}
|
|
357
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Başarısız bir tarayıcı fetch'ini Errors listesine yazar. Gövdeyi klonlayıp
|
|
394
|
+
* okur; asıl Response tüketilmez.
|
|
395
|
+
*
|
|
396
|
+
* @param {{ url: string, method: string, status: number, response: Response }} call
|
|
397
|
+
*/
|
|
398
|
+
async function reportFailedFetch(call) {
|
|
399
|
+
const details = await readResponseDetails(call.response);
|
|
400
|
+
const summary = detailSummary(details);
|
|
401
|
+
pushError(
|
|
402
|
+
"error",
|
|
403
|
+
`${call.method} ${shortUrl(call.url)} → ${call.status}${
|
|
404
|
+
summary ? `: ${summary}` : ""
|
|
405
|
+
}`,
|
|
406
|
+
{
|
|
407
|
+
source: "fetch",
|
|
408
|
+
url: call.url,
|
|
409
|
+
page: location.pathname + location.search,
|
|
410
|
+
island: inferIsland(),
|
|
411
|
+
details,
|
|
412
|
+
},
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* @param {Response} response
|
|
418
|
+
* @returns {Promise<unknown>}
|
|
419
|
+
*/
|
|
420
|
+
async function readResponseDetails(response) {
|
|
421
|
+
try {
|
|
422
|
+
const text = (await response.clone().text()).slice(0, 4000);
|
|
423
|
+
if (!text) return null;
|
|
424
|
+
try {
|
|
425
|
+
return JSON.parse(text);
|
|
426
|
+
} catch {
|
|
427
|
+
return text;
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* @param {unknown} details
|
|
436
|
+
* @returns {string | null}
|
|
437
|
+
*/
|
|
438
|
+
function detailSummary(details) {
|
|
439
|
+
if (details == null) return null;
|
|
440
|
+
if (typeof details === "string") return details.slice(0, 200);
|
|
441
|
+
if (typeof details !== "object") return String(details);
|
|
442
|
+
const record = /** @type {Record<string, unknown>} */ (details);
|
|
443
|
+
for (const key of ["details", "detail", "message", "error", "title"]) {
|
|
444
|
+
const value = record[key];
|
|
445
|
+
if (typeof value === "string" && value.trim()) return value.slice(0, 200);
|
|
446
|
+
if (value && typeof value === "object") {
|
|
447
|
+
const nested = detailSummary(value);
|
|
448
|
+
if (nested) return nested;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
return JSON.stringify(details).slice(0, 200);
|
|
453
|
+
} catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** @param {string} url */
|
|
459
|
+
function shortUrl(url) {
|
|
460
|
+
try {
|
|
461
|
+
const parsed = new URL(url, location.origin);
|
|
462
|
+
return parsed.origin === location.origin
|
|
463
|
+
? parsed.pathname + parsed.search
|
|
464
|
+
: parsed.host + parsed.pathname + parsed.search;
|
|
465
|
+
} catch {
|
|
466
|
+
return url;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Çağrı yığınında `islands/ad` izi varsa ya da bir island mount edilirken
|
|
472
|
+
* yakalanmışsa ada bağlar.
|
|
473
|
+
*
|
|
474
|
+
* @returns {string | null}
|
|
475
|
+
*/
|
|
476
|
+
function inferIsland() {
|
|
477
|
+
const mounting = globalThis.__JSKELET_MOUNTING_ISLAND;
|
|
478
|
+
if (typeof mounting === "string" && mounting) return mounting;
|
|
479
|
+
|
|
480
|
+
const stack = new Error().stack ?? "";
|
|
481
|
+
const match = stack.match(/islands[/\\]([A-Za-z0-9_-]+)/);
|
|
482
|
+
return match?.[1] ?? null;
|
|
483
|
+
}
|
|
484
|
+
|
|
358
485
|
/**
|
|
359
486
|
* Sayfa ölçümlerini rapora gönderir. Sekme kapanırken de gitmesi gerektiği
|
|
360
487
|
* için mümkünse `sendBeacon` kullanılır.
|
|
@@ -503,7 +630,7 @@ function handleServerMessage(payload) {
|
|
|
503
630
|
}
|
|
504
631
|
|
|
505
632
|
if (payload.type === "css") {
|
|
506
|
-
swapStylesheet(payload.href);
|
|
633
|
+
swapStylesheet(payload.href, payload.name);
|
|
507
634
|
return;
|
|
508
635
|
}
|
|
509
636
|
|
|
@@ -537,13 +664,18 @@ function startFallback() {
|
|
|
537
664
|
|
|
538
665
|
/**
|
|
539
666
|
* Yeni sheet yüklenmeden eskisi kaldırılmaz; böylece stilsiz bir kare oluşmaz.
|
|
667
|
+
* `name` varsa `data-jskelet-css` ile doğru link seçilir (sayfa sheet'leri).
|
|
668
|
+
*
|
|
540
669
|
* @param {string} href
|
|
670
|
+
* @param {string} [name] Manifest anahtarı (`app.css`, `home.css`, …)
|
|
541
671
|
*/
|
|
542
|
-
function swapStylesheet(href) {
|
|
543
|
-
const current =
|
|
672
|
+
function swapStylesheet(href, name) {
|
|
673
|
+
const current = name
|
|
674
|
+
? document.querySelector(`link[rel="stylesheet"][data-jskelet-css="${CSS.escape(name)}"]`)
|
|
675
|
+
: document.querySelector('link[rel="stylesheet"]');
|
|
544
676
|
if (!current || current.getAttribute("href") === href) return;
|
|
545
677
|
|
|
546
|
-
const next = current.cloneNode();
|
|
678
|
+
const next = /** @type {HTMLLinkElement} */ (current.cloneNode());
|
|
547
679
|
next.href = href;
|
|
548
680
|
next.addEventListener("load", () => current.remove(), { once: true });
|
|
549
681
|
current.after(next);
|
|
@@ -847,7 +979,11 @@ h4:first-child { margin-top: 0; }
|
|
|
847
979
|
.item.warn { border-inline-start-color: var(--mid); }
|
|
848
980
|
.item.selected { border-color: rgba(255,255,255,.22); background: var(--surface-hover); }
|
|
849
981
|
.item .meta { color: var(--muted); font-size: 10.5px; display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
|
|
982
|
+
.item .ctx-row { flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
|
983
|
+
.item .ctx { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 10.5px; color: #cfd6de; }
|
|
984
|
+
.item .ctx.muted { color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
850
985
|
.tag { border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; font-size: 9.5px; }
|
|
986
|
+
.tag.island { text-transform: none; letter-spacing: 0; color: #c4b5fd; border-color: rgba(196,181,253,.35); }
|
|
851
987
|
.msg { word-break: break-word; color: var(--text); }
|
|
852
988
|
.item pre { margin: 8px 0 0; padding: 8px 10px; background: rgba(0,0,0,.4); border-radius: 8px; white-space: pre-wrap; word-break: break-word; color: #b8c0cb; font-size: 11px; font-family: ui-monospace, SFMono-Regular, monospace; max-height: 150px; overflow: auto; }
|
|
853
989
|
.item .detail-text { margin: 8px 0 0; color: #b8c0cb; font-size: 12px; line-height: 1.55; }
|
|
@@ -1101,29 +1237,80 @@ const clipboard = new Map();
|
|
|
1101
1237
|
let copiedKey = null;
|
|
1102
1238
|
|
|
1103
1239
|
/**
|
|
1104
|
-
* @param {{
|
|
1240
|
+
* @param {{
|
|
1241
|
+
* level: string,
|
|
1242
|
+
* message: string,
|
|
1243
|
+
* stack?: string | null,
|
|
1244
|
+
* source?: string,
|
|
1245
|
+
* page?: string | null,
|
|
1246
|
+
* island?: string | null,
|
|
1247
|
+
* url?: string | null,
|
|
1248
|
+
* details?: unknown,
|
|
1249
|
+
* at: number,
|
|
1250
|
+
* }} item
|
|
1105
1251
|
* @returns {string}
|
|
1106
1252
|
*/
|
|
1107
1253
|
function asText(item) {
|
|
1108
1254
|
const time = new Date(item.at).toISOString();
|
|
1255
|
+
const details =
|
|
1256
|
+
item.details == null
|
|
1257
|
+
? ""
|
|
1258
|
+
: typeof item.details === "string"
|
|
1259
|
+
? item.details
|
|
1260
|
+
: safeJson(item.details);
|
|
1109
1261
|
return [
|
|
1110
1262
|
`[${item.level}] ${item.source ?? "server"} · ${time}`,
|
|
1263
|
+
item.page ? `page: ${item.page}` : "",
|
|
1264
|
+
item.island ? `island: ${item.island}` : "",
|
|
1265
|
+
item.url ? `url: ${item.url}` : "",
|
|
1111
1266
|
item.message,
|
|
1267
|
+
details,
|
|
1112
1268
|
item.stack ?? "",
|
|
1113
1269
|
]
|
|
1114
1270
|
.filter(Boolean)
|
|
1115
1271
|
.join("\n");
|
|
1116
1272
|
}
|
|
1117
1273
|
|
|
1118
|
-
/**
|
|
1274
|
+
/**
|
|
1275
|
+
* @param {unknown} value
|
|
1276
|
+
* @returns {string}
|
|
1277
|
+
*/
|
|
1278
|
+
function formatDetails(value) {
|
|
1279
|
+
if (value == null) return "";
|
|
1280
|
+
if (typeof value === "string") return value;
|
|
1281
|
+
try {
|
|
1282
|
+
return JSON.stringify(value, null, 2);
|
|
1283
|
+
} catch {
|
|
1284
|
+
return safeJson(value);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** @param {{ id?: number, level: string, message: string, stack?: string | null, source?: string, page?: string | null, island?: string | null, url?: string | null, details?: unknown, at: number }[]} list */
|
|
1119
1289
|
function errorList(list, scope) {
|
|
1120
1290
|
if (!list.length) return `<div class="empty">No records.</div>`;
|
|
1121
1291
|
return list
|
|
1122
1292
|
.map((item) => {
|
|
1123
1293
|
const key = `${scope}:${item.id ?? item.at}`;
|
|
1124
1294
|
const shown = expanded.has(key);
|
|
1295
|
+
const detailsKey = `${key}:details`;
|
|
1296
|
+
const detailsShown = expanded.has(detailsKey);
|
|
1297
|
+
const detailsText = formatDetails(item.details);
|
|
1125
1298
|
clipboard.set(key, asText(item));
|
|
1126
1299
|
|
|
1300
|
+
const context = [
|
|
1301
|
+
item.page
|
|
1302
|
+
? `<span class="ctx" title="Page">${pathLink(item.page)}</span>`
|
|
1303
|
+
: "",
|
|
1304
|
+
item.island
|
|
1305
|
+
? `<span class="tag island" title="Island">${escapeHtml(item.island)}</span>`
|
|
1306
|
+
: "",
|
|
1307
|
+
item.url && item.url !== item.page
|
|
1308
|
+
? `<span class="ctx muted" title="Request">${escapeHtml(shortUrl(item.url))}</span>`
|
|
1309
|
+
: "",
|
|
1310
|
+
]
|
|
1311
|
+
.filter(Boolean)
|
|
1312
|
+
.join("");
|
|
1313
|
+
|
|
1127
1314
|
return `<div class="item ${item.level === "warn" ? "warn" : ""}">
|
|
1128
1315
|
<div class="meta">
|
|
1129
1316
|
<span class="tag">${escapeHtml(item.source ?? "server")}</span>
|
|
@@ -1131,7 +1318,14 @@ function errorList(list, scope) {
|
|
|
1131
1318
|
<span class="spacer"></span>
|
|
1132
1319
|
${copyButton(key)}
|
|
1133
1320
|
</div>
|
|
1321
|
+
${context ? `<div class="meta ctx-row">${context}</div>` : ""}
|
|
1134
1322
|
<div class="msg">${escapeHtml(item.message)}</div>
|
|
1323
|
+
${
|
|
1324
|
+
detailsText
|
|
1325
|
+
? `<button class="link" data-action="stack" data-key="${escapeHtml(detailsKey)}">${detailsShown ? "▾ hide details" : "▸ show details"}</button>
|
|
1326
|
+
${detailsShown ? `<pre>${escapeHtml(detailsText)}</pre>` : ""}`
|
|
1327
|
+
: ""
|
|
1328
|
+
}
|
|
1135
1329
|
${
|
|
1136
1330
|
item.stack
|
|
1137
1331
|
? `<button class="link" data-action="stack" data-key="${escapeHtml(key)}">${shown ? "▾ hide stack" : "▸ show stack"}</button>
|
|
@@ -1187,7 +1381,7 @@ function errorsTab() {
|
|
|
1187
1381
|
${lede(
|
|
1188
1382
|
errorCount ? "bad" : total ? "mid" : "good",
|
|
1189
1383
|
errorCount
|
|
1190
|
-
? `<strong>${errorCount} errors</strong> captured${total - errorCount ? `, plus ${total - errorCount} warnings` : ""}. Newest first; expand the stack
|
|
1384
|
+
? `<strong>${errorCount} errors</strong> captured${total - errorCount ? `, plus ${total - errorCount} warnings` : ""}. Newest first; expand details or the stack, and copy a record with one click. Failed fetch calls include the page and API path.`
|
|
1191
1385
|
: total
|
|
1192
1386
|
? `No errors, <strong>${total} warnings</strong>. Warnings usually come from missing data or islands that were never ported.`
|
|
1193
1387
|
: "No browser or server side errors were seen in this session.",
|
|
@@ -556,6 +556,7 @@ function apiTab() {
|
|
|
556
556
|
"api-calls",
|
|
557
557
|
[
|
|
558
558
|
{ key: "url", label: "URL" },
|
|
559
|
+
{ key: "page", label: "Page", render: (row) => (row.page ? link(row.page) : "—") },
|
|
559
560
|
{ key: "method", label: "Method" },
|
|
560
561
|
{ key: "status", label: "Status", render: (row) => `<span class="status ${row.error ? "bad" : ""}">${row.status || "—"}</span>` },
|
|
561
562
|
{ key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
|
|
@@ -589,6 +590,25 @@ function errorsTab() {
|
|
|
589
590
|
"errors",
|
|
590
591
|
[
|
|
591
592
|
{ key: "message", label: "Message" },
|
|
593
|
+
{ key: "page", label: "Page", render: (row) => (row.page ? link(row.page) : "—") },
|
|
594
|
+
{
|
|
595
|
+
key: "details",
|
|
596
|
+
label: "Details",
|
|
597
|
+
render: (row) => {
|
|
598
|
+
if (row.details == null) return "—";
|
|
599
|
+
const text =
|
|
600
|
+
typeof row.details === "string"
|
|
601
|
+
? row.details
|
|
602
|
+
: (() => {
|
|
603
|
+
try {
|
|
604
|
+
return JSON.stringify(row.details);
|
|
605
|
+
} catch {
|
|
606
|
+
return String(row.details);
|
|
607
|
+
}
|
|
608
|
+
})();
|
|
609
|
+
return `<span class="hint" title="${escapeHtml(text)}">${escapeHtml(text.slice(0, 120))}${text.length > 120 ? "…" : ""}</span>`;
|
|
610
|
+
},
|
|
611
|
+
},
|
|
592
612
|
{ key: "level", label: "Level", render: (row) => `<span class="tag">${escapeHtml(row.level)}</span>` },
|
|
593
613
|
{ key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
|
|
594
614
|
],
|
package/src/client/registry.js
CHANGED
|
@@ -110,6 +110,12 @@ async function mountIsland(element) {
|
|
|
110
110
|
|
|
111
111
|
markMounted(element);
|
|
112
112
|
|
|
113
|
+
// Dev overlay başarısız fetch'i hangi island'dan geldiğine bağlayabilsin.
|
|
114
|
+
// Mount bittikten sonra (event handler'lar) bu işaret kalkar; o durumda
|
|
115
|
+
// yığın izine düşülür.
|
|
116
|
+
const previous = globalThis.__JSKELET_MOUNTING_ISLAND;
|
|
117
|
+
globalThis.__JSKELET_MOUNTING_ISLAND = name;
|
|
118
|
+
|
|
113
119
|
try {
|
|
114
120
|
const module = await loader();
|
|
115
121
|
const cleanup = module.mount(element, readProps(element));
|
|
@@ -130,6 +136,8 @@ async function mountIsland(element) {
|
|
|
130
136
|
element.dataset.islandReady = "true";
|
|
131
137
|
} catch (error) {
|
|
132
138
|
console.error(`[island] ${name} failed to load`, error);
|
|
139
|
+
} finally {
|
|
140
|
+
globalThis.__JSKELET_MOUNTING_ISLAND = previous;
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
143
|
|
|
@@ -25,13 +25,15 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
25
25
|
* @property {string[]} taintReasons Hangi erişimler işaretledi (teşhis için).
|
|
26
26
|
* @property {string | null} csrfToken
|
|
27
27
|
* @property {import('express').Response | null} res
|
|
28
|
+
* @property {string | null} pathname Devtools: bu isteğin sayfa yolu (SSR
|
|
29
|
+
* sırasında upstream hatasına "hangi sayfa" bağlamak için).
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
32
|
/** @type {AsyncLocalStorage<RequestContext>} */
|
|
31
33
|
const storage = new AsyncLocalStorage();
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* @param {{ private?: boolean, res?: import('express').Response }} [initial]
|
|
36
|
+
* @param {{ private?: boolean, res?: import('express').Response, pathname?: string | null }} [initial]
|
|
35
37
|
* @returns {RequestContext}
|
|
36
38
|
*/
|
|
37
39
|
export function createRequestContext(initial = {}) {
|
|
@@ -41,6 +43,7 @@ export function createRequestContext(initial = {}) {
|
|
|
41
43
|
taintReasons: [],
|
|
42
44
|
csrfToken: null,
|
|
43
45
|
res: initial.res ?? null,
|
|
46
|
+
pathname: initial.pathname ?? null,
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -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
|
-
/**
|
|
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 {{
|
|
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} */
|
|
@@ -313,8 +426,12 @@ function watchManifest() {
|
|
|
313
426
|
// sayfa silinmiş dosyayı istemeye devam eder.
|
|
314
427
|
clearHtmlCache();
|
|
315
428
|
|
|
316
|
-
|
|
317
|
-
|
|
429
|
+
// Yalnızca stylesheet anahtarları değiştiyse hot-swap; JS/sprite için
|
|
430
|
+
// tam yenileme gerekir. Birden fazla sayfa sheet'i de desteklenir.
|
|
431
|
+
if (changed.every((key) => key.endsWith(".css"))) {
|
|
432
|
+
for (const name of changed) {
|
|
433
|
+
broadcast({ type: "css", name, href: next[name] });
|
|
434
|
+
}
|
|
318
435
|
return;
|
|
319
436
|
}
|
|
320
437
|
|
|
@@ -448,7 +565,7 @@ export function mountDevtools(app) {
|
|
|
448
565
|
|
|
449
566
|
restore();
|
|
450
567
|
patchConsole();
|
|
451
|
-
trackServerFetch();
|
|
568
|
+
trackServerFetch({ onFailure: recordApiFailure });
|
|
452
569
|
watchManifest();
|
|
453
570
|
startVersionCheck();
|
|
454
571
|
startHeartbeat();
|