-
Notifications
You must be signed in to change notification settings - Fork 19
Expand file tree
/
Copy pathFPC.js
More file actions
307 lines (268 loc) · 9.79 KB
/
FPC.js
File metadata and controls
307 lines (268 loc) · 9.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
import http from "http";
import Redis from "ioredis";
import NodeCache from "node-cache";
import dotenv from "dotenv";
//import { gunzipSync } from "zlib";
import { gunzip } from "zlib";
import crypto from "crypto";
import { minify } from "html-minifier-terser";
dotenv.config();
const corePrefix = "zc:k:";
const prefix = corePrefix + (process.env.PREFIX || 'b30_');
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: parseInt(process.env.REDIS_PORT) || 6379,
db: parseInt(process.env.REDIS_DB) || 11,
keyPrefix: prefix // Magento Redis Prefix
});
// Config Options
const DEBUG = getEnvBoolean("DEBUG", false);
const USE_CACHE = getEnvBoolean("USE_CACHE", false); // Enable in-memory cache
const CACHE_TTL = parseInt(process.env.CACHE_TTL) || 60;
const IGNORED_URLS = ["/customer", "/media", "/admin", "/checkout"];
const HTTPS = getEnvBoolean("HTTPS", true);
const HOST = process.env.HOST || false;
const MINIFY = getEnvBoolean("MINIFY");
const USE_STALE = getEnvBoolean("USE_STALE", true);
// APCu Equivalent (Node.js in-memory cache)
const cache = new NodeCache({ stdTTL: CACHE_TTL }); // 5 min cache
if (USE_STALE) {
cache.on("del"/*expired*/, (key, value) => {
console.log("EXPIRED:" + key);
value.expired = true;
cache.set(key, value, CACHE_TTL * 10);
});
}
// Start HTTP Server
const server = http.createServer(async (req, res) => {
const startTime = process.hrtime();
if (!isCached(req)) {
if (DEBUG) {
res.setHeader("Fast-Cache", "FALSE");
console.log("URL:" + req.url);
}
return sendNotFound(res);
}
try {
const cacheKey = getCacheKey(req);
if (DEBUG) {
res.setHeader("FPC-KEY", cacheKey);
console.log("KEY:" + prefix + cacheKey);
}
// Try APCu like (NodeCache) First
var cacheInfo = null;
let cachedPage = USE_CACHE ? cache.get(cacheKey) : null;
if (cachedPage) {
cacheInfo = getCacheInfo(cacheKey);
res.setHeader("Node-Cache", "true");
console.log('NodeCACHE: HIT');
} else if (USE_STALE) {
// when cache expired we need check stale twice
cachedPage = cache.get(cacheKey)
if (cachedPage) {
cacheInfo = getCacheInfo(cacheKey);
res.setHeader("Node-Stale", "true");
console.log('NodeCACHE: STALE');
} else {
console.log('NodeCACHE: MISS');
}
}
if (!cachedPage) {
const redisStartTime = process.hrtime();
cachedPage = await getRedisValue(cacheKey, "d");
const redisEndTime = process.hrtime(redisStartTime);
const redisTimeMs = (redisEndTime[1] / 1e6).toFixed(2);
if (DEBUG) res.setHeader("Server-Timing", `redis;dur=${redisTimeMs}`);
if (!cachedPage) {
if (DEBUG) res.setHeader("Fast-Cache", "MISS");
return sendNotFound(res);
}
if (USE_CACHE) cache.set(cacheKey, cachedPage, CACHE_TTL);
} else {
if (DEBUG) res.setHeader("Fast-Cache", "HIT (NodeCACHE)");
}
// Set Cached Headers
if (cachedPage.headers) {
for (const [header, value] of Object.entries(cachedPage.headers)) {
res.setHeader(header, value);
}
}
let content = cachedPage.content;
if (MINIFY && !cachedPage.minified) {
(async () => {
content = await minifyHTML(content);
cachedPage.content = content;
cachedPage.minified = true;
if (USE_CACHE) cache.set(cacheKey, cachedPage, CACHE_TTL);
//ToDo: resave minified to Redis ;)
})();
}
// Measure Total Execution Time
const endTime = process.hrtime(startTime);
const fpcTimeMs = (endTime[1] / 1e6).toFixed(2);
res.setHeader("Server-Timing", `fpc;dur=${fpcTimeMs}`);
console.log("FPC-TIME:[" + req.url + "]->" + (endTime[1] / 1e6).toFixed(2) + "ms");
res.writeHead(200, { "Content-Type": "text/html" });
if (USE_STALE && cacheInfo !== null && cacheInfo.stale) {
(async () => {
try {
console.log('Fetched new data');
const newContent = await fetchOriginalData(req, {'Refresh': '1'});
if (newContent) {
let oldCache = cache.get(cacheKey);
let newCache = await getRedisValue(cacheKey, "d");
if (oldCache && newCache) {
// Update the cache with new data
console.log('SET new data');
cache.set(cacheKey, newCache, CACHE_TTL);
}
}
} catch (error) {
console.error('Error fetching new data:', error);
}
})();
}
console.log('----Response Return---');
res.end(content);
} catch (err) {
if (DEBUG) {
console.error("FPC Error:", err);
}
sendNotFound(res);
}
});
// Function to Check if Request is Cacheable
function isCached(req) {
if (req.method !== "GET") return false;
// Bypass cache for refresh requests
if (req.headers["refresh"] === "1") return false;
return !IGNORED_URLS.some(url => req.url.startsWith(url));
}
// Generate Cache Key (Same as Magento)
function getCacheKey(req) {
const httpsFlag = req.headers["x-forwarded-proto"] === "https" || HTTPS;
const varyString = req.headers["cookie"]?.includes("X-Magento-Vary") || null;
return hashData([httpsFlag, getUrl(req), varyString]);
}
// Get Full Request URL
function getUrl(req) {
let scheme = req.headers["x-forwarded-proto"] || "http";
if (HTTPS) {
scheme = "https";
}
let host = req.headers.host;
if (HOST) {
host = HOST;
}
// [true,"https:\/\/react-luma.cnxt.link\/",null]
const url = (scheme + "://" + host + req.url)
if (DEBUG) {
console.log(JSON.stringify(url));
}
return url;
}
// Gzip Decompression for Cached Content
async function uncompress(page) {
return await decompressGzippedBase64(page);
}
// Generate SHA1 Hash for Cache Keys
function hashData(data) {
// to match PHP json_encode
var jsonString = JSON.stringify(data).replace(/\//g, "/")
.replace(/\//g, "\\/") // Escape slashes like PHP (\/)
.replace(/[\u007f-\uffff]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`); // Unicode fix
if (DEBUG) {
console.log("HASH-DATA:" + jsonString);
}
return crypto.createHash("sha1").update(jsonString).digest("hex").toUpperCase();
}
// Send 404 Not Found Response
function sendNotFound(res) {
res.writeHead(406, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not Cached" }));
}
async function getRedisValue(key, field = "d") {
try {
let value = await redis.hget(key, field); // Use HGET instead of HGETALL
console.log("HGET:", Boolean(value));
if (!value) {
return false;
}
value = await uncompress(value);
value = JSON.parse(value);
return value;
} catch (err) {
console.error("Redis Error:", err);
}
}
function getEnvBoolean(key, defaultValue) {
return process.env[key]?.toLowerCase() === "true"
? true
: process.env[key]?.toLowerCase() === "false"
? false
: defaultValue;
};
function decompressGzippedBase64(page) {
return new Promise((resolve, reject) => {
// For now we are just supporting GZip
if (!page.startsWith("gz")) {
return resolve(page); // Return original if not gzipped
}
const buffer = Buffer.from(page, "base64");
console.log("REDIS-GZIPed");
gunzip(buffer, (err, decompressed) => {
if (err) {
reject(err);
} else {
resolve(decompressed.toString());
}
});
});
}
async function minifyHTML(htmlContent) {
return await minify(htmlContent, {
collapseWhitespace: true, // Remove unnecessary spaces
removeComments: true, // Remove HTML comments
removeRedundantAttributes: true, // Remove default attributes (e.g., `<input type="text">`)
removeEmptyAttributes: true, // Remove empty attributes
minifyCSS: true, // Minify inline CSS
minifyJS: true, // Minify inline JS
});
}
// Function to get the TTL and saved time of a cached object
function getCacheInfo(key) {
const now = Date.now();
const ttl = cache.getTtl(key); // Get the TTL of the key timestamp when expired
let stale = false;
if (cache.get(key).expired) {
stale = true;
}
if (ttl !== undefined) {
console.log(`Key: ${key}`);
console.log(`TTL: ${ttl} ms`);
console.log(`Expired:`, stale);
} else {
//console.log(`Key: ${key} not found in cache.`);
}
return {stale, ttl}
}
// Function to fetch original data with additional headers
async function fetchOriginalData(req, additionalHeaders = {}) {
try {
const url = getUrl(req);
const originalHeaders = req.headers;
// Merge original headers with additional headers
const headers = { ...originalHeaders, ...additionalHeaders };
const response = await fetch(url, { headers });
if (!response.ok) {
return false;
}
return response;
} catch (error) {
console.error('Error fetching data:', error);
return null;
}
}
// Start Server
const PORT = process.env.PORT || 3001;
server.listen(PORT, () => console.log(`🚀 Node.js FPC Server running on port ${PORT}`));