@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.
Files changed (35) hide show
  1. package/lib/PageRenderWrapper.browser.js +4 -4
  2. package/lib/PageRenderWrapper.d.ts +1 -1
  3. package/lib/PageRenderWrapper.es.js +4 -4
  4. package/lib/PageRenderWrapper.js +3 -3
  5. package/lib/browser.js +27 -3
  6. package/lib/private-tokens.browser.js +9 -0
  7. package/lib/private-tokens.d.ts +27 -0
  8. package/lib/private-tokens.es.js +9 -0
  9. package/lib/private-tokens.js +17 -0
  10. package/lib/server.es.js +1 -1
  11. package/lib/server.js +5 -0
  12. package/lib/staticPages/backgroundFetchService.d.ts +9 -7
  13. package/lib/staticPages/backgroundFetchService.es.js +20 -20
  14. package/lib/staticPages/backgroundFetchService.js +20 -20
  15. package/lib/staticPages/fileSystemCache.d.ts +72 -0
  16. package/lib/staticPages/fileSystemCache.es.js +367 -0
  17. package/lib/staticPages/fileSystemCache.js +376 -0
  18. package/lib/staticPages/staticPagesService.d.ts +16 -9
  19. package/lib/staticPages/staticPagesService.es.js +124 -39
  20. package/lib/staticPages/staticPagesService.js +124 -39
  21. package/lib/staticPages.d.ts +410 -155
  22. package/lib/staticPages.es.js +233 -67
  23. package/lib/staticPages.js +232 -70
  24. package/lib/tokens.browser.js +15 -1
  25. package/lib/tokens.d.ts +90 -32
  26. package/lib/tokens.es.js +15 -1
  27. package/lib/tokens.js +19 -0
  28. package/lib/utils/cacheKey.d.ts +4 -6
  29. package/lib/utils/cacheKey.es.js +8 -3
  30. package/lib/utils/cacheKey.js +8 -2
  31. package/lib/utils/getPageRenderMode.browser.js +14 -2
  32. package/lib/utils/getPageRenderMode.d.ts +8 -3
  33. package/lib/utils/getPageRenderMode.es.js +14 -2
  34. package/lib/utils/getPageRenderMode.js +14 -2
  35. 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, STATIC_PAGES_GET_CACHE_KEY_TOKEN } from '../staticPages';
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 path: string;
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({ getCacheKey, requestManager, response, responseManager, environmentManager, userAgent, logger, cache, modifyCache, shouldUseCache, backgroundFetchService, options, cache5xxResponse, }: {
31
- getCacheKey: ExtractDependencyType<typeof STATIC_PAGES_GET_CACHE_KEY_TOKEN>;
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
- // It is critical to ignore cached Set-Cookie header and use fresh one from current request
2
- // COMMAND_LINE_EXECUTION_END_TOKEN with fresh server timings will not works for responses from cache
3
- const HEADERS_BLACKLIST = ['Set-Cookie', 'server-timing'];
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
- path;
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({ getCacheKey, requestManager, response, responseManager, environmentManager, userAgent, logger, cache, modifyCache, shouldUseCache, backgroundFetchService, options, cache5xxResponse, }) {
19
- this.key = getCacheKey();
20
- this.path = requestManager.getParsedUrl().pathname;
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
- key: this.key,
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 { status, headers, body } = cacheEntry;
60
+ const { ttl, allowStale } = this.options;
61
+ const { status, headers, body, source, updatedAt } = cacheEntry;
48
62
  const isOutdated = this.cacheOutdated(cacheEntry);
49
- const currentHeaders = this.responseManager.getHeaders();
50
- if (!isOutdated) {
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
- key: this.key,
81
+ cacheKey: this.cacheKey,
82
+ stale: isStale,
54
83
  });
55
- HEADERS_BLACKLIST.forEach((header) => {
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
- delete headers[header];
94
+ allowedHeaders[header] = headers[header];
58
95
  }
59
- if (currentHeaders[header]) {
60
- headers[header] = currentHeaders[header];
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
- .headers(headers)
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
- key: this.key,
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
- key: this.key,
92
- path: this.path,
93
- port: this.port,
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
- key: this.key,
160
+ cacheKey: this.cacheKey,
104
161
  });
105
162
  return;
106
163
  }
107
- this.setCache(response);
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
- return this.cache.has(this.path) && this.cache.get(this.path).has(this.key);
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
- return this.cache.get(this.path).get(this.key);
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
- key: this.key,
203
+ cacheKey: this.cacheKey,
120
204
  });
121
- if (!this.cache.has(this.path)) {
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;