@tramvai/module-page-render-mode 7.5.3 → 7.7.0
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/lib/PageRenderWrapper.browser.js +4 -4
- package/lib/PageRenderWrapper.d.ts +1 -1
- package/lib/PageRenderWrapper.es.js +4 -4
- package/lib/PageRenderWrapper.js +3 -3
- package/lib/browser.js +27 -3
- package/lib/private-tokens.browser.js +9 -0
- package/lib/private-tokens.d.ts +27 -0
- package/lib/private-tokens.es.js +9 -0
- package/lib/private-tokens.js +17 -0
- package/lib/server.es.js +1 -1
- package/lib/server.js +5 -0
- package/lib/staticPages/backgroundFetchService.d.ts +9 -7
- package/lib/staticPages/backgroundFetchService.es.js +20 -20
- package/lib/staticPages/backgroundFetchService.js +20 -20
- package/lib/staticPages/fileSystemCache.d.ts +72 -0
- package/lib/staticPages/fileSystemCache.es.js +367 -0
- package/lib/staticPages/fileSystemCache.js +376 -0
- package/lib/staticPages/staticPagesService.d.ts +16 -9
- package/lib/staticPages/staticPagesService.es.js +124 -39
- package/lib/staticPages/staticPagesService.js +124 -39
- package/lib/staticPages.d.ts +410 -155
- package/lib/staticPages.es.js +233 -67
- package/lib/staticPages.js +232 -70
- package/lib/tokens.browser.js +15 -1
- package/lib/tokens.d.ts +90 -32
- package/lib/tokens.es.js +15 -1
- package/lib/tokens.js +19 -0
- package/lib/utils/cacheKey.d.ts +4 -6
- package/lib/utils/cacheKey.es.js +8 -3
- package/lib/utils/cacheKey.js +8 -2
- package/lib/utils/getPageRenderMode.browser.js +14 -2
- package/lib/utils/getPageRenderMode.d.ts +8 -3
- package/lib/utils/getPageRenderMode.es.js +14 -2
- package/lib/utils/getPageRenderMode.js +14 -2
- package/package.json +16 -14
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var node_fs = require('node:fs');
|
|
6
|
+
var path = require('node:path');
|
|
7
|
+
var pLimit = require('p-limit');
|
|
8
|
+
var core = require('@tramvai/core');
|
|
9
|
+
var cacheKey = require('../utils/cacheKey.js');
|
|
10
|
+
|
|
11
|
+
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
|
12
|
+
|
|
13
|
+
var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
|
|
14
|
+
var pLimit__default = /*#__PURE__*/_interopDefaultLegacy(pLimit);
|
|
15
|
+
|
|
16
|
+
class FileSystemCache {
|
|
17
|
+
directory;
|
|
18
|
+
maxSize;
|
|
19
|
+
ttl;
|
|
20
|
+
allowStale;
|
|
21
|
+
log;
|
|
22
|
+
cache;
|
|
23
|
+
list;
|
|
24
|
+
// save double linked list nodes for O(1) access when moving nodes
|
|
25
|
+
nodes;
|
|
26
|
+
staticPages;
|
|
27
|
+
metrics;
|
|
28
|
+
limit = pLimit__default["default"](8);
|
|
29
|
+
constructor({ directory, maxSize, ttl, allowStale, logger, metrics, }) {
|
|
30
|
+
this.directory = directory;
|
|
31
|
+
this.maxSize = maxSize;
|
|
32
|
+
this.ttl = ttl;
|
|
33
|
+
this.allowStale = allowStale;
|
|
34
|
+
this.log = logger('static-pages:fs-cache');
|
|
35
|
+
this.cache = new Map();
|
|
36
|
+
this.list = new core.DoubleLinkedList();
|
|
37
|
+
this.nodes = new Map();
|
|
38
|
+
this.staticPages = [];
|
|
39
|
+
this.metrics = metrics;
|
|
40
|
+
}
|
|
41
|
+
async init() {
|
|
42
|
+
this.log.info({
|
|
43
|
+
event: 'init-start',
|
|
44
|
+
directory: this.directory,
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
await this.scanDirectory();
|
|
48
|
+
this.log.info({
|
|
49
|
+
event: 'init-complete',
|
|
50
|
+
size: this.cache.size,
|
|
51
|
+
totalBytes: this.getTotalBytes(),
|
|
52
|
+
});
|
|
53
|
+
this.updateMetrics();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (process.env.NODE_ENV === 'development' && error?.code === 'ENOENT') {
|
|
57
|
+
// in development mode, static directory path includes unique build id and can be missing on first run, so just create it
|
|
58
|
+
await node_fs.promises.mkdir(this.directory, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.log.warn({
|
|
62
|
+
event: 'init-error',
|
|
63
|
+
message: error?.code === 'ENOENT'
|
|
64
|
+
? `"${this.directory}" directory is not found. To effectively use file system cache, run "tramvai static {appName} --buildType=none" after application build,
|
|
65
|
+
and copy "dist/static" folder into the Docker image, to ensure the pages cache is populated on the first run.`
|
|
66
|
+
: 'Failed to initialize file system cache',
|
|
67
|
+
error: error,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// eslint-disable-next-line max-statements
|
|
73
|
+
async get(cacheKey) {
|
|
74
|
+
const metadata = this.cache.get(cacheKey);
|
|
75
|
+
if (!metadata) {
|
|
76
|
+
this.metrics.miss.inc();
|
|
77
|
+
this.log.debug({
|
|
78
|
+
event: 'get-miss',
|
|
79
|
+
cacheKey,
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const isStale = Date.now() - metadata.mtime > this.ttl;
|
|
84
|
+
if (isStale && !this.allowStale) {
|
|
85
|
+
this.log.debug({
|
|
86
|
+
event: 'get-miss',
|
|
87
|
+
stale: true,
|
|
88
|
+
cacheKey,
|
|
89
|
+
});
|
|
90
|
+
this.metrics.miss.inc();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// TODO: race condition protection, lock file or just return null?
|
|
94
|
+
try {
|
|
95
|
+
const html = await node_fs.promises.readFile(metadata.filePath, 'utf-8');
|
|
96
|
+
const response = JSON.parse(await node_fs.promises.readFile(metadata.filePath.replace(/\.html$/, '.json'), 'utf-8'));
|
|
97
|
+
// async cleanup stale entry, don't block response
|
|
98
|
+
if (isStale) {
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
this.log.debug({
|
|
101
|
+
event: 'delete-stale',
|
|
102
|
+
cacheKey,
|
|
103
|
+
});
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
105
|
+
const metadata = this.cache.get(cacheKey);
|
|
106
|
+
if (metadata) {
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
108
|
+
const isStale = Date.now() - metadata.mtime > this.ttl;
|
|
109
|
+
// prevent to delete entry if it was updated while timeout was waiting
|
|
110
|
+
if (isStale) {
|
|
111
|
+
this.delete(cacheKey);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}, 1).unref();
|
|
115
|
+
}
|
|
116
|
+
this.log.debug({
|
|
117
|
+
event: 'get-hit',
|
|
118
|
+
cacheKey,
|
|
119
|
+
});
|
|
120
|
+
this.metrics.hit.inc();
|
|
121
|
+
this.moveToHead(metadata.cacheKey);
|
|
122
|
+
return {
|
|
123
|
+
body: html,
|
|
124
|
+
updatedAt: metadata.mtime,
|
|
125
|
+
headers: response.headers,
|
|
126
|
+
status: response.status,
|
|
127
|
+
source: 'fs',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
this.log.warn({
|
|
132
|
+
event: 'get-error',
|
|
133
|
+
cacheKey,
|
|
134
|
+
error: error,
|
|
135
|
+
});
|
|
136
|
+
// if file is missing or corrupted, remove from cache
|
|
137
|
+
setTimeout(() => {
|
|
138
|
+
this.log.debug({
|
|
139
|
+
event: 'delete-broken',
|
|
140
|
+
cacheKey,
|
|
141
|
+
});
|
|
142
|
+
this.delete(cacheKey);
|
|
143
|
+
}, 1).unref();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async set(cacheKey$1, cacheEntry) {
|
|
147
|
+
this.log.debug({
|
|
148
|
+
event: 'set-start',
|
|
149
|
+
cacheKey: cacheKey$1,
|
|
150
|
+
});
|
|
151
|
+
const [pathname, key] = cacheKey.parseCacheKey(cacheKey$1);
|
|
152
|
+
const filename = key ? `index.${key}.html` : 'index.html';
|
|
153
|
+
const metaFilename = key ? `index.${key}.json` : 'index.json';
|
|
154
|
+
const filePath = path__default["default"].join(this.directory, pathname, filename);
|
|
155
|
+
const metaFilePath = path__default["default"].join(this.directory, pathname, metaFilename);
|
|
156
|
+
try {
|
|
157
|
+
await node_fs.promises.mkdir(path__default["default"].dirname(filePath), { recursive: true });
|
|
158
|
+
await node_fs.promises.writeFile(filePath, cacheEntry.body, 'utf-8');
|
|
159
|
+
await node_fs.promises.writeFile(metaFilePath, JSON.stringify({
|
|
160
|
+
headers: cacheEntry.headers,
|
|
161
|
+
status: cacheEntry.status,
|
|
162
|
+
}, null, 2), 'utf-8');
|
|
163
|
+
const metadata = {
|
|
164
|
+
filePath,
|
|
165
|
+
pathname,
|
|
166
|
+
key,
|
|
167
|
+
cacheKey: cacheKey$1,
|
|
168
|
+
size: Buffer.byteLength(cacheEntry.body, 'utf-8'),
|
|
169
|
+
mtime: cacheEntry.updatedAt,
|
|
170
|
+
};
|
|
171
|
+
this.cache.set(cacheKey$1, metadata);
|
|
172
|
+
this.moveToHead(metadata.cacheKey);
|
|
173
|
+
this.updateMetrics();
|
|
174
|
+
if (this.cache.size > this.maxSize) {
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
this.evictTail();
|
|
177
|
+
}, 1).unref();
|
|
178
|
+
}
|
|
179
|
+
this.log.debug({
|
|
180
|
+
event: 'set-success',
|
|
181
|
+
cacheKey: cacheKey$1,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
this.log.warn({
|
|
186
|
+
event: 'set-error',
|
|
187
|
+
cacheKey: cacheKey$1,
|
|
188
|
+
error: error,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
has(cacheKey) {
|
|
193
|
+
if (!this.cache.has(cacheKey)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const metadata = this.cache.get(cacheKey);
|
|
197
|
+
const isStale = Date.now() - metadata.mtime > this.ttl;
|
|
198
|
+
if (isStale && !this.allowStale) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
async delete(cacheKey) {
|
|
204
|
+
const metadata = this.cache.get(cacheKey);
|
|
205
|
+
if (!metadata) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
this.log.debug({
|
|
209
|
+
event: 'delete-start',
|
|
210
|
+
filePath: metadata.filePath,
|
|
211
|
+
});
|
|
212
|
+
const node = this.nodes.get(cacheKey);
|
|
213
|
+
this.cache.delete(cacheKey);
|
|
214
|
+
this.removeNode(node);
|
|
215
|
+
this.updateMetrics();
|
|
216
|
+
try {
|
|
217
|
+
await node_fs.promises.unlink(metadata.filePath);
|
|
218
|
+
await node_fs.promises.unlink(metadata.filePath.replace(/\.html$/, '.json'));
|
|
219
|
+
this.log.debug({
|
|
220
|
+
event: 'delete-success',
|
|
221
|
+
filePath: metadata.filePath,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
this.log.debug({
|
|
226
|
+
event: 'delete-error',
|
|
227
|
+
filePath: metadata.filePath,
|
|
228
|
+
error: error,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
async clear() {
|
|
234
|
+
this.log.debug({
|
|
235
|
+
event: 'clear-start',
|
|
236
|
+
});
|
|
237
|
+
await Promise.allSettled(Array.from(this.cache.keys()).map((cacheKey) => {
|
|
238
|
+
return this.limit(() => this.delete(cacheKey));
|
|
239
|
+
}));
|
|
240
|
+
this.log.debug({
|
|
241
|
+
event: 'clear-success',
|
|
242
|
+
});
|
|
243
|
+
this.updateMetrics();
|
|
244
|
+
}
|
|
245
|
+
get size() {
|
|
246
|
+
return this.cache.size;
|
|
247
|
+
}
|
|
248
|
+
dump() {
|
|
249
|
+
return Array.from(this.cache.entries());
|
|
250
|
+
}
|
|
251
|
+
async scanDirectory() {
|
|
252
|
+
try {
|
|
253
|
+
this.log.debug({
|
|
254
|
+
event: 'meta-read-start',
|
|
255
|
+
});
|
|
256
|
+
const meta = await node_fs.promises.readFile(path__default["default"].join(this.directory, 'meta.json'), 'utf-8');
|
|
257
|
+
this.staticPages = JSON.parse(meta).staticPages;
|
|
258
|
+
this.log.debug({
|
|
259
|
+
event: 'meta-read-success',
|
|
260
|
+
staticPages: this.staticPages,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
265
|
+
this.log.warn({
|
|
266
|
+
event: 'meta-read-error',
|
|
267
|
+
error: error,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const entries = await node_fs.promises.readdir(this.directory, { withFileTypes: true, recursive: true });
|
|
272
|
+
const promises = [];
|
|
273
|
+
let pages = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.html'));
|
|
274
|
+
if (pages.length > this.maxSize) {
|
|
275
|
+
this.log.info({
|
|
276
|
+
event: 'too-many-pages-prerendered',
|
|
277
|
+
pagesCount: pages.length,
|
|
278
|
+
});
|
|
279
|
+
pages = pages.slice(0, this.maxSize);
|
|
280
|
+
}
|
|
281
|
+
for (const entry of pages) {
|
|
282
|
+
const fullPath = path__default["default"].join(entry.parentPath, entry.name);
|
|
283
|
+
promises.push(this.limit(() => this.addFileToCache(fullPath)));
|
|
284
|
+
}
|
|
285
|
+
await Promise.all(promises);
|
|
286
|
+
}
|
|
287
|
+
async addFileToCache(filePath) {
|
|
288
|
+
try {
|
|
289
|
+
const stats = await node_fs.promises.stat(filePath);
|
|
290
|
+
const relativePath = path__default["default"].relative(this.directory, filePath);
|
|
291
|
+
// foo/index.html -> 'index'
|
|
292
|
+
// foo/index.mobile.html -> 'index.mobile'
|
|
293
|
+
const filename = path__default["default"].basename(filePath, '.html');
|
|
294
|
+
// index -> ['index', '']
|
|
295
|
+
// index.mobile -> ['index', 'mobile']
|
|
296
|
+
const [, key = ''] = filename.split('.');
|
|
297
|
+
// index.html -> '/'
|
|
298
|
+
// foo/bar/index.html -> '/foo/bar/'
|
|
299
|
+
// foo/bar/index.mobile.html -> '/foo/bar/'
|
|
300
|
+
const pathname = path__default["default"].join('/', relativePath.replace(path__default["default"].basename(relativePath), ''), '/');
|
|
301
|
+
const cacheKey$1 = cacheKey.getCacheKey({ pathname, key });
|
|
302
|
+
const metadata = {
|
|
303
|
+
filePath,
|
|
304
|
+
pathname,
|
|
305
|
+
key,
|
|
306
|
+
cacheKey: cacheKey$1,
|
|
307
|
+
size: stats.size,
|
|
308
|
+
// TODO: if file already stale on init, what to do?
|
|
309
|
+
mtime: stats.mtimeMs,
|
|
310
|
+
};
|
|
311
|
+
this.cache.set(cacheKey$1, metadata);
|
|
312
|
+
// order doesn't matter when initializing
|
|
313
|
+
const node = this.list.push(cacheKey$1);
|
|
314
|
+
this.nodes.set(cacheKey$1, node);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
this.log.warn({
|
|
318
|
+
event: 'process-file-error',
|
|
319
|
+
filePath,
|
|
320
|
+
error: error,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
moveToHead(cacheKey) {
|
|
325
|
+
if (this.list.start?.value === cacheKey) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const currentNode = this.nodes.get(cacheKey);
|
|
329
|
+
if (currentNode) {
|
|
330
|
+
this.removeNode(currentNode);
|
|
331
|
+
}
|
|
332
|
+
const node = this.list.unshift(cacheKey);
|
|
333
|
+
this.nodes.set(cacheKey, node);
|
|
334
|
+
}
|
|
335
|
+
removeNode(node) {
|
|
336
|
+
if (this.list.start?.value === node.value) {
|
|
337
|
+
this.list.start = node.next;
|
|
338
|
+
}
|
|
339
|
+
if (this.list.end?.value === node.value) {
|
|
340
|
+
this.list.end = node.prev;
|
|
341
|
+
}
|
|
342
|
+
if (node.prev) {
|
|
343
|
+
node.prev.next = node.next;
|
|
344
|
+
}
|
|
345
|
+
if (node.next) {
|
|
346
|
+
node.next.prev = node.prev;
|
|
347
|
+
}
|
|
348
|
+
this.list.length--;
|
|
349
|
+
this.nodes.delete(node.value);
|
|
350
|
+
}
|
|
351
|
+
async evictTail() {
|
|
352
|
+
if (!this.list.end) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const node = this.list.end;
|
|
356
|
+
const cacheKey = node.value;
|
|
357
|
+
this.log.debug({
|
|
358
|
+
event: 'evict',
|
|
359
|
+
cacheKey,
|
|
360
|
+
});
|
|
361
|
+
await this.delete(cacheKey);
|
|
362
|
+
}
|
|
363
|
+
getTotalBytes() {
|
|
364
|
+
let total = 0;
|
|
365
|
+
for (const metadata of this.cache.values()) {
|
|
366
|
+
total += metadata.size;
|
|
367
|
+
}
|
|
368
|
+
return total;
|
|
369
|
+
}
|
|
370
|
+
updateMetrics() {
|
|
371
|
+
this.metrics.size.set(this.cache.size);
|
|
372
|
+
this.metrics.bytes.set(this.getTotalBytes());
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
exports.FileSystemCache = FileSystemCache;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ExtractDependencyType } from '@tinkoff/dippy';
|
|
2
|
-
import type { USER_AGENT_TOKEN } from '@tramvai/module-client-hints';
|
|
3
2
|
import type { ENV_MANAGER_TOKEN, LOGGER_TOKEN, REQUEST_MANAGER_TOKEN, RESPONSE_MANAGER_TOKEN } from '@tramvai/tokens-common';
|
|
4
3
|
import type { FASTIFY_RESPONSE } from '@tramvai/tokens-server-private';
|
|
5
|
-
import type { STATIC_PAGES_BACKGROUND_FETCH_SERVICE
|
|
6
|
-
import type { STATIC_PAGES_SHOULD_USE_CACHE, STATIC_PAGES_CACHE_TOKEN, STATIC_PAGES_MODIFY_CACHE, STATIC_PAGES_OPTIONS_TOKEN, STATIC_PAGES_CACHE_5xx_RESPONSE } from '../tokens';
|
|
4
|
+
import type { STATIC_PAGES_BACKGROUND_FETCH_SERVICE } from '../private-tokens';
|
|
5
|
+
import type { STATIC_PAGES_SHOULD_USE_CACHE, STATIC_PAGES_CACHE_TOKEN, STATIC_PAGES_MODIFY_CACHE, STATIC_PAGES_OPTIONS_TOKEN, STATIC_PAGES_CACHE_5xx_RESPONSE, STATIC_PAGES_KEY_TOKEN, STATIC_PAGES_FS_CACHE_ENABLED, STATIC_PAGES_CACHE_CONTROL_HEADER_TOKEN } from '../tokens';
|
|
6
|
+
import type { FileSystemCache } from './fileSystemCache';
|
|
7
7
|
type ResponseManager = ExtractDependencyType<typeof RESPONSE_MANAGER_TOKEN>;
|
|
8
8
|
type Response = ExtractDependencyType<typeof FASTIFY_RESPONSE>;
|
|
9
9
|
type Logger = ExtractDependencyType<typeof LOGGER_TOKEN>;
|
|
@@ -13,36 +13,43 @@ type Cache = ExtractDependencyType<typeof STATIC_PAGES_CACHE_TOKEN>;
|
|
|
13
13
|
type ModifyCache = ExtractDependencyType<typeof STATIC_PAGES_MODIFY_CACHE> | null;
|
|
14
14
|
type Options = ExtractDependencyType<typeof STATIC_PAGES_OPTIONS_TOKEN>;
|
|
15
15
|
type Cache5xxResponse = ExtractDependencyType<typeof STATIC_PAGES_CACHE_5xx_RESPONSE>;
|
|
16
|
+
type CacheControlFactory = ExtractDependencyType<typeof STATIC_PAGES_CACHE_CONTROL_HEADER_TOKEN>;
|
|
16
17
|
export declare class StaticPagesService {
|
|
17
18
|
readonly key: string;
|
|
18
|
-
readonly
|
|
19
|
+
readonly pathname: string;
|
|
20
|
+
readonly cacheKey: string;
|
|
19
21
|
readonly port: string;
|
|
20
|
-
readonly deviceType: string;
|
|
21
22
|
private responseManager;
|
|
23
|
+
private requestManager;
|
|
22
24
|
private response;
|
|
23
25
|
private log;
|
|
24
26
|
private cache;
|
|
27
|
+
private fsCache;
|
|
28
|
+
private fsCacheEnabled;
|
|
25
29
|
private modifyCache;
|
|
26
30
|
private backgroundFetchService;
|
|
27
31
|
private options;
|
|
28
32
|
private cache5xxResponse;
|
|
33
|
+
private cacheControlFactory;
|
|
29
34
|
shouldUseCache: () => boolean;
|
|
30
|
-
constructor({
|
|
31
|
-
|
|
35
|
+
constructor({ staticPagesKey, requestManager, response, responseManager, environmentManager, logger, cache, fsCache, fsCacheEnabled, modifyCache, shouldUseCache, backgroundFetchService, options, cache5xxResponse, cacheControlFactory, }: {
|
|
36
|
+
staticPagesKey: ExtractDependencyType<typeof STATIC_PAGES_KEY_TOKEN>;
|
|
32
37
|
requestManager: ExtractDependencyType<typeof REQUEST_MANAGER_TOKEN>;
|
|
33
38
|
responseManager: ResponseManager;
|
|
34
39
|
response: Response;
|
|
35
40
|
environmentManager: ExtractDependencyType<typeof ENV_MANAGER_TOKEN>;
|
|
36
|
-
userAgent: ExtractDependencyType<typeof USER_AGENT_TOKEN>;
|
|
37
41
|
logger: Logger;
|
|
38
42
|
cache: Cache;
|
|
43
|
+
fsCache: FileSystemCache | null;
|
|
44
|
+
fsCacheEnabled: ExtractDependencyType<typeof STATIC_PAGES_FS_CACHE_ENABLED>;
|
|
39
45
|
modifyCache: ModifyCache;
|
|
40
46
|
shouldUseCache: ShouldUseCache;
|
|
41
47
|
backgroundFetchService: BackgroundFetchService;
|
|
42
48
|
options: Options;
|
|
43
49
|
cache5xxResponse: Cache5xxResponse;
|
|
50
|
+
cacheControlFactory: CacheControlFactory;
|
|
44
51
|
});
|
|
45
|
-
respond(onSuccess: () => void): void
|
|
52
|
+
respond(onSuccess: () => void): Promise<void>;
|
|
46
53
|
revalidate(): Promise<void>;
|
|
47
54
|
private hasCache;
|
|
48
55
|
private getCache;
|
|
@@ -1,69 +1,112 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { getCacheKey } from '../utils/cacheKey.es.js';
|
|
2
|
+
|
|
3
|
+
// `Location` is required for 3xx responses
|
|
4
|
+
const DEFAULT_HEADERS_WHITELIST = ['Location', 'Content-Type', 'Content-Length', 'X-App-Id'];
|
|
4
5
|
class StaticPagesService {
|
|
5
6
|
key;
|
|
6
|
-
|
|
7
|
+
pathname;
|
|
8
|
+
cacheKey;
|
|
7
9
|
port;
|
|
8
|
-
deviceType;
|
|
9
10
|
responseManager;
|
|
11
|
+
requestManager;
|
|
10
12
|
response;
|
|
11
13
|
log;
|
|
12
14
|
cache;
|
|
15
|
+
fsCache;
|
|
16
|
+
fsCacheEnabled;
|
|
13
17
|
modifyCache;
|
|
14
18
|
backgroundFetchService;
|
|
15
19
|
options;
|
|
16
20
|
cache5xxResponse;
|
|
21
|
+
cacheControlFactory;
|
|
17
22
|
shouldUseCache;
|
|
18
|
-
constructor({
|
|
19
|
-
this.key =
|
|
20
|
-
this.
|
|
23
|
+
constructor({ staticPagesKey, requestManager, response, responseManager, environmentManager, logger, cache, fsCache, fsCacheEnabled, modifyCache, shouldUseCache, backgroundFetchService, options, cache5xxResponse, cacheControlFactory, }) {
|
|
24
|
+
this.key = staticPagesKey();
|
|
25
|
+
this.pathname = requestManager.getParsedUrl().pathname;
|
|
26
|
+
this.cacheKey = getCacheKey({ pathname: this.pathname, key: this.key });
|
|
21
27
|
this.port = environmentManager.get('PORT');
|
|
22
|
-
this.deviceType = userAgent.mobileOS ? 'mobile' : 'desktop';
|
|
23
28
|
this.log = logger('static-pages');
|
|
24
29
|
this.responseManager = responseManager;
|
|
30
|
+
this.requestManager = requestManager;
|
|
25
31
|
this.response = response;
|
|
26
32
|
this.cache = cache;
|
|
33
|
+
this.fsCacheEnabled = fsCacheEnabled();
|
|
34
|
+
this.fsCache = fsCache;
|
|
27
35
|
this.modifyCache = modifyCache;
|
|
28
36
|
this.shouldUseCache = () => shouldUseCache.every((fn) => fn());
|
|
29
37
|
this.backgroundFetchService = backgroundFetchService;
|
|
30
38
|
this.options = options;
|
|
31
39
|
this.cache5xxResponse = cache5xxResponse;
|
|
40
|
+
this.cacheControlFactory = cacheControlFactory;
|
|
32
41
|
}
|
|
33
|
-
respond(onSuccess) {
|
|
42
|
+
async respond(onSuccess) {
|
|
34
43
|
if (!this.hasCache()) {
|
|
35
44
|
this.log.debug({
|
|
36
45
|
event: 'no-cache',
|
|
37
|
-
|
|
46
|
+
cacheKey: this.cacheKey,
|
|
38
47
|
});
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
// async revalidation, response is not delayed
|
|
50
|
+
this.revalidate();
|
|
51
|
+
}, 1).unref();
|
|
39
52
|
return;
|
|
40
53
|
}
|
|
41
|
-
let cacheEntry = this.getCache();
|
|
54
|
+
let cacheEntry = await this.getCache();
|
|
42
55
|
if (Array.isArray(this.modifyCache)) {
|
|
43
56
|
cacheEntry = this.modifyCache.reduce((result, modifier) => {
|
|
44
57
|
return modifier(result);
|
|
45
58
|
}, cacheEntry);
|
|
46
59
|
}
|
|
47
|
-
const {
|
|
60
|
+
const { ttl, allowStale } = this.options;
|
|
61
|
+
const { status, headers, body, source, updatedAt } = cacheEntry;
|
|
48
62
|
const isOutdated = this.cacheOutdated(cacheEntry);
|
|
49
|
-
|
|
50
|
-
|
|
63
|
+
if (!this.cache5xxResponse() && status >= 500) {
|
|
64
|
+
this.log.debug({
|
|
65
|
+
event: 'cache-5xx',
|
|
66
|
+
cacheKey: this.cacheKey,
|
|
67
|
+
status,
|
|
68
|
+
});
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
// it is possible that 5xx response is generated while "tramvai static" command,
|
|
71
|
+
// we can just remove it when `STATIC_PAGES_CACHE_5xx_RESPONSE` is disabled
|
|
72
|
+
// async revalidation, response is not delayed
|
|
73
|
+
this.revalidate();
|
|
74
|
+
}, 1).unref();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const isStale = isOutdated && allowStale;
|
|
78
|
+
if (!isOutdated || isStale) {
|
|
51
79
|
this.log.debug({
|
|
52
80
|
event: 'cache-hit',
|
|
53
|
-
|
|
81
|
+
cacheKey: this.cacheKey,
|
|
82
|
+
stale: isStale,
|
|
54
83
|
});
|
|
55
|
-
|
|
84
|
+
if (isStale) {
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
// async revalidation, response is not delayed
|
|
87
|
+
this.revalidate();
|
|
88
|
+
}, 1).unref();
|
|
89
|
+
}
|
|
90
|
+
const allowedHeaders = {};
|
|
91
|
+
this.options.allowedHeaders.concat(DEFAULT_HEADERS_WHITELIST).forEach((header) => {
|
|
92
|
+
const lowercaseHeader = header.toLowerCase();
|
|
56
93
|
if (headers[header]) {
|
|
57
|
-
|
|
94
|
+
allowedHeaders[header] = headers[header];
|
|
58
95
|
}
|
|
59
|
-
if (
|
|
60
|
-
|
|
96
|
+
else if (headers[lowercaseHeader]) {
|
|
97
|
+
allowedHeaders[lowercaseHeader] = headers[lowercaseHeader];
|
|
61
98
|
}
|
|
62
99
|
});
|
|
63
100
|
this.response
|
|
101
|
+
.headers(allowedHeaders)
|
|
64
102
|
.header('content-type', 'text/html')
|
|
103
|
+
.header('cache-control', this.cacheControlFactory({ ttl, updatedAt }))
|
|
104
|
+
// Vary header is required for correct cache behavior in CDNs and browsers,
|
|
105
|
+
// it indicates that response may vary based on `X-Tramvai-Static-Page-Key` header
|
|
106
|
+
.header('Vary', 'X-Tramvai-Static-Page-Key')
|
|
107
|
+
.header('X-Tramvai-Static-Page-Key', this.key)
|
|
65
108
|
.header('X-Tramvai-Static-Page-From-Cache', 'true')
|
|
66
|
-
.
|
|
109
|
+
.header('X-Tramvai-Static-Page-Cache-Source', source)
|
|
67
110
|
.status(status)
|
|
68
111
|
.send(body);
|
|
69
112
|
onSuccess();
|
|
@@ -71,8 +114,12 @@ class StaticPagesService {
|
|
|
71
114
|
else {
|
|
72
115
|
this.log.debug({
|
|
73
116
|
event: 'cache-outdated',
|
|
74
|
-
|
|
117
|
+
cacheKey: this.cacheKey,
|
|
75
118
|
});
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
// async revalidation, response is not delayed
|
|
121
|
+
this.revalidate();
|
|
122
|
+
}, 1).unref();
|
|
76
123
|
}
|
|
77
124
|
}
|
|
78
125
|
async revalidate() {
|
|
@@ -80,18 +127,28 @@ class StaticPagesService {
|
|
|
80
127
|
return;
|
|
81
128
|
}
|
|
82
129
|
if (this.hasCache()) {
|
|
83
|
-
const cacheEntry = this.getCache();
|
|
130
|
+
const cacheEntry = await this.getCache();
|
|
84
131
|
const isOutdated = this.cacheOutdated(cacheEntry);
|
|
85
132
|
if (!isOutdated) {
|
|
86
133
|
return;
|
|
87
134
|
}
|
|
88
135
|
}
|
|
136
|
+
const incomingHeaders = this.requestManager.getHeaders();
|
|
137
|
+
const revalidateHeaders = {};
|
|
138
|
+
this.options.allowedHeaders.concat(DEFAULT_HEADERS_WHITELIST).forEach((header) => {
|
|
139
|
+
const lowercaseHeader = header.toLowerCase();
|
|
140
|
+
if (incomingHeaders[header]) {
|
|
141
|
+
revalidateHeaders[header] = incomingHeaders[header];
|
|
142
|
+
}
|
|
143
|
+
else if (incomingHeaders[lowercaseHeader]) {
|
|
144
|
+
revalidateHeaders[lowercaseHeader] = incomingHeaders[lowercaseHeader];
|
|
145
|
+
}
|
|
146
|
+
});
|
|
89
147
|
await this.backgroundFetchService
|
|
90
148
|
.revalidate({
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
deviceType: this.deviceType,
|
|
149
|
+
cacheKey: this.cacheKey,
|
|
150
|
+
pathname: this.pathname,
|
|
151
|
+
headers: revalidateHeaders,
|
|
95
152
|
})
|
|
96
153
|
.then((response) => {
|
|
97
154
|
if (!response) {
|
|
@@ -100,31 +157,59 @@ class StaticPagesService {
|
|
|
100
157
|
if (!this.cache5xxResponse() && response.status >= 500) {
|
|
101
158
|
this.log.debug({
|
|
102
159
|
event: 'cache-set-5xx',
|
|
103
|
-
|
|
160
|
+
cacheKey: this.cacheKey,
|
|
104
161
|
});
|
|
105
162
|
return;
|
|
106
163
|
}
|
|
107
|
-
|
|
164
|
+
const cachedHeaders = {};
|
|
165
|
+
const responseHeaders = response.headers;
|
|
166
|
+
this.options.allowedHeaders.concat(DEFAULT_HEADERS_WHITELIST).forEach((header) => {
|
|
167
|
+
const lowercaseHeader = header.toLowerCase();
|
|
168
|
+
if (responseHeaders[header]) {
|
|
169
|
+
cachedHeaders[header] = responseHeaders[header];
|
|
170
|
+
}
|
|
171
|
+
else if (responseHeaders[lowercaseHeader]) {
|
|
172
|
+
cachedHeaders[lowercaseHeader] = responseHeaders[lowercaseHeader];
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return this.setCache({ ...response, headers: cachedHeaders });
|
|
108
176
|
});
|
|
109
177
|
}
|
|
110
178
|
hasCache() {
|
|
111
|
-
|
|
179
|
+
let result = this.cache.has(this.cacheKey);
|
|
180
|
+
if (!result && this.fsCacheEnabled) {
|
|
181
|
+
result = this.fsCache.has(this.cacheKey);
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
112
184
|
}
|
|
113
|
-
getCache() {
|
|
114
|
-
|
|
185
|
+
async getCache() {
|
|
186
|
+
let result = this.cache.get(this.cacheKey);
|
|
187
|
+
// update item recency in FS-cache
|
|
188
|
+
if (result && this.fsCacheEnabled) {
|
|
189
|
+
this.fsCache.moveToHead(this.cacheKey);
|
|
190
|
+
}
|
|
191
|
+
if (!result && this.fsCacheEnabled) {
|
|
192
|
+
result = await this.fsCache.get(this.cacheKey);
|
|
193
|
+
// always fill memory cache
|
|
194
|
+
if (result) {
|
|
195
|
+
this.cache.set(this.cacheKey, { ...result, source: 'memory' });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
115
199
|
}
|
|
116
|
-
setCache(cacheEntry) {
|
|
200
|
+
async setCache(cacheEntry) {
|
|
117
201
|
this.log.debug({
|
|
118
202
|
event: 'cache-set',
|
|
119
|
-
|
|
203
|
+
cacheKey: this.cacheKey,
|
|
120
204
|
});
|
|
121
|
-
|
|
122
|
-
this.cache.set(this.path, new Map());
|
|
123
|
-
}
|
|
124
|
-
this.cache.get(this.path).set(this.key, {
|
|
205
|
+
const entry = {
|
|
125
206
|
...cacheEntry,
|
|
126
207
|
updatedAt: Date.now(),
|
|
127
|
-
}
|
|
208
|
+
};
|
|
209
|
+
this.cache.set(this.cacheKey, { ...entry, source: 'memory' });
|
|
210
|
+
if (this.fsCacheEnabled) {
|
|
211
|
+
await this.fsCache.set(this.cacheKey, { ...entry, source: 'fs' });
|
|
212
|
+
}
|
|
128
213
|
}
|
|
129
214
|
cacheOutdated(cacheEntry) {
|
|
130
215
|
const { ttl } = this.options;
|