jskelet 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +633 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +700 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +351 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +708 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +355 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +965 -356
  134. package/src/server/og-raster.mjs +21 -0
  135. package/src/server/port-guard.js +255 -255
  136. package/src/server/prewarm.js +1082 -1082
  137. package/src/server/redis.js +588 -588
  138. package/src/server/render.js +910 -910
  139. package/src/server/router.js +157 -157
  140. package/src/server/status-page.js +265 -265
  141. package/src/server/upstream-limiter.js +376 -376
  142. package/src/server/upstream-tracking.js +166 -166
  143. package/src/shared/cookie-domain.js +66 -66
  144. package/src/start.mjs +22 -22
  145. package/src/templates/layout.ejs +30 -30
  146. package/src/templates/layout.jsk +30 -30
  147. package/src/version.mjs +31 -31
  148. package/src/views/components/loader.js +101 -101
  149. package/src/views/helpers/html.js +102 -102
  150. package/src/views/helpers/tags.js +375 -375
  151. package/types/config/defaults.d.ts +6 -0
  152. package/types/config/index.d.ts +6 -0
  153. package/types/server/cache-control.d.ts +28 -0
  154. package/types/server/og-image.d.ts +51 -3
@@ -1,366 +1,366 @@
1
- /**
2
- * Express uygulamasını kurar ve dinlemeye başlar.
3
- *
4
- * Middleware **sırası** bu dosyanın asıl değeri; her konumun bir sebebi var
5
- * ve yer değiştirmek sessiz bozulmalara yol açıyor:
6
- *
7
- * 1. rewrites(beforeFiles) — statik dosyalardan da önce çalışmalı, yoksa
8
- * `/assets/x.js` yolunu başka bir yere taşıyan kural işlemez.
9
- * 2. compression — static'ten önce; sonra gelirse statik dosyalar sıkışmaz.
10
- * 3. headers → devGate → redirects → trailingSlash — gate'in 404'ü
11
- * redirect'ten önce; trailingSlash config redirects'ten sonra, böylece
12
- * açık kurallar istenen yolu önce görür.
13
- * 3b. access log (açıksa) — tamamlanan yanıtların süresi; admin/prewarm
14
- * içeride elenir.
15
- * 3c. robots.txt — statikten önce, compression'ın içinde: kullanıcının
16
- * gövdesine framework Disallow bloğu eklenir. Sıkıştırılmış kopyanın
17
- * (Content-Encoding dolu) üzerine yazılmaz.
18
- * 4. staticPrecompressed → express.static — build'de üretilmiş `.br`/`.gz`
19
- * kopyalar varsa onlar servis edilir (kalite 11), yoksa istek altındaki
20
- * static'e düşer ve middleware anında sıkıştırır (kalite 5).
21
- * 4b. admin paneli (açıksa) — statikten sonra, route'lardan önce: kendi
22
- * gövde ayrıştırıcısını taşır ve uygulama yolunu gölgeleyemez.
23
- * 4c. image optimizer (images.remote) — uzak görselleri webp'ye çevirir;
24
- * body parser'dan önce, admin ile aynı katmanda.
25
- * 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
26
- * maliyeti ödenmesin.
27
- * 5b. csrf — body parser'lardan sonra: token form alanından okunuyor.
28
- * Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
29
- * 5c. auth handoff (açıksa) — CSRF'den *sonra*: mint POST origin koruması
30
- * görsün. Redeem GET güvenli metot; route render'ından önce kalır.
31
- * 6. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
32
- * 7. route'lar → 404 → hata yönetimi.
33
- */
34
- import path from "node:path";
35
- import process from "node:process";
36
- import express from "express";
37
- import { compression } from "./middleware/compression.js";
38
- import { robotsTxtMiddleware } from "./middleware/robots-txt.js";
39
- import { headersMiddleware } from "./middleware/headers.js";
40
- import { csrf } from "./middleware/csrf.js";
41
- import { staticPrecompressed } from "./middleware/static-precompressed.js";
42
- import { devGate } from "./middleware/dev-gate.js";
43
- import { redirects } from "./middleware/redirects.js";
44
- import { trailingSlash } from "./middleware/trailing-slash.js";
45
- import { configRewrites } from "./middleware/upstream-proxy.js";
46
- import { getConfig, loadConfig } from "../config/index.js";
47
- import { IMMUTABLE_CACHE } from "../config/defaults.js";
48
- import { registerRoutes } from "./router.js";
49
- import { renderNotFound } from "./render.js";
50
- import { renderStatusPage, statusFromError } from "./status-page.js";
51
- import { isPrewarmRequest, notePrewarmError, startPrewarm } from "./prewarm.js";
52
- import { trackUpstreamFetch } from "./upstream-tracking.js";
53
- import { configureUpstreamLimiter } from "./upstream-limiter.js";
54
- import { connectRedis, disconnectRedis } from "./redis.js";
55
- import { configureLogs, flushLogs, closeLogs } from "./logs/pipeline.js";
56
- import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
57
- import { ensurePortFree } from "./port-guard.js";
58
-
59
- /**
60
- * @param {{ root?: string, configFile?: string }} [options]
61
- * @returns {Promise<import('express').Express>}
62
- */
63
- export async function createApp(options = {}) {
64
- // Config middleware'lerden önce okunur; yoksa uyarı basar, akış durmaz.
65
- await loadConfig(options);
66
- const config = getConfig();
67
-
68
- // Upstream hatalarının izlenmesi route'lardan önce kurulmalı: sarmalayıcı
69
- // yalnızca render bağlamı içindeki `fetch` çağrılarına bakar, ama bağlamın
70
- // ilk kurulduğu istek de kapsanmalı.
71
- if (config.trackUpstream) trackUpstreamFetch();
72
-
73
- // Hız freni sarmalayıcının içinden okunuyor; ayarı ona vermek yeterli.
74
- // `cache().upstream.rate` verilmedikçe hiçbir istek beklemez.
75
- configureUpstreamLimiter(config.upstream);
76
-
77
- // Önbelleğin ikinci kademesi route'lardan önce kurulmalı: ilk istek de
78
- // paylaşımlı kopyayı görebilsin. Bağlanamazsa uyarı basılır ve uygulama
79
- // bellek içi önbellekle çalışmaya devam eder — middleware sırasına
80
- // dokunmayan, tamamen opsiyonel bir adım.
81
- await connectRedis(config);
82
-
83
- // Kalıcı log sink'leri (dosya / S3). Credential eksikse uyarı + no-op;
84
- // site düşmez. Access middleware gerektiğinde biraz aşağıda mount edilir.
85
- const { accessLog } = await configureLogs(config);
86
-
87
- const app = express();
88
-
89
- app.disable("x-powered-by");
90
- app.use((req, res, next) => {
91
- res.setHeader("X-Powered-By", config.brand.poweredBy);
92
- next();
93
- });
94
-
95
- app.set("etag", "strong");
96
- // Ters proxy arkasında doğru protokol ve istemci IP'si için. Doğrudan
97
- // internete açık bir sunucuda kapatılmalı: açıkken istemci kendi
98
- // `X-Forwarded-For` başlığını uydurabilir ve rate limit ile audit log
99
- // yanlış IP görür.
100
- app.set("trust proxy", config.security.trustProxy);
101
-
102
- app.use(configRewrites("beforeFiles"));
103
- app.use(compression());
104
- app.use(headersMiddleware());
105
- app.use(devGate());
106
- app.use(redirects());
107
- app.use(trailingSlash());
108
-
109
- // Access log: headers/redirects sonrası, statikten önce — böylece
110
- // tamamlanan her yanıt (304 dahil) süre alır; admin/prewarm içeride elenir.
111
- if (accessLog) {
112
- const { accessLogMiddleware } = await import("./logs/access-middleware.js");
113
- app.use(accessLogMiddleware());
114
- }
115
-
116
- // Kullanıcının robots.txt'i statik dosya da olabilir, route da. İkisi de
117
- // bu sarmalayıcıdan geçer. compression'dan sonra durur: ek, sıkıştırılmış
118
- // baytların değil düz metnin sonuna yazılır.
119
- app.use(robotsTxtMiddleware());
120
-
121
- app.use(staticPrecompressed(config.dirs.public));
122
- app.use(
123
- express.static(config.dirs.public, {
124
- index: false,
125
- redirect: false,
126
- // Hash'siz dosyalar (favicon, robots eki, elle konmuş görseller) içerik
127
- // değişse de aynı adla kalıyor; bir yıllık cache onları güncellenemez
128
- // hâle getiriyordu. Hash'li çıktı aşağıda ayrıca immutable işaretlenir.
129
- maxAge: "1h",
130
- setHeaders(res, filePath) {
131
- if (filePath.includes(`${path.sep}assets${path.sep}`)) {
132
- res.setHeader("Cache-Control", IMMUTABLE_CACHE);
133
- }
134
- },
135
- }),
136
- );
137
-
138
- // Dev overlay yalnızca development'ta; dinamik import sayesinde prod
139
- // sürecine hiçbir şey yüklenmez.
140
- if (process.env.NODE_ENV === "development") {
141
- const { mountDevtools } = await import("./dev/devtools.js");
142
- mountDevtools(app);
143
- }
144
-
145
- // Yönetim paneli ortama bakmaz, config'e bakar: açıkça etkinleştirilmedikçe
146
- // modül hiç yüklenmez ve yol da yoktur. Kendi gövde ayrıştırıcısını
147
- // taşıdığı için aşağıdaki parser'lardan önce durabiliyor; route'lardan
148
- // önce olması gerekiyor ki uygulama aynı yolu gölgeleyemesin.
149
- if (config.admin.enabled) {
150
- const { mountAdmin } = await import("./admin/mount.js");
151
- mountAdmin(app);
152
- }
153
-
154
- // Uzak görsel proxy: allowHosts doluysa mount. Statikten sonra, body
155
- // parser'dan önce — görsel GET'lerinde gövde ayrıştırma maliyeti ödenmesin.
156
- if (config.images && config.images !== false && config.images.remote) {
157
- const { mountImageOptimizer } = await import("./image-optimizer.js");
158
- await mountImageOptimizer(app);
159
- }
160
-
161
- app.use(express.urlencoded({ extended: false, limit: "64kb" }));
162
- app.use(express.json({ limit: "256kb" }));
163
-
164
- app.use(csrf());
165
-
166
- const handoff = config.auth?.crossSubdomainHandoff;
167
- if (
168
- handoff === true ||
169
- (handoff &&
170
- typeof handoff === "object" &&
171
- /** @type {{ enabled?: boolean }} */ (handoff).enabled !== false)
172
- ) {
173
- const { mountAuthHandoff } = await import("./auth/handoff.js");
174
- mountAuthHandoff(app);
175
- }
176
-
177
- app.use(configRewrites("afterFiles"));
178
-
179
- await registerRoutes(app);
180
-
181
- app.use(async (req, res, next) => {
182
- try {
183
- // headersMiddleware `/assets/*` için immutable basmış olabilir; eksik bir
184
- // hash'li dosyanın 404'ü CDN'de bir yıl zehirlenmesin.
185
- res.status(404).setHeader("Cache-Control", "no-store");
186
- res.type("html").send(await renderNotFound());
187
- } catch (error) {
188
- next(error);
189
- }
190
- });
191
-
192
- // Hata yönetimi: notFound/redirect kontrol akışı burada da yakalanır,
193
- // çünkü bir controller dışında (ör. middleware içinde) fırlatılabilir.
194
- app.use(async (error, req, res, next) => {
195
- if (res.headersSent) {
196
- next(error);
197
- return;
198
- }
199
-
200
- if (isRedirectError(error)) {
201
- res.redirect(error.statusCode, error.location);
202
- return;
203
- }
204
-
205
- if (isNotFoundError(error)) {
206
- res.status(404).setHeader("Cache-Control", "no-store");
207
- res.type("html").send(await renderNotFound());
208
- return;
209
- }
210
-
211
- const status = statusFromError(error);
212
- // Isıtma turunun hataları tek tek loglanmaz; tur bitince özet olarak
213
- // basılır. Yüzlerce yolu tarayan bir tur, upstream bir an tıksırdığında
214
- // logu yığın izleriyle dolduruyordu.
215
- if (isPrewarmRequest(req)) notePrewarmError(status, error);
216
- else console.error(`[${status}] ${req.method} ${req.originalUrl}`, error);
217
-
218
- // Hata sayfası hiçbir katmanda saklanmamalı: geçici bir upstream arızası
219
- // CDN'de dakikalarca yaşayan bir 500 sayfasına dönüşmesin.
220
- res.setHeader("Cache-Control", "no-store");
221
- res
222
- .status(status)
223
- .type("html")
224
- .send(await renderStatusPage(status, { error }));
225
- });
226
-
227
- return app;
228
- }
229
-
230
- /**
231
- * Uygulamayı kurup dinlemeye başlar. CLI `jskelet start` bunu çağırır;
232
- * gömülü kullanımda `createApp()` tercih edilir.
233
- *
234
- * Port doluysa başlamaz. `murder: true` veya argv'de `--murder` varsa
235
- * dinleyen süreç öldürülüp bağlama denenir.
236
- *
237
- * @param {{ root?: string, configFile?: string, port?: number, host?: string, murder?: boolean }} [options]
238
- * @returns {Promise<import('http').Server>}
239
- */
240
- export async function startServer(options = {}) {
241
- const port = Number(options.port ?? process.env.PORT ?? 3000);
242
- const murder =
243
- options.murder === true || process.argv.includes("--murder");
244
-
245
- // createApp pahalı; dolu portta uygulama kurmadan önce net hata ver.
246
- await ensurePortFree(port, { murder });
247
-
248
- const app = await createApp(options);
249
-
250
- // Varsayılan `::`, `0.0.0.0` değil: ikisi de "tüm arayüzler" demek, ama
251
- // yalnızca IPv6 soketi çift yığın çalışır ve `localhost`un `::1`e çözüldüğü
252
- // durumu da kapsar. Tarayıcılar `localhost` için önce `::1` deniyor; sıradan
253
- // isteklerde IPv4'e düşüyorlar ama WebSocket el sıkışması bu geri düşüşü
254
- // yapmadan "failed" veriyordu. IPv6'sı olmayan bir makinede bağlama hata
255
- // verir; aşağıda IPv4'e dönülür.
256
- const host = options.host ?? process.env.HOST ?? null;
257
-
258
- // Tek bir istek hatası süreci düşürmesin; logla ve ayakta kal. Bir haber
259
- // sitesinde tek sayfanın hatası tüm siteyi indirmemeli.
260
- process.on("unhandledRejection", (reason) => {
261
- console.error("[unhandledRejection]", reason);
262
- });
263
- process.on("uncaughtException", (error) => {
264
- console.error("[uncaughtException]", error);
265
- });
266
-
267
- // Dev panelinin canlı kanalı: el sıkışma `upgrade` olayında geçtiği için
268
- // middleware zincirine değil, doğrudan sunucuya bağlanır. Modül `listen`den
269
- // önce yüklenir; dinleme başladıktan sonra beklenen bir `await` kalırsa ilk
270
- // upgrade isteği dinleyici yokken gelip reddedilebiliyor.
271
- const attachDevSocket =
272
- process.env.NODE_ENV === "development"
273
- ? (await import("./dev/devtools.js")).attachDevSocket
274
- : null;
275
-
276
- return new Promise((resolve, reject) => {
277
- /** @param {string} address */
278
- const listen = (address) => {
279
- const server = app.listen(port, address, () => {
280
- // Bu satırın biçimi sözleşme: `jskelet dev` sunucunun hazır olduğunu
281
- // buradan anlar ve özet satırını ona göre basar.
282
- console.log(
283
- `jskelet → http://localhost:${port} (${process.env.NODE_ENV ?? "production"})`,
284
- );
285
- startPrewarm({ port });
286
- attachShutdown(server);
287
- resolve(server);
288
- });
289
-
290
- server.on("error", (error) => {
291
- // IPv6 desteklenmiyorsa yalnızca varsayılan adres için IPv4'e dönülür;
292
- // kullanıcı bir adres verdiyse sessizce başkasını dinlemek yanlış olur.
293
- if (!host && isAddressUnsupported(error)) {
294
- listen("0.0.0.0");
295
- return;
296
- }
297
- if (error.code === "EADDRINUSE") {
298
- const hint = murder
299
- ? `port ${port} is still in use after --murder`
300
- : `port ${port} is already in use. Pass --murder to kill it and start, or set PORT to another value.`;
301
- const wrapped = new Error(hint);
302
- /** @type {NodeJS.ErrnoException} */ (wrapped).code = "EADDRINUSE";
303
- reject(wrapped);
304
- return;
305
- }
306
- reject(error);
307
- });
308
-
309
- attachDevSocket?.(server);
310
- };
311
-
312
- listen(host ?? "::");
313
- });
314
- }
315
-
316
- /**
317
- * `SIGTERM`/`SIGINT` sonrası düzenli kapanış.
318
- *
319
- * Kapatılması gereken dış kaynaklar Redis ve log sink buffer'ları. Redis
320
- * `quit` uçuştaki komutların bitmesini bekliyor; sert `disconnect` yarıda
321
- * kalan bir `SET` bırakabiliyor. S3 sink kapanışta kalan batch'i PutObject
322
- * ile gönderir.
323
- *
324
- * Açık HTTP bağlantıları **beklenmez**. `close()` tek başına yalnızca yeni
325
- * bağlantıyı reddediyor; keep-alive bir istemci ya da dev panelinin
326
- * WebSocket'i sunucuyu süresiz ayakta tutuyor ve Ctrl+C yanıt vermiyormuş gibi
327
- * görünüyordu. Sinyal geldiğinde ters proxy zaten trafik göndermiyor.
328
- *
329
- * Yine de bir zamanlayıcı var: Redis kapanışı askıda kalırsa süreç `SIGKILL`
330
- * beklemek zorunda kalmasın.
331
- *
332
- * @param {import('http').Server} server
333
- */
334
- function attachShutdown(server) {
335
- let closing = false;
336
-
337
- const shutdown = () => {
338
- // İkinci sinyal beklemeyi kısa kessin: kullanıcı Ctrl+C'ye tekrar bastıysa
339
- // gerçekten çıkmak istiyor.
340
- if (closing) process.exit(0);
341
- closing = true;
342
-
343
- const timer = setTimeout(() => process.exit(0), 3000);
344
- timer.unref();
345
-
346
- server.close();
347
- server.closeAllConnections?.();
348
-
349
- void Promise.all([disconnectRedis(), flushLogs().then(() => closeLogs())])
350
- .finally(() => {
351
- clearTimeout(timer);
352
- process.exit(0);
353
- });
354
- };
355
-
356
- process.once("SIGTERM", shutdown);
357
- process.once("SIGINT", shutdown);
358
- }
359
-
360
- /**
361
- * @param {NodeJS.ErrnoException} error
362
- * @returns {boolean} adres ailesi bu makinede kullanılamıyor mu
363
- */
364
- function isAddressUnsupported(error) {
365
- return error.code === "EAFNOSUPPORT" || error.code === "EADDRNOTAVAIL";
366
- }
1
+ /**
2
+ * Express uygulamasını kurar ve dinlemeye başlar.
3
+ *
4
+ * Middleware **sırası** bu dosyanın asıl değeri; her konumun bir sebebi var
5
+ * ve yer değiştirmek sessiz bozulmalara yol açıyor:
6
+ *
7
+ * 1. rewrites(beforeFiles) — statik dosyalardan da önce çalışmalı, yoksa
8
+ * `/assets/x.js` yolunu başka bir yere taşıyan kural işlemez.
9
+ * 2. compression — static'ten önce; sonra gelirse statik dosyalar sıkışmaz.
10
+ * 3. headers → devGate → redirects → trailingSlash — gate'in 404'ü
11
+ * redirect'ten önce; trailingSlash config redirects'ten sonra, böylece
12
+ * açık kurallar istenen yolu önce görür.
13
+ * 3b. access log (açıksa) — tamamlanan yanıtların süresi; admin/prewarm
14
+ * içeride elenir.
15
+ * 3c. robots.txt — statikten önce, compression'ın içinde: kullanıcının
16
+ * gövdesine framework Disallow bloğu eklenir. Sıkıştırılmış kopyanın
17
+ * (Content-Encoding dolu) üzerine yazılmaz.
18
+ * 4. staticPrecompressed → express.static — build'de üretilmiş `.br`/`.gz`
19
+ * kopyalar varsa onlar servis edilir (kalite 11), yoksa istek altındaki
20
+ * static'e düşer ve middleware anında sıkıştırır (kalite 5).
21
+ * 4b. admin paneli (açıksa) — statikten sonra, route'lardan önce: kendi
22
+ * gövde ayrıştırıcısını taşır ve uygulama yolunu gölgeleyemez.
23
+ * 4c. image optimizer (images.remote) — uzak görselleri webp'ye çevirir;
24
+ * body parser'dan önce, admin ile aynı katmanda.
25
+ * 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
26
+ * maliyeti ödenmesin.
27
+ * 5b. csrf — body parser'lardan sonra: token form alanından okunuyor.
28
+ * Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
29
+ * 5c. auth handoff (açıksa) — CSRF'den *sonra*: mint POST origin koruması
30
+ * görsün. Redeem GET güvenli metot; route render'ından önce kalır.
31
+ * 6. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
32
+ * 7. route'lar → 404 → hata yönetimi.
33
+ */
34
+ import path from "node:path";
35
+ import process from "node:process";
36
+ import express from "express";
37
+ import { compression } from "./middleware/compression.js";
38
+ import { robotsTxtMiddleware } from "./middleware/robots-txt.js";
39
+ import { headersMiddleware } from "./middleware/headers.js";
40
+ import { csrf } from "./middleware/csrf.js";
41
+ import { staticPrecompressed } from "./middleware/static-precompressed.js";
42
+ import { devGate } from "./middleware/dev-gate.js";
43
+ import { redirects } from "./middleware/redirects.js";
44
+ import { trailingSlash } from "./middleware/trailing-slash.js";
45
+ import { configRewrites } from "./middleware/upstream-proxy.js";
46
+ import { getConfig, loadConfig } from "../config/index.js";
47
+ import { IMMUTABLE_CACHE } from "../config/defaults.js";
48
+ import { registerRoutes } from "./router.js";
49
+ import { renderNotFound } from "./render.js";
50
+ import { renderStatusPage, statusFromError } from "./status-page.js";
51
+ import { isPrewarmRequest, notePrewarmError, startPrewarm } from "./prewarm.js";
52
+ import { trackUpstreamFetch } from "./upstream-tracking.js";
53
+ import { configureUpstreamLimiter } from "./upstream-limiter.js";
54
+ import { connectRedis, disconnectRedis } from "./redis.js";
55
+ import { configureLogs, flushLogs, closeLogs } from "./logs/pipeline.js";
56
+ import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
57
+ import { ensurePortFree } from "./port-guard.js";
58
+
59
+ /**
60
+ * @param {{ root?: string, configFile?: string }} [options]
61
+ * @returns {Promise<import('express').Express>}
62
+ */
63
+ export async function createApp(options = {}) {
64
+ // Config middleware'lerden önce okunur; yoksa uyarı basar, akış durmaz.
65
+ await loadConfig(options);
66
+ const config = getConfig();
67
+
68
+ // Upstream hatalarının izlenmesi route'lardan önce kurulmalı: sarmalayıcı
69
+ // yalnızca render bağlamı içindeki `fetch` çağrılarına bakar, ama bağlamın
70
+ // ilk kurulduğu istek de kapsanmalı.
71
+ if (config.trackUpstream) trackUpstreamFetch();
72
+
73
+ // Hız freni sarmalayıcının içinden okunuyor; ayarı ona vermek yeterli.
74
+ // `cache().upstream.rate` verilmedikçe hiçbir istek beklemez.
75
+ configureUpstreamLimiter(config.upstream);
76
+
77
+ // Önbelleğin ikinci kademesi route'lardan önce kurulmalı: ilk istek de
78
+ // paylaşımlı kopyayı görebilsin. Bağlanamazsa uyarı basılır ve uygulama
79
+ // bellek içi önbellekle çalışmaya devam eder — middleware sırasına
80
+ // dokunmayan, tamamen opsiyonel bir adım.
81
+ await connectRedis(config);
82
+
83
+ // Kalıcı log sink'leri (dosya / S3). Credential eksikse uyarı + no-op;
84
+ // site düşmez. Access middleware gerektiğinde biraz aşağıda mount edilir.
85
+ const { accessLog } = await configureLogs(config);
86
+
87
+ const app = express();
88
+
89
+ app.disable("x-powered-by");
90
+ app.use((req, res, next) => {
91
+ res.setHeader("X-Powered-By", config.brand.poweredBy);
92
+ next();
93
+ });
94
+
95
+ app.set("etag", "strong");
96
+ // Ters proxy arkasında doğru protokol ve istemci IP'si için. Doğrudan
97
+ // internete açık bir sunucuda kapatılmalı: açıkken istemci kendi
98
+ // `X-Forwarded-For` başlığını uydurabilir ve rate limit ile audit log
99
+ // yanlış IP görür.
100
+ app.set("trust proxy", config.security.trustProxy);
101
+
102
+ app.use(configRewrites("beforeFiles"));
103
+ app.use(compression());
104
+ app.use(headersMiddleware());
105
+ app.use(devGate());
106
+ app.use(redirects());
107
+ app.use(trailingSlash());
108
+
109
+ // Access log: headers/redirects sonrası, statikten önce — böylece
110
+ // tamamlanan her yanıt (304 dahil) süre alır; admin/prewarm içeride elenir.
111
+ if (accessLog) {
112
+ const { accessLogMiddleware } = await import("./logs/access-middleware.js");
113
+ app.use(accessLogMiddleware());
114
+ }
115
+
116
+ // Kullanıcının robots.txt'i statik dosya da olabilir, route da. İkisi de
117
+ // bu sarmalayıcıdan geçer. compression'dan sonra durur: ek, sıkıştırılmış
118
+ // baytların değil düz metnin sonuna yazılır.
119
+ app.use(robotsTxtMiddleware());
120
+
121
+ app.use(staticPrecompressed(config.dirs.public));
122
+ app.use(
123
+ express.static(config.dirs.public, {
124
+ index: false,
125
+ redirect: false,
126
+ // Hash'siz dosyalar (favicon, robots eki, elle konmuş görseller) içerik
127
+ // değişse de aynı adla kalıyor; bir yıllık cache onları güncellenemez
128
+ // hâle getiriyordu. Hash'li çıktı aşağıda ayrıca immutable işaretlenir.
129
+ maxAge: "1h",
130
+ setHeaders(res, filePath) {
131
+ if (filePath.includes(`${path.sep}assets${path.sep}`)) {
132
+ res.setHeader("Cache-Control", IMMUTABLE_CACHE);
133
+ }
134
+ },
135
+ }),
136
+ );
137
+
138
+ // Dev overlay yalnızca development'ta; dinamik import sayesinde prod
139
+ // sürecine hiçbir şey yüklenmez.
140
+ if (process.env.NODE_ENV === "development") {
141
+ const { mountDevtools } = await import("./dev/devtools.js");
142
+ mountDevtools(app);
143
+ }
144
+
145
+ // Yönetim paneli ortama bakmaz, config'e bakar: açıkça etkinleştirilmedikçe
146
+ // modül hiç yüklenmez ve yol da yoktur. Kendi gövde ayrıştırıcısını
147
+ // taşıdığı için aşağıdaki parser'lardan önce durabiliyor; route'lardan
148
+ // önce olması gerekiyor ki uygulama aynı yolu gölgeleyemesin.
149
+ if (config.admin.enabled) {
150
+ const { mountAdmin } = await import("./admin/mount.js");
151
+ mountAdmin(app);
152
+ }
153
+
154
+ // Uzak görsel proxy: allowHosts doluysa mount. Statikten sonra, body
155
+ // parser'dan önce — görsel GET'lerinde gövde ayrıştırma maliyeti ödenmesin.
156
+ if (config.images && config.images !== false && config.images.remote) {
157
+ const { mountImageOptimizer } = await import("./image-optimizer.js");
158
+ await mountImageOptimizer(app);
159
+ }
160
+
161
+ app.use(express.urlencoded({ extended: false, limit: "64kb" }));
162
+ app.use(express.json({ limit: "256kb" }));
163
+
164
+ app.use(csrf());
165
+
166
+ const handoff = config.auth?.crossSubdomainHandoff;
167
+ if (
168
+ handoff === true ||
169
+ (handoff &&
170
+ typeof handoff === "object" &&
171
+ /** @type {{ enabled?: boolean }} */ (handoff).enabled !== false)
172
+ ) {
173
+ const { mountAuthHandoff } = await import("./auth/handoff.js");
174
+ mountAuthHandoff(app);
175
+ }
176
+
177
+ app.use(configRewrites("afterFiles"));
178
+
179
+ await registerRoutes(app);
180
+
181
+ app.use(async (req, res, next) => {
182
+ try {
183
+ // headersMiddleware `/assets/*` için immutable basmış olabilir; eksik bir
184
+ // hash'li dosyanın 404'ü CDN'de bir yıl zehirlenmesin.
185
+ res.status(404).setHeader("Cache-Control", "no-store");
186
+ res.type("html").send(await renderNotFound());
187
+ } catch (error) {
188
+ next(error);
189
+ }
190
+ });
191
+
192
+ // Hata yönetimi: notFound/redirect kontrol akışı burada da yakalanır,
193
+ // çünkü bir controller dışında (ör. middleware içinde) fırlatılabilir.
194
+ app.use(async (error, req, res, next) => {
195
+ if (res.headersSent) {
196
+ next(error);
197
+ return;
198
+ }
199
+
200
+ if (isRedirectError(error)) {
201
+ res.redirect(error.statusCode, error.location);
202
+ return;
203
+ }
204
+
205
+ if (isNotFoundError(error)) {
206
+ res.status(404).setHeader("Cache-Control", "no-store");
207
+ res.type("html").send(await renderNotFound());
208
+ return;
209
+ }
210
+
211
+ const status = statusFromError(error);
212
+ // Isıtma turunun hataları tek tek loglanmaz; tur bitince özet olarak
213
+ // basılır. Yüzlerce yolu tarayan bir tur, upstream bir an tıksırdığında
214
+ // logu yığın izleriyle dolduruyordu.
215
+ if (isPrewarmRequest(req)) notePrewarmError(status, error);
216
+ else console.error(`[${status}] ${req.method} ${req.originalUrl}`, error);
217
+
218
+ // Hata sayfası hiçbir katmanda saklanmamalı: geçici bir upstream arızası
219
+ // CDN'de dakikalarca yaşayan bir 500 sayfasına dönüşmesin.
220
+ res.setHeader("Cache-Control", "no-store");
221
+ res
222
+ .status(status)
223
+ .type("html")
224
+ .send(await renderStatusPage(status, { error }));
225
+ });
226
+
227
+ return app;
228
+ }
229
+
230
+ /**
231
+ * Uygulamayı kurup dinlemeye başlar. CLI `jskelet start` bunu çağırır;
232
+ * gömülü kullanımda `createApp()` tercih edilir.
233
+ *
234
+ * Port doluysa başlamaz. `murder: true` veya argv'de `--murder` varsa
235
+ * dinleyen süreç öldürülüp bağlama denenir.
236
+ *
237
+ * @param {{ root?: string, configFile?: string, port?: number, host?: string, murder?: boolean }} [options]
238
+ * @returns {Promise<import('http').Server>}
239
+ */
240
+ export async function startServer(options = {}) {
241
+ const port = Number(options.port ?? process.env.PORT ?? 3000);
242
+ const murder =
243
+ options.murder === true || process.argv.includes("--murder");
244
+
245
+ // createApp pahalı; dolu portta uygulama kurmadan önce net hata ver.
246
+ await ensurePortFree(port, { murder });
247
+
248
+ const app = await createApp(options);
249
+
250
+ // Varsayılan `::`, `0.0.0.0` değil: ikisi de "tüm arayüzler" demek, ama
251
+ // yalnızca IPv6 soketi çift yığın çalışır ve `localhost`un `::1`e çözüldüğü
252
+ // durumu da kapsar. Tarayıcılar `localhost` için önce `::1` deniyor; sıradan
253
+ // isteklerde IPv4'e düşüyorlar ama WebSocket el sıkışması bu geri düşüşü
254
+ // yapmadan "failed" veriyordu. IPv6'sı olmayan bir makinede bağlama hata
255
+ // verir; aşağıda IPv4'e dönülür.
256
+ const host = options.host ?? process.env.HOST ?? null;
257
+
258
+ // Tek bir istek hatası süreci düşürmesin; logla ve ayakta kal. Bir haber
259
+ // sitesinde tek sayfanın hatası tüm siteyi indirmemeli.
260
+ process.on("unhandledRejection", (reason) => {
261
+ console.error("[unhandledRejection]", reason);
262
+ });
263
+ process.on("uncaughtException", (error) => {
264
+ console.error("[uncaughtException]", error);
265
+ });
266
+
267
+ // Dev panelinin canlı kanalı: el sıkışma `upgrade` olayında geçtiği için
268
+ // middleware zincirine değil, doğrudan sunucuya bağlanır. Modül `listen`den
269
+ // önce yüklenir; dinleme başladıktan sonra beklenen bir `await` kalırsa ilk
270
+ // upgrade isteği dinleyici yokken gelip reddedilebiliyor.
271
+ const attachDevSocket =
272
+ process.env.NODE_ENV === "development"
273
+ ? (await import("./dev/devtools.js")).attachDevSocket
274
+ : null;
275
+
276
+ return new Promise((resolve, reject) => {
277
+ /** @param {string} address */
278
+ const listen = (address) => {
279
+ const server = app.listen(port, address, () => {
280
+ // Bu satırın biçimi sözleşme: `jskelet dev` sunucunun hazır olduğunu
281
+ // buradan anlar ve özet satırını ona göre basar.
282
+ console.log(
283
+ `jskelet → http://localhost:${port} (${process.env.NODE_ENV ?? "production"})`,
284
+ );
285
+ startPrewarm({ port });
286
+ attachShutdown(server);
287
+ resolve(server);
288
+ });
289
+
290
+ server.on("error", (error) => {
291
+ // IPv6 desteklenmiyorsa yalnızca varsayılan adres için IPv4'e dönülür;
292
+ // kullanıcı bir adres verdiyse sessizce başkasını dinlemek yanlış olur.
293
+ if (!host && isAddressUnsupported(error)) {
294
+ listen("0.0.0.0");
295
+ return;
296
+ }
297
+ if (error.code === "EADDRINUSE") {
298
+ const hint = murder
299
+ ? `port ${port} is still in use after --murder`
300
+ : `port ${port} is already in use. Pass --murder to kill it and start, or set PORT to another value.`;
301
+ const wrapped = new Error(hint);
302
+ /** @type {NodeJS.ErrnoException} */ (wrapped).code = "EADDRINUSE";
303
+ reject(wrapped);
304
+ return;
305
+ }
306
+ reject(error);
307
+ });
308
+
309
+ attachDevSocket?.(server);
310
+ };
311
+
312
+ listen(host ?? "::");
313
+ });
314
+ }
315
+
316
+ /**
317
+ * `SIGTERM`/`SIGINT` sonrası düzenli kapanış.
318
+ *
319
+ * Kapatılması gereken dış kaynaklar Redis ve log sink buffer'ları. Redis
320
+ * `quit` uçuştaki komutların bitmesini bekliyor; sert `disconnect` yarıda
321
+ * kalan bir `SET` bırakabiliyor. S3 sink kapanışta kalan batch'i PutObject
322
+ * ile gönderir.
323
+ *
324
+ * Açık HTTP bağlantıları **beklenmez**. `close()` tek başına yalnızca yeni
325
+ * bağlantıyı reddediyor; keep-alive bir istemci ya da dev panelinin
326
+ * WebSocket'i sunucuyu süresiz ayakta tutuyor ve Ctrl+C yanıt vermiyormuş gibi
327
+ * görünüyordu. Sinyal geldiğinde ters proxy zaten trafik göndermiyor.
328
+ *
329
+ * Yine de bir zamanlayıcı var: Redis kapanışı askıda kalırsa süreç `SIGKILL`
330
+ * beklemek zorunda kalmasın.
331
+ *
332
+ * @param {import('http').Server} server
333
+ */
334
+ function attachShutdown(server) {
335
+ let closing = false;
336
+
337
+ const shutdown = () => {
338
+ // İkinci sinyal beklemeyi kısa kessin: kullanıcı Ctrl+C'ye tekrar bastıysa
339
+ // gerçekten çıkmak istiyor.
340
+ if (closing) process.exit(0);
341
+ closing = true;
342
+
343
+ const timer = setTimeout(() => process.exit(0), 3000);
344
+ timer.unref();
345
+
346
+ server.close();
347
+ server.closeAllConnections?.();
348
+
349
+ void Promise.all([disconnectRedis(), flushLogs().then(() => closeLogs())])
350
+ .finally(() => {
351
+ clearTimeout(timer);
352
+ process.exit(0);
353
+ });
354
+ };
355
+
356
+ process.once("SIGTERM", shutdown);
357
+ process.once("SIGINT", shutdown);
358
+ }
359
+
360
+ /**
361
+ * @param {NodeJS.ErrnoException} error
362
+ * @returns {boolean} adres ailesi bu makinede kullanılamıyor mu
363
+ */
364
+ function isAddressUnsupported(error) {
365
+ return error.code === "EAFNOSUPPORT" || error.code === "EADDRNOTAVAIL";
366
+ }