koa-classic-server 2.6.1 → 3.0.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 (57) hide show
  1. package/CLAUDE.md +101 -0
  2. package/README.md +564 -591
  3. package/__tests__/benchmark-results-v3.0.0.txt +372 -0
  4. package/__tests__/benchmark.js +1 -1
  5. package/__tests__/caching-headers.test.js +30 -30
  6. package/__tests__/compression-fixtures/data.json +1 -0
  7. package/__tests__/compression-fixtures/large.txt +1 -0
  8. package/__tests__/compression-fixtures/small.txt +1 -0
  9. package/__tests__/compression.test.js +284 -0
  10. package/__tests__/customTest/serversToLoad.util.js +5 -5
  11. package/__tests__/demo-regex-index.js +4 -4
  12. package/__tests__/deprecation-warnings.test.js +71 -183
  13. package/__tests__/directory-sorting-links.test.js +1 -1
  14. package/__tests__/dt-unknown.test.js +39 -28
  15. package/__tests__/ejs.test.js +1 -1
  16. package/__tests__/hidden-fixtures/.dot-dir/inside.txt +1 -0
  17. package/__tests__/hidden-fixtures/.env +2 -0
  18. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +1 -0
  19. package/__tests__/hidden-fixtures/config/secrets/password.txt +1 -0
  20. package/__tests__/hidden-fixtures/data.key +1 -0
  21. package/__tests__/hidden-fixtures/file.secret +1 -0
  22. package/__tests__/hidden-fixtures/index.html +1 -0
  23. package/__tests__/hidden-fixtures/normal.txt +1 -0
  24. package/__tests__/hidden-fixtures/subdir/.env +1 -0
  25. package/__tests__/hidden-fixtures/subdir/regular.txt +1 -0
  26. package/__tests__/hidden-option.test.js +407 -0
  27. package/__tests__/hideExtension.test.js +70 -13
  28. package/__tests__/index-option.test.js +18 -16
  29. package/__tests__/index.test.js +14 -10
  30. package/__tests__/listing.test.js +437 -0
  31. package/__tests__/logger.test.js +232 -0
  32. package/__tests__/range-fixtures/sample.txt +1 -0
  33. package/__tests__/range.test.js +223 -0
  34. package/__tests__/security-headers.test.js +165 -0
  35. package/__tests__/security.test.js +148 -162
  36. package/__tests__/server-cache-fixtures/large.txt +1 -0
  37. package/__tests__/server-cache-fixtures/small.txt +1 -0
  38. package/__tests__/server-cache.test.js +594 -0
  39. package/__tests__/symlink.test.js +18 -15
  40. package/__tests__/template-timeout.test.js +321 -0
  41. package/docs/ACTION_PLAN.md +293 -0
  42. package/docs/CHANGELOG.md +289 -0
  43. package/docs/CODE_REVIEW.md +2 -0
  44. package/docs/DOCUMENTATION.md +259 -32
  45. package/docs/EXAMPLES_INDEX_OPTION.md +3 -3
  46. package/docs/FLOW_DIAGRAM.md +15 -13
  47. package/docs/INDEX_OPTION_PRIORITY.md +2 -2
  48. package/docs/OPTIMIZATION_HTTP_CACHING.md +2 -0
  49. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +864 -0
  50. package/docs/PERFORMANCE_COMPARISON.md +7 -7
  51. package/docs/security_improvement_for_V3.md +421 -0
  52. package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +5 -5
  53. package/docs/template-engine/esempi-incrementali.js +1 -1
  54. package/eslint.config.mjs +17 -0
  55. package/index.cjs +1507 -429
  56. package/index.mjs +1 -5
  57. package/package.json +9 -1
