jskelet 0.5.5 → 0.6.1
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/AGENTS.md +19 -15
- package/CHANGELOG.md +165 -15
- package/README.md +16 -21
- package/bin/jskelet.mjs +23 -9
- package/docs/01-baslangic.md +4 -3
- package/docs/02-mimari.md +10 -4
- package/docs/03-routing.md +14 -7
- package/docs/04-render-ve-sablonlar.md +60 -43
- package/docs/05-islands.md +12 -8
- package/docs/06-cache.md +18 -7
- package/docs/07-yapilandirma.md +69 -27
- package/docs/08-build.md +15 -9
- package/docs/09-dev-araclari.md +22 -8
- package/docs/10-dagitim.md +14 -13
- package/docs/11-tasima.md +51 -17
- package/docs/12-panel-ve-oturum.md +10 -4
- package/docs/README.md +10 -33
- package/docs/en/01-getting-started.md +4 -3
- package/docs/en/02-architecture.md +12 -6
- package/docs/en/03-routing.md +15 -8
- package/docs/en/04-rendering.md +71 -59
- package/docs/en/05-islands.md +13 -8
- package/docs/en/06-caching.md +21 -7
- package/docs/en/07-configuration.md +69 -29
- package/docs/en/08-build.md +16 -10
- package/docs/en/09-dev-tools.md +24 -8
- package/docs/en/10-deployment.md +14 -14
- package/docs/en/11-migration.md +51 -16
- package/docs/en/12-dashboards-and-sessions.md +9 -4
- package/docs/en/README.md +10 -35
- package/package.json +48 -13
- package/src/build/tasks/client.mjs +91 -10
- package/src/build/tasks/icons.mjs +11 -1
- package/src/client/index.js +2 -2
- package/src/compile/codegen.js +4 -0
- package/src/compile/compile-all.js +12 -21
- package/src/compile/expr.js +5 -0
- package/src/compile/parse.js +64 -8
- package/src/compile/resolve.js +3 -0
- package/src/config/defaults.js +48 -5
- package/src/config/index.js +138 -27
- package/src/dev-server.mjs +26 -3
- package/src/http/cookies-entry.js +1 -0
- package/src/http/cookies.js +18 -0
- package/src/logo.png +0 -0
- package/src/migrate/apply.mjs +262 -0
- package/src/migrate/babel.mjs +79 -0
- package/src/migrate/classify.mjs +155 -0
- package/src/migrate/config.mjs +126 -0
- package/src/migrate/fs-walk.mjs +191 -0
- package/src/migrate/parse.mjs +26 -0
- package/src/migrate/scan.mjs +177 -0
- package/src/migrate/transform/expr-source.mjs +168 -0
- package/src/migrate/transform/island.mjs +67 -0
- package/src/migrate/transform/jsx-to-component.mjs +302 -0
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -0
- package/src/migrate/transform/page-split.mjs +435 -0
- package/src/migrate/write.mjs +81 -0
- package/src/migrate.mjs +171 -0
- package/src/server/auth/handoff.js +94 -11
- package/src/server/create-app.js +37 -10
- package/src/server/ejs-adapter.js +59 -0
- package/src/server/html-cache.js +178 -32
- package/src/server/image-optimizer.js +94 -26
- package/src/server/middleware/dev-gate.js +21 -8
- package/src/server/middleware/robots-txt.js +341 -0
- package/src/server/port-guard.js +255 -0
- package/src/server/prewarm.js +137 -51
- package/src/server/render.js +30 -10
- package/src/server/status-page.js +105 -4
- package/src/start.mjs +18 -3
- package/src/templates/layout.ejs +8 -28
- package/src/templates/layout.jsk +30 -0
- package/src/templates/layout.render.js +41 -0
- package/src/views/helpers/tags.js +86 -3
- package/types/build/resolve-peer.d.mts +13 -0
- package/types/client/dom.d.ts +55 -0
- package/types/client/form.d.ts +19 -0
- package/types/client/index.d.ts +20 -0
- package/types/client/registry.d.ts +53 -0
- package/types/client/safe-image.d.ts +19 -0
- package/types/client/shared-cookie.d.ts +82 -0
- package/types/client/store.d.ts +18 -0
- package/types/client/swap.d.ts +46 -0
- package/types/compile/codegen.d.ts +32 -0
- package/types/compile/compile-all.d.ts +42 -0
- package/types/compile/errors.d.ts +30 -0
- package/types/compile/expr.d.ts +67 -0
- package/types/compile/index.d.ts +10 -0
- package/types/compile/parse.d.ts +82 -0
- package/types/compile/resolve.d.ts +46 -0
- package/types/compile/scan-exports.d.ts +9 -0
- package/types/config/defaults.d.ts +477 -0
- package/types/config/index.d.ts +304 -0
- package/types/config/pattern.d.ts +38 -0
- package/types/http/control-flow.d.ts +45 -0
- package/types/http/cookies-entry.d.ts +5 -0
- package/types/http/cookies.d.ts +113 -0
- package/types/http/request-cache.d.ts +13 -0
- package/types/http/request-context.d.ts +67 -0
- package/types/http/shared-cookie.d.ts +73 -0
- package/types/index.d.ts +30 -0
- package/types/log.d.mts +153 -0
- package/types/server/admin/actions.d.ts +16 -0
- package/types/server/admin/auth.d.ts +52 -0
- package/types/server/admin/event-log.d.ts +38 -0
- package/types/server/admin/gate.d.ts +43 -0
- package/types/server/admin/inventory.d.ts +40 -0
- package/types/server/admin/mount.d.ts +6 -0
- package/types/server/admin/router.d.ts +6 -0
- package/types/server/admin/snapshot.d.ts +6 -0
- package/types/server/assets.d.ts +47 -0
- package/types/server/auth/handoff.d.ts +12 -0
- package/types/server/cache-deps.d.ts +16 -0
- package/types/server/cache-vary.d.ts +30 -0
- package/types/server/cloudflare.d.ts +163 -0
- package/types/server/create-app.d.ts +25 -0
- package/types/server/data-cache.d.ts +116 -0
- package/types/server/dev/devtools.d.ts +44 -0
- package/types/server/dev/report.d.ts +229 -0
- package/types/server/dev/socket.d.ts +17 -0
- package/types/server/dev/version-check.d.mts +15 -0
- package/types/server/ejs-adapter.d.ts +11 -0
- package/types/server/head-hints.d.ts +40 -0
- package/types/server/html-cache.d.ts +207 -0
- package/types/server/image-optimizer.d.ts +68 -0
- package/types/server/logs/access-middleware.d.ts +7 -0
- package/types/server/logs/file-sink.d.ts +17 -0
- package/types/server/logs/pipeline.d.ts +37 -0
- package/types/server/logs/s3-put.d.ts +85 -0
- package/types/server/logs/s3-sink.d.ts +26 -0
- package/types/server/metadata.d.ts +38 -0
- package/types/server/middleware/compression.d.ts +17 -0
- package/types/server/middleware/csrf.d.ts +4 -0
- package/types/server/middleware/dev-gate.d.ts +2 -0
- package/types/server/middleware/headers.d.ts +2 -0
- package/types/server/middleware/redirects.d.ts +2 -0
- package/types/server/middleware/robots-txt.d.ts +33 -0
- package/types/server/middleware/static-precompressed.d.ts +5 -0
- package/types/server/middleware/trailing-slash.d.ts +11 -0
- package/types/server/middleware/upstream-proxy.d.ts +21 -0
- package/types/server/og-image.d.ts +149 -0
- package/types/server/port-guard.d.ts +50 -0
- package/types/server/prewarm.d.ts +131 -0
- package/types/server/redis.d.ts +163 -0
- package/types/server/render.d.ts +101 -0
- package/types/server/router.d.ts +5 -0
- package/types/server/status-page.d.ts +24 -0
- package/types/server/upstream-limiter.d.ts +123 -0
- package/types/server/upstream-tracking.d.ts +42 -0
- package/types/shared/cookie-domain.d.ts +29 -0
- package/types/templates/layout.render.d.ts +7 -0
- package/types/version.d.mts +10 -0
- package/types/views/components/loader.d.ts +5 -0
- package/types/views/helpers/html.d.ts +39 -0
- package/types/views/helpers/tags.d.ts +127 -0
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Bilet süreç belleğinde; tek süreçte tüm locale host'ları servis eden
|
|
11
11
|
* kurulumlar için yeterli. Değer kısa session id olmalı.
|
|
12
|
+
*
|
|
13
|
+
* Güvenlik: mint CSRF'den *sonra* mount edilir (create-app); cookie adı
|
|
14
|
+
* allowlist + RFC 6265 doğrulaması; bekleyen bilet ve IP başına mint sınırı.
|
|
12
15
|
*/
|
|
13
16
|
import { getConfig } from "../../config/index.js";
|
|
14
|
-
import { randomToken, setCookie } from "../../http/cookies.js";
|
|
17
|
+
import { isValidCookieName, randomToken, setCookie } from "../../http/cookies.js";
|
|
15
18
|
import {
|
|
16
19
|
SHARED_COOKIE_WARN_BYTES,
|
|
17
20
|
requestIsHttps,
|
|
@@ -22,11 +25,26 @@ import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
|
|
|
22
25
|
/** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
|
|
23
26
|
const tickets = new Map();
|
|
24
27
|
|
|
28
|
+
/** IP → { count, windowStart } — basit mint rate limit. */
|
|
29
|
+
/** @type {Map<string, { count: number, windowStart: number }>} */
|
|
30
|
+
const mintWindows = new Map();
|
|
31
|
+
|
|
25
32
|
const DEFAULT_TTL_SECONDS = 60;
|
|
33
|
+
const DEFAULT_MAX_PENDING = 256;
|
|
34
|
+
const DEFAULT_MAX_MINTS_PER_IP = 30;
|
|
35
|
+
const MINT_WINDOW_MS = 60_000;
|
|
26
36
|
const HANDOFF_PATH = "/_jskelet/auth/handoff";
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
|
-
* @returns {{
|
|
39
|
+
* @returns {{
|
|
40
|
+
* enabled: boolean,
|
|
41
|
+
* ttlSeconds: number,
|
|
42
|
+
* path: string,
|
|
43
|
+
* maxValueBytes: number,
|
|
44
|
+
* allowedCookieNames: string[],
|
|
45
|
+
* maxPendingTickets: number,
|
|
46
|
+
* maxMintsPerIpPerMinute: number,
|
|
47
|
+
* }}
|
|
30
48
|
*/
|
|
31
49
|
function settings() {
|
|
32
50
|
const auth = getConfig().auth ?? {};
|
|
@@ -37,21 +55,26 @@ function settings() {
|
|
|
37
55
|
ttlSeconds: DEFAULT_TTL_SECONDS,
|
|
38
56
|
path: HANDOFF_PATH,
|
|
39
57
|
maxValueBytes: SHARED_COOKIE_WARN_BYTES,
|
|
58
|
+
allowedCookieNames: [],
|
|
59
|
+
maxPendingTickets: DEFAULT_MAX_PENDING,
|
|
60
|
+
maxMintsPerIpPerMinute: DEFAULT_MAX_MINTS_PER_IP,
|
|
40
61
|
};
|
|
41
62
|
}
|
|
42
63
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
enabled: true,
|
|
46
|
-
ttlSeconds: DEFAULT_TTL_SECONDS,
|
|
47
|
-
path: HANDOFF_PATH,
|
|
48
|
-
maxValueBytes: SHARED_COOKIE_WARN_BYTES,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
64
|
+
/** @type {Record<string, unknown>} */
|
|
65
|
+
const source = raw === true ? {} : /** @type {Record<string, unknown>} */ (raw);
|
|
51
66
|
|
|
52
|
-
const source = /** @type {Record<string, unknown>} */ (raw);
|
|
53
67
|
const ttl = Number(source.ttlSeconds);
|
|
54
68
|
const maxBytes = Number(source.maxValueBytes);
|
|
69
|
+
const maxPending = Number(source.maxPendingTickets);
|
|
70
|
+
const maxMints = Number(source.maxMintsPerIpPerMinute);
|
|
71
|
+
|
|
72
|
+
const namesRaw = source.allowedCookieNames;
|
|
73
|
+
/** @type {string[]} */
|
|
74
|
+
const allowedCookieNames = Array.isArray(namesRaw)
|
|
75
|
+
? namesRaw.filter((n) => typeof n === "string" && isValidCookieName(n.trim())).map((n) => n.trim())
|
|
76
|
+
: [];
|
|
77
|
+
|
|
55
78
|
return {
|
|
56
79
|
enabled: source.enabled !== false,
|
|
57
80
|
ttlSeconds:
|
|
@@ -64,6 +87,15 @@ function settings() {
|
|
|
64
87
|
Number.isFinite(maxBytes) && maxBytes > 0
|
|
65
88
|
? Math.floor(maxBytes)
|
|
66
89
|
: SHARED_COOKIE_WARN_BYTES,
|
|
90
|
+
allowedCookieNames,
|
|
91
|
+
maxPendingTickets:
|
|
92
|
+
Number.isFinite(maxPending) && maxPending > 0
|
|
93
|
+
? Math.floor(maxPending)
|
|
94
|
+
: DEFAULT_MAX_PENDING,
|
|
95
|
+
maxMintsPerIpPerMinute:
|
|
96
|
+
Number.isFinite(maxMints) && maxMints > 0
|
|
97
|
+
? Math.floor(maxMints)
|
|
98
|
+
: DEFAULT_MAX_MINTS_PER_IP,
|
|
67
99
|
};
|
|
68
100
|
}
|
|
69
101
|
|
|
@@ -74,6 +106,24 @@ function sweep() {
|
|
|
74
106
|
}
|
|
75
107
|
}
|
|
76
108
|
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} ip
|
|
111
|
+
* @param {number} limit
|
|
112
|
+
* @returns {boolean} true ise mint'e izin var
|
|
113
|
+
*/
|
|
114
|
+
function allowMint(ip, limit) {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const key = ip || "unknown";
|
|
117
|
+
const entry = mintWindows.get(key);
|
|
118
|
+
if (!entry || now - entry.windowStart >= MINT_WINDOW_MS) {
|
|
119
|
+
mintWindows.set(key, { count: 1, windowStart: now });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (entry.count >= limit) return false;
|
|
123
|
+
entry.count += 1;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
77
127
|
/**
|
|
78
128
|
* @param {string} next
|
|
79
129
|
* @param {import('express').Request} req
|
|
@@ -146,6 +196,7 @@ function redeem(ticketId) {
|
|
|
146
196
|
|
|
147
197
|
/**
|
|
148
198
|
* Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
|
|
199
|
+
* create-app CSRF'den *sonra* çağırmalıdır.
|
|
149
200
|
*
|
|
150
201
|
* @param {import('express').Express} app
|
|
151
202
|
* @returns {void}
|
|
@@ -154,9 +205,27 @@ export function mountAuthHandoff(app) {
|
|
|
154
205
|
const cfg = settings();
|
|
155
206
|
if (!cfg.enabled) return;
|
|
156
207
|
|
|
208
|
+
if (cfg.allowedCookieNames.length === 0) {
|
|
209
|
+
console.warn(
|
|
210
|
+
"[auth.handoff] enabled but allowedCookieNames is empty — " +
|
|
211
|
+
"POST mint will return 400 until you list cookie names in " +
|
|
212
|
+
"auth.crossSubdomainHandoff.allowedCookieNames",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
157
216
|
app.post(cfg.path, (req, res) => {
|
|
158
217
|
sweep();
|
|
159
218
|
|
|
219
|
+
if (!allowMint(String(req.ip || ""), cfg.maxMintsPerIpPerMinute)) {
|
|
220
|
+
res.status(429).json({ ok: false, error: "too many handoff requests" });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (tickets.size >= cfg.maxPendingTickets) {
|
|
225
|
+
res.status(503).json({ ok: false, error: "handoff ticket store is full" });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
160
229
|
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
|
|
161
230
|
const value = typeof req.body?.value === "string" ? req.body.value : "";
|
|
162
231
|
const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
|
|
@@ -170,6 +239,19 @@ export function mountAuthHandoff(app) {
|
|
|
170
239
|
return;
|
|
171
240
|
}
|
|
172
241
|
|
|
242
|
+
if (!isValidCookieName(name)) {
|
|
243
|
+
res.status(400).json({ ok: false, error: "invalid cookie name" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (cfg.allowedCookieNames.length === 0 || !cfg.allowedCookieNames.includes(name)) {
|
|
248
|
+
res.status(400).json({
|
|
249
|
+
ok: false,
|
|
250
|
+
error: "cookie name is not in allowedCookieNames",
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
173
255
|
if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
|
|
174
256
|
res.status(400).json({
|
|
175
257
|
ok: false,
|
|
@@ -218,6 +300,7 @@ export function mountAuthHandoff(app) {
|
|
|
218
300
|
/** @returns {void} */
|
|
219
301
|
export function _resetHandoffTickets() {
|
|
220
302
|
tickets.clear();
|
|
303
|
+
mintWindows.clear();
|
|
221
304
|
}
|
|
222
305
|
|
|
223
306
|
/** @returns {number} */
|
package/src/server/create-app.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* açık kurallar istenen yolu önce görür.
|
|
13
13
|
* 3b. access log (açıksa) — tamamlanan yanıtların süresi; admin/prewarm
|
|
14
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.
|
|
15
18
|
* 4. staticPrecompressed → express.static — build'de üretilmiş `.br`/`.gz`
|
|
16
19
|
* kopyalar varsa onlar servis edilir (kalite 11), yoksa istek altındaki
|
|
17
20
|
* static'e düşer ve middleware anında sıkıştırır (kalite 5).
|
|
@@ -21,18 +24,18 @@
|
|
|
21
24
|
* body parser'dan önce, admin ile aynı katmanda.
|
|
22
25
|
* 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
|
|
23
26
|
* maliyeti ödenmesin.
|
|
24
|
-
* 5b.
|
|
25
|
-
* önce değil sonra: JSON gövde parser'ı hazır olsun. Redeem GET route
|
|
26
|
-
* render'ından önce olmalı.
|
|
27
|
-
* 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
|
|
27
|
+
* 5b. csrf — body parser'lardan sonra: token form alanından okunuyor.
|
|
28
28
|
* Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
31
33
|
*/
|
|
32
34
|
import path from "node:path";
|
|
33
35
|
import process from "node:process";
|
|
34
36
|
import express from "express";
|
|
35
37
|
import { compression } from "./middleware/compression.js";
|
|
38
|
+
import { robotsTxtMiddleware } from "./middleware/robots-txt.js";
|
|
36
39
|
import { headersMiddleware } from "./middleware/headers.js";
|
|
37
40
|
import { csrf } from "./middleware/csrf.js";
|
|
38
41
|
import { staticPrecompressed } from "./middleware/static-precompressed.js";
|
|
@@ -51,6 +54,7 @@ import { configureUpstreamLimiter } from "./upstream-limiter.js";
|
|
|
51
54
|
import { connectRedis, disconnectRedis } from "./redis.js";
|
|
52
55
|
import { configureLogs, flushLogs, closeLogs } from "./logs/pipeline.js";
|
|
53
56
|
import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
|
|
57
|
+
import { ensurePortFree } from "./port-guard.js";
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
60
|
* @param {{ root?: string, configFile?: string }} [options]
|
|
@@ -109,6 +113,11 @@ export async function createApp(options = {}) {
|
|
|
109
113
|
app.use(accessLogMiddleware());
|
|
110
114
|
}
|
|
111
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
|
+
|
|
112
121
|
app.use(staticPrecompressed(config.dirs.public));
|
|
113
122
|
app.use(
|
|
114
123
|
express.static(config.dirs.public, {
|
|
@@ -152,6 +161,8 @@ export async function createApp(options = {}) {
|
|
|
152
161
|
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
|
|
153
162
|
app.use(express.json({ limit: "256kb" }));
|
|
154
163
|
|
|
164
|
+
app.use(csrf());
|
|
165
|
+
|
|
155
166
|
const handoff = config.auth?.crossSubdomainHandoff;
|
|
156
167
|
if (
|
|
157
168
|
handoff === true ||
|
|
@@ -163,8 +174,6 @@ export async function createApp(options = {}) {
|
|
|
163
174
|
mountAuthHandoff(app);
|
|
164
175
|
}
|
|
165
176
|
|
|
166
|
-
app.use(csrf());
|
|
167
|
-
|
|
168
177
|
app.use(configRewrites("afterFiles"));
|
|
169
178
|
|
|
170
179
|
await registerRoutes(app);
|
|
@@ -222,12 +231,21 @@ export async function createApp(options = {}) {
|
|
|
222
231
|
* Uygulamayı kurup dinlemeye başlar. CLI `jskelet start` bunu çağırır;
|
|
223
232
|
* gömülü kullanımda `createApp()` tercih edilir.
|
|
224
233
|
*
|
|
225
|
-
*
|
|
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]
|
|
226
238
|
* @returns {Promise<import('http').Server>}
|
|
227
239
|
*/
|
|
228
240
|
export async function startServer(options = {}) {
|
|
229
|
-
const app = await createApp(options);
|
|
230
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);
|
|
231
249
|
|
|
232
250
|
// Varsayılan `::`, `0.0.0.0` değil: ikisi de "tüm arayüzler" demek, ama
|
|
233
251
|
// yalnızca IPv6 soketi çift yığın çalışır ve `localhost`un `::1`e çözüldüğü
|
|
@@ -276,6 +294,15 @@ export async function startServer(options = {}) {
|
|
|
276
294
|
listen("0.0.0.0");
|
|
277
295
|
return;
|
|
278
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
|
+
}
|
|
279
306
|
reject(error);
|
|
280
307
|
});
|
|
281
308
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy EJS şablon motoru — opsiyonel peer.
|
|
3
|
+
*
|
|
4
|
+
* `.jsk`-only uygulamalar `ejs` kurmadan çalışır. Bir `.ejs` view veya layout
|
|
5
|
+
* istendiğinde bu modül uygulamadan (yoksa framework'ten) `ejs` yükler;
|
|
6
|
+
* paket yoksa göç yolunu gösteren net bir hata verir.
|
|
7
|
+
*/
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { getConfig, FRAMEWORK_ROOT } from "../config/index.js";
|
|
12
|
+
import { tryImportFromApp } from "../build/resolve-peer.mjs";
|
|
13
|
+
|
|
14
|
+
/** @type {import('ejs') | null | undefined} */
|
|
15
|
+
let cached;
|
|
16
|
+
|
|
17
|
+
const MISSING =
|
|
18
|
+
"EJS templates require the optional peer dependency `ejs`. " +
|
|
19
|
+
"Install it (`npm i ejs`) or migrate the template to `.jsk`.";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @returns {Promise<import('ejs')>}
|
|
23
|
+
*/
|
|
24
|
+
export async function loadEjs() {
|
|
25
|
+
if (cached) return cached;
|
|
26
|
+
if (cached === null) throw new Error(MISSING);
|
|
27
|
+
|
|
28
|
+
const config = getConfig();
|
|
29
|
+
let mod = await tryImportFromApp(config.root, "ejs");
|
|
30
|
+
|
|
31
|
+
// Framework kendi test/dev ortamında peer olarak tutuyor olabilir.
|
|
32
|
+
if (!mod) {
|
|
33
|
+
try {
|
|
34
|
+
const require = createRequire(path.join(FRAMEWORK_ROOT, "package.json"));
|
|
35
|
+
mod = await import(pathToFileURL(require.resolve("ejs")).href);
|
|
36
|
+
} catch {
|
|
37
|
+
mod = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!mod) {
|
|
42
|
+
cached = null;
|
|
43
|
+
throw new Error(MISSING);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
cached = /** @type {import('ejs')} */ (mod.default ?? mod);
|
|
47
|
+
return cached;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} file
|
|
52
|
+
* @param {Record<string, unknown>} data
|
|
53
|
+
* @param {object} options
|
|
54
|
+
* @returns {Promise<string>}
|
|
55
|
+
*/
|
|
56
|
+
export async function renderEjsFile(file, data, options) {
|
|
57
|
+
const ejs = await loadEjs();
|
|
58
|
+
return ejs.renderFile(file, data, options);
|
|
59
|
+
}
|
package/src/server/html-cache.js
CHANGED
|
@@ -31,10 +31,13 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { getConfig } from "../config/index.js";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
DEFAULT_HTML_CACHE_MAX_ENTRIES,
|
|
36
|
+
HTML_CACHE_BYTE_BUDGET,
|
|
37
|
+
} from "../config/defaults.js";
|
|
35
38
|
import { collectDependencies } from "./cache-deps.js";
|
|
36
39
|
import { compilePattern, matchPattern } from "../config/pattern.js";
|
|
37
|
-
import { pathOfCacheKey } from "./cache-vary.js";
|
|
40
|
+
import { pathOfCacheKey, publicHost } from "./cache-vary.js";
|
|
38
41
|
import {
|
|
39
42
|
cacheKey,
|
|
40
43
|
onCacheEvent,
|
|
@@ -118,6 +121,19 @@ const EARLY_SWEEP_INTERVAL_MS = 1000;
|
|
|
118
121
|
/** @type {Map<string, HtmlEntry>} */
|
|
119
122
|
const store = new Map();
|
|
120
123
|
|
|
124
|
+
/**
|
|
125
|
+
* `store` içindeki HTML string + sıkıştırılmış gövdelerin toplamı.
|
|
126
|
+
* Her `drop` / `install` bunu günceller; sıkıştırma sonradan eklendiğinde
|
|
127
|
+
* `noteHtmlCacheGrowth` artışı yazar. Tam tarama yapmamak için tutulur.
|
|
128
|
+
*/
|
|
129
|
+
let storedBytes = 0;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Testler tahliyeyi küçük bir bütçeyle doğrular. `null` → üretim tavanı.
|
|
133
|
+
* @type {number | null}
|
|
134
|
+
*/
|
|
135
|
+
let byteBudgetOverride = null;
|
|
136
|
+
|
|
121
137
|
/** @type {Map<string, Promise<{ html: string, status: number }>>} */
|
|
122
138
|
const inflight = new Map();
|
|
123
139
|
|
|
@@ -237,8 +253,45 @@ function unlink(key, entry) {
|
|
|
237
253
|
}
|
|
238
254
|
|
|
239
255
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
256
|
+
* Ham HTML + sıkıştırılmış gövdeler. Bayt bütçesi girdi sayısından bağımsız
|
|
257
|
+
* bu ağırlığa bakar.
|
|
258
|
+
*
|
|
259
|
+
* @param {HtmlEntry} entry
|
|
260
|
+
* @returns {number}
|
|
261
|
+
*/
|
|
262
|
+
function entryBytes(entry) {
|
|
263
|
+
let bytes = Buffer.byteLength(entry.html);
|
|
264
|
+
for (const buffer of entry.encoded.values()) bytes += buffer.length;
|
|
265
|
+
return bytes;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @returns {number}
|
|
270
|
+
*/
|
|
271
|
+
function activeByteBudget() {
|
|
272
|
+
return byteBudgetOverride ?? HTML_CACHE_BYTE_BUDGET;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Sayı tavanı veya bayt bütçesi aşılınca en eski girdiden düşer. Tek başına
|
|
277
|
+
* bütçeyi aşan girdi de gider: yanıt o istekte zaten üretilmiştir, L1'de
|
|
278
|
+
* durması süreci şişirir.
|
|
279
|
+
*/
|
|
280
|
+
function evictOverflow() {
|
|
281
|
+
const limit = maxEntries();
|
|
282
|
+
const budget = activeByteBudget();
|
|
283
|
+
|
|
284
|
+
while (store.size > 0 && (store.size > limit || storedBytes > budget)) {
|
|
285
|
+
const oldest = store.keys().next().value;
|
|
286
|
+
if (oldest === undefined) break;
|
|
287
|
+
if (!drop(oldest)) break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Store'dan silmenin **tek** yolu. Ters indeks ve bayt sayacı buraya bağlı;
|
|
293
|
+
* hiçbir yerde doğrudan `store.delete()` çağrılmaz. `read()` içindeki
|
|
294
|
+
* sil-yaz yalnızca LRU sırası içindir, sayacı değiştirmez.
|
|
242
295
|
*
|
|
243
296
|
* @param {string} key
|
|
244
297
|
* @returns {boolean} Girdi var mıydı.
|
|
@@ -248,6 +301,7 @@ function drop(key) {
|
|
|
248
301
|
if (!entry) return false;
|
|
249
302
|
|
|
250
303
|
unlink(key, entry);
|
|
304
|
+
storedBytes = Math.max(0, storedBytes - entryBytes(entry));
|
|
251
305
|
store.delete(key);
|
|
252
306
|
return true;
|
|
253
307
|
}
|
|
@@ -406,8 +460,9 @@ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_
|
|
|
406
460
|
produceMs: normalizeProduceMs(produceMs),
|
|
407
461
|
};
|
|
408
462
|
|
|
409
|
-
|
|
410
|
-
|
|
463
|
+
// Bütçeye sığmayan sayfa Redis'e de yazılmaz: bir sonraki istek onu
|
|
464
|
+
// geri alıp yine reddeder, stringify ise o anki RSS'i şişirir.
|
|
465
|
+
if (install(key, entry)) share(key, entry);
|
|
411
466
|
}
|
|
412
467
|
|
|
413
468
|
/**
|
|
@@ -416,13 +471,20 @@ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_
|
|
|
416
471
|
*
|
|
417
472
|
* @param {string} key
|
|
418
473
|
* @param {HtmlEntry} entry
|
|
474
|
+
* @returns {boolean} Girdi L1'e girdi mi.
|
|
419
475
|
*/
|
|
420
476
|
function install(key, entry) {
|
|
421
477
|
// Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
|
|
422
478
|
// tazelemeden tazelemeye değişebilir.
|
|
423
479
|
drop(key);
|
|
424
480
|
|
|
481
|
+
const weight = entryBytes(entry);
|
|
482
|
+
// Tek sayfa bütçeden büyükse saklama. Eski kopya da düştü; bu yanıt
|
|
483
|
+
// üreticinin döndürdüğü HTML ile gider, L1'e girmez.
|
|
484
|
+
if (weight > activeByteBudget()) return false;
|
|
485
|
+
|
|
425
486
|
store.set(key, entry);
|
|
487
|
+
storedBytes += weight;
|
|
426
488
|
|
|
427
489
|
for (const dep of entry.deps) {
|
|
428
490
|
let set = dependents.get(dep);
|
|
@@ -430,12 +492,35 @@ function install(key, entry) {
|
|
|
430
492
|
set.add(key);
|
|
431
493
|
}
|
|
432
494
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
495
|
+
evictOverflow();
|
|
496
|
+
return store.has(key);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Sıkıştırılmış gövde `install()`'dan sonra, ilk brotli/gzip yanıtında
|
|
501
|
+
* girdinin `encoded` haritasına eklenir. Sayacı delta ile büyütmek, o sıra
|
|
502
|
+
* LRU'dan düşmüş bir haritaya yazınca bir daha inmeyen bir artık bırakır;
|
|
503
|
+
* store'dan yeniden okumak o artığı taşımaz.
|
|
504
|
+
*
|
|
505
|
+
* @returns {void}
|
|
506
|
+
*/
|
|
507
|
+
export function noteHtmlCacheGrowth() {
|
|
508
|
+
let bytes = 0;
|
|
509
|
+
for (const entry of store.values()) bytes += entryBytes(entry);
|
|
510
|
+
storedBytes = bytes;
|
|
511
|
+
evictOverflow();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Bellek freninin bayt tavanını geçici olarak değiştirir. Testler LRU
|
|
516
|
+
* tahliyesini küçük bir değerle doğrular; `null` üretim tavanına döner.
|
|
517
|
+
*
|
|
518
|
+
* @param {number | null} bytes
|
|
519
|
+
* @returns {void}
|
|
520
|
+
*/
|
|
521
|
+
export function setHtmlCacheByteBudget(bytes) {
|
|
522
|
+
byteBudgetOverride = bytes == null ? null : bytes;
|
|
523
|
+
evictOverflow();
|
|
439
524
|
}
|
|
440
525
|
|
|
441
526
|
/**
|
|
@@ -589,6 +674,7 @@ export function clearHtmlCache() {
|
|
|
589
674
|
*/
|
|
590
675
|
function clearLocal() {
|
|
591
676
|
store.clear();
|
|
677
|
+
storedBytes = 0;
|
|
592
678
|
dependents.clear();
|
|
593
679
|
tokens.clear();
|
|
594
680
|
invalidated.clear();
|
|
@@ -877,27 +963,65 @@ function dropLocalKey(key) {
|
|
|
877
963
|
}
|
|
878
964
|
|
|
879
965
|
/**
|
|
880
|
-
*
|
|
881
|
-
* boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
|
|
882
|
-
* ısıtmasın diye okuma yıkıcıdır.
|
|
966
|
+
* Vary önekinden `h=` parçasını okur. Önek yoksa boş string.
|
|
883
967
|
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
968
|
+
* @param {string} key
|
|
969
|
+
* @returns {string}
|
|
970
|
+
*/
|
|
971
|
+
function hostOfCacheKey(key) {
|
|
972
|
+
const sep = key.indexOf("|/");
|
|
973
|
+
if (sep === -1) return "";
|
|
974
|
+
const prefix = key.slice(0, sep);
|
|
975
|
+
for (const part of prefix.split("&")) {
|
|
976
|
+
if (part.startsWith("h=")) return part.slice(2);
|
|
977
|
+
}
|
|
978
|
+
return "";
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* @param {string} key
|
|
983
|
+
* @returns {string}
|
|
984
|
+
*/
|
|
985
|
+
function pathWithQuery(key) {
|
|
986
|
+
const pathname = pathOfCacheKey(key);
|
|
987
|
+
const q = key.indexOf("?");
|
|
988
|
+
if (q === -1) return pathname;
|
|
989
|
+
const query = key.slice(q + 1);
|
|
990
|
+
return query ? `${pathname}?${query}` : pathname;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Invalidate edilmiş yollar. Okuma yıkıcıdır; iki tur aynı yolu tekrar
|
|
995
|
+
* ısıtmasın. `onlyHost` verilirse başka host'ların anahtarları kuyrukta
|
|
996
|
+
* kalır — süre dolumu onları kendi host'uyla ısıtır, `127.0.0.1` anahtarı
|
|
997
|
+
* açılmaz.
|
|
886
998
|
*
|
|
887
|
-
* @
|
|
999
|
+
* @param {string} [onlyHost]
|
|
1000
|
+
* @returns {{ path: string, host: string }[]}
|
|
888
1001
|
*/
|
|
889
|
-
export function
|
|
1002
|
+
export function takeInvalidatedTargets(onlyHost) {
|
|
890
1003
|
if (!invalidated.size) return [];
|
|
891
1004
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
const
|
|
897
|
-
if (
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
}
|
|
1005
|
+
/** @type {{ path: string, host: string }[]} */
|
|
1006
|
+
const taken = [];
|
|
1007
|
+
|
|
1008
|
+
for (const key of [...invalidated]) {
|
|
1009
|
+
const host = hostOfCacheKey(key);
|
|
1010
|
+
if (onlyHost && host && host !== onlyHost) continue;
|
|
1011
|
+
invalidated.delete(key);
|
|
1012
|
+
taken.push({ path: pathWithQuery(key), host });
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
return taken;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Yol listesi. Vary öneki düşülür; host ayrımı `takeInvalidatedTargets`.
|
|
1020
|
+
*
|
|
1021
|
+
* @returns {string[]}
|
|
1022
|
+
*/
|
|
1023
|
+
export function takeInvalidatedPaths() {
|
|
1024
|
+
return takeInvalidatedTargets().map((target) => target.path);
|
|
901
1025
|
}
|
|
902
1026
|
|
|
903
1027
|
/**
|
|
@@ -926,14 +1050,36 @@ export function getHtmlCacheEntries() {
|
|
|
926
1050
|
* Yol (query'siz) için taze bir HTML girdisi var mı? Ziyaret ısıtması yalnızca
|
|
927
1051
|
* soğuk / bayat hedefleri kuyruğa alır; HIT'leri yeniden çekmez.
|
|
928
1052
|
*
|
|
1053
|
+
* Gerçek anahtar `h=host|/yol?` biçimindedir: düz `store.get(pathname)` hem
|
|
1054
|
+
* vary önekini hem sondaki `?` işaretini kaçırır ve sıcak sayfayı yeniden
|
|
1055
|
+
* ısıtır. `vary.host` açıkken yalnızca bu isteğin host'u sayılır; diğer
|
|
1056
|
+
* locale'in kopyası bu yolu sıcak yapmaz.
|
|
1057
|
+
*
|
|
929
1058
|
* @param {string} pathname
|
|
1059
|
+
* @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
|
|
930
1060
|
* @returns {boolean}
|
|
931
1061
|
*/
|
|
932
|
-
export function isHtmlCacheFresh(pathname) {
|
|
1062
|
+
export function isHtmlCacheFresh(pathname, req) {
|
|
933
1063
|
if (typeof pathname !== "string" || !pathname.startsWith("/")) return false;
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1064
|
+
|
|
1065
|
+
let host = "";
|
|
1066
|
+
try {
|
|
1067
|
+
if (getConfig().cacheVary?.host && req) host = publicHost(req);
|
|
1068
|
+
} catch {
|
|
1069
|
+
// Config yokken (testler) önek aranmaz; yol eşleşmesi yeter.
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const now = Date.now();
|
|
1073
|
+
for (const [key, entry] of store) {
|
|
1074
|
+
if (now >= entry.expiresAt) continue;
|
|
1075
|
+
if (pathOfCacheKey(key) !== pathname) continue;
|
|
1076
|
+
const q = key.indexOf("?");
|
|
1077
|
+
if (q !== -1 && key.slice(q + 1)) continue;
|
|
1078
|
+
if (host && hostOfCacheKey(key) !== host) continue;
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return false;
|
|
937
1083
|
}
|
|
938
1084
|
|
|
939
1085
|
/**
|