jskelet 0.7.1 → 0.7.2
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 +37 -0
- package/docs/04-render-ve-sablonlar.md +7 -6
- package/docs/05-islands.md +8 -4
- package/docs/06-cache.md +18 -16
- package/docs/07-yapilandirma.md +8 -7
- package/docs/en/04-rendering.md +8 -6
- package/docs/en/05-islands.md +8 -4
- package/docs/en/06-caching.md +18 -16
- package/docs/en/07-configuration.md +8 -6
- package/package.json +1 -1
- package/src/build/build.mjs +23 -8
- package/src/build/tasks/precompress.mjs +51 -11
- package/src/client/registry.js +13 -3
- package/src/compile/compile-all.js +14 -8
- package/src/config/defaults.js +9 -5
- package/src/config/index.js +3 -3
- package/src/http/request-cache.js +19 -1
- package/src/index.js +2 -0
- package/src/server/cache-blob.js +33 -0
- package/src/server/create-app.js +5 -0
- package/src/server/data-cache.js +77 -11
- package/src/server/disk-cache.js +2 -2
- package/src/server/head-hints.js +21 -9
- package/src/server/html-cache.js +169 -39
- package/src/server/image-optimizer.js +72 -28
- package/src/server/redis.js +2 -2
- package/src/server/render.js +165 -53
- package/types/client/registry.d.ts +2 -0
- package/types/config/defaults.d.ts +8 -4
- package/types/config/index.d.ts +1 -0
- package/types/index.d.ts +1 -1
- package/types/server/cache-blob.d.ts +8 -0
- package/types/server/data-cache.d.ts +1 -0
- package/types/server/html-cache.d.ts +4 -5
- package/types/server/render.d.ts +19 -0
|
@@ -39,6 +39,17 @@ const imageCacheIndex = new Map();
|
|
|
39
39
|
/** @type {number} */
|
|
40
40
|
let imageCacheBytes = 0;
|
|
41
41
|
|
|
42
|
+
/** Aynı url+w+q için eşzamanlı optimize birleşir. @type {Map<string, Promise<void>>} */
|
|
43
|
+
const optimizeInflight = new Map();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* DNS güven kontrolü kısa TTL'li cache — her hop'ta tekrar lookup olmasın.
|
|
47
|
+
* @type {Map<string, { ok: boolean, until: number }>}
|
|
48
|
+
*/
|
|
49
|
+
const dnsSafeCache = new Map();
|
|
50
|
+
const DNS_SAFE_TTL_MS = 60_000;
|
|
51
|
+
const MAX_DNS_SAFE = 500;
|
|
52
|
+
|
|
42
53
|
const MAX_REDIRECTS = 5;
|
|
43
54
|
|
|
44
55
|
/**
|
|
@@ -172,16 +183,27 @@ export function isRemoteUrlShapeAllowed(url, allowHosts) {
|
|
|
172
183
|
*/
|
|
173
184
|
export async function assertResolvedHostSafe(hostname) {
|
|
174
185
|
if (isBlockedAddress(hostname)) return false;
|
|
186
|
+
|
|
187
|
+
const cached = dnsSafeCache.get(hostname);
|
|
188
|
+
if (cached && Date.now() < cached.until) return cached.ok;
|
|
189
|
+
|
|
190
|
+
/** @type {boolean} */
|
|
191
|
+
let ok = false;
|
|
175
192
|
try {
|
|
176
193
|
const results = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
if (isBlockedAddress(address)) return false;
|
|
194
|
+
if (results.length) {
|
|
195
|
+
ok = results.every(({ address }) => !isBlockedAddress(address));
|
|
180
196
|
}
|
|
181
|
-
return true;
|
|
182
197
|
} catch {
|
|
183
|
-
|
|
198
|
+
ok = false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (dnsSafeCache.size >= MAX_DNS_SAFE) {
|
|
202
|
+
const oldest = dnsSafeCache.keys().next().value;
|
|
203
|
+
if (oldest !== undefined) dnsSafeCache.delete(oldest);
|
|
184
204
|
}
|
|
205
|
+
dnsSafeCache.set(hostname, { ok, until: Date.now() + DNS_SAFE_TTL_MS });
|
|
206
|
+
return ok;
|
|
185
207
|
}
|
|
186
208
|
|
|
187
209
|
/**
|
|
@@ -355,32 +377,23 @@ async function handleOptimize(req, res, remote) {
|
|
|
355
377
|
return;
|
|
356
378
|
}
|
|
357
379
|
|
|
358
|
-
|
|
359
|
-
if (!
|
|
360
|
-
|
|
380
|
+
let pending = optimizeInflight.get(key);
|
|
381
|
+
if (!pending) {
|
|
382
|
+
pending = materializeOptimized(allowed.href, filePath, width, quality, remote).finally(
|
|
383
|
+
() => {
|
|
384
|
+
optimizeInflight.delete(key);
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
optimizeInflight.set(key, pending);
|
|
388
|
+
}
|
|
389
|
+
await pending;
|
|
390
|
+
|
|
391
|
+
if (imageCacheIndex.has(filePath) || fs.existsSync(filePath)) {
|
|
392
|
+
sendCached(res, filePath, remote.cacheMaxAge);
|
|
361
393
|
return;
|
|
362
394
|
}
|
|
363
395
|
|
|
364
|
-
|
|
365
|
-
const buffer = await sharp(upstream.buffer, { failOn: "none" })
|
|
366
|
-
.rotate()
|
|
367
|
-
.resize({
|
|
368
|
-
width,
|
|
369
|
-
withoutEnlargement: true,
|
|
370
|
-
fit: "inside",
|
|
371
|
-
})
|
|
372
|
-
.webp({ quality, effort: 4 })
|
|
373
|
-
.toBuffer();
|
|
374
|
-
|
|
375
|
-
// Atomik yaz: yarım dosya immutable cache'e düşmesin.
|
|
376
|
-
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
377
|
-
await fs.promises.writeFile(tmp, buffer);
|
|
378
|
-
await fs.promises.rename(tmp, filePath);
|
|
379
|
-
// Yeni dosya için yer aç: en eski düşer. Az önce yazılan, tek başına
|
|
380
|
-
// tavanı aşıyorsa kalır; bir sonraki yazım onu düşürür. Silme, bu
|
|
381
|
-
// yanıtın `sendFile`'ından önce olmasın.
|
|
382
|
-
noteImageCacheFile(filePath, buffer.length);
|
|
383
|
-
sendCached(res, filePath, remote.cacheMaxAge);
|
|
396
|
+
res.status(502).type("text").send("Upstream image fetch failed");
|
|
384
397
|
} catch (error) {
|
|
385
398
|
console.warn("[images.remote] optimize failed:", error);
|
|
386
399
|
// Bozuk kaynakta sayfa boş kalmasın: orijinale düş.
|
|
@@ -388,6 +401,37 @@ async function handleOptimize(req, res, remote) {
|
|
|
388
401
|
}
|
|
389
402
|
}
|
|
390
403
|
|
|
404
|
+
/**
|
|
405
|
+
* @param {string} href
|
|
406
|
+
* @param {string} filePath
|
|
407
|
+
* @param {number} width
|
|
408
|
+
* @param {number} quality
|
|
409
|
+
* @param {import('../config/index.js').ImagesRemoteConfig} remote
|
|
410
|
+
* @returns {Promise<void>}
|
|
411
|
+
*/
|
|
412
|
+
async function materializeOptimized(href, filePath, width, quality, remote) {
|
|
413
|
+
if (imageCacheIndex.has(filePath) || fs.existsSync(filePath)) return;
|
|
414
|
+
|
|
415
|
+
const upstream = await fetchUpstream(href, remote);
|
|
416
|
+
if (!upstream.ok) return;
|
|
417
|
+
|
|
418
|
+
const sharp = /** @type {NonNullable<typeof sharpModule>} */ (sharpModule).default;
|
|
419
|
+
const buffer = await sharp(upstream.buffer, { failOn: "none" })
|
|
420
|
+
.rotate()
|
|
421
|
+
.resize({
|
|
422
|
+
width,
|
|
423
|
+
withoutEnlargement: true,
|
|
424
|
+
fit: "inside",
|
|
425
|
+
})
|
|
426
|
+
.webp({ quality, effort: 4 })
|
|
427
|
+
.toBuffer();
|
|
428
|
+
|
|
429
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
430
|
+
await fs.promises.writeFile(tmp, buffer);
|
|
431
|
+
await fs.promises.rename(tmp, filePath);
|
|
432
|
+
noteImageCacheFile(filePath, buffer.length);
|
|
433
|
+
}
|
|
434
|
+
|
|
391
435
|
/**
|
|
392
436
|
* Redirect'leri elle takip eder; her hop allowlist + DNS private kontrolünden geçer.
|
|
393
437
|
*
|
package/src/server/redis.js
CHANGED
|
@@ -22,7 +22,7 @@ import crypto from "node:crypto";
|
|
|
22
22
|
import { DEFAULT_REDIS } from "../config/defaults.js";
|
|
23
23
|
import { tryImportFromApp } from "../build/resolve-peer.mjs";
|
|
24
24
|
import { getBuildId } from "./assets.js";
|
|
25
|
-
import {
|
|
25
|
+
import { decodeCacheValueAsync, encodeCacheValue } from "./cache-blob.js";
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* @typedef {import('../config/index.js').RedisConfig} RedisConfig
|
|
@@ -295,7 +295,7 @@ export async function redisGetJson(key) {
|
|
|
295
295
|
const raw =
|
|
296
296
|
typeof client.getBuffer === "function" ? await client.getBuffer(key) : await client.get(key);
|
|
297
297
|
noteSuccess();
|
|
298
|
-
return raw == null ? null :
|
|
298
|
+
return raw == null ? null : await decodeCacheValueAsync(raw);
|
|
299
299
|
} catch (error) {
|
|
300
300
|
noteFailure("get", error);
|
|
301
301
|
return null;
|
package/src/server/render.js
CHANGED
|
@@ -58,16 +58,22 @@ const isDev = process.env.NODE_ENV === "development";
|
|
|
58
58
|
* taraması dosya sistemine dokunduğu için bir kez yapılır; config yüklenmeden
|
|
59
59
|
* hesaplanamaz, bu yüzden ilk render'da kurulur.
|
|
60
60
|
*
|
|
61
|
-
*
|
|
61
|
+
* Sayfa `.jsk` şablonları lazy yüklenir; layout + `components/*.jsk` eager.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {{ helpers: Record<string, unknown>, options: Record<string, unknown>,
|
|
62
64
|
* viewsDir: string, viewRoots: string[], layout: string,
|
|
63
65
|
* compiled: Map<string, (data: object, helpers: object) => string>,
|
|
66
|
+
* loading: Map<string, Promise<(data: object, helpers: object) => string | null>>,
|
|
67
|
+
* manifest: Record<string, string>, templatesDir: string, bust: string,
|
|
64
68
|
* layoutRender: ((data: object, helpers: object) => string) | null
|
|
65
|
-
* }
|
|
69
|
+
* }} RenderEngine
|
|
66
70
|
*/
|
|
71
|
+
|
|
72
|
+
/** @type {RenderEngine | null} */
|
|
67
73
|
let engine = null;
|
|
68
74
|
|
|
69
75
|
/**
|
|
70
|
-
* @returns {Promise<
|
|
76
|
+
* @returns {Promise<RenderEngine>}
|
|
71
77
|
*/
|
|
72
78
|
async function getEngine() {
|
|
73
79
|
if (engine) return engine;
|
|
@@ -104,14 +110,11 @@ async function getEngine() {
|
|
|
104
110
|
if (!(pascal in helpers)) helpers[pascal] = value;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
const
|
|
108
|
-
config,
|
|
109
|
-
helpers,
|
|
110
|
-
);
|
|
113
|
+
const loaded = await loadCompiledTemplates(config, helpers);
|
|
111
114
|
|
|
112
115
|
// Yalnızca framework varsayılan layout'una düşüldüğünde hazır modülü kullan;
|
|
113
116
|
// uygulamanın kendi layout.jsk'si derlenmemişse sessizce framework'e kayma.
|
|
114
|
-
let layoutRender =
|
|
117
|
+
let layoutRender = loaded.layoutRender;
|
|
115
118
|
if (!layoutRender) {
|
|
116
119
|
const fwLayout = path.join(FRAMEWORK_ROOT, "src", "templates", "layout.jsk");
|
|
117
120
|
if (path.resolve(config.layout) === path.resolve(fwLayout)) {
|
|
@@ -125,7 +128,11 @@ async function getEngine() {
|
|
|
125
128
|
viewRoots,
|
|
126
129
|
layout: config.layout,
|
|
127
130
|
helpers,
|
|
128
|
-
compiled,
|
|
131
|
+
compiled: loaded.compiled,
|
|
132
|
+
loading: new Map(),
|
|
133
|
+
manifest: loaded.manifest,
|
|
134
|
+
templatesDir: loaded.templatesDir,
|
|
135
|
+
bust: loaded.bust,
|
|
129
136
|
layoutRender,
|
|
130
137
|
options: {
|
|
131
138
|
// `include('partials/header')` gibi çağrılar views kökünden çözülür.
|
|
@@ -141,11 +148,48 @@ async function getEngine() {
|
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
/**
|
|
144
|
-
*
|
|
151
|
+
* Manifest'ten tek bir derlenmiş view yükler. Include'lar modül grafiğinde
|
|
152
|
+
* statik import ile gelir.
|
|
153
|
+
*
|
|
154
|
+
* @param {RenderEngine} eng
|
|
155
|
+
* @param {string} viewId
|
|
156
|
+
* @returns {Promise<((data: object, helpers: object) => string) | null>}
|
|
157
|
+
*/
|
|
158
|
+
async function ensureCompiledView(eng, viewId) {
|
|
159
|
+
const hit = eng.compiled.get(viewId);
|
|
160
|
+
if (hit) return hit;
|
|
161
|
+
|
|
162
|
+
const rel = eng.manifest[viewId];
|
|
163
|
+
if (!rel) return null;
|
|
164
|
+
|
|
165
|
+
const pending = eng.loading.get(viewId);
|
|
166
|
+
if (pending) return pending;
|
|
167
|
+
|
|
168
|
+
const task = (async () => {
|
|
169
|
+
const file = path.join(eng.templatesDir, rel);
|
|
170
|
+
try {
|
|
171
|
+
const mod = await import(pathToFileURL(file).href + eng.bust);
|
|
172
|
+
if (typeof mod.render !== "function") return null;
|
|
173
|
+
eng.compiled.set(viewId, mod.render);
|
|
174
|
+
return mod.render;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
} finally {
|
|
178
|
+
eng.loading.delete(viewId);
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
|
|
182
|
+
eng.loading.set(viewId, task);
|
|
183
|
+
return task;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Layout + `components/*.jsk` eager; sayfa şablonları ilk `renderView`'da.
|
|
145
188
|
*
|
|
146
189
|
* @param {import('../config/index.js').ResolvedConfig} config
|
|
147
190
|
* @param {Record<string, unknown>} helpers
|
|
148
|
-
* @returns {Promise<{ compiled: Map<string, Function>, layoutRender: Function | null
|
|
191
|
+
* @returns {Promise<{ compiled: Map<string, Function>, layoutRender: Function | null,
|
|
192
|
+
* manifest: Record<string, string>, templatesDir: string, bust: string }>}
|
|
149
193
|
*/
|
|
150
194
|
async function loadCompiledTemplates(config, helpers) {
|
|
151
195
|
/** @type {Map<string, (data: object, helpers: object) => string>} */
|
|
@@ -154,16 +198,26 @@ async function loadCompiledTemplates(config, helpers) {
|
|
|
154
198
|
const manifestPath = path.join(templatesDir, "manifest.json");
|
|
155
199
|
|
|
156
200
|
if (!fs.existsSync(manifestPath)) {
|
|
157
|
-
return {
|
|
201
|
+
return {
|
|
202
|
+
compiled,
|
|
203
|
+
layoutRender: null,
|
|
204
|
+
manifest: {},
|
|
205
|
+
templatesDir,
|
|
206
|
+
bust: "",
|
|
207
|
+
};
|
|
158
208
|
}
|
|
159
209
|
|
|
160
210
|
/** @type {Record<string, string>} */
|
|
161
211
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
162
212
|
const bust = isDev ? `?t=${Date.now()}` : "";
|
|
163
213
|
|
|
164
|
-
|
|
214
|
+
const eagerIds = Object.keys(manifest).filter(
|
|
215
|
+
(id) => id === "layout" || id.startsWith("components/"),
|
|
216
|
+
);
|
|
217
|
+
|
|
165
218
|
const entries = await Promise.all(
|
|
166
|
-
|
|
219
|
+
eagerIds.map(async (viewId) => {
|
|
220
|
+
const rel = manifest[viewId];
|
|
167
221
|
const file = path.join(templatesDir, rel);
|
|
168
222
|
try {
|
|
169
223
|
const mod = await import(pathToFileURL(file).href + bust);
|
|
@@ -178,7 +232,6 @@ async function loadCompiledTemplates(config, helpers) {
|
|
|
178
232
|
if (!mod || typeof mod.render !== "function") continue;
|
|
179
233
|
compiled.set(viewId, mod.render);
|
|
180
234
|
|
|
181
|
-
// `components/*.jsk` → helpers.PascalName
|
|
182
235
|
if (viewId.startsWith("components/")) {
|
|
183
236
|
const base = viewId.slice("components/".length).split("/").pop() ?? "";
|
|
184
237
|
const pascal = base
|
|
@@ -193,7 +246,24 @@ async function loadCompiledTemplates(config, helpers) {
|
|
|
193
246
|
}
|
|
194
247
|
|
|
195
248
|
const layoutRender = compiled.get("layout") ?? null;
|
|
196
|
-
return { compiled, layoutRender };
|
|
249
|
+
return { compiled, layoutRender, manifest, templatesDir, bust };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Listen öncesi: motor + asset manifest ısınır (sayfa prewarm değil).
|
|
254
|
+
*
|
|
255
|
+
* @returns {Promise<void>}
|
|
256
|
+
*/
|
|
257
|
+
export async function warmRenderEngine() {
|
|
258
|
+
await getEngine();
|
|
259
|
+
hasAsset("app.css");
|
|
260
|
+
hasAsset("main.js");
|
|
261
|
+
try {
|
|
262
|
+
const { getBuildId } = await import("./assets.js");
|
|
263
|
+
getBuildId();
|
|
264
|
+
} catch {
|
|
265
|
+
/* yoksa sessiz */
|
|
266
|
+
}
|
|
197
267
|
}
|
|
198
268
|
|
|
199
269
|
/**
|
|
@@ -232,8 +302,13 @@ export function resetRenderEngine() {
|
|
|
232
302
|
* @returns {Promise<string>}
|
|
233
303
|
*/
|
|
234
304
|
export async function renderView(view, data = {}) {
|
|
235
|
-
const
|
|
236
|
-
const
|
|
305
|
+
const eng = await getEngine();
|
|
306
|
+
const { helpers, options, viewRoots, viewsDir } = eng;
|
|
307
|
+
|
|
308
|
+
let renderFn = eng.compiled.get(view) ?? null;
|
|
309
|
+
if (!renderFn && eng.manifest[view]) {
|
|
310
|
+
renderFn = await ensureCompiledView(eng, view);
|
|
311
|
+
}
|
|
237
312
|
if (renderFn) {
|
|
238
313
|
return renderFn({ ...data }, helpers);
|
|
239
314
|
}
|
|
@@ -256,17 +331,31 @@ export async function renderPage(page) {
|
|
|
256
331
|
const { helpers, options, layout, layoutRender } = await getEngine();
|
|
257
332
|
const config = getConfig();
|
|
258
333
|
|
|
259
|
-
|
|
334
|
+
// Gövde `page.metadata` ile başlar (hook beklemez). Head/layout birleşik
|
|
335
|
+
// metadata'yı alır — siteName/titleTemplate gibi hook alanları layout'ta.
|
|
336
|
+
const pageMeta = page.metadata ?? {};
|
|
337
|
+
const metadataPromise = (async () => ({
|
|
260
338
|
...(await hook("metadata", {}, page)),
|
|
261
|
-
...
|
|
262
|
-
};
|
|
339
|
+
...pageMeta,
|
|
340
|
+
}))();
|
|
263
341
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
342
|
+
const bodyPromise = renderView(page.view, {
|
|
343
|
+
...(page.data ?? {}),
|
|
344
|
+
metadata: pageMeta,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// metadata → layoutContext zinciri gövdeyle paralel.
|
|
348
|
+
const layoutPromise = metadataPromise.then(async (metadata) => {
|
|
349
|
+
const context = await hook("layoutContext", {}, {
|
|
350
|
+
pathname: page.pathname ?? "",
|
|
351
|
+
metadata,
|
|
352
|
+
});
|
|
353
|
+
return { metadata, context };
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const [body, { metadata, context }] = await Promise.all([
|
|
357
|
+
bodyPromise,
|
|
358
|
+
layoutPromise,
|
|
270
359
|
]);
|
|
271
360
|
|
|
272
361
|
// Layout local'leri. `.jsk` `(data, helpers)` aldığı için helpers'ı data'ya
|
|
@@ -694,31 +783,28 @@ async function sendHtml(req, res, body, encoded, options = {}) {
|
|
|
694
783
|
const encoding =
|
|
695
784
|
req.method === "HEAD" ? null : negotiateEncoding(req.headers["accept-encoding"]);
|
|
696
785
|
|
|
697
|
-
if (!encoding
|
|
786
|
+
if (!encoding) {
|
|
698
787
|
if (options.etag === false) {
|
|
699
|
-
// Sıkıştırma middleware'i devreye girerse Content-Length'i kendisi
|
|
700
|
-
// kaldırır; girmezse doğru uzunlukla gider.
|
|
701
788
|
res.setHeader("Content-Length", String(Buffer.byteLength(body)));
|
|
702
789
|
res.end(req.method === "HEAD" ? undefined : body);
|
|
703
790
|
return;
|
|
704
791
|
}
|
|
705
|
-
|
|
706
792
|
res.send(body);
|
|
707
793
|
return;
|
|
708
794
|
}
|
|
709
795
|
|
|
710
|
-
|
|
796
|
+
// Public cache haritası veya private/fragment: HTML'i burada sıkıştır —
|
|
797
|
+
// middleware'in ikinci kez buffer'lamasını ve private yolda q=5 maliyetini kes.
|
|
798
|
+
let buffer = encoded?.get(encoding);
|
|
711
799
|
if (!buffer) {
|
|
712
800
|
buffer = await encodeText(body, encoding);
|
|
713
|
-
|
|
714
|
-
// gelince eski buffer düşer, bayt bütçesi iki gövdeyi birden saymaz.
|
|
715
|
-
rememberHtmlEncoding(encoded, encoding, buffer);
|
|
801
|
+
if (encoded) rememberHtmlEncoding(encoded, encoding, buffer);
|
|
716
802
|
}
|
|
717
803
|
|
|
718
804
|
res.setHeader("Content-Encoding", encoding);
|
|
719
805
|
res.setHeader("Vary", "Accept-Encoding");
|
|
720
806
|
res.setHeader("Content-Length", String(buffer.length));
|
|
721
|
-
res.end(buffer);
|
|
807
|
+
res.end(req.method === "HEAD" ? undefined : buffer);
|
|
722
808
|
}
|
|
723
809
|
|
|
724
810
|
/**
|
|
@@ -770,12 +856,47 @@ async function produce(controller, ctx) {
|
|
|
770
856
|
if (!first.transient.length) return { html: await renderNotFound(), status: 404 };
|
|
771
857
|
|
|
772
858
|
// Buraya gelindiyse `notFound()` veri gelmediği için çağrılmış. **Var olan
|
|
773
|
-
// bir sayfayı** 404 olarak servis etmek en kötü sonu
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
const { attempts, delayMs } = transientRetry();
|
|
859
|
+
// bir sayfayı** 404 olarak servis etmek en kötü sonuç. Varsayılan: hemen
|
|
860
|
+
// 503 dön, retry'yi arka planda çalıştır (data cache ısınır). `background:
|
|
861
|
+
// false` eski senkron retry davranışını geri getirir.
|
|
862
|
+
const { attempts, delayMs, background } = transientRetry();
|
|
777
863
|
let failures = first.transient;
|
|
778
864
|
|
|
865
|
+
if (background && attempts > 0) {
|
|
866
|
+
const retryDetail =
|
|
867
|
+
`returned notFound() while upstream is failing ` +
|
|
868
|
+
`(${summarizeFailures(failures)}), retrying in background (${attempts}×)`;
|
|
869
|
+
if (!suppressForPrewarm(retryDetail)) {
|
|
870
|
+
console.warn(`[render] ${ctx.pathname} ${retryDetail}`);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
void (async () => {
|
|
874
|
+
for (let round = 1; round <= attempts; round += 1) {
|
|
875
|
+
await sleep(delayMs * round);
|
|
876
|
+
try {
|
|
877
|
+
const retried = await withUpstreamTracking(() =>
|
|
878
|
+
withRequestCache(() => attempt(controller, ctx)),
|
|
879
|
+
);
|
|
880
|
+
if ("page" in retried || !retried.transient.length) return;
|
|
881
|
+
failures = retried.transient;
|
|
882
|
+
} catch (error) {
|
|
883
|
+
console.warn(
|
|
884
|
+
`[render] ${ctx.pathname} background retry failed`,
|
|
885
|
+
error instanceof Error ? error.message : error,
|
|
886
|
+
);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
})();
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
html: await renderStatusPage(503),
|
|
894
|
+
status: 503,
|
|
895
|
+
degraded: true,
|
|
896
|
+
retryAfter: RETRY_AFTER_SECONDS,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
779
900
|
for (let round = 1; round <= attempts; round += 1) {
|
|
780
901
|
const retryDetail =
|
|
781
902
|
`returned notFound() while upstream is failing ` +
|
|
@@ -784,29 +905,20 @@ async function produce(controller, ctx) {
|
|
|
784
905
|
console.warn(`[render] ${ctx.pathname} ${retryDetail}`);
|
|
785
906
|
}
|
|
786
907
|
|
|
787
|
-
// Beklemeden tekrar denemek rate limit'e girmiş bir API'de aynı 429'u
|
|
788
|
-
// getirir; kısa bekleme hem pencerenin dönmesine şans verir hem de
|
|
789
|
-
// fırtınayı büyütmez.
|
|
790
908
|
await sleep(delayMs * round);
|
|
791
909
|
|
|
792
|
-
// Her deneme kendi upstream ve istek içi cache bağlamında çalışır: ilk
|
|
793
|
-
// turun hataları ikinci turun kararını kirletmesin ve memoize edilmiş
|
|
794
|
-
// boş cevaplar tekrar kullanılmasın.
|
|
795
910
|
const retried = await withUpstreamTracking(() =>
|
|
796
911
|
withRequestCache(() => attempt(controller, ctx)),
|
|
797
912
|
);
|
|
798
913
|
|
|
799
914
|
if ("page" in retried) return retried.page;
|
|
800
915
|
if (!retried.transient.length) {
|
|
801
|
-
// Bu kez upstream sağlam cevap verdi ve "yok" dedi: gerçek 404.
|
|
802
916
|
return { html: await renderNotFound(), status: 404 };
|
|
803
917
|
}
|
|
804
918
|
|
|
805
919
|
failures = retried.transient;
|
|
806
920
|
}
|
|
807
921
|
|
|
808
|
-
// Denemeler tükendi. 404 yerine 503: önbelleğe girmez, `Retry-After` taşır
|
|
809
|
-
// ve bir sonraki istek gerçek içeriği üretebilir.
|
|
810
922
|
const outageDetail =
|
|
811
923
|
`could not be produced, upstream is still failing ` +
|
|
812
924
|
`(${summarizeFailures(failures)}), serving an uncached 503 instead of a 404`;
|
|
@@ -830,11 +942,10 @@ async function produce(controller, ctx) {
|
|
|
830
942
|
const RETRY_AFTER_SECONDS = 30;
|
|
831
943
|
|
|
832
944
|
/**
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
* iki katına çıkabilir, ama alternatifi var olan sayfaları 404'e düşürmek.
|
|
945
|
+
* Varsayılan: arka plan retry + hemen 503 (TTFB'yi sleep ile şişirmez).
|
|
946
|
+
* `background: false` → eski senkron döngü.
|
|
836
947
|
*
|
|
837
|
-
* @returns {{ attempts: number, delayMs: number }}
|
|
948
|
+
* @returns {{ attempts: number, delayMs: number, background: boolean }}
|
|
838
949
|
*/
|
|
839
950
|
function transientRetry() {
|
|
840
951
|
const raw = /** @type {any} */ (getConfig().transientRetry ?? {});
|
|
@@ -844,6 +955,7 @@ function transientRetry() {
|
|
|
844
955
|
return {
|
|
845
956
|
attempts: Number.isFinite(attempts) && attempts >= 0 ? Math.floor(attempts) : 1,
|
|
846
957
|
delayMs: Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 300,
|
|
958
|
+
background: raw.background !== false,
|
|
847
959
|
};
|
|
848
960
|
}
|
|
849
961
|
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* `data-island-idle` görünür olsa bile sayfa sakinleşene (load + boş zaman)
|
|
17
17
|
* kadar bekletir. İlk ekranda görünen ama kritik olmayan ağır modüller
|
|
18
18
|
* (ör. grafik kütüphanesi çeken mini grafik) LCP ile yarışmasın diye.
|
|
19
|
+
* Gizli (zero-box) host'larda da aynı yol kullanılır — `hydrate()` onları
|
|
20
|
+
* `mountWhenReady` ile bağlar, hemen `mountIsland` çağırmaz.
|
|
19
21
|
*
|
|
20
22
|
* `data-island-props` JSON ise parse edilip props olarak geçer.
|
|
21
23
|
*/
|
|
@@ -175,6 +175,11 @@ export declare const DATA_CACHE_MAX_ENTRIES_CEILING = 20000;
|
|
|
175
175
|
export declare const DEFAULT_TRANSIENT_RETRY: {
|
|
176
176
|
attempts: number;
|
|
177
177
|
delayMs: number;
|
|
178
|
+
/**
|
|
179
|
+
* `true` (varsayılan): ilk transient `notFound`ta hemen 503; retry arka
|
|
180
|
+
* planda data cache'i ısıtır. `false`: yanıt öncesi senkron retry (eski).
|
|
181
|
+
*/
|
|
182
|
+
background: boolean;
|
|
178
183
|
};
|
|
179
184
|
/**
|
|
180
185
|
* Upstream veri önbelleği.
|
|
@@ -236,9 +241,8 @@ export declare const DEFAULT_UPSTREAM_LIMIT: {
|
|
|
236
241
|
* invalidation'ı bütün node'lara yaymak. Tek instance çalışan bir kurulumda
|
|
237
242
|
* kazanç neredeyse yok; bu yüzden `enabled` varsayılan olarak kapalı.
|
|
238
243
|
*
|
|
239
|
-
* `storeEncoded`
|
|
240
|
-
*
|
|
241
|
-
* indirmekten çoğu zaman daha ucuz.
|
|
244
|
+
* `storeEncoded` açık: soğuk replica L2 HIT'te yeniden brotli ödemesin.
|
|
245
|
+
* Boyut artar; tek encoding (br) tutulduğu için maliyet sınırlıdır.
|
|
242
246
|
*/
|
|
243
247
|
export declare const DEFAULT_REDIS: {
|
|
244
248
|
enabled: boolean;
|
|
@@ -251,7 +255,7 @@ export declare const DEFAULT_REDIS: {
|
|
|
251
255
|
html: boolean;
|
|
252
256
|
/** Veri önbelleği paylaşılsın mı. */
|
|
253
257
|
data: boolean;
|
|
254
|
-
/** Brotli
|
|
258
|
+
/** Brotli gövdeleri de paylaşılsın mı (yazma sonrası preencode). */
|
|
255
259
|
storeEncoded: boolean;
|
|
256
260
|
/** pub/sub üzerinden invalidation yayını. */
|
|
257
261
|
events: boolean;
|
package/types/config/index.d.ts
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* controller'lar bu yüzeyi kullanır. Alt yollardan (`jskelet/server`)
|
|
6
6
|
* ithal etmek de mümkün; buradaki liste "kararlı" sayılan yüzeydir.
|
|
7
7
|
*/
|
|
8
|
-
export { route, fragment, renderPage, renderView, renderNotFound, } from "./server/render.js";
|
|
8
|
+
export { route, fragment, renderPage, renderView, renderNotFound, warmRenderEngine, resetRenderEngine, } from "./server/render.js";
|
|
9
9
|
export { renderStatusPage, statusFromError } from "./server/status-page.js";
|
|
10
10
|
export { createApp, startServer } from "./server/create-app.js";
|
|
11
11
|
export { notFound, redirect, permanentRedirect, seeOther, isNotFoundError, isRedirectError, NotFoundError, RedirectError, } from "./http/control-flow.js";
|
|
@@ -7,7 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function encodeCacheValue(value: unknown): string | Buffer | Promise<string | Buffer> | null;
|
|
9
9
|
/**
|
|
10
|
+
* Senkron çözücü — küçük test / senkron yollar için. Hot path
|
|
11
|
+
* `decodeCacheValueAsync` kullanır (event loop'u brotli ile bloklamaz).
|
|
12
|
+
*
|
|
10
13
|
* @param {Buffer | string} raw
|
|
11
14
|
* @returns {unknown}
|
|
12
15
|
*/
|
|
13
16
|
export declare function decodeCacheValue(raw: Buffer | string): unknown;
|
|
17
|
+
/**
|
|
18
|
+
* @param {Buffer | string} raw
|
|
19
|
+
* @returns {Promise<unknown>}
|
|
20
|
+
*/
|
|
21
|
+
export declare function decodeCacheValueAsync(raw: Buffer | string): Promise<unknown>;
|
|
@@ -50,10 +50,8 @@ export type HtmlEntry = {
|
|
|
50
50
|
*/
|
|
51
51
|
export declare function earlyRefreshLeadMs(produceMs: number, ttlMs: number): number;
|
|
52
52
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* LRU'dan düşmüş bir haritaya yazınca bir daha inmeyen bir artık bırakır;
|
|
56
|
-
* store'dan yeniden okumak o artığı taşımaz.
|
|
53
|
+
* Bayt sayacını store'dan yeniden hesaplar. Testler elle `encoded` doldurunca
|
|
54
|
+
* çağırır; hot path `rememberHtmlEncoding` delta kullanır.
|
|
57
55
|
*
|
|
58
56
|
* @returns {void}
|
|
59
57
|
*/
|
|
@@ -202,7 +200,8 @@ export declare function isHtmlCacheFresh(pathname: string, req?: {
|
|
|
202
200
|
export declare function isHtmlCacheKeyFresh(key: string): boolean;
|
|
203
201
|
/**
|
|
204
202
|
* Girdide en fazla bir sıkıştırılmış gövde durur. Yeni kodlama eskisinin
|
|
205
|
-
* yerini alır; ham HTML kalır. Bayt sayacı
|
|
203
|
+
* yerini alır; ham HTML kalır. Bayt sayacı delta ile güncellenir — harita
|
|
204
|
+
* LRU'dan düşmüşse (store'da yok) sayaç oynatılmaz.
|
|
206
205
|
*
|
|
207
206
|
* @param {Map<string, Buffer>} encoded
|
|
208
207
|
* @param {string} encoding
|
package/types/server/render.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
export type RenderEngine = {
|
|
2
|
+
helpers: Record<string, unknown>;
|
|
3
|
+
options: Record<string, unknown>;
|
|
4
|
+
viewsDir: string;
|
|
5
|
+
viewRoots: string[];
|
|
6
|
+
layout: string;
|
|
7
|
+
compiled: Map<string, (data: object, helpers: object) => string>;
|
|
8
|
+
loading: Map<string, Promise<(data: object, helpers: object) => string | null>>;
|
|
9
|
+
manifest: Record<string, string>;
|
|
10
|
+
templatesDir: string;
|
|
11
|
+
bust: string;
|
|
12
|
+
layoutRender: ((data: object, helpers: object) => string) | null;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Listen öncesi: motor + asset manifest ısınır (sayfa prewarm değil).
|
|
16
|
+
*
|
|
17
|
+
* @returns {Promise<void>}
|
|
18
|
+
*/
|
|
19
|
+
export declare function warmRenderEngine(): Promise<void>;
|
|
1
20
|
/**
|
|
2
21
|
* Uygulama geliştirirken bileşen dosyaları değişince kayıt yenilenmeli.
|
|
3
22
|
* Dev sunucusu süreci yeniden başlattığı için normalde gerekmez; gömülü
|