@tramvai/module-page-render-mode 7.5.3 → 7.11.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,367 @@
|
|
|
1
|
+
import { promises } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import pLimit from 'p-limit';
|
|
4
|
+
import { DoubleLinkedList } from '@tramvai/core';
|
|
5
|
+
import { parseCacheKey, getCacheKey } from '../utils/cacheKey.es.js';
|
|
6
|
+
|
|
7
|
+
class FileSystemCache {
|
|
8
|
+
directory;
|
|
9
|
+
maxSize;
|
|
10
|
+
ttl;
|
|
11
|
+
allowStale;
|
|
12
|
+
log;
|
|
13
|
+
cache;
|
|
14
|
+
list;
|
|
15
|
+
// save double linked list nodes for O(1) access when moving nodes
|
|
16
|
+
nodes;
|
|
17
|
+
staticPages;
|
|
18
|
+
metrics;
|
|
19
|
+
limit = pLimit(8);
|
|
20
|
+
constructor({ directory, maxSize, ttl, allowStale, logger, metrics, }) {
|
|
21
|
+
this.directory = directory;
|
|
22
|
+
this.maxSize = maxSize;
|
|
23
|
+
this.ttl = ttl;
|
|
24
|
+
this.allowStale = allowStale;
|
|
25
|
+
this.log = logger('static-pages:fs-cache');
|
|
26
|
+
this.cache = new Map();
|
|
27
|
+
this.list = new DoubleLinkedList();
|
|
28
|
+
this.nodes = new Map();
|
|
29
|
+
this.staticPages = [];
|
|
30
|
+
this.metrics = metrics;
|
|
31
|
+
}
|
|
32
|
+
async init() {
|
|
33
|
+
this.log.info({
|
|
34
|
+
event: 'init-start',
|
|
35
|
+
directory: this.directory,
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
await this.scanDirectory();
|
|
39
|
+
this.log.info({
|
|
40
|
+
event: 'init-complete',
|
|
41
|
+
size: this.cache.size,
|
|
42
|
+
totalBytes: this.getTotalBytes(),
|
|
43
|
+
});
|
|
44
|
+
this.updateMetrics();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (process.env.NODE_ENV === 'development' && error?.code === 'ENOENT') {
|
|
48
|
+
// in development mode, static directory path includes unique build id and can be missing on first run, so just create it
|
|
49
|
+
await promises.mkdir(this.directory, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
this.log.warn({
|
|
53
|
+
event: 'init-error',
|
|
54
|
+
message: error?.code === 'ENOENT'
|
|
55
|
+
? `"${this.directory}" directory is not found. To effectively use file system cache, run "tramvai static {appName} --buildType=none" after application build,
|
|
56
|
+
and copy "dist/static" folder into the Docker image, to ensure the pages cache is populated on the first run.`
|
|
57
|
+
: 'Failed to initialize file system cache',
|
|
58
|
+
error: error,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// eslint-disable-next-line max-statements
|
|
64
|
+
async get(cacheKey) {
|
|
65
|
+
const metadata = this.cache.get(cacheKey);
|
|
66
|
+
if (!metadata) {
|
|
67
|
+
this.metrics.miss.inc();
|
|
68
|
+
this.log.debug({
|
|
69
|
+
event: 'get-miss',
|
|
70
|
+
cacheKey,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const isStale = Date.now() - metadata.mtime > this.ttl;
|
|
75
|
+
if (isStale && !this.allowStale) {
|
|
76
|
+
this.log.debug({
|
|
77
|
+
event: 'get-miss',
|
|
78
|
+
stale: true,
|
|
79
|
+
cacheKey,
|
|
80
|
+
});
|
|
81
|
+
this.metrics.miss.inc();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// TODO: race condition protection, lock file or just return null?
|
|
85
|
+
try {
|
|
86
|
+
const html = await promises.readFile(metadata.filePath, 'utf-8');
|
|
87
|
+
const response = JSON.parse(await promises.readFile(metadata.filePath.replace(/\.html$/, '.json'), 'utf-8'));
|
|
88
|
+
// async cleanup stale entry, don't block response
|
|
89
|
+
if (isStale) {
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
this.log.debug({
|
|
92
|
+
event: 'delete-stale',
|
|
93
|
+
cacheKey,
|
|
94
|
+
});
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
96
|
+
const metadata = this.cache.get(cacheKey);
|
|
97
|
+
if (metadata) {
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
99
|
+
const isStale = Date.now() - metadata.mtime > this.ttl;
|
|
100
|
+
// prevent to delete entry if it was updated while timeout was waiting
|
|
101
|
+
if (isStale) {
|
|
102
|
+
this.delete(cacheKey);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}, 1).unref();
|
|
106
|
+
}
|
|
107
|
+
this.log.debug({
|
|
108
|
+
event: 'get-hit',
|
|
109
|
+
cacheKey,
|
|
110
|
+
});
|
|
111
|
+
this.metrics.hit.inc();
|
|
112
|
+
this.moveToHead(metadata.cacheKey);
|
|
113
|
+
return {
|
|
114
|
+
body: html,
|
|
115
|
+
updatedAt: metadata.mtime,
|
|
116
|
+
headers: response.headers,
|
|
117
|
+
status: response.status,
|
|
118
|
+
source: 'fs',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
this.log.warn({
|
|
123
|
+
event: 'get-error',
|
|
124
|
+
cacheKey,
|
|
125
|
+
error: error,
|
|
126
|
+
});
|
|
127
|
+
// if file is missing or corrupted, remove from cache
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
this.log.debug({
|
|
130
|
+
event: 'delete-broken',
|
|
131
|
+
cacheKey,
|
|
132
|
+
});
|
|
133
|
+
this.delete(cacheKey);
|
|
134
|
+
}, 1).unref();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async set(cacheKey, cacheEntry) {
|
|
138
|
+
this.log.debug({
|
|
139
|
+
event: 'set-start',
|
|
140
|
+
cacheKey,
|
|
141
|
+
});
|
|
142
|
+
const [pathname, key] = parseCacheKey(cacheKey);
|
|
143
|
+
const filename = key ? `index.${key}.html` : 'index.html';
|
|
144
|
+
const metaFilename = key ? `index.${key}.json` : 'index.json';
|
|
145
|
+
const filePath = path.join(this.directory, pathname, filename);
|
|
146
|
+
const metaFilePath = path.join(this.directory, pathname, metaFilename);
|
|
147
|
+
try {
|
|
148
|
+
await promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
149
|
+
await promises.writeFile(filePath, cacheEntry.body, 'utf-8');
|
|
150
|
+
await promises.writeFile(metaFilePath, JSON.stringify({
|
|
151
|
+
headers: cacheEntry.headers,
|
|
152
|
+
status: cacheEntry.status,
|
|
153
|
+
}, null, 2), 'utf-8');
|
|
154
|
+
const metadata = {
|
|
155
|
+
filePath,
|
|
156
|
+
pathname,
|
|
157
|
+
key,
|
|
158
|
+
cacheKey,
|
|
159
|
+
size: Buffer.byteLength(cacheEntry.body, 'utf-8'),
|
|
160
|
+
mtime: cacheEntry.updatedAt,
|
|
161
|
+
};
|
|
162
|
+
this.cache.set(cacheKey, metadata);
|
|
163
|
+
this.moveToHead(metadata.cacheKey);
|
|
164
|
+
this.updateMetrics();
|
|
165
|
+
if (this.cache.size > this.maxSize) {
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
this.evictTail();
|
|
168
|
+
}, 1).unref();
|
|
169
|
+
}
|
|
170
|
+
this.log.debug({
|
|
171
|
+
event: 'set-success',
|
|
172
|
+
cacheKey,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
this.log.warn({
|
|
177
|
+
event: 'set-error',
|
|
178
|
+
cacheKey,
|
|
179
|
+
error: error,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
has(cacheKey) {
|
|
184
|
+
if (!this.cache.has(cacheKey)) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
const metadata = this.cache.get(cacheKey);
|
|
188
|
+
const isStale = Date.now() - metadata.mtime > this.ttl;
|
|
189
|
+
if (isStale && !this.allowStale) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
async delete(cacheKey) {
|
|
195
|
+
const metadata = this.cache.get(cacheKey);
|
|
196
|
+
if (!metadata) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
this.log.debug({
|
|
200
|
+
event: 'delete-start',
|
|
201
|
+
filePath: metadata.filePath,
|
|
202
|
+
});
|
|
203
|
+
const node = this.nodes.get(cacheKey);
|
|
204
|
+
this.cache.delete(cacheKey);
|
|
205
|
+
this.removeNode(node);
|
|
206
|
+
this.updateMetrics();
|
|
207
|
+
try {
|
|
208
|
+
await promises.unlink(metadata.filePath);
|
|
209
|
+
await promises.unlink(metadata.filePath.replace(/\.html$/, '.json'));
|
|
210
|
+
this.log.debug({
|
|
211
|
+
event: 'delete-success',
|
|
212
|
+
filePath: metadata.filePath,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
this.log.debug({
|
|
217
|
+
event: 'delete-error',
|
|
218
|
+
filePath: metadata.filePath,
|
|
219
|
+
error: error,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
async clear() {
|
|
225
|
+
this.log.debug({
|
|
226
|
+
event: 'clear-start',
|
|
227
|
+
});
|
|
228
|
+
await Promise.allSettled(Array.from(this.cache.keys()).map((cacheKey) => {
|
|
229
|
+
return this.limit(() => this.delete(cacheKey));
|
|
230
|
+
}));
|
|
231
|
+
this.log.debug({
|
|
232
|
+
event: 'clear-success',
|
|
233
|
+
});
|
|
234
|
+
this.updateMetrics();
|
|
235
|
+
}
|
|
236
|
+
get size() {
|
|
237
|
+
return this.cache.size;
|
|
238
|
+
}
|
|
239
|
+
dump() {
|
|
240
|
+
return Array.from(this.cache.entries());
|
|
241
|
+
}
|
|
242
|
+
async scanDirectory() {
|
|
243
|
+
try {
|
|
244
|
+
this.log.debug({
|
|
245
|
+
event: 'meta-read-start',
|
|
246
|
+
});
|
|
247
|
+
const meta = await promises.readFile(path.join(this.directory, 'meta.json'), 'utf-8');
|
|
248
|
+
this.staticPages = JSON.parse(meta).staticPages;
|
|
249
|
+
this.log.debug({
|
|
250
|
+
event: 'meta-read-success',
|
|
251
|
+
staticPages: this.staticPages,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
256
|
+
this.log.warn({
|
|
257
|
+
event: 'meta-read-error',
|
|
258
|
+
error: error,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const entries = await promises.readdir(this.directory, { withFileTypes: true, recursive: true });
|
|
263
|
+
const promises$1 = [];
|
|
264
|
+
let pages = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.html'));
|
|
265
|
+
if (pages.length > this.maxSize) {
|
|
266
|
+
this.log.info({
|
|
267
|
+
event: 'too-many-pages-prerendered',
|
|
268
|
+
pagesCount: pages.length,
|
|
269
|
+
});
|
|
270
|
+
pages = pages.slice(0, this.maxSize);
|
|
271
|
+
}
|
|
272
|
+
for (const entry of pages) {
|
|
273
|
+
const fullPath = path.join(entry.parentPath, entry.name);
|
|
274
|
+
promises$1.push(this.limit(() => this.addFileToCache(fullPath)));
|
|
275
|
+
}
|
|
276
|
+
await Promise.all(promises$1);
|
|
277
|
+
}
|
|
278
|
+
async addFileToCache(filePath) {
|
|
279
|
+
try {
|
|
280
|
+
const stats = await promises.stat(filePath);
|
|
281
|
+
const relativePath = path.relative(this.directory, filePath);
|
|
282
|
+
// foo/index.html -> 'index'
|
|
283
|
+
// foo/index.mobile.html -> 'index.mobile'
|
|
284
|
+
const filename = path.basename(filePath, '.html');
|
|
285
|
+
// index -> ['index', '']
|
|
286
|
+
// index.mobile -> ['index', 'mobile']
|
|
287
|
+
const [, key = ''] = filename.split('.');
|
|
288
|
+
// index.html -> '/'
|
|
289
|
+
// foo/bar/index.html -> '/foo/bar/'
|
|
290
|
+
// foo/bar/index.mobile.html -> '/foo/bar/'
|
|
291
|
+
const pathname = path.join('/', relativePath.replace(path.basename(relativePath), ''), '/');
|
|
292
|
+
const cacheKey = getCacheKey({ pathname, key });
|
|
293
|
+
const metadata = {
|
|
294
|
+
filePath,
|
|
295
|
+
pathname,
|
|
296
|
+
key,
|
|
297
|
+
cacheKey,
|
|
298
|
+
size: stats.size,
|
|
299
|
+
// TODO: if file already stale on init, what to do?
|
|
300
|
+
mtime: stats.mtimeMs,
|
|
301
|
+
};
|
|
302
|
+
this.cache.set(cacheKey, metadata);
|
|
303
|
+
// order doesn't matter when initializing
|
|
304
|
+
const node = this.list.push(cacheKey);
|
|
305
|
+
this.nodes.set(cacheKey, node);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
this.log.warn({
|
|
309
|
+
event: 'process-file-error',
|
|
310
|
+
filePath,
|
|
311
|
+
error: error,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
moveToHead(cacheKey) {
|
|
316
|
+
if (this.list.start?.value === cacheKey) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const currentNode = this.nodes.get(cacheKey);
|
|
320
|
+
if (currentNode) {
|
|
321
|
+
this.removeNode(currentNode);
|
|
322
|
+
}
|
|
323
|
+
const node = this.list.unshift(cacheKey);
|
|
324
|
+
this.nodes.set(cacheKey, node);
|
|
325
|
+
}
|
|
326
|
+
removeNode(node) {
|
|
327
|
+
if (this.list.start?.value === node.value) {
|
|
328
|
+
this.list.start = node.next;
|
|
329
|
+
}
|
|
330
|
+
if (this.list.end?.value === node.value) {
|
|
331
|
+
this.list.end = node.prev;
|
|
332
|
+
}
|
|
333
|
+
if (node.prev) {
|
|
334
|
+
node.prev.next = node.next;
|
|
335
|
+
}
|
|
336
|
+
if (node.next) {
|
|
337
|
+
node.next.prev = node.prev;
|
|
338
|
+
}
|
|
339
|
+
this.list.length--;
|
|
340
|
+
this.nodes.delete(node.value);
|
|
341
|
+
}
|
|
342
|
+
async evictTail() {
|
|
343
|
+
if (!this.list.end) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const node = this.list.end;
|
|
347
|
+
const cacheKey = node.value;
|
|
348
|
+
this.log.debug({
|
|
349
|
+
event: 'evict',
|
|
350
|
+
cacheKey,
|
|
351
|
+
});
|
|
352
|
+
await this.delete(cacheKey);
|
|
353
|
+
}
|
|
354
|
+
getTotalBytes() {
|
|
355
|
+
let total = 0;
|
|
356
|
+
for (const metadata of this.cache.values()) {
|
|
357
|
+
total += metadata.size;
|
|
358
|
+
}
|
|
359
|
+
return total;
|
|
360
|
+
}
|
|
361
|
+
updateMetrics() {
|
|
362
|
+
this.metrics.size.set(this.cache.size);
|
|
363
|
+
this.metrics.bytes.set(this.getTotalBytes());
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export { FileSystemCache };
|