jskelet 0.1.4 → 0.1.6
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 +29 -0
- package/docs/06-cache.md +73 -8
- package/docs/07-yapilandirma.md +15 -5
- package/docs/09-dev-araclari.md +31 -6
- package/docs/11-tasima.md +2 -3
- package/docs/en/06-caching.md +73 -8
- package/docs/en/07-configuration.md +16 -5
- package/docs/en/09-dev-tools.md +30 -4
- package/docs/en/11-migration.md +2 -3
- package/package.json +1 -1
- package/src/client/devtools/overlay.js +111 -46
- package/src/config/index.js +8 -0
- package/src/index.js +1 -0
- package/src/server/cache-deps.js +42 -0
- package/src/server/create-app.js +11 -0
- package/src/server/data-cache.js +23 -10
- package/src/server/dev/devtools.js +101 -15
- package/src/server/dev/socket.js +157 -0
- package/src/server/html-cache.js +312 -9
- package/src/server/prewarm.js +23 -10
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev araçlarının canlı kanalı: küçük bir WebSocket sunucusu.
|
|
3
|
+
*
|
|
4
|
+
* Overlay eskiden istatistikleri iki saniyede bir `GET /stats` ile çekiyordu;
|
|
5
|
+
* panel kapalıyken bile her sekme sürekli istek atıyor, dev sunucusunun istek
|
|
6
|
+
* günlüğünü de kirletiyordu. Artık sunucu değişiklik oldukça (istek, hata,
|
|
7
|
+
* ısıtma ilerlemesi, CSS/JS değişimi) tek bağlantı üzerinden itiyor.
|
|
8
|
+
*
|
|
9
|
+
* Bağımlılık eklemiyoruz: yalnızca sunucu→istemci metin çerçevesi yazmak ve
|
|
10
|
+
* istemcinin ping/close çerçevelerini anlamak gerekiyor; bu da RFC 6455'in
|
|
11
|
+
* küçük bir alt kümesi. `permessage-deflate` yok, parça (fragment) yok —
|
|
12
|
+
* gönderdiğimiz paketler için ikisi de gereksiz.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
/** RFC 6455'te sabitlenmiş el sıkışma tuzu. */
|
|
17
|
+
const GUID = "258EAFA5-E914-47DA-95CA-5AB0DC85B39A";
|
|
18
|
+
|
|
19
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
20
|
+
const sockets = new Set();
|
|
21
|
+
|
|
22
|
+
/** @param {string} key */
|
|
23
|
+
function accept(key) {
|
|
24
|
+
return createHash("sha1")
|
|
25
|
+
.update(key + GUID)
|
|
26
|
+
.digest("base64");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tek parçalı metin çerçevesi. Maskeleme yalnızca istemci→sunucu yönünde
|
|
31
|
+
* zorunlu olduğu için burada yok.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text
|
|
34
|
+
* @returns {Buffer}
|
|
35
|
+
*/
|
|
36
|
+
function frame(text) {
|
|
37
|
+
const payload = Buffer.from(text, "utf8");
|
|
38
|
+
const length = payload.length;
|
|
39
|
+
|
|
40
|
+
/** @type {Buffer} */
|
|
41
|
+
let header;
|
|
42
|
+
if (length < 126) {
|
|
43
|
+
header = Buffer.from([0x81, length]);
|
|
44
|
+
} else if (length < 65536) {
|
|
45
|
+
header = Buffer.alloc(4);
|
|
46
|
+
header[0] = 0x81;
|
|
47
|
+
header[1] = 126;
|
|
48
|
+
header.writeUInt16BE(length, 2);
|
|
49
|
+
} else {
|
|
50
|
+
header = Buffer.alloc(10);
|
|
51
|
+
header[0] = 0x81;
|
|
52
|
+
header[1] = 127;
|
|
53
|
+
header.writeBigUInt64BE(BigInt(length), 2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Buffer.concat([header, payload]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* İstemciden gelen çerçeveleri ayıklar. İçerik bizi ilgilendirmiyor; amaç
|
|
61
|
+
* kapanışı ve ping'i doğru yanıtlamak, aksi hâlde tarayıcı bağlantıyı
|
|
62
|
+
* "protokol hatası" sayıp kapatıyor.
|
|
63
|
+
*
|
|
64
|
+
* @param {import('node:net').Socket} socket
|
|
65
|
+
*/
|
|
66
|
+
function readFrames(socket) {
|
|
67
|
+
let buffer = Buffer.alloc(0);
|
|
68
|
+
|
|
69
|
+
socket.on("data", (chunk) => {
|
|
70
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
71
|
+
|
|
72
|
+
while (buffer.length >= 2) {
|
|
73
|
+
const opcode = buffer[0] & 0x0f;
|
|
74
|
+
const masked = (buffer[1] & 0x80) !== 0;
|
|
75
|
+
let length = buffer[1] & 0x7f;
|
|
76
|
+
let offset = 2;
|
|
77
|
+
|
|
78
|
+
if (length === 126) {
|
|
79
|
+
if (buffer.length < offset + 2) return;
|
|
80
|
+
length = buffer.readUInt16BE(offset);
|
|
81
|
+
offset += 2;
|
|
82
|
+
} else if (length === 127) {
|
|
83
|
+
if (buffer.length < offset + 8) return;
|
|
84
|
+
length = Number(buffer.readBigUInt64BE(offset));
|
|
85
|
+
offset += 8;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (masked) offset += 4;
|
|
89
|
+
if (buffer.length < offset + length) return;
|
|
90
|
+
|
|
91
|
+
buffer = buffer.subarray(offset + length);
|
|
92
|
+
|
|
93
|
+
// 0x8 close, 0x9 ping. Veri çerçeveleri yok sayılır: istemcinin bize
|
|
94
|
+
// söyleyeceği bir şey yok, kanal tek yönlü kullanılıyor.
|
|
95
|
+
if (opcode === 0x8) {
|
|
96
|
+
socket.end(Buffer.from([0x88, 0x00]));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (opcode === 0x9) socket.write(Buffer.from([0x8a, 0x00]));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* HTTP upgrade isteğini WebSocket bağlantısına çevirir.
|
|
106
|
+
*
|
|
107
|
+
* @param {import('node:http').IncomingMessage} req
|
|
108
|
+
* @param {import('node:net').Socket} socket
|
|
109
|
+
* @param {(send: (payload: object) => void) => void} onOpen
|
|
110
|
+
* Bağlantı kurulunca çağrılır; ilk paketi göndermek için kullanılır.
|
|
111
|
+
*/
|
|
112
|
+
export function upgradeToSocket(req, socket, onOpen) {
|
|
113
|
+
const key = req.headers["sec-websocket-key"];
|
|
114
|
+
if (!key) {
|
|
115
|
+
socket.destroy();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
socket.write(
|
|
120
|
+
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
121
|
+
"Upgrade: websocket\r\n" +
|
|
122
|
+
"Connection: Upgrade\r\n" +
|
|
123
|
+
`Sec-WebSocket-Accept: ${accept(String(key))}\r\n\r\n`,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Küçük paketleri geciktirmenin anlamı yok; panel anlık görünmeli.
|
|
127
|
+
socket.setNoDelay(true);
|
|
128
|
+
// Bağlantı hataları (sekme kapanması, ağ) süreci düşürmemeli.
|
|
129
|
+
socket.on("error", () => socket.destroy());
|
|
130
|
+
socket.on("close", () => sockets.delete(socket));
|
|
131
|
+
|
|
132
|
+
readFrames(socket);
|
|
133
|
+
sockets.add(socket);
|
|
134
|
+
|
|
135
|
+
onOpen((payload) => {
|
|
136
|
+
if (!socket.destroyed) socket.write(frame(JSON.stringify(payload)));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Bağlı tüm panellere gönderir.
|
|
142
|
+
* @param {object} payload
|
|
143
|
+
*/
|
|
144
|
+
export function broadcastSocket(payload) {
|
|
145
|
+
if (!sockets.size) return;
|
|
146
|
+
|
|
147
|
+
const packet = frame(JSON.stringify(payload));
|
|
148
|
+
for (const socket of sockets) {
|
|
149
|
+
if (socket.destroyed) sockets.delete(socket);
|
|
150
|
+
else socket.write(packet);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @returns {number} açık panel sayısı */
|
|
155
|
+
export function socketCount() {
|
|
156
|
+
return sockets.size;
|
|
157
|
+
}
|
package/src/server/html-cache.js
CHANGED
|
@@ -6,14 +6,24 @@
|
|
|
6
6
|
* render'ı beklemez; buna karşılık HTML'deki veri en fazla `revalidate + bir
|
|
7
7
|
* tazeleme turu` kadar geride olabilir. Fiyat gibi canlı alanlar istemcide
|
|
8
8
|
* WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
|
|
9
|
+
*
|
|
10
|
+
* TTL'in yanında ikinci bir tazelik kaynağı daha var: **hedefli
|
|
11
|
+
* invalidation**. Bir içerik güncellendiğinde tüm önbelleği boşaltmak
|
|
12
|
+
* (`clearHtmlCache()`) o an sıcak olan her sayfayı soğuk render'a çevirir;
|
|
13
|
+
* TTL'i beklemek ise güncellemeyi dakikalarca geciktirir.
|
|
14
|
+
* `invalidateHtmlCache()` ikisinin arasını açar ve varsayılan davranışı
|
|
15
|
+
* **bayatlatmaktır**: girdi silinmez, süresi geçmiş sayılır. Ziyaretçi eski
|
|
16
|
+
* HTML'i beklemeden alır, tazeleme arkada tek seferde koşar.
|
|
9
17
|
*/
|
|
10
18
|
|
|
11
19
|
import { getConfig } from "../config/index.js";
|
|
12
20
|
import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
|
|
21
|
+
import { collectDependencies } from "./cache-deps.js";
|
|
22
|
+
import { compilePattern, matchPattern } from "../config/pattern.js";
|
|
13
23
|
|
|
14
24
|
/**
|
|
15
25
|
* @typedef {{ html: string, status: number, expiresAt: number,
|
|
16
|
-
* staleUntil: number, encoded: Map<string, Buffer> }} HtmlEntry
|
|
26
|
+
* staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string> }} HtmlEntry
|
|
17
27
|
*/
|
|
18
28
|
|
|
19
29
|
/**
|
|
@@ -34,6 +44,20 @@ function maxEntries() {
|
|
|
34
44
|
}
|
|
35
45
|
}
|
|
36
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Bağımlılık izleme kapatılabilir olmalı: `withDataCache` kullanmayan bir
|
|
49
|
+
* uygulamada hiçbir şey kaydedilmez ama bağlam kurma maliyeti kalır.
|
|
50
|
+
*
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function trackDependencies() {
|
|
54
|
+
try {
|
|
55
|
+
return getConfig().trackDependencies;
|
|
56
|
+
} catch {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
/**
|
|
38
62
|
* TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
|
|
39
63
|
* Tazeleme genelde ilk stale istekte tamamlandığı için bu pencere yalnızca
|
|
@@ -47,6 +71,86 @@ const store = new Map();
|
|
|
47
71
|
/** @type {Map<string, Promise<{ html: string, status: number }>>} */
|
|
48
72
|
const inflight = new Map();
|
|
49
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Uçuştaki her tazelemenin kimliği. Bir girdi tazelenirken invalidate
|
|
76
|
+
* edilirse o tazelemenin sonucu **artık geçersizdir**: render, purge'den önce
|
|
77
|
+
* okunmuş veriyle üretildi. Token silinince `write()` atlanır ve bir sonraki
|
|
78
|
+
* istek yeni bir tur başlatır.
|
|
79
|
+
*
|
|
80
|
+
* @type {Map<string, object>}
|
|
81
|
+
*/
|
|
82
|
+
const tokens = new Map();
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ters indeks: veri anahtarı → onu okumuş HTML anahtarları. `clearDataCache()`
|
|
86
|
+
* bunu okuyup etkilenen sayfaları bayatlatır.
|
|
87
|
+
*
|
|
88
|
+
* @type {Map<string, Set<string>>}
|
|
89
|
+
*/
|
|
90
|
+
const dependents = new Map();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Invalidate edilmiş ama henüz kimsenin istemediği yollar. Isıtma turu bunları
|
|
94
|
+
* kuyruğun başına alır: "içerik güncellendi" bilgisi geldiğinde sayfa,
|
|
95
|
+
* ziyaretçi gelmesini beklemeden tazelenir.
|
|
96
|
+
*
|
|
97
|
+
* Sınırlı tutulur — kimse ısıtma yapmıyorsa bu küme sessizce büyümemeli.
|
|
98
|
+
*
|
|
99
|
+
* @type {Set<string>}
|
|
100
|
+
*/
|
|
101
|
+
const invalidated = new Set();
|
|
102
|
+
|
|
103
|
+
const MAX_INVALIDATED = 500;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Son zamanlarda düşürülen veri anahtarları ve düşürülme zamanları.
|
|
107
|
+
*
|
|
108
|
+
* Bir webhook, sayfa **render edilirken** gelirse ters indeks henüz o sayfayı
|
|
109
|
+
* tanımıyor (bağımlılıklar yazma anında kaydediliyor) ve render, purge'den
|
|
110
|
+
* önce okunmuş veriyle önbelleğe girerdi. Yazma anında bu haritaya bakmak,
|
|
111
|
+
* "doğduğu anda bayat" girdiyi engeller.
|
|
112
|
+
*
|
|
113
|
+
* Render'lar saniyeler sürdüğü için harita kısa tutulur; sınır aşılınca en
|
|
114
|
+
* eski kayıt düşer.
|
|
115
|
+
*
|
|
116
|
+
* @type {Map<string, number>}
|
|
117
|
+
*/
|
|
118
|
+
const purgedDeps = new Map();
|
|
119
|
+
|
|
120
|
+
const MAX_PURGED_DEPS = 1000;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Girdiyi ters indeksten söker. Bu adım atlanırsa indeks, düşen girdilerin
|
|
124
|
+
* anahtarlarını tutmaya devam eder ve sessizce sızar.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} key
|
|
127
|
+
* @param {HtmlEntry} entry
|
|
128
|
+
*/
|
|
129
|
+
function unlink(key, entry) {
|
|
130
|
+
for (const dep of entry.deps) {
|
|
131
|
+
const set = dependents.get(dep);
|
|
132
|
+
if (!set) continue;
|
|
133
|
+
set.delete(key);
|
|
134
|
+
if (!set.size) dependents.delete(dep);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Store'dan silmenin **tek** yolu. Ters indeks bakımı buraya bağlı olduğu için
|
|
140
|
+
* hiçbir yerde doğrudan `store.delete()` çağrılmaz.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} key
|
|
143
|
+
* @returns {boolean} Girdi var mıydı.
|
|
144
|
+
*/
|
|
145
|
+
function drop(key) {
|
|
146
|
+
const entry = store.get(key);
|
|
147
|
+
if (!entry) return false;
|
|
148
|
+
|
|
149
|
+
unlink(key, entry);
|
|
150
|
+
store.delete(key);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
50
154
|
/**
|
|
51
155
|
* @param {string} key
|
|
52
156
|
* @returns {{ html: string, status: number, encoded: Map<string, Buffer>,
|
|
@@ -58,7 +162,7 @@ function read(key) {
|
|
|
58
162
|
|
|
59
163
|
const now = Date.now();
|
|
60
164
|
if (now >= entry.staleUntil) {
|
|
61
|
-
|
|
165
|
+
drop(key);
|
|
62
166
|
return null;
|
|
63
167
|
}
|
|
64
168
|
|
|
@@ -78,10 +182,15 @@ function read(key) {
|
|
|
78
182
|
* @param {string} key
|
|
79
183
|
* @param {{ html: string, status: number }} value
|
|
80
184
|
* @param {number} ttlSeconds
|
|
185
|
+
* @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
|
|
81
186
|
*/
|
|
82
|
-
function write(key, value, ttlSeconds) {
|
|
187
|
+
function write(key, value, ttlSeconds, deps = null) {
|
|
83
188
|
const now = Date.now();
|
|
84
189
|
|
|
190
|
+
// Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
|
|
191
|
+
// tazelemeden tazelemeye değişebilir.
|
|
192
|
+
drop(key);
|
|
193
|
+
|
|
85
194
|
store.set(key, {
|
|
86
195
|
html: value.html,
|
|
87
196
|
status: value.status,
|
|
@@ -90,14 +199,40 @@ function write(key, value, ttlSeconds) {
|
|
|
90
199
|
encoded: new Map(),
|
|
91
200
|
expiresAt: now + ttlSeconds * 1000,
|
|
92
201
|
staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
|
|
202
|
+
deps: deps ?? new Set(),
|
|
93
203
|
});
|
|
94
204
|
|
|
205
|
+
if (deps) {
|
|
206
|
+
for (const dep of deps) {
|
|
207
|
+
let set = dependents.get(dep);
|
|
208
|
+
if (!set) dependents.set(dep, (set = new Set()));
|
|
209
|
+
set.add(key);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
95
213
|
const limit = maxEntries();
|
|
96
214
|
while (store.size > limit) {
|
|
97
215
|
const oldest = store.keys().next().value;
|
|
98
216
|
if (oldest === undefined) break;
|
|
99
|
-
|
|
217
|
+
drop(oldest);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Bu render, başladıktan sonra düşürülmüş bir veriyi mi okudu.
|
|
223
|
+
*
|
|
224
|
+
* @param {Set<string> | null} deps
|
|
225
|
+
* @param {number} startedAt
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
function readsPurgedData(deps, startedAt) {
|
|
229
|
+
if (!deps) return false;
|
|
230
|
+
|
|
231
|
+
for (const dep of deps) {
|
|
232
|
+
const purgedAt = purgedDeps.get(dep);
|
|
233
|
+
if (purgedAt !== undefined && purgedAt >= startedAt) return true;
|
|
100
234
|
}
|
|
235
|
+
return false;
|
|
101
236
|
}
|
|
102
237
|
|
|
103
238
|
/**
|
|
@@ -112,7 +247,15 @@ function refresh(key, ttlSeconds, producer) {
|
|
|
112
247
|
const pending = inflight.get(key);
|
|
113
248
|
if (pending) return pending;
|
|
114
249
|
|
|
115
|
-
const
|
|
250
|
+
const token = {};
|
|
251
|
+
tokens.set(key, token);
|
|
252
|
+
const startedAt = Date.now();
|
|
253
|
+
|
|
254
|
+
// Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
|
|
255
|
+
// okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
|
|
256
|
+
const deps = trackDependencies() ? new Set() : null;
|
|
257
|
+
|
|
258
|
+
const task = (deps ? collectDependencies(deps, producer) : producer())
|
|
116
259
|
.then((value) => {
|
|
117
260
|
// `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
|
|
118
261
|
// Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
|
|
@@ -120,13 +263,18 @@ function refresh(key, ttlSeconds, producer) {
|
|
|
120
263
|
// `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
|
|
121
264
|
// okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
|
|
122
265
|
// kullanıcının HTML'ini bir başkasına servis etmek olur.
|
|
123
|
-
|
|
124
|
-
|
|
266
|
+
//
|
|
267
|
+
// Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
|
|
268
|
+
// öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
|
|
269
|
+
const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
|
|
270
|
+
if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
|
|
271
|
+
write(key, value, ttlSeconds, deps);
|
|
125
272
|
}
|
|
126
273
|
return value;
|
|
127
274
|
})
|
|
128
275
|
.finally(() => {
|
|
129
276
|
inflight.delete(key);
|
|
277
|
+
if (tokens.get(key) === token) tokens.delete(key);
|
|
130
278
|
});
|
|
131
279
|
|
|
132
280
|
inflight.set(key, task);
|
|
@@ -152,6 +300,7 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
|
|
|
152
300
|
// Süresi geçmiş girdi anında döner; tazeleme arkada yürür ve hatası
|
|
153
301
|
// isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
|
|
154
302
|
if (hit.stale) {
|
|
303
|
+
invalidated.delete(key);
|
|
155
304
|
void refresh(key, ttlSeconds, producer).catch((error) => {
|
|
156
305
|
console.error(`[html-cache] background refresh failed: ${key}`, error);
|
|
157
306
|
});
|
|
@@ -159,24 +308,177 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
|
|
|
159
308
|
return { ...hit, cached: true };
|
|
160
309
|
}
|
|
161
310
|
|
|
311
|
+
invalidated.delete(key);
|
|
162
312
|
const value = await refresh(key, ttlSeconds, producer);
|
|
163
313
|
return { ...value, encoded: store.get(key)?.encoded, cached: false };
|
|
164
314
|
}
|
|
165
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Store'u tamamen boşaltır. Dev sunucusu manifest her değiştiğinde bunu
|
|
318
|
+
* çağırır: saklanan HTML artık var olmayan hash'li varlıkları işaret ediyor,
|
|
319
|
+
* yani gerçekten **geçersiz** — bayatlatmak yetmez.
|
|
320
|
+
*/
|
|
166
321
|
export function clearHtmlCache() {
|
|
167
322
|
store.clear();
|
|
323
|
+
dependents.clear();
|
|
324
|
+
tokens.clear();
|
|
325
|
+
invalidated.clear();
|
|
326
|
+
purgedDeps.clear();
|
|
168
327
|
}
|
|
169
328
|
|
|
170
329
|
export function getHtmlCacheSize() {
|
|
171
330
|
return store.size;
|
|
172
331
|
}
|
|
173
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Verilen hedefi HTML anahtarının yol kısmıyla eşleştiren bir eşleyici üretir.
|
|
335
|
+
*
|
|
336
|
+
* Üç biçim kabul edilir:
|
|
337
|
+
* `"/haber/abc"` → o yol ve altındaki her şey (`/haber/abc/yorumlar`)
|
|
338
|
+
* `"/haber/:slug"` → config'in her yerinde geçerli desen sözdizimi
|
|
339
|
+
* `/-yorumlar$/` → desen sözdiziminin karşılamadığı kurallar için
|
|
340
|
+
*
|
|
341
|
+
* Düz string'te "önek" bilinçli olarak **segment sınırında** kesilir: `/haber`
|
|
342
|
+
* kuralı `/haberler`i düşürmemeli.
|
|
343
|
+
*
|
|
344
|
+
* @param {string | RegExp} target
|
|
345
|
+
* @returns {((pathname: string) => boolean) | null}
|
|
346
|
+
*/
|
|
347
|
+
function toMatcher(target) {
|
|
348
|
+
if (target instanceof RegExp) return (pathname) => target.test(pathname);
|
|
349
|
+
|
|
350
|
+
if (typeof target !== "string" || !target.startsWith("/")) {
|
|
351
|
+
console.warn(`[html-cache] invalid invalidation target (must start with \`/\`): ${target}`);
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (target.includes(":")) {
|
|
356
|
+
const compiled = compilePattern(target);
|
|
357
|
+
if (!compiled) return null;
|
|
358
|
+
return (pathname) => matchPattern(compiled, pathname) !== null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const prefix = target.endsWith("/") ? target : `${target}/`;
|
|
362
|
+
return (pathname) => pathname === target || pathname.startsWith(prefix);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Etkilenen girdiyi bayatlatır ya da düşürür.
|
|
367
|
+
*
|
|
368
|
+
* @param {string} key
|
|
369
|
+
* @param {boolean} hard
|
|
370
|
+
*/
|
|
371
|
+
function invalidateKey(key, hard) {
|
|
372
|
+
// Uçuştaki tazeleme bu invalidation'dan önce başladıysa sonucu eski veriyle
|
|
373
|
+
// üretilmiş demektir; token'ı düşürmek onu yazılamaz hâle getirir. Girdi
|
|
374
|
+
// henüz hiç yazılmamış olsa bile (ilk render sürüyor) bu geçerli.
|
|
375
|
+
tokens.delete(key);
|
|
376
|
+
|
|
377
|
+
const entry = store.get(key);
|
|
378
|
+
if (entry) {
|
|
379
|
+
// Bayat penceresi de dolmuşsa girdi zaten ölü: bayatlatmanın etkisi olmaz.
|
|
380
|
+
if (hard || Date.now() >= entry.staleUntil) drop(key);
|
|
381
|
+
else entry.expiresAt = 0;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Hedefli invalidation: TTL'i beklemeden, ama tüm önbelleği boşaltmadan.
|
|
389
|
+
*
|
|
390
|
+
* Varsayılan **yumuşaktır** (`hard: false`): girdi silinmez, süresi geçmiş
|
|
391
|
+
* sayılır. Bir webhook beş yüz sayfayı birden düşürdüğünde sert silme, tam da
|
|
392
|
+
* içeriğin güncellendiği anda beş yüz soğuk render başlatır ve upstream'i
|
|
393
|
+
* döver. Bayatlatmada ise ziyaretçi eski HTML'i beklemeden alır, tazeleme
|
|
394
|
+
* arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
|
|
395
|
+
* HTML'in gerçekten geçersiz olduğu durumlar için.
|
|
396
|
+
*
|
|
397
|
+
* Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir
|
|
398
|
+
* yolun bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer.
|
|
399
|
+
*
|
|
400
|
+
* @param {string | RegExp | (string | RegExp)[]} target
|
|
401
|
+
* @param {{ hard?: boolean }} [options]
|
|
402
|
+
* @returns {number} Etkilenen girdi sayısı (uçuştaki render'lar dahil).
|
|
403
|
+
*/
|
|
404
|
+
export function invalidateHtmlCache(target, options = {}) {
|
|
405
|
+
const matchers = (Array.isArray(target) ? target : [target])
|
|
406
|
+
.map(toMatcher)
|
|
407
|
+
.filter((matcher) => matcher !== null);
|
|
408
|
+
|
|
409
|
+
if (!matchers.length) return 0;
|
|
410
|
+
|
|
411
|
+
const hard = options.hard === true;
|
|
412
|
+
let count = 0;
|
|
413
|
+
|
|
414
|
+
// Uçuştaki render'lar da hedeflenir: henüz yazılmamış bir tur, purge'den
|
|
415
|
+
// önce okunmuş veriyle önbelleğe girmemeli. Anahtarlar kopyalanır, çünkü
|
|
416
|
+
// `invalidateKey` sert modda store'dan siliyor.
|
|
417
|
+
for (const key of new Set([...store.keys(), ...tokens.keys()])) {
|
|
418
|
+
const mark = key.indexOf("?");
|
|
419
|
+
const pathname = mark === -1 ? key : key.slice(0, mark);
|
|
420
|
+
if (!matchers.some((matcher) => matcher(pathname))) continue;
|
|
421
|
+
invalidateKey(key, hard);
|
|
422
|
+
count += 1;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return count;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Verilen veri anahtarlarını render sırasında okumuş sayfaları bayatlatır.
|
|
430
|
+
* `clearDataCache()` bunu çağırır; uygulamanın hiçbir şey bildirmesi gerekmez.
|
|
431
|
+
*
|
|
432
|
+
* @param {Iterable<string>} dataKeys
|
|
433
|
+
* @returns {number} Etkilenen HTML girdisi sayısı.
|
|
434
|
+
*/
|
|
435
|
+
export function invalidateHtmlByDependency(dataKeys) {
|
|
436
|
+
/** @type {Set<string>} */
|
|
437
|
+
const keys = new Set();
|
|
438
|
+
const now = Date.now();
|
|
439
|
+
|
|
440
|
+
for (const dep of dataKeys) {
|
|
441
|
+
// Şu anda render edilen bir sayfa bu veriyi okuduysa ters indekste henüz
|
|
442
|
+
// görünmüyor; yazma anındaki kontrol için zaman damgası bırakılır.
|
|
443
|
+
purgedDeps.set(dep, now);
|
|
444
|
+
|
|
445
|
+
const set = dependents.get(dep);
|
|
446
|
+
if (set) for (const key of set) keys.add(key);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
while (purgedDeps.size > MAX_PURGED_DEPS) {
|
|
450
|
+
const oldest = purgedDeps.keys().next().value;
|
|
451
|
+
if (oldest === undefined) break;
|
|
452
|
+
purgedDeps.delete(oldest);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
for (const key of keys) invalidateKey(key, false);
|
|
456
|
+
return keys.size;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
|
|
461
|
+
* boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
|
|
462
|
+
* ısıtmasın diye okuma yıkıcıdır.
|
|
463
|
+
*
|
|
464
|
+
* @returns {string[]}
|
|
465
|
+
*/
|
|
466
|
+
export function takeInvalidatedPaths() {
|
|
467
|
+
if (!invalidated.size) return [];
|
|
468
|
+
|
|
469
|
+
const paths = [...invalidated];
|
|
470
|
+
invalidated.clear();
|
|
471
|
+
// Anahtar `yol?query`; query boşsa sondaki `?` atılır.
|
|
472
|
+
return paths.map((key) => (key.endsWith("?") ? key.slice(0, -1) : key));
|
|
473
|
+
}
|
|
474
|
+
|
|
174
475
|
/**
|
|
175
476
|
* Dev raporu için önbellek dökümü: hangi sayfa ne kadar HTML tutuyor, ne
|
|
176
|
-
* zaman bayatlıyor
|
|
477
|
+
* zaman bayatlıyor, kaç veri anahtarına bağlı. HTML gövdesi dönmez, yalnızca
|
|
478
|
+
* boyutu.
|
|
177
479
|
*
|
|
178
480
|
* @returns {{ key: string, bytes: number, status: number, stale: boolean,
|
|
179
|
-
* expiresIn: number, encodings: string[] }[]}
|
|
481
|
+
* expiresIn: number, encodings: string[], deps: number }[]}
|
|
180
482
|
*/
|
|
181
483
|
export function getHtmlCacheEntries() {
|
|
182
484
|
const now = Date.now();
|
|
@@ -188,5 +490,6 @@ export function getHtmlCacheEntries() {
|
|
|
188
490
|
stale: now >= entry.expiresAt,
|
|
189
491
|
expiresIn: Math.round((entry.expiresAt - now) / 1000),
|
|
190
492
|
encodings: [...entry.encoded.keys()],
|
|
493
|
+
deps: entry.deps.size,
|
|
191
494
|
}));
|
|
192
495
|
}
|
package/src/server/prewarm.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import process from "node:process";
|
|
22
22
|
import { getConfig, hook } from "../config/index.js";
|
|
23
|
+
import { takeInvalidatedPaths } from "./html-cache.js";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
|
|
@@ -286,24 +287,35 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
|
|
|
286
287
|
export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
287
288
|
const started = Date.now();
|
|
288
289
|
const limit = setting("PREWARM_MAX", "max", 400);
|
|
289
|
-
|
|
290
|
-
// render'ıyla CPU için yarışmasın.
|
|
291
|
-
const concurrency = setting(
|
|
292
|
-
"PREWARM_CONCURRENCY",
|
|
293
|
-
"concurrency",
|
|
294
|
-
process.env.NODE_ENV === "development" ? 2 : 4,
|
|
295
|
-
);
|
|
290
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
296
291
|
|
|
297
|
-
|
|
292
|
+
// Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
|
|
293
|
+
// için yarışmasın.
|
|
294
|
+
const concurrency = setting("PREWARM_CONCURRENCY", "concurrency", isDev ? 1 : 4);
|
|
295
|
+
|
|
296
|
+
// Render tek bir olay döngüsünde çalışıyor: aralıksız bir tur, geliştirme
|
|
297
|
+
// sırasında sayfa isteklerini ve dev panelinin kanalını arkasında bekletiyor.
|
|
298
|
+
// Dev'de varsayılan bir hız freni bu yüzden var; üretimde ısıtma bir kez
|
|
299
|
+
// olup bittiği için fren yalnızca istenirse (`prewarm.rps`) devreye girer.
|
|
300
|
+
const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, isDev ? 4 : 0));
|
|
298
301
|
const pace = createPacer(rps);
|
|
299
302
|
|
|
300
303
|
const all = only?.length ? only : await collectPaths();
|
|
301
304
|
// Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
|
|
302
305
|
// istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
|
|
303
|
-
const
|
|
306
|
+
const selected = only?.length
|
|
304
307
|
? all
|
|
305
308
|
: selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
|
|
306
309
|
|
|
310
|
+
// Invalidate edilmiş sayfalar kuyruğun önüne geçer: "içerik güncellendi"
|
|
311
|
+
// bilgisi geldiğinde sayfa, ziyaretçi gelmesini beklemeden tazelenir. Bu
|
|
312
|
+
// yollar `max` bütçesinin dışında tutulur — sayıları zaten gerçekleşen
|
|
313
|
+
// invalidation kadar ve rotasyonun sırasını bozmaları istenmez.
|
|
314
|
+
const pending = only?.length ? [] : takeInvalidatedPaths();
|
|
315
|
+
const paths = pending.length
|
|
316
|
+
? [...new Set([...pending, ...selected])]
|
|
317
|
+
: selected;
|
|
318
|
+
|
|
307
319
|
Object.assign(prewarmProgress, {
|
|
308
320
|
active: true,
|
|
309
321
|
done: 0,
|
|
@@ -363,13 +375,14 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
363
375
|
const elapsed = Date.now() - started;
|
|
364
376
|
|
|
365
377
|
if (!quiet && paths.length) {
|
|
366
|
-
const skipped = all.length -
|
|
378
|
+
const skipped = all.length - selected.length;
|
|
367
379
|
// Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
|
|
368
380
|
// tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
|
|
369
381
|
// hatalı bir kurulum sanılıyor.
|
|
370
382
|
const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
|
|
371
383
|
console.log(
|
|
372
384
|
`[prewarm] warmed ${ok}/${paths.length} pages` +
|
|
385
|
+
`${pending.length ? `, ${pending.length} invalidated` : ""}` +
|
|
373
386
|
`${failed ? `, ${failed} failed` : ""}` +
|
|
374
387
|
`${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
|
|
375
388
|
`${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
|