jskelet 0.6.2 → 0.6.4
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 +32 -0
- package/README.md +2 -0
- package/docs/02-mimari.md +1 -0
- package/docs/04-render-ve-sablonlar.md +9 -3
- package/docs/06-cache.md +53 -9
- package/docs/07-yapilandirma.md +1208 -1191
- package/docs/08-build.md +2 -1
- package/docs/10-dagitim.md +16 -6
- package/docs/12-panel-ve-oturum.md +2 -1
- package/docs/en/02-architecture.md +2 -1
- package/docs/en/04-rendering.md +9 -3
- package/docs/en/06-caching.md +54 -9
- package/docs/en/07-configuration.md +24 -9
- package/docs/en/08-build.md +2 -1
- package/docs/en/10-deployment.md +17 -6
- package/docs/en/12-dashboards-and-sessions.md +2 -1
- package/package.json +1 -1
- package/src/config/defaults.js +541 -518
- package/src/config/index.js +1500 -1456
- package/src/init.mjs +2 -0
- package/src/server/cache-blob.js +70 -0
- package/src/server/cache-control.js +45 -0
- package/src/server/data-cache.js +118 -27
- package/src/server/disk-cache.js +233 -0
- package/src/server/html-cache.js +90 -16
- package/src/server/image-optimizer.js +95 -2
- package/src/server/logs/file-sink.js +159 -32
- package/src/server/logs/pipeline.js +10 -3
- package/src/server/middleware/static-precompressed.js +31 -10
- package/src/server/og-image.js +17 -4
- package/src/server/prewarm.js +25 -1
- package/src/server/redis.js +31 -12
- package/src/server/render.js +910 -910
- package/types/config/defaults.d.ts +21 -1
- package/types/config/index.d.ts +14 -0
- package/types/server/cache-blob.d.ts +13 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/data-cache.d.ts +9 -0
- package/types/server/disk-cache.d.ts +36 -0
- package/types/server/html-cache.d.ts +26 -3
- package/types/server/logs/file-sink.d.ts +16 -5
- package/types/server/og-image.d.ts +5 -0
- package/types/server/redis.d.ts +2 -1
package/src/server/render.js
CHANGED
|
@@ -1,910 +1,910 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Render katmanı + HTML TTL cache (ISR ikamesi).
|
|
3
|
-
* Varsayılan şablon yolu derlenmiş `.jsk`; EJS opsiyonel legacy peer.
|
|
4
|
-
*
|
|
5
|
-
* Controller sözleşmesi:
|
|
6
|
-
* async (ctx) => { view, data?, metadata?, status?, revalidate?, head?,
|
|
7
|
-
* bodyClass?, entries?, styles? }
|
|
8
|
-
* `ctx` → { params, query, pathname, req }
|
|
9
|
-
*
|
|
10
|
-
* `route()` üç kapsamı belirli bir sırayla iç içe kurar:
|
|
11
|
-
* withHtmlCache( withUpstreamTracking( withRequestCache( controller ) ) )
|
|
12
|
-
* Sıra önemli — istek içi cache en içte olmalı ki aynı render'daki iki
|
|
13
|
-
* çağrı tek upstream isteğine düşsün; upstream takibi HTML cache'in içinde
|
|
14
|
-
* olmalı ki eksik veriyle üretilen çıktı önbelleğe yazılmasın.
|
|
15
|
-
*/
|
|
16
|
-
import path from "node:path";
|
|
17
|
-
import process from "node:process";
|
|
18
|
-
import fs from "node:fs";
|
|
19
|
-
import { pathToFileURL } from "node:url";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import
|
|
42
|
-
import * as
|
|
43
|
-
import
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
...
|
|
84
|
-
...
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* @param {
|
|
147
|
-
* @
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
.
|
|
176
|
-
.
|
|
177
|
-
.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
*
|
|
191
|
-
* @param {string}
|
|
192
|
-
* @
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* @param {
|
|
221
|
-
* @
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
...(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
...
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
(
|
|
278
|
-
(
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
* @
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
`
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
res.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
);
|
|
408
|
-
} else {
|
|
409
|
-
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
410
|
-
// Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
|
|
411
|
-
// engeller; `no-store`'a uymayan bir katman için ikinci savunma.
|
|
412
|
-
res.setHeader("Vary", "Cookie");
|
|
413
|
-
|
|
414
|
-
if (leaked) {
|
|
415
|
-
console.warn(
|
|
416
|
-
`[render] ${req.path} read identity-bound data (${context.taintReasons.join(", ")}), ` +
|
|
417
|
-
`not cached. The route should be registered with 'private: true'.`,
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// ETag kişiye özel HTML için kullanıcıya özgü bir doğrulayıcıdır ve
|
|
423
|
-
// `no-store` ile birlikte hiçbir işe yaramaz; üretilmesi engellenir.
|
|
424
|
-
await sendHtml(req, res, result.html, result.encoded, { etag: publicCache });
|
|
425
|
-
|
|
426
|
-
// onVisit: yanıt gittikten sonra linkleri kuyruğa al — TTFB'yi şişirmez.
|
|
427
|
-
// Yalnızca herkese açık, önbelleklenebilir 200 HTML; private / degraded
|
|
428
|
-
// sayfadaki linkler kişiye özel veya eksik olabilir.
|
|
429
|
-
if (publicCache && result.status === 200 && result.html) {
|
|
430
|
-
const pagePath = req.path;
|
|
431
|
-
const pageHtml = result.html;
|
|
432
|
-
queueMicrotask(() => {
|
|
433
|
-
noteVisitWarm(pageHtml, { path: pagePath, req });
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
} catch (error) {
|
|
437
|
-
if (isRedirectError(error)) {
|
|
438
|
-
// Oturuma bağlı bir yönlendirme de kişiye özeldir: "giriş yapmalısın"
|
|
439
|
-
// kararının cache'lenmesi, oturum açmış kullanıcıyı da login sayfasına
|
|
440
|
-
// atan türde hatalara yol açıyor.
|
|
441
|
-
if (isPrivate || context.tainted) {
|
|
442
|
-
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
443
|
-
res.setHeader("Vary", "Cookie");
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
res.redirect(error.statusCode, error.location);
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
next(error);
|
|
450
|
-
}
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Layout'suz, asla cache'lenmeyen parça yanıtı.
|
|
456
|
-
*
|
|
457
|
-
* Fragment uçları (tablo sayfası, sekme paneli, canlı tazelenen kart) her
|
|
458
|
-
* projede elle yazılıyor ve `no-store` yazmayı unutmak sessiz bir sızıntıya
|
|
459
|
-
* dönüşüyor. Burada politika sabit: HTML cache'e hiç uğramaz, `no-store` ile
|
|
460
|
-
* ve ETag'siz gider.
|
|
461
|
-
*
|
|
462
|
-
* Hata durumunda tüm sayfa yerine küçük bir hata parçası döner: takas edilen
|
|
463
|
-
* bölge bir hata sayfasının tamamını içine almasın.
|
|
464
|
-
*
|
|
465
|
-
* @param {(ctx: { params: object, query: object, pathname: string,
|
|
466
|
-
* req: import('express').Request }) => Promise<{ view: string, data?: object,
|
|
467
|
-
* status?: number } | string>} controller
|
|
468
|
-
* @returns {import('express').RequestHandler}
|
|
469
|
-
*/
|
|
470
|
-
export function fragment(controller) {
|
|
471
|
-
return async (req, res, next) => {
|
|
472
|
-
const context = createRequestContext({
|
|
473
|
-
private: true,
|
|
474
|
-
res,
|
|
475
|
-
pathname: req.path,
|
|
476
|
-
});
|
|
477
|
-
const ctx = {
|
|
478
|
-
params: req.params ?? {},
|
|
479
|
-
query: req.query ?? {},
|
|
480
|
-
pathname: req.path,
|
|
481
|
-
req: guardRequest(req),
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
try {
|
|
485
|
-
const result = await withRequestContext(context, () =>
|
|
486
|
-
withRequestCache(async () => {
|
|
487
|
-
const value = await controller(ctx);
|
|
488
|
-
if (typeof value === "string") return { html: value, status: 200 };
|
|
489
|
-
|
|
490
|
-
return {
|
|
491
|
-
html: await renderView(value.view, value.data ?? {}),
|
|
492
|
-
status: value.status ?? 200,
|
|
493
|
-
};
|
|
494
|
-
}),
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
res.status(result.status);
|
|
498
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
499
|
-
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
500
|
-
res.setHeader("Vary", "Cookie");
|
|
501
|
-
await sendHtml(req, res, result.html, undefined, { etag: false });
|
|
502
|
-
} catch (error) {
|
|
503
|
-
if (isRedirectError(error)) {
|
|
504
|
-
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
505
|
-
res.redirect(error.statusCode, error.location);
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
if (isNotFoundError(error)) {
|
|
510
|
-
res.status(404).setHeader("Cache-Control", PRIVATE_CACHE);
|
|
511
|
-
res.type("html").send(fragmentError("notFound"));
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
console.error(`[fragment] ${req.method} ${req.originalUrl}`, error);
|
|
516
|
-
|
|
517
|
-
if (res.headersSent) {
|
|
518
|
-
next(error);
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
res.status(500).setHeader("Cache-Control", PRIVATE_CACHE);
|
|
523
|
-
res.type("html").send(fragmentError("failed"));
|
|
524
|
-
}
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* Ziyaretçiye görünen parça hatası metinleri. Framework'ün kendi logları
|
|
530
|
-
* İngilizce ama bu satırlar ekranda okunuyor, bu yüzden durum sayfalarıyla
|
|
531
|
-
* aynı kuralı izliyorlar: dil `brand.lang`.
|
|
532
|
-
*/
|
|
533
|
-
const FRAGMENT_MESSAGES = {
|
|
534
|
-
tr: { notFound: "Bu içerik bulunamadı.", failed: "Bu bölüm yüklenemedi." },
|
|
535
|
-
en: { notFound: "This content was not found.", failed: "This section could not be loaded." },
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* Fragment hatası için minimal işaretleme. Şablona bağlı olmaması bilinçli:
|
|
540
|
-
* hata yolu, hatanın kaynağı olabilecek render katmanına geri dönmemeli.
|
|
541
|
-
*
|
|
542
|
-
* @param {"notFound" | "failed"} kind
|
|
543
|
-
* @returns {string}
|
|
544
|
-
*/
|
|
545
|
-
function fragmentError(kind) {
|
|
546
|
-
let lang = "en";
|
|
547
|
-
try {
|
|
548
|
-
lang = getConfig().brand.lang ?? "en";
|
|
549
|
-
} catch {
|
|
550
|
-
/* config yüklenmemişse İngilizce kalır */
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const table = FRAGMENT_MESSAGES[lang.slice(0, 2).toLowerCase()] ?? FRAGMENT_MESSAGES.en;
|
|
554
|
-
return `<div role="alert" data-fragment-error>${html.esc(table[kind])}</div>`;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* `jskelet.config.mjs` → `cache().html` route'un kendi `revalidate`'ini ezer.
|
|
559
|
-
* Sonuç yol başına hatırlanır: her istekte desen taraması yapılmaz.
|
|
560
|
-
*
|
|
561
|
-
* @type {Map<string, number | undefined>}
|
|
562
|
-
*/
|
|
563
|
-
const revalidateByPath = new Map();
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* Yakalayıcı bir route'ta (`/:slug`) her benzersiz yol burada kalıcı bir girdi
|
|
567
|
-
* bırakıyor; sınır olmadan bu, uzun ömürlü süreçte bellek sızıntısına dönüşür.
|
|
568
|
-
* Desen taraması ucuz olduğu için en eski girdileri atmak güvenli.
|
|
569
|
-
*/
|
|
570
|
-
const REVALIDATE_CACHE_MAX = 2000;
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* Aynı gerekçeyle (yakalayıcı route'ta sınırsız büyüme) query kuralı da yol
|
|
574
|
-
* başına hatırlanır.
|
|
575
|
-
*
|
|
576
|
-
* @type {Map<string, true | string[] | null>}
|
|
577
|
-
*/
|
|
578
|
-
const queryPolicyByPath = new Map();
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* @param {string} pathname
|
|
582
|
-
* @returns {true | string[] | null} `null`: bu yol için hiçbir parametreye
|
|
583
|
-
* izin verilmiyor.
|
|
584
|
-
*/
|
|
585
|
-
function resolveQueryPolicy(pathname) {
|
|
586
|
-
if (queryPolicyByPath.has(pathname)) {
|
|
587
|
-
return queryPolicyByPath.get(pathname) ?? null;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const rules = getConfig().cacheQuery;
|
|
591
|
-
const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
|
|
592
|
-
|
|
593
|
-
if (queryPolicyByPath.size >= REVALIDATE_CACHE_MAX) {
|
|
594
|
-
const oldest = queryPolicyByPath.keys().next().value;
|
|
595
|
-
if (oldest !== undefined) queryPolicyByPath.delete(oldest);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const policy = match ? match.allow : null;
|
|
599
|
-
queryPolicyByPath.set(pathname, policy);
|
|
600
|
-
return policy;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* HTML cache anahtarı, ya da query bu yol için cache'lenebilir değilse `null`.
|
|
605
|
-
*
|
|
606
|
-
* Biçim: `${varyPrefix}${pathname}?${izin verilen query}`.
|
|
607
|
-
* Vary (`cache().vary`) query allowlist'ten bağımsız; host/locale sitelerinde
|
|
608
|
-
* ilk host'un HTML'inin diğerine servis edilmesini engeller.
|
|
609
|
-
*
|
|
610
|
-
* Varsayılan olarak query parametresi taşıyan istek dinamiktir: `cache().query`
|
|
611
|
-
* altında eşleşen bir kural olmadıkça cache'e hiç girmez. Aksi hâlde bir yolun
|
|
612
|
-
* bütün `?utm_source=…` varyantları ayrı girdi olur ve LRU'daki gerçek
|
|
613
|
-
* sayfaları dışarı atar.
|
|
614
|
-
*
|
|
615
|
-
* İzin verilen parametreler **sıralı** yazılır: `?a=1&b=2` ile `?b=2&a=1` aynı
|
|
616
|
-
* sayfa olduğu için aynı anahtarı almalı.
|
|
617
|
-
*
|
|
618
|
-
* @param {string} pathname
|
|
619
|
-
* @param {Record<string, unknown>} query
|
|
620
|
-
* @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
|
|
621
|
-
* @returns {string | null}
|
|
622
|
-
*/
|
|
623
|
-
function buildCacheKey(pathname, query, req) {
|
|
624
|
-
const vary = buildVaryPrefix(req);
|
|
625
|
-
const entries = Object.entries(query);
|
|
626
|
-
if (!entries.length) return `${vary}${pathname}?`;
|
|
627
|
-
|
|
628
|
-
const policy = resolveQueryPolicy(pathname);
|
|
629
|
-
if (policy === null) return null;
|
|
630
|
-
|
|
631
|
-
const kept =
|
|
632
|
-
policy === true ? entries : entries.filter(([name]) => policy.includes(name));
|
|
633
|
-
|
|
634
|
-
// İzin listesi dışındaki parametreler anahtara girmez: sayfa cache'lenir ve
|
|
635
|
-
// bütün kampanya varyantları tek kopyayı paylaşır.
|
|
636
|
-
const params = new URLSearchParams(
|
|
637
|
-
kept
|
|
638
|
-
.map(/** @returns {[string, string]} */ ([name, value]) => [name, String(value)])
|
|
639
|
-
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
640
|
-
);
|
|
641
|
-
|
|
642
|
-
return `${vary}${pathname}?${params.toString()}`;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* @param {string} pathname
|
|
647
|
-
* @param {number | undefined} fallback
|
|
648
|
-
* @returns {number | undefined}
|
|
649
|
-
*/
|
|
650
|
-
function resolveRevalidate(pathname, fallback) {
|
|
651
|
-
const rules = getConfig().html;
|
|
652
|
-
if (!rules.length) return fallback;
|
|
653
|
-
|
|
654
|
-
if (revalidateByPath.has(pathname)) {
|
|
655
|
-
return revalidateByPath.get(pathname) ?? fallback;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
|
|
659
|
-
|
|
660
|
-
if (revalidateByPath.size >= REVALIDATE_CACHE_MAX) {
|
|
661
|
-
const oldest = revalidateByPath.keys().next().value;
|
|
662
|
-
if (oldest !== undefined) revalidateByPath.delete(oldest);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
revalidateByPath.set(pathname, match?.seconds);
|
|
666
|
-
return match ? match.seconds : fallback;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* Cache'lenmiş bir sayfa aynı gövdeyi her istekte yeniden sıkıştırmasın diye
|
|
671
|
-
* brotli/gzip çıktısı HTML ile birlikte saklanır. `Content-Encoding` burada
|
|
672
|
-
* ayarlandığı için sıkıştırma middleware'i devreye girmez.
|
|
673
|
-
*
|
|
674
|
-
* @param {import('express').Request} req
|
|
675
|
-
* @param {import('express').Response} res
|
|
676
|
-
* @param {string} body
|
|
677
|
-
* @param {Map<string, Buffer>} [encoded]
|
|
678
|
-
* @param {{ etag?: boolean }} [options] `etag: false` → `res.send()` atlanır,
|
|
679
|
-
* böylece Express kullanıcıya özel gövde için doğrulayıcı üretmez.
|
|
680
|
-
* @returns {Promise<void>}
|
|
681
|
-
*/
|
|
682
|
-
async function sendHtml(req, res, body, encoded, options = {}) {
|
|
683
|
-
const encoding =
|
|
684
|
-
req.method === "HEAD" ? null : negotiateEncoding(req.headers["accept-encoding"]);
|
|
685
|
-
|
|
686
|
-
if (!encoding || !encoded) {
|
|
687
|
-
if (options.etag === false) {
|
|
688
|
-
// Sıkıştırma middleware'i devreye girerse Content-Length'i kendisi
|
|
689
|
-
// kaldırır; girmezse doğru uzunlukla gider.
|
|
690
|
-
res.setHeader("Content-Length", String(Buffer.byteLength(body)));
|
|
691
|
-
res.end(req.method === "HEAD" ? undefined : body);
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
res.send(body);
|
|
696
|
-
return;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
let buffer = encoded.get(encoding);
|
|
700
|
-
if (!buffer) {
|
|
701
|
-
buffer = await encodeText(body, encoding);
|
|
702
|
-
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
res.setHeader("Content-Encoding", encoding);
|
|
708
|
-
res.setHeader("Vary", "Accept-Encoding");
|
|
709
|
-
res.setHeader("Content-Length", String(buffer.length));
|
|
710
|
-
res.end(buffer);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* @typedef {{ html: string, status: number, degraded?: boolean,
|
|
715
|
-
* storable?: boolean, retryAfter?: number }} Produced
|
|
716
|
-
*/
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* Controller'ı bir kez çalıştırır. `notFound()` fırlatıldığında sonucu
|
|
720
|
-
* ayırt edilebilir biçimde döner: çağıran taraf bunun gerçek bir 404 mü,
|
|
721
|
-
* yoksa upstream düştüğü için verinin gelmemesi mi olduğuna karar verecek.
|
|
722
|
-
*
|
|
723
|
-
* @param {Function} controller
|
|
724
|
-
* @param {{ pathname: string }} ctx
|
|
725
|
-
* @returns {Promise<{ page: Produced } | { notFound: true,
|
|
726
|
-
* transient: import('./upstream-tracking.js').UpstreamFailure[] }>}
|
|
727
|
-
*/
|
|
728
|
-
async function attempt(controller, ctx) {
|
|
729
|
-
try {
|
|
730
|
-
const page = await controller(ctx);
|
|
731
|
-
const rendered = await renderPage({ pathname: ctx.pathname, ...page });
|
|
732
|
-
return {
|
|
733
|
-
page: {
|
|
734
|
-
html: rendered,
|
|
735
|
-
status: page.status ?? 200,
|
|
736
|
-
degraded: hasUpstreamFailures(ctx.pathname),
|
|
737
|
-
// Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
|
|
738
|
-
// `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
|
|
739
|
-
storable: getRequestContext()?.tainted !== true,
|
|
740
|
-
},
|
|
741
|
-
};
|
|
742
|
-
} catch (error) {
|
|
743
|
-
if (isNotFoundError(error)) {
|
|
744
|
-
return { notFound: true, transient: transientUpstreamFailures() };
|
|
745
|
-
}
|
|
746
|
-
throw error;
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/**
|
|
751
|
-
* @param {Function} controller
|
|
752
|
-
* @param {{ pathname: string }} ctx
|
|
753
|
-
* @returns {Promise<Produced>}
|
|
754
|
-
*/
|
|
755
|
-
async function produce(controller, ctx) {
|
|
756
|
-
const first = await attempt(controller, ctx);
|
|
757
|
-
if ("page" in first) return first.page;
|
|
758
|
-
// Deterministik "böyle bir sayfa yok" cevabı: tekrar denemenin anlamı yok.
|
|
759
|
-
if (!first.transient.length) return { html: await renderNotFound(), status: 404 };
|
|
760
|
-
|
|
761
|
-
// Buraya gelindiyse `notFound()` veri gelmediği için çağrılmış. **Var olan
|
|
762
|
-
// bir sayfayı** 404 olarak servis etmek en kötü sonuç: arama motoru geçici
|
|
763
|
-
// bir rate limit'i kalıcı bir kayıp sanar. Bu yüzden sayfa yeniden denenir —
|
|
764
|
-
// ısıtma günlükleri gösteriyor ki aynı yol saniyeler sonra 200 dönüyor.
|
|
765
|
-
const { attempts, delayMs } = transientRetry();
|
|
766
|
-
let failures = first.transient;
|
|
767
|
-
|
|
768
|
-
for (let round = 1; round <= attempts; round += 1) {
|
|
769
|
-
const retryDetail =
|
|
770
|
-
`returned notFound() while upstream is failing ` +
|
|
771
|
-
`(${summarizeFailures(failures)}), retrying (${round}/${attempts})`;
|
|
772
|
-
if (!suppressForPrewarm(retryDetail)) {
|
|
773
|
-
console.warn(`[render] ${ctx.pathname} ${retryDetail}`);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// Beklemeden tekrar denemek rate limit'e girmiş bir API'de aynı 429'u
|
|
777
|
-
// getirir; kısa bekleme hem pencerenin dönmesine şans verir hem de
|
|
778
|
-
// fırtınayı büyütmez.
|
|
779
|
-
await sleep(delayMs * round);
|
|
780
|
-
|
|
781
|
-
// Her deneme kendi upstream ve istek içi cache bağlamında çalışır: ilk
|
|
782
|
-
// turun hataları ikinci turun kararını kirletmesin ve memoize edilmiş
|
|
783
|
-
// boş cevaplar tekrar kullanılmasın.
|
|
784
|
-
const retried = await withUpstreamTracking(() =>
|
|
785
|
-
withRequestCache(() => attempt(controller, ctx)),
|
|
786
|
-
);
|
|
787
|
-
|
|
788
|
-
if ("page" in retried) return retried.page;
|
|
789
|
-
if (!retried.transient.length) {
|
|
790
|
-
// Bu kez upstream sağlam cevap verdi ve "yok" dedi: gerçek 404.
|
|
791
|
-
return { html: await renderNotFound(), status: 404 };
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
failures = retried.transient;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// Denemeler tükendi. 404 yerine 503: önbelleğe girmez, `Retry-After` taşır
|
|
798
|
-
// ve bir sonraki istek gerçek içeriği üretebilir.
|
|
799
|
-
const outageDetail =
|
|
800
|
-
`could not be produced, upstream is still failing ` +
|
|
801
|
-
`(${summarizeFailures(failures)}), serving an uncached 503 instead of a 404`;
|
|
802
|
-
if (!suppressForPrewarm(outageDetail)) {
|
|
803
|
-
console.warn(`[render] ${ctx.pathname} ${outageDetail}`);
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
return {
|
|
807
|
-
html: await renderStatusPage(503),
|
|
808
|
-
status: 503,
|
|
809
|
-
degraded: true,
|
|
810
|
-
retryAfter: RETRY_AFTER_SECONDS,
|
|
811
|
-
};
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* Geçici bir upstream hatası yüzünden üretilemeyen sayfanın `Retry-After`
|
|
816
|
-
* değeri. Kısa tutuluyor: ziyaretçi de bot da birkaç saniye sonra gerçek
|
|
817
|
-
* içeriği bulabilsin.
|
|
818
|
-
*/
|
|
819
|
-
const RETRY_AFTER_SECONDS = 30;
|
|
820
|
-
|
|
821
|
-
/**
|
|
822
|
-
* Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turu; bu yüzden
|
|
823
|
-
* varsayılan tek deneme ve kısa bekleme. Rate limit fırtınasında toplam yük
|
|
824
|
-
* iki katına çıkabilir, ama alternatifi var olan sayfaları 404'e düşürmek.
|
|
825
|
-
*
|
|
826
|
-
* @returns {{ attempts: number, delayMs: number }}
|
|
827
|
-
*/
|
|
828
|
-
function transientRetry() {
|
|
829
|
-
const raw = /** @type {any} */ (getConfig().transientRetry ?? {});
|
|
830
|
-
const attempts = Number(raw.attempts);
|
|
831
|
-
const delayMs = Number(raw.delayMs);
|
|
832
|
-
|
|
833
|
-
return {
|
|
834
|
-
attempts: Number.isFinite(attempts) && attempts >= 0 ? Math.floor(attempts) : 1,
|
|
835
|
-
delayMs: Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 300,
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
/** @param {number} ms */
|
|
840
|
-
function sleep(ms) {
|
|
841
|
-
return new Promise((resolve) => {
|
|
842
|
-
setTimeout(resolve, ms).unref?.();
|
|
843
|
-
});
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
|
|
849
|
-
* Böyle bir HTML önbelleğe yazılmaz: sonraki istek yeniden dener.
|
|
850
|
-
*
|
|
851
|
-
* Ancak yalnızca *geçici* hatalar için. 400/403/404 gibi deterministik
|
|
852
|
-
* cevaplar tekrar denemekle düzelmez; onlar yüzünden önbelleği kapatmak
|
|
853
|
-
* sayfayı her ziyarette baştan render etmek olur — içerik yine aynı eksik
|
|
854
|
-
* hâliyle döner, ziyaretçi sadece render süresini öder. Bu yüzden kalıcı
|
|
855
|
-
* hatalar yalnızca loglanır, önbelleği engellemez.
|
|
856
|
-
*
|
|
857
|
-
* @param {string} pathname
|
|
858
|
-
* @returns {boolean}
|
|
859
|
-
*/
|
|
860
|
-
function hasUpstreamFailures(pathname) {
|
|
861
|
-
const failures = getUpstreamFailures();
|
|
862
|
-
if (!failures.length) return false;
|
|
863
|
-
|
|
864
|
-
const transient = failures.filter((failure) => isTransientStatus(failure.status));
|
|
865
|
-
const permanent = failures.filter((failure) => !isTransientStatus(failure.status));
|
|
866
|
-
|
|
867
|
-
// Isıtma turu yüzlerce yolu tarıyor; aynı upstream arızası her yol için bir
|
|
868
|
-
// satır basınca tur özeti kayboluyor. Turun uyarıları sayılıp sonunda
|
|
869
|
-
// toplanır (bkz. `suppressForPrewarm`).
|
|
870
|
-
if (permanent.length) {
|
|
871
|
-
const detail = `missing data, upstream is failing permanently (${summarizeFailures(permanent)})`;
|
|
872
|
-
if (!suppressForPrewarm(detail)) {
|
|
873
|
-
console.warn(`[render] ${pathname} was produced with ${detail}`);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
if (!transient.length) return false;
|
|
878
|
-
|
|
879
|
-
const detail = `missing data, not caching it (${summarizeFailures(transient)})`;
|
|
880
|
-
if (!suppressForPrewarm(detail)) {
|
|
881
|
-
console.warn(`[render] ${pathname} was produced with ${detail}`);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
return true;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
/**
|
|
888
|
-
* @returns {import('./upstream-tracking.js').UpstreamFailure[]}
|
|
889
|
-
*/
|
|
890
|
-
function transientUpstreamFailures() {
|
|
891
|
-
return getUpstreamFailures().filter((failure) => isTransientStatus(failure.status));
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
/**
|
|
895
|
-
* @param {import('./upstream-tracking.js').UpstreamFailure[]} failures
|
|
896
|
-
* @returns {string}
|
|
897
|
-
*/
|
|
898
|
-
function summarizeFailures(failures) {
|
|
899
|
-
return failures.map((failure) => `${failure.status} ${failure.path}`).join(", ");
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
/**
|
|
903
|
-
* 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
|
|
904
|
-
* ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.
|
|
905
|
-
*
|
|
906
|
-
* @returns {Promise<string>}
|
|
907
|
-
*/
|
|
908
|
-
export async function renderNotFound() {
|
|
909
|
-
return renderStatusPage(404);
|
|
910
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Render katmanı + HTML TTL cache (ISR ikamesi).
|
|
3
|
+
* Varsayılan şablon yolu derlenmiş `.jsk`; EJS opsiyonel legacy peer.
|
|
4
|
+
*
|
|
5
|
+
* Controller sözleşmesi:
|
|
6
|
+
* async (ctx) => { view, data?, metadata?, status?, revalidate?, head?,
|
|
7
|
+
* bodyClass?, entries?, styles? }
|
|
8
|
+
* `ctx` → { params, query, pathname, req }
|
|
9
|
+
*
|
|
10
|
+
* `route()` üç kapsamı belirli bir sırayla iç içe kurar:
|
|
11
|
+
* withHtmlCache( withUpstreamTracking( withRequestCache( controller ) ) )
|
|
12
|
+
* Sıra önemli — istek içi cache en içte olmalı ki aynı render'daki iki
|
|
13
|
+
* çağrı tek upstream isteğine düşsün; upstream takibi HTML cache'in içinde
|
|
14
|
+
* olmalı ki eksik veriyle üretilen çıktı önbelleğe yazılmasın.
|
|
15
|
+
*/
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import process from "node:process";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
import { setEdgeCacheHeaders } from "./cache-control.js";
|
|
21
|
+
import { rememberHtmlEncoding, withHtmlCache } from "./html-cache.js";
|
|
22
|
+
import { buildVaryPrefix } from "./cache-vary.js";
|
|
23
|
+
import { getConfig, hook, FRAMEWORK_ROOT } from "../config/index.js";
|
|
24
|
+
import { matchPattern } from "../config/pattern.js";
|
|
25
|
+
import { encodeText, negotiateEncoding } from "./middleware/compression.js";
|
|
26
|
+
import { navigationHints, preconnectHints } from "./head-hints.js";
|
|
27
|
+
import { withRequestCache } from "../http/request-cache.js";
|
|
28
|
+
import {
|
|
29
|
+
createRequestContext,
|
|
30
|
+
getRequestContext,
|
|
31
|
+
guardRequest,
|
|
32
|
+
withRequestContext,
|
|
33
|
+
} from "../http/request-context.js";
|
|
34
|
+
import {
|
|
35
|
+
getUpstreamFailures,
|
|
36
|
+
isTransientStatus,
|
|
37
|
+
withUpstreamTracking,
|
|
38
|
+
} from "./upstream-tracking.js";
|
|
39
|
+
import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
|
|
40
|
+
import { renderHeadMeta } from "./metadata.js";
|
|
41
|
+
import { asset, hasAsset } from "./assets.js";
|
|
42
|
+
import * as html from "../views/helpers/html.js";
|
|
43
|
+
import * as tags from "../views/helpers/tags.js";
|
|
44
|
+
import { loadComponents } from "../views/components/loader.js";
|
|
45
|
+
import { renderStatusPage } from "./status-page.js";
|
|
46
|
+
import { suppressForPrewarm, noteVisitWarm } from "./prewarm.js";
|
|
47
|
+
import {
|
|
48
|
+
ensureTemplatesCompiled,
|
|
49
|
+
getComponentDirs,
|
|
50
|
+
getViewRoots,
|
|
51
|
+
} from "../compile/index.js";
|
|
52
|
+
import { renderEjsFile } from "./ejs-adapter.js";
|
|
53
|
+
|
|
54
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Şablonlara otomatik geçen yardımcılar ve (legacy) EJS ayarları. Bileşen
|
|
58
|
+
* taraması dosya sistemine dokunduğu için bir kez yapılır; config yüklenmeden
|
|
59
|
+
* hesaplanamaz, bu yüzden ilk render'da kurulur.
|
|
60
|
+
*
|
|
61
|
+
* @type {{ helpers: Record<string, unknown>, options: Record<string, unknown>,
|
|
62
|
+
* viewsDir: string, viewRoots: string[], layout: string,
|
|
63
|
+
* compiled: Map<string, (data: object, helpers: object) => string>,
|
|
64
|
+
* layoutRender: ((data: object, helpers: object) => string) | null
|
|
65
|
+
* } | null}
|
|
66
|
+
*/
|
|
67
|
+
let engine = null;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @returns {Promise<NonNullable<typeof engine>>}
|
|
71
|
+
*/
|
|
72
|
+
async function getEngine() {
|
|
73
|
+
if (engine) return engine;
|
|
74
|
+
|
|
75
|
+
const config = getConfig();
|
|
76
|
+
const viewsDir = config.dirs.views;
|
|
77
|
+
const viewRoots = getViewRoots(config);
|
|
78
|
+
|
|
79
|
+
await ensureTemplatesCompiled(config);
|
|
80
|
+
|
|
81
|
+
/** @type {Record<string, unknown>} */
|
|
82
|
+
const helpers = {
|
|
83
|
+
...html,
|
|
84
|
+
...tags,
|
|
85
|
+
...(await loadComponents(getComponentDirs(config))),
|
|
86
|
+
asset,
|
|
87
|
+
hasAsset,
|
|
88
|
+
// Yerleşik PascalCase etiketler (.jsk bileşen sözdizimi).
|
|
89
|
+
Link: (props) => tags.link(props),
|
|
90
|
+
Image: (props) => tags.image(props),
|
|
91
|
+
Icon: (props) => tags.icon(props),
|
|
92
|
+
CsrfField: () => tags.csrfField(),
|
|
93
|
+
PreloadImage: (props) => tags.preloadImage(props),
|
|
94
|
+
Stylesheets: (props) => tags.stylesheets(props),
|
|
95
|
+
BodyScripts: (props) => tags.bodyScripts(props),
|
|
96
|
+
JsonLd: (props) => tags.jsonLd(props),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// camelCase JS bileşenlerini PascalCase etiket adıyla da erişilebilir yap.
|
|
100
|
+
for (const [name, value] of Object.entries(helpers)) {
|
|
101
|
+
if (typeof value !== "function") continue;
|
|
102
|
+
if (/^[A-Z]/.test(name)) continue;
|
|
103
|
+
const pascal = name.charAt(0).toUpperCase() + name.slice(1);
|
|
104
|
+
if (!(pascal in helpers)) helpers[pascal] = value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { compiled, layoutRender: appLayoutRender } = await loadCompiledTemplates(
|
|
108
|
+
config,
|
|
109
|
+
helpers,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Yalnızca framework varsayılan layout'una düşüldüğünde hazır modülü kullan;
|
|
113
|
+
// uygulamanın kendi layout.jsk'si derlenmemişse sessizce framework'e kayma.
|
|
114
|
+
let layoutRender = appLayoutRender;
|
|
115
|
+
if (!layoutRender) {
|
|
116
|
+
const fwLayout = path.join(FRAMEWORK_ROOT, "src", "templates", "layout.jsk");
|
|
117
|
+
if (path.resolve(config.layout) === path.resolve(fwLayout)) {
|
|
118
|
+
const fw = await import("../templates/layout.render.js");
|
|
119
|
+
if (typeof fw.render === "function") layoutRender = fw.render;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
engine = {
|
|
124
|
+
viewsDir,
|
|
125
|
+
viewRoots,
|
|
126
|
+
layout: config.layout,
|
|
127
|
+
helpers,
|
|
128
|
+
compiled,
|
|
129
|
+
layoutRender,
|
|
130
|
+
options: {
|
|
131
|
+
// `include('partials/header')` gibi çağrılar views kökünden çözülür.
|
|
132
|
+
root: viewsDir,
|
|
133
|
+
views: viewRoots.length ? viewRoots : [viewsDir],
|
|
134
|
+
cache: !isDev,
|
|
135
|
+
rmWhitespace: true,
|
|
136
|
+
async: true,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return engine;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Derlenmiş `.jsk` modüllerini yükler. İstek anında parse/compile yok.
|
|
145
|
+
*
|
|
146
|
+
* @param {import('../config/index.js').ResolvedConfig} config
|
|
147
|
+
* @param {Record<string, unknown>} helpers
|
|
148
|
+
* @returns {Promise<{ compiled: Map<string, Function>, layoutRender: Function | null }>}
|
|
149
|
+
*/
|
|
150
|
+
async function loadCompiledTemplates(config, helpers) {
|
|
151
|
+
/** @type {Map<string, (data: object, helpers: object) => string>} */
|
|
152
|
+
const compiled = new Map();
|
|
153
|
+
const templatesDir = path.join(config.dirs.generated, "templates");
|
|
154
|
+
const manifestPath = path.join(templatesDir, "manifest.json");
|
|
155
|
+
|
|
156
|
+
if (!fs.existsSync(manifestPath)) {
|
|
157
|
+
return { compiled, layoutRender: null };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** @type {Record<string, string>} */
|
|
161
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
162
|
+
const bust = isDev ? `?t=${Date.now()}` : "";
|
|
163
|
+
|
|
164
|
+
for (const [viewId, rel] of Object.entries(manifest)) {
|
|
165
|
+
const file = path.join(templatesDir, rel);
|
|
166
|
+
if (!fs.existsSync(file)) continue;
|
|
167
|
+
const mod = await import(pathToFileURL(file).href + bust);
|
|
168
|
+
if (typeof mod.render !== "function") continue;
|
|
169
|
+
compiled.set(viewId, mod.render);
|
|
170
|
+
|
|
171
|
+
// `components/*.jsk` → helpers.PascalName
|
|
172
|
+
if (viewId.startsWith("components/")) {
|
|
173
|
+
const base = viewId.slice("components/".length).split("/").pop() ?? "";
|
|
174
|
+
const pascal = base
|
|
175
|
+
.split(/[-_/]+/)
|
|
176
|
+
.filter(Boolean)
|
|
177
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
178
|
+
.join("");
|
|
179
|
+
if (pascal) {
|
|
180
|
+
helpers[pascal] = (props) => mod.render(props ?? {}, helpers);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const layoutRender = compiled.get("layout") ?? null;
|
|
186
|
+
return { compiled, layoutRender };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* View id için kaynak `.ejs` dosyasını çoklu kökte ara.
|
|
191
|
+
* @param {string[]} viewRoots
|
|
192
|
+
* @param {string} view
|
|
193
|
+
* @returns {string | null}
|
|
194
|
+
*/
|
|
195
|
+
function findEjsView(viewRoots, view) {
|
|
196
|
+
for (const root of viewRoots) {
|
|
197
|
+
const file = path.join(root, `${view}.ejs`);
|
|
198
|
+
if (fs.existsSync(file)) return file;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Uygulama geliştirirken bileşen dosyaları değişince kayıt yenilenmeli.
|
|
205
|
+
* Dev sunucusu süreci yeniden başlattığı için normalde gerekmez; gömülü
|
|
206
|
+
* kullanımlar (test, script) için dışa açık.
|
|
207
|
+
*
|
|
208
|
+
* @returns {void}
|
|
209
|
+
*/
|
|
210
|
+
export function resetRenderEngine() {
|
|
211
|
+
engine = null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Layout kullanmadan tek bir şablon render eder. Fragment/partial uçları
|
|
216
|
+
* ve e-posta şablonları bunu kullanır.
|
|
217
|
+
*
|
|
218
|
+
* Öncelik: derlenmiş `.jsk` → `.ejs`. İstek anında şablon derlenmez.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} view `views/` altındaki yol, uzantısız (örn. "pages/home")
|
|
221
|
+
* @param {object} [data]
|
|
222
|
+
* @returns {Promise<string>}
|
|
223
|
+
*/
|
|
224
|
+
export async function renderView(view, data = {}) {
|
|
225
|
+
const { helpers, options, compiled, viewRoots, viewsDir } = await getEngine();
|
|
226
|
+
const renderFn = compiled.get(view);
|
|
227
|
+
if (renderFn) {
|
|
228
|
+
return renderFn({ ...data }, helpers);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const file =
|
|
232
|
+
findEjsView(viewRoots.length ? viewRoots : [viewsDir], view) ??
|
|
233
|
+
path.join(viewsDir, `${view}.ejs`);
|
|
234
|
+
return renderEjsFile(file, { ...helpers, ...data }, options);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Sayfayı layout içinde render eder.
|
|
239
|
+
*
|
|
240
|
+
* @param {{ view: string, data?: object, metadata?: object, head?: string,
|
|
241
|
+
* bodyClass?: string, entries?: string[], styles?: string[],
|
|
242
|
+
* pathname?: string }} page
|
|
243
|
+
* @returns {Promise<string>}
|
|
244
|
+
*/
|
|
245
|
+
export async function renderPage(page) {
|
|
246
|
+
const { helpers, options, layout, layoutRender } = await getEngine();
|
|
247
|
+
const config = getConfig();
|
|
248
|
+
|
|
249
|
+
const metadata = {
|
|
250
|
+
...(await hook("metadata", {}, page)),
|
|
251
|
+
...(page.metadata ?? {}),
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Layout bağlamı ve gövde paralel üretilir: navigasyon çoğu projede
|
|
255
|
+
// upstream'den geliyor ve gövde render'ıyla sırayla beklemek her sayfaya
|
|
256
|
+
// gereksiz gecikme ekliyor.
|
|
257
|
+
const [body, context] = await Promise.all([
|
|
258
|
+
renderView(page.view, { ...(page.data ?? {}), metadata }),
|
|
259
|
+
hook("layoutContext", {}, { pathname: page.pathname ?? "", metadata }),
|
|
260
|
+
]);
|
|
261
|
+
|
|
262
|
+
const locals = {
|
|
263
|
+
...helpers,
|
|
264
|
+
...context,
|
|
265
|
+
metadata,
|
|
266
|
+
// Boş varsayılan bilinçli: "/" yazmak her sayfayı ana sayfa sanıp
|
|
267
|
+
// logoyu <h1> olarak bastıran türde hatalara yol açıyor.
|
|
268
|
+
pathname: page.pathname ?? "",
|
|
269
|
+
lang: context.lang ?? config.brand.lang ?? "en",
|
|
270
|
+
headMeta: renderHeadMeta(metadata),
|
|
271
|
+
structuredData: context.structuredData ?? [],
|
|
272
|
+
// Preconnect her sayfada aynı; LCP preload'ını sayfa kendisi ekler.
|
|
273
|
+
// Gezinme ipuçları preconnect'ten sonra: spekülasyon bir sonraki sayfayı
|
|
274
|
+
// ilgilendiriyor, bu sayfanın LCP'sinin önüne geçmemeli.
|
|
275
|
+
extraHead:
|
|
276
|
+
preconnectHints() +
|
|
277
|
+
navigationHints() +
|
|
278
|
+
(page.head ?? "") +
|
|
279
|
+
(context.extraHead ?? ""),
|
|
280
|
+
bodyClass: page.bodyClass ?? context.bodyClass ?? "",
|
|
281
|
+
entries: page.entries ?? [],
|
|
282
|
+
styles: page.styles ?? [],
|
|
283
|
+
devtools: isDev,
|
|
284
|
+
devBasePath: config.brand.devBasePath,
|
|
285
|
+
body,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
if (layoutRender) {
|
|
289
|
+
return layoutRender(locals, helpers);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (layout.endsWith(".jsk")) {
|
|
293
|
+
// Kaynak var ama derlenmemiş — ensureTemplates sonrası olmamalı.
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Layout ${path.relative(config.root, layout)} is not compiled — run jskelet build`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return renderEjsFile(layout, locals, options);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Kişiye özel yanıtın cache direktifi. Dinamik (cache'lenmeyen) her sayfa da
|
|
304
|
+
* bunu alır: bir yanıt hiçbir direktif taşımadığında HTTP onu "sezgisel olarak
|
|
305
|
+
* cache'lenebilir" sayar ve araya giren bir proxy ya da tarayıcının geri
|
|
306
|
+
* tuşu kullanıcıya özel HTML'i saklayabilir.
|
|
307
|
+
*/
|
|
308
|
+
const PRIVATE_CACHE = "private, no-store";
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Controller'ı çalıştırıp yanıtı yazar; notFound/redirect kontrol akışını,
|
|
312
|
+
* HTML cache'ini ve hata yönetimini üstlenir.
|
|
313
|
+
*
|
|
314
|
+
* `private: true` kişiye özel sayfaları public cache yolundan tamamen ayırır:
|
|
315
|
+
* HTML cache devre dışı kalır, config'in `cache.html` deseni bu kararı
|
|
316
|
+
* ezemez, yanıt `no-store` ile ve ETag'siz gider. Dashboard tipi sayfalarda
|
|
317
|
+
* bu bayrak olmadan çalışmak, bir kullanıcının HTML'inin bir başkasına
|
|
318
|
+
* servis edilmesi anlamına gelir.
|
|
319
|
+
*
|
|
320
|
+
* @param {(ctx: { params: object, query: object, pathname: string,
|
|
321
|
+
* req: import('express').Request }) => Promise<object>} controller
|
|
322
|
+
* @param {{ revalidate?: number, private?: boolean }} [options]
|
|
323
|
+
* @returns {import('express').RequestHandler}
|
|
324
|
+
*/
|
|
325
|
+
export function route(controller, options = {}) {
|
|
326
|
+
const isPrivate = options.private === true;
|
|
327
|
+
|
|
328
|
+
return async (req, res, next) => {
|
|
329
|
+
const context = createRequestContext({
|
|
330
|
+
private: isPrivate,
|
|
331
|
+
res,
|
|
332
|
+
pathname: req.path,
|
|
333
|
+
});
|
|
334
|
+
const ctx = {
|
|
335
|
+
params: req.params ?? {},
|
|
336
|
+
query: req.query ?? {},
|
|
337
|
+
pathname: req.path,
|
|
338
|
+
// Cookie/Authorization okunursa çıktı kullanıcıya bağlıdır; cache'e
|
|
339
|
+
// yazılmaması için işaretlenmesi gerekiyor.
|
|
340
|
+
req: guardRequest(req),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// Private route'ta desen taraması hiç yapılmaz: `cache.html` altındaki
|
|
344
|
+
// geniş bir kural (`/**` gibi) bu sayfayı cache'lenebilir hâle
|
|
345
|
+
// getirmesin. Kilit tek yönlü — route "özel" dediyse config açamaz.
|
|
346
|
+
const revalidate = isPrivate
|
|
347
|
+
? undefined
|
|
348
|
+
: resolveRevalidate(req.path, options.revalidate);
|
|
349
|
+
// Anahtar `null` ise query bu yol için cache'lenebilir değil: sayfa
|
|
350
|
+
// dinamik davranır. Anahtar yine de gerekiyor (hata sayfası ölçümü,
|
|
351
|
+
// teşhis) ama TTL sıfırlanıp cache yolu kapatılır.
|
|
352
|
+
const key = buildCacheKey(req.path, ctx.query, req);
|
|
353
|
+
const cacheable =
|
|
354
|
+
!isPrivate && req.method === "GET" && Boolean(revalidate) && key !== null;
|
|
355
|
+
const cacheKey =
|
|
356
|
+
key ??
|
|
357
|
+
`${buildVaryPrefix(req)}${req.path}?${new URLSearchParams(
|
|
358
|
+
Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
|
|
359
|
+
).toString()}`;
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const result = await withRequestContext(context, () =>
|
|
363
|
+
withHtmlCache(cacheKey, cacheable ? revalidate : 0, () =>
|
|
364
|
+
withUpstreamTracking(() => withRequestCache(() => produce(controller, ctx))),
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// Cache'lenebilir bir route kimliğe dokunduysa yanıt yine gider ama
|
|
369
|
+
// saklanmaz (`produce` bunu `storable: false` ile bildirdi) ve public
|
|
370
|
+
// direktif yazılmaz.
|
|
371
|
+
const leaked = cacheable && context.tainted;
|
|
372
|
+
if (leaked && isDev) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`[render] ${req.path} is a cacheable route but read identity-bound data ` +
|
|
375
|
+
`(${context.taintReasons.join(", ")}). This page must be registered with ` +
|
|
376
|
+
`'route(fn, { private: true })'; otherwise one user's HTML is served to another.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Eksik veriyle üretilen çıktı süreç içi önbelleğe yazılmıyor; aynı
|
|
381
|
+
// çıktıya edge'de taze pencere vermek o kararı bir katman yukarıda geri
|
|
382
|
+
// almak olurdu. Geçici bir 429 yüzünden üretilen 503, ters proxy'de
|
|
383
|
+
// dakikalarca yaşamamalı.
|
|
384
|
+
const publicCache = cacheable && !leaked && !result.degraded;
|
|
385
|
+
|
|
386
|
+
res.status(result.status);
|
|
387
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
388
|
+
|
|
389
|
+
// Geçici upstream hatası: istemciye ve bota "bu kalıcı değil, sonra gel"
|
|
390
|
+
// demenin standart yolu.
|
|
391
|
+
if (result.retryAfter) {
|
|
392
|
+
res.setHeader("Retry-After", String(result.retryAfter));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Teşhis başlığı `degraded` yanıtta da yazılır: "MISS" görmek, sayfanın
|
|
396
|
+
// önbellek yolundan geçtiğini ama saklanmadığını anlatan tek ipucu.
|
|
397
|
+
if (cacheable && !leaked) {
|
|
398
|
+
res.setHeader(
|
|
399
|
+
getConfig().brand.cacheHeader,
|
|
400
|
+
result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (publicCache) {
|
|
405
|
+
// Tarayıcıda max-age=0 kalır; edge süresi HTML TTL, stale penceresi
|
|
406
|
+
// `cache().staleWhileRevalidate`. s-maxage yazılmaz.
|
|
407
|
+
setEdgeCacheHeaders(res, revalidate, getConfig().staleWhileRevalidate);
|
|
408
|
+
} else {
|
|
409
|
+
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
410
|
+
// Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
|
|
411
|
+
// engeller; `no-store`'a uymayan bir katman için ikinci savunma.
|
|
412
|
+
res.setHeader("Vary", "Cookie");
|
|
413
|
+
|
|
414
|
+
if (leaked) {
|
|
415
|
+
console.warn(
|
|
416
|
+
`[render] ${req.path} read identity-bound data (${context.taintReasons.join(", ")}), ` +
|
|
417
|
+
`not cached. The route should be registered with 'private: true'.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ETag kişiye özel HTML için kullanıcıya özgü bir doğrulayıcıdır ve
|
|
423
|
+
// `no-store` ile birlikte hiçbir işe yaramaz; üretilmesi engellenir.
|
|
424
|
+
await sendHtml(req, res, result.html, result.encoded, { etag: publicCache });
|
|
425
|
+
|
|
426
|
+
// onVisit: yanıt gittikten sonra linkleri kuyruğa al — TTFB'yi şişirmez.
|
|
427
|
+
// Yalnızca herkese açık, önbelleklenebilir 200 HTML; private / degraded
|
|
428
|
+
// sayfadaki linkler kişiye özel veya eksik olabilir.
|
|
429
|
+
if (publicCache && result.status === 200 && result.html) {
|
|
430
|
+
const pagePath = req.path;
|
|
431
|
+
const pageHtml = result.html;
|
|
432
|
+
queueMicrotask(() => {
|
|
433
|
+
noteVisitWarm(pageHtml, { path: pagePath, req });
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (isRedirectError(error)) {
|
|
438
|
+
// Oturuma bağlı bir yönlendirme de kişiye özeldir: "giriş yapmalısın"
|
|
439
|
+
// kararının cache'lenmesi, oturum açmış kullanıcıyı da login sayfasına
|
|
440
|
+
// atan türde hatalara yol açıyor.
|
|
441
|
+
if (isPrivate || context.tainted) {
|
|
442
|
+
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
443
|
+
res.setHeader("Vary", "Cookie");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
res.redirect(error.statusCode, error.location);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
next(error);
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Layout'suz, asla cache'lenmeyen parça yanıtı.
|
|
456
|
+
*
|
|
457
|
+
* Fragment uçları (tablo sayfası, sekme paneli, canlı tazelenen kart) her
|
|
458
|
+
* projede elle yazılıyor ve `no-store` yazmayı unutmak sessiz bir sızıntıya
|
|
459
|
+
* dönüşüyor. Burada politika sabit: HTML cache'e hiç uğramaz, `no-store` ile
|
|
460
|
+
* ve ETag'siz gider.
|
|
461
|
+
*
|
|
462
|
+
* Hata durumunda tüm sayfa yerine küçük bir hata parçası döner: takas edilen
|
|
463
|
+
* bölge bir hata sayfasının tamamını içine almasın.
|
|
464
|
+
*
|
|
465
|
+
* @param {(ctx: { params: object, query: object, pathname: string,
|
|
466
|
+
* req: import('express').Request }) => Promise<{ view: string, data?: object,
|
|
467
|
+
* status?: number } | string>} controller
|
|
468
|
+
* @returns {import('express').RequestHandler}
|
|
469
|
+
*/
|
|
470
|
+
export function fragment(controller) {
|
|
471
|
+
return async (req, res, next) => {
|
|
472
|
+
const context = createRequestContext({
|
|
473
|
+
private: true,
|
|
474
|
+
res,
|
|
475
|
+
pathname: req.path,
|
|
476
|
+
});
|
|
477
|
+
const ctx = {
|
|
478
|
+
params: req.params ?? {},
|
|
479
|
+
query: req.query ?? {},
|
|
480
|
+
pathname: req.path,
|
|
481
|
+
req: guardRequest(req),
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const result = await withRequestContext(context, () =>
|
|
486
|
+
withRequestCache(async () => {
|
|
487
|
+
const value = await controller(ctx);
|
|
488
|
+
if (typeof value === "string") return { html: value, status: 200 };
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
html: await renderView(value.view, value.data ?? {}),
|
|
492
|
+
status: value.status ?? 200,
|
|
493
|
+
};
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
res.status(result.status);
|
|
498
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
499
|
+
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
500
|
+
res.setHeader("Vary", "Cookie");
|
|
501
|
+
await sendHtml(req, res, result.html, undefined, { etag: false });
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (isRedirectError(error)) {
|
|
504
|
+
res.setHeader("Cache-Control", PRIVATE_CACHE);
|
|
505
|
+
res.redirect(error.statusCode, error.location);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (isNotFoundError(error)) {
|
|
510
|
+
res.status(404).setHeader("Cache-Control", PRIVATE_CACHE);
|
|
511
|
+
res.type("html").send(fragmentError("notFound"));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
console.error(`[fragment] ${req.method} ${req.originalUrl}`, error);
|
|
516
|
+
|
|
517
|
+
if (res.headersSent) {
|
|
518
|
+
next(error);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
res.status(500).setHeader("Cache-Control", PRIVATE_CACHE);
|
|
523
|
+
res.type("html").send(fragmentError("failed"));
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Ziyaretçiye görünen parça hatası metinleri. Framework'ün kendi logları
|
|
530
|
+
* İngilizce ama bu satırlar ekranda okunuyor, bu yüzden durum sayfalarıyla
|
|
531
|
+
* aynı kuralı izliyorlar: dil `brand.lang`.
|
|
532
|
+
*/
|
|
533
|
+
const FRAGMENT_MESSAGES = {
|
|
534
|
+
tr: { notFound: "Bu içerik bulunamadı.", failed: "Bu bölüm yüklenemedi." },
|
|
535
|
+
en: { notFound: "This content was not found.", failed: "This section could not be loaded." },
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Fragment hatası için minimal işaretleme. Şablona bağlı olmaması bilinçli:
|
|
540
|
+
* hata yolu, hatanın kaynağı olabilecek render katmanına geri dönmemeli.
|
|
541
|
+
*
|
|
542
|
+
* @param {"notFound" | "failed"} kind
|
|
543
|
+
* @returns {string}
|
|
544
|
+
*/
|
|
545
|
+
function fragmentError(kind) {
|
|
546
|
+
let lang = "en";
|
|
547
|
+
try {
|
|
548
|
+
lang = getConfig().brand.lang ?? "en";
|
|
549
|
+
} catch {
|
|
550
|
+
/* config yüklenmemişse İngilizce kalır */
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const table = FRAGMENT_MESSAGES[lang.slice(0, 2).toLowerCase()] ?? FRAGMENT_MESSAGES.en;
|
|
554
|
+
return `<div role="alert" data-fragment-error>${html.esc(table[kind])}</div>`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* `jskelet.config.mjs` → `cache().html` route'un kendi `revalidate`'ini ezer.
|
|
559
|
+
* Sonuç yol başına hatırlanır: her istekte desen taraması yapılmaz.
|
|
560
|
+
*
|
|
561
|
+
* @type {Map<string, number | undefined>}
|
|
562
|
+
*/
|
|
563
|
+
const revalidateByPath = new Map();
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Yakalayıcı bir route'ta (`/:slug`) her benzersiz yol burada kalıcı bir girdi
|
|
567
|
+
* bırakıyor; sınır olmadan bu, uzun ömürlü süreçte bellek sızıntısına dönüşür.
|
|
568
|
+
* Desen taraması ucuz olduğu için en eski girdileri atmak güvenli.
|
|
569
|
+
*/
|
|
570
|
+
const REVALIDATE_CACHE_MAX = 2000;
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Aynı gerekçeyle (yakalayıcı route'ta sınırsız büyüme) query kuralı da yol
|
|
574
|
+
* başına hatırlanır.
|
|
575
|
+
*
|
|
576
|
+
* @type {Map<string, true | string[] | null>}
|
|
577
|
+
*/
|
|
578
|
+
const queryPolicyByPath = new Map();
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* @param {string} pathname
|
|
582
|
+
* @returns {true | string[] | null} `null`: bu yol için hiçbir parametreye
|
|
583
|
+
* izin verilmiyor.
|
|
584
|
+
*/
|
|
585
|
+
function resolveQueryPolicy(pathname) {
|
|
586
|
+
if (queryPolicyByPath.has(pathname)) {
|
|
587
|
+
return queryPolicyByPath.get(pathname) ?? null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const rules = getConfig().cacheQuery;
|
|
591
|
+
const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
|
|
592
|
+
|
|
593
|
+
if (queryPolicyByPath.size >= REVALIDATE_CACHE_MAX) {
|
|
594
|
+
const oldest = queryPolicyByPath.keys().next().value;
|
|
595
|
+
if (oldest !== undefined) queryPolicyByPath.delete(oldest);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const policy = match ? match.allow : null;
|
|
599
|
+
queryPolicyByPath.set(pathname, policy);
|
|
600
|
+
return policy;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* HTML cache anahtarı, ya da query bu yol için cache'lenebilir değilse `null`.
|
|
605
|
+
*
|
|
606
|
+
* Biçim: `${varyPrefix}${pathname}?${izin verilen query}`.
|
|
607
|
+
* Vary (`cache().vary`) query allowlist'ten bağımsız; host/locale sitelerinde
|
|
608
|
+
* ilk host'un HTML'inin diğerine servis edilmesini engeller.
|
|
609
|
+
*
|
|
610
|
+
* Varsayılan olarak query parametresi taşıyan istek dinamiktir: `cache().query`
|
|
611
|
+
* altında eşleşen bir kural olmadıkça cache'e hiç girmez. Aksi hâlde bir yolun
|
|
612
|
+
* bütün `?utm_source=…` varyantları ayrı girdi olur ve LRU'daki gerçek
|
|
613
|
+
* sayfaları dışarı atar.
|
|
614
|
+
*
|
|
615
|
+
* İzin verilen parametreler **sıralı** yazılır: `?a=1&b=2` ile `?b=2&a=1` aynı
|
|
616
|
+
* sayfa olduğu için aynı anahtarı almalı.
|
|
617
|
+
*
|
|
618
|
+
* @param {string} pathname
|
|
619
|
+
* @param {Record<string, unknown>} query
|
|
620
|
+
* @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
|
|
621
|
+
* @returns {string | null}
|
|
622
|
+
*/
|
|
623
|
+
function buildCacheKey(pathname, query, req) {
|
|
624
|
+
const vary = buildVaryPrefix(req);
|
|
625
|
+
const entries = Object.entries(query);
|
|
626
|
+
if (!entries.length) return `${vary}${pathname}?`;
|
|
627
|
+
|
|
628
|
+
const policy = resolveQueryPolicy(pathname);
|
|
629
|
+
if (policy === null) return null;
|
|
630
|
+
|
|
631
|
+
const kept =
|
|
632
|
+
policy === true ? entries : entries.filter(([name]) => policy.includes(name));
|
|
633
|
+
|
|
634
|
+
// İzin listesi dışındaki parametreler anahtara girmez: sayfa cache'lenir ve
|
|
635
|
+
// bütün kampanya varyantları tek kopyayı paylaşır.
|
|
636
|
+
const params = new URLSearchParams(
|
|
637
|
+
kept
|
|
638
|
+
.map(/** @returns {[string, string]} */ ([name, value]) => [name, String(value)])
|
|
639
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
return `${vary}${pathname}?${params.toString()}`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* @param {string} pathname
|
|
647
|
+
* @param {number | undefined} fallback
|
|
648
|
+
* @returns {number | undefined}
|
|
649
|
+
*/
|
|
650
|
+
function resolveRevalidate(pathname, fallback) {
|
|
651
|
+
const rules = getConfig().html;
|
|
652
|
+
if (!rules.length) return fallback;
|
|
653
|
+
|
|
654
|
+
if (revalidateByPath.has(pathname)) {
|
|
655
|
+
return revalidateByPath.get(pathname) ?? fallback;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const match = rules.find((rule) => matchPattern(rule.pattern, pathname));
|
|
659
|
+
|
|
660
|
+
if (revalidateByPath.size >= REVALIDATE_CACHE_MAX) {
|
|
661
|
+
const oldest = revalidateByPath.keys().next().value;
|
|
662
|
+
if (oldest !== undefined) revalidateByPath.delete(oldest);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
revalidateByPath.set(pathname, match?.seconds);
|
|
666
|
+
return match ? match.seconds : fallback;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Cache'lenmiş bir sayfa aynı gövdeyi her istekte yeniden sıkıştırmasın diye
|
|
671
|
+
* brotli/gzip çıktısı HTML ile birlikte saklanır. `Content-Encoding` burada
|
|
672
|
+
* ayarlandığı için sıkıştırma middleware'i devreye girmez.
|
|
673
|
+
*
|
|
674
|
+
* @param {import('express').Request} req
|
|
675
|
+
* @param {import('express').Response} res
|
|
676
|
+
* @param {string} body
|
|
677
|
+
* @param {Map<string, Buffer>} [encoded]
|
|
678
|
+
* @param {{ etag?: boolean }} [options] `etag: false` → `res.send()` atlanır,
|
|
679
|
+
* böylece Express kullanıcıya özel gövde için doğrulayıcı üretmez.
|
|
680
|
+
* @returns {Promise<void>}
|
|
681
|
+
*/
|
|
682
|
+
async function sendHtml(req, res, body, encoded, options = {}) {
|
|
683
|
+
const encoding =
|
|
684
|
+
req.method === "HEAD" ? null : negotiateEncoding(req.headers["accept-encoding"]);
|
|
685
|
+
|
|
686
|
+
if (!encoding || !encoded) {
|
|
687
|
+
if (options.etag === false) {
|
|
688
|
+
// Sıkıştırma middleware'i devreye girerse Content-Length'i kendisi
|
|
689
|
+
// kaldırır; girmezse doğru uzunlukla gider.
|
|
690
|
+
res.setHeader("Content-Length", String(Buffer.byteLength(body)));
|
|
691
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
res.send(body);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
let buffer = encoded.get(encoding);
|
|
700
|
+
if (!buffer) {
|
|
701
|
+
buffer = await encodeText(body, encoding);
|
|
702
|
+
// Ham HTML kalır; sıkıştırılmış kopya tek kodlamadır. Diğer istemci
|
|
703
|
+
// gelince eski buffer düşer, bayt bütçesi iki gövdeyi birden saymaz.
|
|
704
|
+
rememberHtmlEncoding(encoded, encoding, buffer);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
res.setHeader("Content-Encoding", encoding);
|
|
708
|
+
res.setHeader("Vary", "Accept-Encoding");
|
|
709
|
+
res.setHeader("Content-Length", String(buffer.length));
|
|
710
|
+
res.end(buffer);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* @typedef {{ html: string, status: number, degraded?: boolean,
|
|
715
|
+
* storable?: boolean, retryAfter?: number }} Produced
|
|
716
|
+
*/
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Controller'ı bir kez çalıştırır. `notFound()` fırlatıldığında sonucu
|
|
720
|
+
* ayırt edilebilir biçimde döner: çağıran taraf bunun gerçek bir 404 mü,
|
|
721
|
+
* yoksa upstream düştüğü için verinin gelmemesi mi olduğuna karar verecek.
|
|
722
|
+
*
|
|
723
|
+
* @param {Function} controller
|
|
724
|
+
* @param {{ pathname: string }} ctx
|
|
725
|
+
* @returns {Promise<{ page: Produced } | { notFound: true,
|
|
726
|
+
* transient: import('./upstream-tracking.js').UpstreamFailure[] }>}
|
|
727
|
+
*/
|
|
728
|
+
async function attempt(controller, ctx) {
|
|
729
|
+
try {
|
|
730
|
+
const page = await controller(ctx);
|
|
731
|
+
const rendered = await renderPage({ pathname: ctx.pathname, ...page });
|
|
732
|
+
return {
|
|
733
|
+
page: {
|
|
734
|
+
html: rendered,
|
|
735
|
+
status: page.status ?? 200,
|
|
736
|
+
degraded: hasUpstreamFailures(ctx.pathname),
|
|
737
|
+
// Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
|
|
738
|
+
// `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
|
|
739
|
+
storable: getRequestContext()?.tainted !== true,
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
} catch (error) {
|
|
743
|
+
if (isNotFoundError(error)) {
|
|
744
|
+
return { notFound: true, transient: transientUpstreamFailures() };
|
|
745
|
+
}
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* @param {Function} controller
|
|
752
|
+
* @param {{ pathname: string }} ctx
|
|
753
|
+
* @returns {Promise<Produced>}
|
|
754
|
+
*/
|
|
755
|
+
async function produce(controller, ctx) {
|
|
756
|
+
const first = await attempt(controller, ctx);
|
|
757
|
+
if ("page" in first) return first.page;
|
|
758
|
+
// Deterministik "böyle bir sayfa yok" cevabı: tekrar denemenin anlamı yok.
|
|
759
|
+
if (!first.transient.length) return { html: await renderNotFound(), status: 404 };
|
|
760
|
+
|
|
761
|
+
// Buraya gelindiyse `notFound()` veri gelmediği için çağrılmış. **Var olan
|
|
762
|
+
// bir sayfayı** 404 olarak servis etmek en kötü sonuç: arama motoru geçici
|
|
763
|
+
// bir rate limit'i kalıcı bir kayıp sanar. Bu yüzden sayfa yeniden denenir —
|
|
764
|
+
// ısıtma günlükleri gösteriyor ki aynı yol saniyeler sonra 200 dönüyor.
|
|
765
|
+
const { attempts, delayMs } = transientRetry();
|
|
766
|
+
let failures = first.transient;
|
|
767
|
+
|
|
768
|
+
for (let round = 1; round <= attempts; round += 1) {
|
|
769
|
+
const retryDetail =
|
|
770
|
+
`returned notFound() while upstream is failing ` +
|
|
771
|
+
`(${summarizeFailures(failures)}), retrying (${round}/${attempts})`;
|
|
772
|
+
if (!suppressForPrewarm(retryDetail)) {
|
|
773
|
+
console.warn(`[render] ${ctx.pathname} ${retryDetail}`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Beklemeden tekrar denemek rate limit'e girmiş bir API'de aynı 429'u
|
|
777
|
+
// getirir; kısa bekleme hem pencerenin dönmesine şans verir hem de
|
|
778
|
+
// fırtınayı büyütmez.
|
|
779
|
+
await sleep(delayMs * round);
|
|
780
|
+
|
|
781
|
+
// Her deneme kendi upstream ve istek içi cache bağlamında çalışır: ilk
|
|
782
|
+
// turun hataları ikinci turun kararını kirletmesin ve memoize edilmiş
|
|
783
|
+
// boş cevaplar tekrar kullanılmasın.
|
|
784
|
+
const retried = await withUpstreamTracking(() =>
|
|
785
|
+
withRequestCache(() => attempt(controller, ctx)),
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
if ("page" in retried) return retried.page;
|
|
789
|
+
if (!retried.transient.length) {
|
|
790
|
+
// Bu kez upstream sağlam cevap verdi ve "yok" dedi: gerçek 404.
|
|
791
|
+
return { html: await renderNotFound(), status: 404 };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
failures = retried.transient;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Denemeler tükendi. 404 yerine 503: önbelleğe girmez, `Retry-After` taşır
|
|
798
|
+
// ve bir sonraki istek gerçek içeriği üretebilir.
|
|
799
|
+
const outageDetail =
|
|
800
|
+
`could not be produced, upstream is still failing ` +
|
|
801
|
+
`(${summarizeFailures(failures)}), serving an uncached 503 instead of a 404`;
|
|
802
|
+
if (!suppressForPrewarm(outageDetail)) {
|
|
803
|
+
console.warn(`[render] ${ctx.pathname} ${outageDetail}`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
html: await renderStatusPage(503),
|
|
808
|
+
status: 503,
|
|
809
|
+
degraded: true,
|
|
810
|
+
retryAfter: RETRY_AFTER_SECONDS,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Geçici bir upstream hatası yüzünden üretilemeyen sayfanın `Retry-After`
|
|
816
|
+
* değeri. Kısa tutuluyor: ziyaretçi de bot da birkaç saniye sonra gerçek
|
|
817
|
+
* içeriği bulabilsin.
|
|
818
|
+
*/
|
|
819
|
+
const RETRY_AFTER_SECONDS = 30;
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turu; bu yüzden
|
|
823
|
+
* varsayılan tek deneme ve kısa bekleme. Rate limit fırtınasında toplam yük
|
|
824
|
+
* iki katına çıkabilir, ama alternatifi var olan sayfaları 404'e düşürmek.
|
|
825
|
+
*
|
|
826
|
+
* @returns {{ attempts: number, delayMs: number }}
|
|
827
|
+
*/
|
|
828
|
+
function transientRetry() {
|
|
829
|
+
const raw = /** @type {any} */ (getConfig().transientRetry ?? {});
|
|
830
|
+
const attempts = Number(raw.attempts);
|
|
831
|
+
const delayMs = Number(raw.delayMs);
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
attempts: Number.isFinite(attempts) && attempts >= 0 ? Math.floor(attempts) : 1,
|
|
835
|
+
delayMs: Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 300,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** @param {number} ms */
|
|
840
|
+
function sleep(ms) {
|
|
841
|
+
return new Promise((resolve) => {
|
|
842
|
+
setTimeout(resolve, ms).unref?.();
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
|
|
849
|
+
* Böyle bir HTML önbelleğe yazılmaz: sonraki istek yeniden dener.
|
|
850
|
+
*
|
|
851
|
+
* Ancak yalnızca *geçici* hatalar için. 400/403/404 gibi deterministik
|
|
852
|
+
* cevaplar tekrar denemekle düzelmez; onlar yüzünden önbelleği kapatmak
|
|
853
|
+
* sayfayı her ziyarette baştan render etmek olur — içerik yine aynı eksik
|
|
854
|
+
* hâliyle döner, ziyaretçi sadece render süresini öder. Bu yüzden kalıcı
|
|
855
|
+
* hatalar yalnızca loglanır, önbelleği engellemez.
|
|
856
|
+
*
|
|
857
|
+
* @param {string} pathname
|
|
858
|
+
* @returns {boolean}
|
|
859
|
+
*/
|
|
860
|
+
function hasUpstreamFailures(pathname) {
|
|
861
|
+
const failures = getUpstreamFailures();
|
|
862
|
+
if (!failures.length) return false;
|
|
863
|
+
|
|
864
|
+
const transient = failures.filter((failure) => isTransientStatus(failure.status));
|
|
865
|
+
const permanent = failures.filter((failure) => !isTransientStatus(failure.status));
|
|
866
|
+
|
|
867
|
+
// Isıtma turu yüzlerce yolu tarıyor; aynı upstream arızası her yol için bir
|
|
868
|
+
// satır basınca tur özeti kayboluyor. Turun uyarıları sayılıp sonunda
|
|
869
|
+
// toplanır (bkz. `suppressForPrewarm`).
|
|
870
|
+
if (permanent.length) {
|
|
871
|
+
const detail = `missing data, upstream is failing permanently (${summarizeFailures(permanent)})`;
|
|
872
|
+
if (!suppressForPrewarm(detail)) {
|
|
873
|
+
console.warn(`[render] ${pathname} was produced with ${detail}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (!transient.length) return false;
|
|
878
|
+
|
|
879
|
+
const detail = `missing data, not caching it (${summarizeFailures(transient)})`;
|
|
880
|
+
if (!suppressForPrewarm(detail)) {
|
|
881
|
+
console.warn(`[render] ${pathname} was produced with ${detail}`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return true;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* @returns {import('./upstream-tracking.js').UpstreamFailure[]}
|
|
889
|
+
*/
|
|
890
|
+
function transientUpstreamFailures() {
|
|
891
|
+
return getUpstreamFailures().filter((failure) => isTransientStatus(failure.status));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* @param {import('./upstream-tracking.js').UpstreamFailure[]} failures
|
|
896
|
+
* @returns {string}
|
|
897
|
+
*/
|
|
898
|
+
function summarizeFailures(failures) {
|
|
899
|
+
return failures.map((failure) => `${failure.status} ${failure.path}`).join(", ");
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
|
|
904
|
+
* ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.
|
|
905
|
+
*
|
|
906
|
+
* @returns {Promise<string>}
|
|
907
|
+
*/
|
|
908
|
+
export async function renderNotFound() {
|
|
909
|
+
return renderStatusPage(404);
|
|
910
|
+
}
|