koa-classic-server 2.6.0 → 3.0.0-alpha.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 (40) hide show
  1. package/README.md +68 -10
  2. package/__tests__/caching-headers.test.js +30 -30
  3. package/__tests__/compression-fixtures/data.json +1 -0
  4. package/__tests__/compression-fixtures/large.txt +1 -0
  5. package/__tests__/compression-fixtures/small.txt +1 -0
  6. package/__tests__/compression.test.js +270 -0
  7. package/__tests__/customTest/serversToLoad.util.js +1 -1
  8. package/__tests__/deprecation-warnings.test.js +71 -183
  9. package/__tests__/dt-unknown.test.js +635 -0
  10. package/__tests__/hidden-fixtures/.dot-dir/inside.txt +1 -0
  11. package/__tests__/hidden-fixtures/.env +2 -0
  12. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +1 -0
  13. package/__tests__/hidden-fixtures/config/secrets/password.txt +1 -0
  14. package/__tests__/hidden-fixtures/data.key +1 -0
  15. package/__tests__/hidden-fixtures/file.secret +1 -0
  16. package/__tests__/hidden-fixtures/index.html +1 -0
  17. package/__tests__/hidden-fixtures/normal.txt +1 -0
  18. package/__tests__/hidden-fixtures/subdir/.env +1 -0
  19. package/__tests__/hidden-fixtures/subdir/regular.txt +1 -0
  20. package/__tests__/hidden-option.test.js +422 -0
  21. package/__tests__/index-option.test.js +18 -16
  22. package/__tests__/index.test.js +8 -4
  23. package/__tests__/range-fixtures/sample.txt +1 -0
  24. package/__tests__/range.test.js +223 -0
  25. package/__tests__/security-headers.test.js +153 -0
  26. package/__tests__/security.test.js +145 -159
  27. package/__tests__/server-cache-fixtures/large.txt +1 -0
  28. package/__tests__/server-cache-fixtures/small.txt +1 -0
  29. package/__tests__/server-cache.test.js +423 -0
  30. package/__tests__/symlink.test.js +8 -5
  31. package/docs/ACTION_PLAN.md +293 -0
  32. package/docs/CHANGELOG.md +118 -0
  33. package/docs/EXAMPLES_INDEX_OPTION.md +2 -2
  34. package/docs/FLOW_DIAGRAM.md +13 -13
  35. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +864 -0
  36. package/docs/PERFORMANCE_COMPARISON.md +7 -7
  37. package/eslint.config.mjs +17 -0
  38. package/index.cjs +1114 -378
  39. package/index.mjs +1 -5
  40. package/package.json +4 -1