@@ -0,0 +1,594 @@
1
+ /**
2
+ * serverCache tests for koa-classic-server
3
+ *
4
+ * Covers serverCache.rawFile and serverCache.compressedFile behaviour:
5
+ * - rawFile disabled by default; opt-in via { serverCache: { rawFile: { enabled: true } } }
6
+ * - compressedFile enabled by default
7
+ * - LFU eviction when maxSize is exceeded
8
+ * - warnInterval throttling of "maxSize reached" warnings
9
+ * - rawFile buffer fed to template render as 4th parameter
10
+ * - rawFile buffer used for Range (206) responses — zero additional disk I/O
11
+ * - rawFile buffer used as input to compression — avoids redundant readFile
12
+ * - Files exceeding maxFileSize are never cached (served via stream)
13
+ * - Cache invalidation when mtime or size changes
14
+ *
15
+ * Fixtures (server-cache-fixtures/):
16
+ * small.txt — 64 bytes of 'A'
17
+ * large.txt — 2048 bytes of 'B' (used for maxFileSize threshold tests)
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+ const Koa = require('koa');
24
+ const supertest = require('supertest');
25
+ const koaClassicServer = require('../index.cjs');
26
+
27
+ const fixturesDir = path.join(__dirname, 'server-cache-fixtures');
28
+
29
+ function createApp(opts = {}) {
30
+ const app = new Koa();
31
+ app.use(koaClassicServer(fixturesDir, { dirListing: { enabled: false }, ...opts }));
32
+ return app.listen();
33
+ }
34
+
35
+ // ─── Default behaviour ────────────────────────────────────────────────────────
36
+
37
+ describe('serverCache.rawFile — disabled by default', () => {
38
+ let server;
39
+ beforeAll(() => { server = createApp(); });
40
+ afterAll(() => server.close());
41
+
42
+ test('GET /small.txt returns 200 without rawFile cache', async () => {
43
+ const res = await supertest(server)
44
+ .get('/small.txt')
45
+ .set('Accept-Encoding', 'identity');
46
+ expect(res.status).toBe(200);
47
+ expect(res.text).toBe('A'.repeat(64));
48
+ });
49
+ });
50
+
51
+ // ─── rawFile cache enabled ────────────────────────────────────────────────────
52
+
53
+ describe('serverCache.rawFile — enabled', () => {
54
+ let server;
55
+ beforeAll(() => {
56
+ server = createApp({ serverCache: { rawFile: { enabled: true } } });
57
+ });
58
+ afterAll(() => server.close());
59
+
60
+ test('first request populates cache and returns correct body', async () => {
61
+ const res = await supertest(server)
62
+ .get('/small.txt')
63
+ .set('Accept-Encoding', 'identity');
64
+ expect(res.status).toBe(200);
65
+ expect(res.text).toBe('A'.repeat(64));
66
+ });
67
+
68
+ test('second request also returns correct body (served from cache)', async () => {
69
+ const res = await supertest(server)
70
+ .get('/small.txt')
71
+ .set('Accept-Encoding', 'identity');
72
+ expect(res.status).toBe(200);
73
+ expect(res.text).toBe('A'.repeat(64));
74
+ });
75
+
76
+ test('Content-Length matches file size', async () => {
77
+ const res = await supertest(server)
78
+ .get('/small.txt')
79
+ .set('Accept-Encoding', 'identity');
80
+ expect(Number(res.headers['content-length'])).toBe(64);
81
+ });
82
+ });
83
+
84
+ // ─── maxFileSize threshold ────────────────────────────────────────────────────
85
+
86
+ describe('serverCache.rawFile — maxFileSize', () => {
87
+ test('file exceeding maxFileSize is served via stream (not cached)', async () => {
88
+ // maxFileSize: 32 bytes — small.txt (64 bytes) exceeds this
89
+ const server = createApp({
90
+ serverCache: { rawFile: { enabled: true, maxFileSize: 32 } }
91
+ });
92
+ const res = await supertest(server)
93
+ .get('/small.txt')
94
+ .set('Accept-Encoding', 'identity');
95
+ server.close();
96
+ expect(res.status).toBe(200);
97
+ expect(res.text).toBe('A'.repeat(64));
98
+ });
99
+
100
+ test('file within maxFileSize is served correctly', async () => {
101
+ // maxFileSize: 128 bytes — small.txt (64 bytes) fits
102
+ const server = createApp({
103
+ serverCache: { rawFile: { enabled: true, maxFileSize: 128 } }
104
+ });
105
+ const res = await supertest(server)
106
+ .get('/small.txt')
107
+ .set('Accept-Encoding', 'identity');
108
+ server.close();
109
+ expect(res.status).toBe(200);
110
+ expect(res.text).toBe('A'.repeat(64));
111
+ expect(Number(res.headers['content-length'])).toBe(64);
112
+ });
113
+ });
114
+
115
+ // ─── Cache invalidation ───────────────────────────────────────────────────────
116
+
117
+ describe('serverCache.rawFile — cache invalidation on file change', () => {
118
+ let tmpDir;
119
+ let server;
120
+
121
+ beforeAll(() => {
122
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-cache-inval-'));
123
+ fs.writeFileSync(path.join(tmpDir, 'dynamic.txt'), 'version-1');
124
+
125
+ const app = new Koa();
126
+ app.use(koaClassicServer(tmpDir, {
127
+ dirListing: { enabled: false },
128
+ serverCache: { rawFile: { enabled: true } }
129
+ }));
130
+ server = app.listen();
131
+ });
132
+
133
+ afterAll(() => {
134
+ server.close();
135
+ fs.rmSync(tmpDir, { recursive: true, force: true });
136
+ });
137
+
138
+ test('initial content is served correctly', async () => {
139
+ const res = await supertest(server)
140
+ .get('/dynamic.txt')
141
+ .set('Accept-Encoding', 'identity');
142
+ expect(res.status).toBe(200);
143
+ expect(res.text).toBe('version-1');
144
+ });
145
+
146
+ test('updated file content is served after cache invalidation', async () => {
147
+ // Wait 10ms to ensure mtime changes on filesystems with 1ms resolution
148
+ await new Promise(r => setTimeout(r, 10));
149
+ fs.writeFileSync(path.join(tmpDir, 'dynamic.txt'), 'version-2');
150
+
151
+ const res = await supertest(server)
152
+ .get('/dynamic.txt')
153
+ .set('Accept-Encoding', 'identity');
154
+ expect(res.status).toBe(200);
155
+ expect(res.text).toBe('version-2');
156
+ });
157
+ });
158
+
159
+ // ─── maxAge: factory validation ───────────────────────────────────────────────
160
+
161
+ describe('serverCache.*.maxAge — factory validation', () => {
162
+ test('rejects negative rawFile.maxAge', () => {
163
+ expect(() => koaClassicServer(fixturesDir, {
164
+ serverCache: { rawFile: { enabled: true, maxAge: -1 } }
165
+ })).toThrow(/rawFile\.maxAge must be a finite number/);
166
+ });
167
+
168
+ test('rejects NaN rawFile.maxAge', () => {
169
+ expect(() => koaClassicServer(fixturesDir, {
170
+ serverCache: { rawFile: { enabled: true, maxAge: NaN } }
171
+ })).toThrow(/rawFile\.maxAge must be a finite number/);
172
+ });
173
+
174
+ test('rejects Infinity rawFile.maxAge', () => {
175
+ expect(() => koaClassicServer(fixturesDir, {
176
+ serverCache: { rawFile: { enabled: true, maxAge: Infinity } }
177
+ })).toThrow(/rawFile\.maxAge must be a finite number/);
178
+ });
179
+
180
+ test('rejects string rawFile.maxAge', () => {
181
+ expect(() => koaClassicServer(fixturesDir, {
182
+ serverCache: { rawFile: { enabled: true, maxAge: '5000' } }
183
+ })).toThrow(/rawFile\.maxAge must be a finite number/);
184
+ });
185
+
186
+ test('rejects negative compressedFile.maxAge', () => {
187
+ expect(() => koaClassicServer(fixturesDir, {
188
+ serverCache: { compressedFile: { maxAge: -1 } }
189
+ })).toThrow(/compressedFile\.maxAge must be a finite number/);
190
+ });
191
+
192
+ test('accepts 0 (disabled) and positive integers', () => {
193
+ expect(() => koaClassicServer(fixturesDir, {
194
+ serverCache: { rawFile: { enabled: true, maxAge: 0 } }
195
+ })).not.toThrow();
196
+ expect(() => koaClassicServer(fixturesDir, {
197
+ serverCache: { rawFile: { enabled: true, maxAge: 1000 } }
198
+ })).not.toThrow();
199
+ });
200
+ });
201
+
202
+ // ─── maxAge: rawFile staleness ────────────────────────────────────────────────
203
+
204
+ describe('serverCache.rawFile.maxAge — time-based staleness', () => {
205
+ // Verify maxAge triggers a fresh disk read even when mtime+size are unchanged.
206
+ // Simulates the NFS attribute-cache scenario by manually restoring mtime after
207
+ // editing the file, so the mtime-based invariant alone cannot detect the change.
208
+ let tmpDir;
209
+ let filePath;
210
+ let server;
211
+
212
+ beforeAll(() => {
213
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-maxage-raw-'));
214
+ filePath = path.join(tmpDir, 'data.txt');
215
+ fs.writeFileSync(filePath, 'version-A');
216
+
217
+ const app = new Koa();
218
+ app.use(koaClassicServer(tmpDir, {
219
+ dirListing: { enabled: false },
220
+ serverCache: { rawFile: { enabled: true, maxAge: 100 } }
221
+ }));
222
+ server = app.listen();
223
+ });
224
+
225
+ afterAll(() => {
226
+ server.close();
227
+ fs.rmSync(tmpDir, { recursive: true, force: true });
228
+ });
229
+
230
+ test('within maxAge: cache is served (no disk re-read)', async () => {
231
+ const first = await supertest(server).get('/data.txt').set('Accept-Encoding', 'identity');
232
+ expect(first.text).toBe('version-A');
233
+
234
+ // Replace content but freeze mtime to its original value (simulates NFS lying about mtime).
235
+ // Length must stay identical so the size check doesn't invalidate independently of maxAge.
236
+ const originalStat = fs.statSync(filePath);
237
+ fs.writeFileSync(filePath, 'version-B'); // same 9 bytes as 'version-A'
238
+ fs.utimesSync(filePath, originalStat.atime, originalStat.mtime);
239
+
240
+ // Within maxAge → stale check passes → still serves cached A
241
+ const second = await supertest(server).get('/data.txt').set('Accept-Encoding', 'identity');
242
+ expect(second.text).toBe('version-A');
243
+ });
244
+
245
+ test('after maxAge: cache is refreshed (disk re-read) even when mtime+size unchanged', async () => {
246
+ await new Promise(r => setTimeout(r, 120)); // exceeds 100 ms maxAge
247
+
248
+ const third = await supertest(server).get('/data.txt').set('Accept-Encoding', 'identity');
249
+ expect(third.text).toBe('version-B');
250
+ });
251
+ });
252
+
253
+ describe('serverCache.rawFile.maxAge — disabled (0) preserves previous behaviour', () => {
254
+ let tmpDir;
255
+ let server;
256
+
257
+ beforeAll(() => {
258
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-maxage-off-'));
259
+ fs.writeFileSync(path.join(tmpDir, 'stable.txt'), 'unchanged');
260
+
261
+ const app = new Koa();
262
+ app.use(koaClassicServer(tmpDir, {
263
+ dirListing: { enabled: false },
264
+ serverCache: { rawFile: { enabled: true, maxAge: 0 } }
265
+ }));
266
+ server = app.listen();
267
+ });
268
+
269
+ afterAll(() => {
270
+ server.close();
271
+ fs.rmSync(tmpDir, { recursive: true, force: true });
272
+ });
273
+
274
+ test('cache served indefinitely while mtime+size unchanged', async () => {
275
+ await supertest(server).get('/stable.txt').set('Accept-Encoding', 'identity');
276
+ await new Promise(r => setTimeout(r, 150));
277
+ const res = await supertest(server).get('/stable.txt').set('Accept-Encoding', 'identity');
278
+ expect(res.status).toBe(200);
279
+ expect(res.text).toBe('unchanged');
280
+ });
281
+ });
282
+
283
+ // ─── maxAge: compressedFile staleness ─────────────────────────────────────────
284
+
285
+ describe('serverCache.compressedFile.maxAge — time-based staleness', () => {
286
+ let tmpDir;
287
+ let filePath;
288
+ let server;
289
+
290
+ beforeAll(() => {
291
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-maxage-cmp-'));
292
+ filePath = path.join(tmpDir, 'data.css');
293
+ fs.writeFileSync(filePath, 'a'.repeat(2048)); // above minFileSize=1024 to ensure compression
294
+
295
+ const app = new Koa();
296
+ app.use(koaClassicServer(tmpDir, {
297
+ dirListing: { enabled: false },
298
+ serverCache: { compressedFile: { maxAge: 100 } }
299
+ }));
300
+ server = app.listen();
301
+ });
302
+
303
+ afterAll(() => {
304
+ server.close();
305
+ fs.rmSync(tmpDir, { recursive: true, force: true });
306
+ });
307
+
308
+ test('after maxAge, compressed cache is rebuilt from updated content', async () => {
309
+ const first = await supertest(server).get('/data.css').set('Accept-Encoding', 'gzip');
310
+ expect(first.status).toBe(200);
311
+ expect(first.headers['content-encoding']).toBe('gzip');
312
+ expect(first.text).toBe('a'.repeat(2048));
313
+
314
+ // Replace content with same length, freeze mtime to simulate NFS staleness.
315
+ const originalStat = fs.statSync(filePath);
316
+ fs.writeFileSync(filePath, 'b'.repeat(2048));
317
+ fs.utimesSync(filePath, originalStat.atime, originalStat.mtime);
318
+
319
+ // Within maxAge → cached gzip of A still served.
320
+ const second = await supertest(server).get('/data.css').set('Accept-Encoding', 'gzip');
321
+ expect(second.text).toBe('a'.repeat(2048));
322
+
323
+ // After maxAge → cache refreshed → gzip of B served.
324
+ await new Promise(r => setTimeout(r, 120));
325
+ const third = await supertest(server).get('/data.css').set('Accept-Encoding', 'gzip');
326
+ expect(third.text).toBe('b'.repeat(2048));
327
+ });
328
+ });
329
+
330
+ // ─── rawFile + Range requests ─────────────────────────────────────────────────
331
+
332
+ describe('serverCache.rawFile — Range request served from buffer', () => {
333
+ let server;
334
+ beforeAll(() => {
335
+ server = createApp({ serverCache: { rawFile: { enabled: true } } });
336
+ });
337
+ afterAll(() => server.close());
338
+
339
+ test('Range request returns 206 with correct slice', async () => {
340
+ // Warm cache first
341
+ await supertest(server).get('/small.txt').set('Accept-Encoding', 'identity');
342
+
343
+ const res = await supertest(server)
344
+ .get('/small.txt')
345
+ .set('Range', 'bytes=0-9')
346
+ .set('Accept-Encoding', 'identity');
347
+ expect(res.status).toBe(206);
348
+ expect(res.text).toBe('A'.repeat(10));
349
+ expect(res.headers['content-length']).toBe('10');
350
+ });
351
+
352
+ test('Range request returns correct Content-Range header', async () => {
353
+ const res = await supertest(server)
354
+ .get('/small.txt')
355
+ .set('Range', 'bytes=10-19')
356
+ .set('Accept-Encoding', 'identity');
357
+ expect(res.status).toBe(206);
358
+ expect(res.headers['content-range']).toBe('bytes 10-19/64');
359
+ });
360
+ });
361
+
362
+ // ─── rawFile + compression ────────────────────────────────────────────────────
363
+
364
+ describe('serverCache.rawFile + compression — rawFile feeds compressedFile', () => {
365
+ let server;
366
+ beforeAll(() => {
367
+ server = createApp({
368
+ compression: { minFileSize: false }, // compress small.txt too
369
+ serverCache: { rawFile: { enabled: true } }
370
+ });
371
+ });
372
+ afterAll(() => server.close());
373
+
374
+ test('compressed response is served correctly when rawFile cache is warm', async () => {
375
+ // Warm rawFile cache
376
+ await supertest(server).get('/small.txt').set('Accept-Encoding', 'identity');
377
+
378
+ // Request compressed version — rawFile buffer used as compression input
379
+ const res = await supertest(server)
380
+ .get('/small.txt')
381
+ .set('Accept-Encoding', 'gzip');
382
+ expect(res.status).toBe(200);
383
+ expect(res.headers['content-encoding']).toBe('gzip');
384
+ // supertest auto-decompresses gzip
385
+ expect(res.text).toBe('A'.repeat(64));
386
+ });
387
+ });
388
+
389
+ // ─── compressedFile cache — default enabled ───────────────────────────────────
390
+
391
+ describe('serverCache.compressedFile — enabled by default', () => {
392
+ let server;
393
+ beforeAll(() => { server = createApp(); });
394
+ afterAll(() => server.close());
395
+
396
+ test('large.txt compressed response has Content-Length (buffered, not streamed)', async () => {
397
+ const res = await supertest(server)
398
+ .get('/large.txt')
399
+ .set('Accept-Encoding', 'gzip');
400
+ expect(res.status).toBe(200);
401
+ expect(res.headers['content-encoding']).toBe('gzip');
402
+ expect(res.headers['content-length']).toBeDefined();
403
+ expect(Number(res.headers['content-length'])).toBeGreaterThan(0);
404
+ });
405
+ });
406
+
407
+ // ─── compressedFile cache — disabled (streaming) ──────────────────────────────
408
+
409
+ describe('serverCache.compressedFile — disabled (streaming mode)', () => {
410
+ let server;
411
+ beforeAll(() => {
412
+ server = createApp({ serverCache: { compressedFile: { enabled: false } } });
413
+ });
414
+ afterAll(() => server.close());
415
+
416
+ test('compressed response has no Content-Length in streaming mode', async () => {
417
+ const res = await supertest(server)
418
+ .get('/large.txt')
419
+ .set('Accept-Encoding', 'gzip');
420
+ expect(res.status).toBe(200);
421
+ expect(res.headers['content-encoding']).toBe('gzip');
422
+ expect(res.headers['content-length']).toBeUndefined();
423
+ });
424
+
425
+ test('streaming body is correctly decompressed', async () => {
426
+ const res = await supertest(server)
427
+ .get('/large.txt')
428
+ .set('Accept-Encoding', 'gzip');
429
+ expect(res.text).toBe('B'.repeat(2048));
430
+ });
431
+ });
432
+
433
+ // ─── LFU eviction ────────────────────────────────────────────────────────────
434
+
435
+ describe('serverCache.rawFile — LFU eviction when maxSize exceeded', () => {
436
+ let tmpDir;
437
+ let server;
438
+
439
+ beforeAll(() => {
440
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-lfu-test-'));
441
+ // fileA: 100 bytes, fileB: 100 bytes — cache maxSize: 150 bytes
442
+ // After caching A (100 bytes) and B (100 bytes), total = 200 > 150.
443
+ // LFU should evict A (hits=1) when B is added if A has fewer hits than B.
444
+ fs.writeFileSync(path.join(tmpDir, 'fileA.txt'), 'A'.repeat(100));
445
+ fs.writeFileSync(path.join(tmpDir, 'fileB.txt'), 'B'.repeat(100));
446
+ fs.writeFileSync(path.join(tmpDir, 'fileC.txt'), 'C'.repeat(100));
447
+
448
+ const app = new Koa();
449
+ app.use(koaClassicServer(tmpDir, {
450
+ dirListing: { enabled: false },
451
+ serverCache: {
452
+ rawFile: {
453
+ enabled: true,
454
+ maxSize: 150, // fits 1 file (100 bytes) + half of another
455
+ maxFileSize: 200,
456
+ warnInterval: false, // suppress warnings during test
457
+ }
458
+ }
459
+ }));
460
+ server = app.listen();
461
+ });
462
+
463
+ afterAll(() => {
464
+ server.close();
465
+ fs.rmSync(tmpDir, { recursive: true, force: true });
466
+ });
467
+
468
+ test('all files return correct content even when eviction occurs', async () => {
469
+ // Access A twice (hits=2), then B once (hits=1), then C once
470
+ // When C is added, LFU should evict B (lowest hits among cached entries)
471
+ const resA1 = await supertest(server).get('/fileA.txt').set('Accept-Encoding', 'identity');
472
+ const resA2 = await supertest(server).get('/fileA.txt').set('Accept-Encoding', 'identity');
473
+ const resB = await supertest(server).get('/fileB.txt').set('Accept-Encoding', 'identity');
474
+ const resC = await supertest(server).get('/fileC.txt').set('Accept-Encoding', 'identity');
475
+
476
+ expect(resA1.text).toBe('A'.repeat(100));
477
+ expect(resA2.text).toBe('A'.repeat(100));
478
+ expect(resB.text).toBe('B'.repeat(100));
479
+ expect(resC.text).toBe('C'.repeat(100));
480
+ });
481
+ });
482
+
483
+ // ─── warnInterval ────────────────────────────────────────────────────────────
484
+
485
+ describe('serverCache.rawFile — warnInterval throttles warnings', () => {
486
+ test('warnInterval: false suppresses all warnings', async () => {
487
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
488
+
489
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-warn-test-'));
490
+ fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'A'.repeat(100));
491
+ fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'B'.repeat(100));
492
+
493
+ const app = new Koa();
494
+ app.use(koaClassicServer(tmpDir, {
495
+ dirListing: { enabled: false },
496
+ serverCache: {
497
+ rawFile: {
498
+ enabled: true,
499
+ maxSize: 50, // too small to fit either file → eviction on every request
500
+ maxFileSize: 200,
501
+ warnInterval: false, // no warnings
502
+ }
503
+ }
504
+ }));
505
+ const server = app.listen();
506
+
507
+ await supertest(server).get('/a.txt').set('Accept-Encoding', 'identity');
508
+ await supertest(server).get('/b.txt').set('Accept-Encoding', 'identity');
509
+
510
+ const cacheWarnings = warnSpy.mock.calls.filter(c =>
511
+ c[0] && c[0].toString().includes('serverCache.rawFile')
512
+ );
513
+ expect(cacheWarnings.length).toBe(0);
514
+
515
+ server.close();
516
+ fs.rmSync(tmpDir, { recursive: true, force: true });
517
+ warnSpy.mockRestore();
518
+ });
519
+ });
520
+
521
+ // ─── rawFile buffer passed to template render ─────────────────────────────────
522
+
523
+ describe('serverCache.rawFile — buffer passed as 4th param to render function', () => {
524
+ let tmpDir;
525
+ let server;
526
+ let capturedBuffer;
527
+
528
+ beforeAll(() => {
529
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-render-buf-'));
530
+ fs.writeFileSync(path.join(tmpDir, 'page.tmpl'), 'hello from template');
531
+
532
+ const app = new Koa();
533
+ app.use(koaClassicServer(tmpDir, {
534
+ dirListing: { enabled: false },
535
+ serverCache: { rawFile: { enabled: true } },
536
+ template: {
537
+ ext: ['tmpl'],
538
+ render: async (ctx, next, filePath, buffer) => {
539
+ capturedBuffer = buffer;
540
+ const content = buffer
541
+ ? buffer.toString('utf-8')
542
+ : await fs.promises.readFile(filePath, 'utf-8');
543
+ ctx.type = 'text/plain';
544
+ ctx.body = content;
545
+ }
546
+ }
547
+ }));
548
+ server = app.listen();
549
+ });
550
+
551
+ afterAll(() => {
552
+ server.close();
553
+ fs.rmSync(tmpDir, { recursive: true, force: true });
554
+ });
555
+
556
+ test('render receives buffer on first request (cache miss populates buffer)', async () => {
557
+ capturedBuffer = undefined;
558
+ const res = await supertest(server).get('/page.tmpl');
559
+ expect(res.status).toBe(200);
560
+ expect(res.text).toBe('hello from template');
561
+ // Buffer should be populated (cache miss still reads file and caches it)
562
+ expect(Buffer.isBuffer(capturedBuffer)).toBe(true);
563
+ expect(capturedBuffer.toString('utf-8')).toBe('hello from template');
564
+ });
565
+
566
+ test('render receives buffer on subsequent request (cache hit)', async () => {
567
+ capturedBuffer = undefined;
568
+ const res = await supertest(server).get('/page.tmpl');
569
+ expect(res.status).toBe(200);
570
+ expect(Buffer.isBuffer(capturedBuffer)).toBe(true);
571
+ expect(capturedBuffer.toString('utf-8')).toBe('hello from template');
572
+ });
573
+
574
+ test('render receives null when rawFile cache is disabled', async () => {
575
+ let bufferWhenDisabled;
576
+ const appNoCache = new Koa();
577
+ appNoCache.use(koaClassicServer(tmpDir, {
578
+ dirListing: { enabled: false },
579
+ // rawFile.enabled: false by default
580
+ template: {
581
+ ext: ['tmpl'],
582
+ render: async (ctx, next, filePath, buffer) => {
583
+ bufferWhenDisabled = buffer;
584
+ ctx.type = 'text/plain';
585
+ ctx.body = 'ok';
586
+ }
587
+ }
588
+ }));
589
+ const s = appNoCache.listen();
590
+ await supertest(s).get('/page.tmpl');
591
+ s.close();
592
+ expect(bufferWhenDisabled).toBeNull();
593
+ });
594
+ });