@@ -0,0 +1,423 @@
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, { showDirContents: 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
+ showDirContents: 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
+ // ─── rawFile + Range requests ─────────────────────────────────────────────────
160
+
161
+ describe('serverCache.rawFile — Range request served from buffer', () => {
162
+ let server;
163
+ beforeAll(() => {
164
+ server = createApp({ serverCache: { rawFile: { enabled: true } } });
165
+ });
166
+ afterAll(() => server.close());
167
+
168
+ test('Range request returns 206 with correct slice', async () => {
169
+ // Warm cache first
170
+ await supertest(server).get('/small.txt').set('Accept-Encoding', 'identity');
171
+
172
+ const res = await supertest(server)
173
+ .get('/small.txt')
174
+ .set('Range', 'bytes=0-9')
175
+ .set('Accept-Encoding', 'identity');
176
+ expect(res.status).toBe(206);
177
+ expect(res.text).toBe('A'.repeat(10));
178
+ expect(res.headers['content-length']).toBe('10');
179
+ });
180
+
181
+ test('Range request returns correct Content-Range header', async () => {
182
+ const res = await supertest(server)
183
+ .get('/small.txt')
184
+ .set('Range', 'bytes=10-19')
185
+ .set('Accept-Encoding', 'identity');
186
+ expect(res.status).toBe(206);
187
+ expect(res.headers['content-range']).toBe('bytes 10-19/64');
188
+ });
189
+ });
190
+
191
+ // ─── rawFile + compression ────────────────────────────────────────────────────
192
+
193
+ describe('serverCache.rawFile + compression — rawFile feeds compressedFile', () => {
194
+ let server;
195
+ beforeAll(() => {
196
+ server = createApp({
197
+ compression: { minSize: false }, // compress small.txt too
198
+ serverCache: { rawFile: { enabled: true } }
199
+ });
200
+ });
201
+ afterAll(() => server.close());
202
+
203
+ test('compressed response is served correctly when rawFile cache is warm', async () => {
204
+ // Warm rawFile cache
205
+ await supertest(server).get('/small.txt').set('Accept-Encoding', 'identity');
206
+
207
+ // Request compressed version — rawFile buffer used as compression input
208
+ const res = await supertest(server)
209
+ .get('/small.txt')
210
+ .set('Accept-Encoding', 'gzip');
211
+ expect(res.status).toBe(200);
212
+ expect(res.headers['content-encoding']).toBe('gzip');
213
+ // supertest auto-decompresses gzip
214
+ expect(res.text).toBe('A'.repeat(64));
215
+ });
216
+ });
217
+
218
+ // ─── compressedFile cache — default enabled ───────────────────────────────────
219
+
220
+ describe('serverCache.compressedFile — enabled by default', () => {
221
+ let server;
222
+ beforeAll(() => { server = createApp(); });
223
+ afterAll(() => server.close());
224
+
225
+ test('large.txt compressed response has Content-Length (buffered, not streamed)', async () => {
226
+ const res = await supertest(server)
227
+ .get('/large.txt')
228
+ .set('Accept-Encoding', 'gzip');
229
+ expect(res.status).toBe(200);
230
+ expect(res.headers['content-encoding']).toBe('gzip');
231
+ expect(res.headers['content-length']).toBeDefined();
232
+ expect(Number(res.headers['content-length'])).toBeGreaterThan(0);
233
+ });
234
+ });
235
+
236
+ // ─── compressedFile cache — disabled (streaming) ──────────────────────────────
237
+
238
+ describe('serverCache.compressedFile — disabled (streaming mode)', () => {
239
+ let server;
240
+ beforeAll(() => {
241
+ server = createApp({ serverCache: { compressedFile: { enabled: false } } });
242
+ });
243
+ afterAll(() => server.close());
244
+
245
+ test('compressed response has no Content-Length in streaming mode', async () => {
246
+ const res = await supertest(server)
247
+ .get('/large.txt')
248
+ .set('Accept-Encoding', 'gzip');
249
+ expect(res.status).toBe(200);
250
+ expect(res.headers['content-encoding']).toBe('gzip');
251
+ expect(res.headers['content-length']).toBeUndefined();
252
+ });
253
+
254
+ test('streaming body is correctly decompressed', async () => {
255
+ const res = await supertest(server)
256
+ .get('/large.txt')
257
+ .set('Accept-Encoding', 'gzip');
258
+ expect(res.text).toBe('B'.repeat(2048));
259
+ });
260
+ });
261
+
262
+ // ─── LFU eviction ────────────────────────────────────────────────────────────
263
+
264
+ describe('serverCache.rawFile — LFU eviction when maxSize exceeded', () => {
265
+ let tmpDir;
266
+ let server;
267
+
268
+ beforeAll(() => {
269
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-lfu-test-'));
270
+ // fileA: 100 bytes, fileB: 100 bytes — cache maxSize: 150 bytes
271
+ // After caching A (100 bytes) and B (100 bytes), total = 200 > 150.
272
+ // LFU should evict A (hits=1) when B is added if A has fewer hits than B.
273
+ fs.writeFileSync(path.join(tmpDir, 'fileA.txt'), 'A'.repeat(100));
274
+ fs.writeFileSync(path.join(tmpDir, 'fileB.txt'), 'B'.repeat(100));
275
+ fs.writeFileSync(path.join(tmpDir, 'fileC.txt'), 'C'.repeat(100));
276
+
277
+ const app = new Koa();
278
+ app.use(koaClassicServer(tmpDir, {
279
+ showDirContents: false,
280
+ serverCache: {
281
+ rawFile: {
282
+ enabled: true,
283
+ maxSize: 150, // fits 1 file (100 bytes) + half of another
284
+ maxFileSize: 200,
285
+ warnInterval: false, // suppress warnings during test
286
+ }
287
+ }
288
+ }));
289
+ server = app.listen();
290
+ });
291
+
292
+ afterAll(() => {
293
+ server.close();
294
+ fs.rmSync(tmpDir, { recursive: true, force: true });
295
+ });
296
+
297
+ test('all files return correct content even when eviction occurs', async () => {
298
+ // Access A twice (hits=2), then B once (hits=1), then C once
299
+ // When C is added, LFU should evict B (lowest hits among cached entries)
300
+ const resA1 = await supertest(server).get('/fileA.txt').set('Accept-Encoding', 'identity');
301
+ const resA2 = await supertest(server).get('/fileA.txt').set('Accept-Encoding', 'identity');
302
+ const resB = await supertest(server).get('/fileB.txt').set('Accept-Encoding', 'identity');
303
+ const resC = await supertest(server).get('/fileC.txt').set('Accept-Encoding', 'identity');
304
+
305
+ expect(resA1.text).toBe('A'.repeat(100));
306
+ expect(resA2.text).toBe('A'.repeat(100));
307
+ expect(resB.text).toBe('B'.repeat(100));
308
+ expect(resC.text).toBe('C'.repeat(100));
309
+ });
310
+ });
311
+
312
+ // ─── warnInterval ────────────────────────────────────────────────────────────
313
+
314
+ describe('serverCache.rawFile — warnInterval throttles warnings', () => {
315
+ test('warnInterval: false suppresses all warnings', async () => {
316
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
317
+
318
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-warn-test-'));
319
+ fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'A'.repeat(100));
320
+ fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'B'.repeat(100));
321
+
322
+ const app = new Koa();
323
+ app.use(koaClassicServer(tmpDir, {
324
+ showDirContents: false,
325
+ serverCache: {
326
+ rawFile: {
327
+ enabled: true,
328
+ maxSize: 50, // too small to fit either file → eviction on every request
329
+ maxFileSize: 200,
330
+ warnInterval: false, // no warnings
331
+ }
332
+ }
333
+ }));
334
+ const server = app.listen();
335
+
336
+ await supertest(server).get('/a.txt').set('Accept-Encoding', 'identity');
337
+ await supertest(server).get('/b.txt').set('Accept-Encoding', 'identity');
338
+
339
+ const cacheWarnings = warnSpy.mock.calls.filter(c =>
340
+ c[0] && c[0].toString().includes('serverCache.rawFile')
341
+ );
342
+ expect(cacheWarnings.length).toBe(0);
343
+
344
+ server.close();
345
+ fs.rmSync(tmpDir, { recursive: true, force: true });
346
+ warnSpy.mockRestore();
347
+ });
348
+ });
349
+
350
+ // ─── rawFile buffer passed to template render ─────────────────────────────────
351
+
352
+ describe('serverCache.rawFile — buffer passed as 4th param to render function', () => {
353
+ let tmpDir;
354
+ let server;
355
+ let capturedBuffer;
356
+
357
+ beforeAll(() => {
358
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-render-buf-'));
359
+ fs.writeFileSync(path.join(tmpDir, 'page.tmpl'), 'hello from template');
360
+
361
+ const app = new Koa();
362
+ app.use(koaClassicServer(tmpDir, {
363
+ showDirContents: false,
364
+ serverCache: { rawFile: { enabled: true } },
365
+ template: {
366
+ ext: ['tmpl'],
367
+ render: async (ctx, next, filePath, buffer) => {
368
+ capturedBuffer = buffer;
369
+ const content = buffer
370
+ ? buffer.toString('utf-8')
371
+ : await fs.promises.readFile(filePath, 'utf-8');
372
+ ctx.type = 'text/plain';
373
+ ctx.body = content;
374
+ }
375
+ }
376
+ }));
377
+ server = app.listen();
378
+ });
379
+
380
+ afterAll(() => {
381
+ server.close();
382
+ fs.rmSync(tmpDir, { recursive: true, force: true });
383
+ });
384
+
385
+ test('render receives buffer on first request (cache miss populates buffer)', async () => {
386
+ capturedBuffer = undefined;
387
+ const res = await supertest(server).get('/page.tmpl');
388
+ expect(res.status).toBe(200);
389
+ expect(res.text).toBe('hello from template');
390
+ // Buffer should be populated (cache miss still reads file and caches it)
391
+ expect(Buffer.isBuffer(capturedBuffer)).toBe(true);
392
+ expect(capturedBuffer.toString('utf-8')).toBe('hello from template');
393
+ });
394
+
395
+ test('render receives buffer on subsequent request (cache hit)', async () => {
396
+ capturedBuffer = undefined;
397
+ const res = await supertest(server).get('/page.tmpl');
398
+ expect(res.status).toBe(200);
399
+ expect(Buffer.isBuffer(capturedBuffer)).toBe(true);
400
+ expect(capturedBuffer.toString('utf-8')).toBe('hello from template');
401
+ });
402
+
403
+ test('render receives null when rawFile cache is disabled', async () => {
404
+ let bufferWhenDisabled;
405
+ const appNoCache = new Koa();
406
+ appNoCache.use(koaClassicServer(tmpDir, {
407
+ showDirContents: false,
408
+ // rawFile.enabled: false by default
409
+ template: {
410
+ ext: ['tmpl'],
411
+ render: async (ctx, next, filePath, buffer) => {
412
+ bufferWhenDisabled = buffer;
413
+ ctx.type = 'text/plain';
414
+ ctx.body = 'ok';
415
+ }
416
+ }
417
+ }));
418
+ const s = appNoCache.listen();
419
+ await supertest(s).get('/page.tmpl');
420
+ s.close();
421
+ expect(bufferWhenDisabled).toBeNull();
422
+ });
423
+ });
@@ -161,8 +161,9 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
161
161
  test('GET / serves symlinked index.ejs, not directory listing', async () => {
162
162
  const res = await request.get('/');
163
163
  expect(res.status).toBe(200);
164
- expect(res.text).toContain('EJS via Symlink');
165
- expect(res.text).not.toContain('Index of');
164
+ const body = res.text !== undefined ? res.text : res.body.toString('utf8');
165
+ expect(body).toContain('EJS via Symlink');
166
+ expect(body).not.toContain('Index of');
166
167
  });
167
168
  });
168
169
 
@@ -187,7 +188,8 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
187
188
  test('GET /index.ejs via symlink returns 200', async () => {
188
189
  const res = await request.get('/index.ejs');
189
190
  expect(res.status).toBe(200);
190
- expect(res.text).toContain('EJS via Symlink');
191
+ const body = res.text !== undefined ? res.text : res.body.toString('utf8');
192
+ expect(body).toContain('EJS via Symlink');
191
193
  });
192
194
 
193
195
  test('GET /linked-style.css via symlink returns 200 with correct mime', async () => {
@@ -431,8 +433,9 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
431
433
  test('GET / finds symlinked index.ejs via RegExp pattern', async () => {
432
434
  const res = await request.get('/');
433
435
  expect(res.status).toBe(200);
434
- expect(res.text).toContain('EJS via Symlink');
435
- expect(res.text).not.toContain('Index of');
436
+ const body = res.text !== undefined ? res.text : res.body.toString('utf8');
437
+ expect(body).toContain('EJS via Symlink');
438
+ expect(body).not.toContain('Index of');
436
439
  });
437
440
  });
438
441
  });