koa-classic-server 2.1.3 → 2.1.4

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.
@@ -0,0 +1,556 @@
1
+ /**
2
+ * HTTP Caching Headers Test
3
+ *
4
+ * Tests to verify correct caching behavior:
5
+ * - When enableCaching: true -> proper cache headers
6
+ * - When enableCaching: false -> anti-cache headers
7
+ */
8
+
9
+ const Koa = require('koa');
10
+ const supertest = require('supertest');
11
+ const koaClassicServer = require('../index.cjs');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ const TEST_DIR = path.join(__dirname, 'test-caching-headers');
16
+
17
+ describe('HTTP Caching Headers', () => {
18
+ beforeAll(() => {
19
+ // Create test directory and file
20
+ if (!fs.existsSync(TEST_DIR)) {
21
+ fs.mkdirSync(TEST_DIR, { recursive: true });
22
+ }
23
+ fs.writeFileSync(path.join(TEST_DIR, 'test.txt'), 'Test content for caching');
24
+ });
25
+
26
+ afterAll(() => {
27
+ // Cleanup
28
+ if (fs.existsSync(TEST_DIR)) {
29
+ fs.rmSync(TEST_DIR, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ describe('When caching is DISABLED (enableCaching: false)', () => {
34
+ let app;
35
+ let server;
36
+ let request;
37
+
38
+ beforeAll(() => {
39
+ app = new Koa();
40
+ app.use(koaClassicServer(TEST_DIR, {
41
+ enableCaching: false
42
+ }));
43
+ server = app.listen();
44
+ request = supertest(server);
45
+ });
46
+
47
+ afterAll(() => {
48
+ server.close();
49
+ });
50
+
51
+ test('Should return anti-cache headers', async () => {
52
+ const res = await request.get('/test.txt');
53
+
54
+ expect(res.status).toBe(200);
55
+
56
+ // Verify anti-cache headers are present
57
+ expect(res.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
58
+ expect(res.headers['pragma']).toBe('no-cache');
59
+ expect(res.headers['expires']).toBe('0');
60
+
61
+ // Verify caching headers are NOT present
62
+ expect(res.headers['etag']).toBeUndefined();
63
+ expect(res.headers['last-modified']).toBeUndefined();
64
+ });
65
+
66
+ test('Should NOT return 304 even with If-None-Match header', async () => {
67
+ // First request to get potential ETag (should not exist)
68
+ const res1 = await request.get('/test.txt');
69
+ expect(res1.status).toBe(200);
70
+
71
+ // Second request with If-None-Match (should still return 200)
72
+ const res2 = await request
73
+ .get('/test.txt')
74
+ .set('If-None-Match', '"fake-etag"');
75
+
76
+ expect(res2.status).toBe(200);
77
+ expect(res2.text).toBe('Test content for caching');
78
+ });
79
+
80
+ test('Should NOT return 304 even with If-Modified-Since header', async () => {
81
+ const futureDate = new Date(Date.now() + 86400000).toUTCString();
82
+
83
+ const res = await request
84
+ .get('/test.txt')
85
+ .set('If-Modified-Since', futureDate);
86
+
87
+ expect(res.status).toBe(200);
88
+ expect(res.text).toBe('Test content for caching');
89
+ });
90
+ });
91
+
92
+ describe('When caching is ENABLED (enableCaching: true)', () => {
93
+ let app;
94
+ let server;
95
+ let request;
96
+
97
+ beforeAll(() => {
98
+ app = new Koa();
99
+ app.use(koaClassicServer(TEST_DIR, {
100
+ enableCaching: true,
101
+ cacheMaxAge: 3600
102
+ }));
103
+ server = app.listen();
104
+ request = supertest(server);
105
+ });
106
+
107
+ afterAll(() => {
108
+ server.close();
109
+ });
110
+
111
+ test('Should return proper cache headers', async () => {
112
+ const res = await request.get('/test.txt');
113
+
114
+ expect(res.status).toBe(200);
115
+
116
+ // Verify cache headers are present
117
+ expect(res.headers['cache-control']).toBe('public, max-age=3600, must-revalidate');
118
+ expect(res.headers['etag']).toBeDefined();
119
+ expect(res.headers['last-modified']).toBeDefined();
120
+
121
+ // Verify anti-cache headers are NOT present
122
+ expect(res.headers['pragma']).toBeUndefined();
123
+ expect(res.headers['expires']).not.toBe('0');
124
+ });
125
+
126
+ test('Should return 304 with matching ETag', async () => {
127
+ // First request to get ETag
128
+ const res1 = await request.get('/test.txt');
129
+ expect(res1.status).toBe(200);
130
+ const etag = res1.headers['etag'];
131
+ expect(etag).toBeDefined();
132
+
133
+ // Second request with If-None-Match
134
+ const res2 = await request
135
+ .get('/test.txt')
136
+ .set('If-None-Match', etag);
137
+
138
+ expect(res2.status).toBe(304);
139
+ expect(res2.text).toBe('');
140
+ });
141
+
142
+ test('Should return 304 with If-Modified-Since (not modified)', async () => {
143
+ // Get file stats and add 1 second to ensure it's after file mtime
144
+ const stats = fs.statSync(path.join(TEST_DIR, 'test.txt'));
145
+ const futureDate = new Date(stats.mtime.getTime() + 1000).toUTCString();
146
+
147
+ // Request with If-Modified-Since header (1 second in future)
148
+ const res = await request
149
+ .get('/test.txt')
150
+ .set('If-Modified-Since', futureDate);
151
+
152
+ expect(res.status).toBe(304);
153
+ expect(res.text).toBe('');
154
+ });
155
+
156
+ test('Should return 200 with If-Modified-Since (file modified)', async () => {
157
+ // Use a date in the past
158
+ const pastDate = new Date(Date.now() - 86400000).toUTCString();
159
+
160
+ const res = await request
161
+ .get('/test.txt')
162
+ .set('If-Modified-Since', pastDate);
163
+
164
+ expect(res.status).toBe(200);
165
+ expect(res.text).toBe('Test content for caching');
166
+ });
167
+ });
168
+
169
+ describe('Default behavior (caching disabled by default)', () => {
170
+ let app;
171
+ let server;
172
+ let request;
173
+
174
+ beforeAll(() => {
175
+ app = new Koa();
176
+ // No options provided - should default to enableCaching: false
177
+ app.use(koaClassicServer(TEST_DIR));
178
+ server = app.listen();
179
+ request = supertest(server);
180
+ });
181
+
182
+ afterAll(() => {
183
+ server.close();
184
+ });
185
+
186
+ test('Should have anti-cache headers by default', async () => {
187
+ const res = await request.get('/test.txt');
188
+
189
+ expect(res.status).toBe(200);
190
+ expect(res.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
191
+ expect(res.headers['pragma']).toBe('no-cache');
192
+ expect(res.headers['expires']).toBe('0');
193
+ });
194
+ });
195
+
196
+ describe('Custom cacheMaxAge values', () => {
197
+ test('Should respect custom cacheMaxAge: 7200', async () => {
198
+ const app = new Koa();
199
+ app.use(koaClassicServer(TEST_DIR, {
200
+ enableCaching: true,
201
+ cacheMaxAge: 7200
202
+ }));
203
+ const server = app.listen();
204
+ const request = supertest(server);
205
+
206
+ const res = await request.get('/test.txt');
207
+
208
+ expect(res.status).toBe(200);
209
+ expect(res.headers['cache-control']).toBe('public, max-age=7200, must-revalidate');
210
+
211
+ server.close();
212
+ });
213
+
214
+ test('Should respect custom cacheMaxAge: 0 (no browser cache)', async () => {
215
+ const app = new Koa();
216
+ app.use(koaClassicServer(TEST_DIR, {
217
+ enableCaching: true,
218
+ cacheMaxAge: 0
219
+ }));
220
+ const server = app.listen();
221
+ const request = supertest(server);
222
+
223
+ const res = await request.get('/test.txt');
224
+
225
+ expect(res.status).toBe(200);
226
+ expect(res.headers['cache-control']).toBe('public, max-age=0, must-revalidate');
227
+ // Should still have ETag for validation
228
+ expect(res.headers['etag']).toBeDefined();
229
+
230
+ server.close();
231
+ });
232
+
233
+ test('Should respect custom cacheMaxAge: 86400 (1 day)', async () => {
234
+ const app = new Koa();
235
+ app.use(koaClassicServer(TEST_DIR, {
236
+ enableCaching: true,
237
+ cacheMaxAge: 86400
238
+ }));
239
+ const server = app.listen();
240
+ const request = supertest(server);
241
+
242
+ const res = await request.get('/test.txt');
243
+
244
+ expect(res.status).toBe(200);
245
+ expect(res.headers['cache-control']).toBe('public, max-age=86400, must-revalidate');
246
+
247
+ server.close();
248
+ });
249
+ });
250
+
251
+ describe('ETag generation and validation', () => {
252
+ test('ETag should change when file is modified', async () => {
253
+ const testFile = path.join(TEST_DIR, 'dynamic-test.txt');
254
+ fs.writeFileSync(testFile, 'Original content');
255
+
256
+ const app = new Koa();
257
+ app.use(koaClassicServer(TEST_DIR, {
258
+ enableCaching: true
259
+ }));
260
+ const server = app.listen();
261
+ const request = supertest(server);
262
+
263
+ // First request
264
+ const res1 = await request.get('/dynamic-test.txt');
265
+ expect(res1.status).toBe(200);
266
+ const etag1 = res1.headers['etag'];
267
+ expect(etag1).toBeDefined();
268
+
269
+ // Wait 10ms to ensure different mtime
270
+ await new Promise(resolve => setTimeout(resolve, 10));
271
+
272
+ // Modify file
273
+ fs.writeFileSync(testFile, 'Modified content - different');
274
+
275
+ // Second request - ETag should be different
276
+ const res2 = await request.get('/dynamic-test.txt');
277
+ expect(res2.status).toBe(200);
278
+ const etag2 = res2.headers['etag'];
279
+ expect(etag2).toBeDefined();
280
+ expect(etag2).not.toBe(etag1);
281
+
282
+ // Cleanup
283
+ fs.unlinkSync(testFile);
284
+ server.close();
285
+ });
286
+
287
+ test('ETag should change when file size changes', async () => {
288
+ const testFile = path.join(TEST_DIR, 'size-test.txt');
289
+ fs.writeFileSync(testFile, 'Short');
290
+
291
+ const app = new Koa();
292
+ app.use(koaClassicServer(TEST_DIR, {
293
+ enableCaching: true
294
+ }));
295
+ const server = app.listen();
296
+ const request = supertest(server);
297
+
298
+ // First request
299
+ const res1 = await request.get('/size-test.txt');
300
+ const etag1 = res1.headers['etag'];
301
+
302
+ // Wait and change file size
303
+ await new Promise(resolve => setTimeout(resolve, 10));
304
+ fs.writeFileSync(testFile, 'Much longer content here');
305
+
306
+ // Second request
307
+ const res2 = await request.get('/size-test.txt');
308
+ const etag2 = res2.headers['etag'];
309
+
310
+ expect(etag2).not.toBe(etag1);
311
+
312
+ fs.unlinkSync(testFile);
313
+ server.close();
314
+ });
315
+ });
316
+
317
+ describe('Bandwidth savings with 304 responses', () => {
318
+ test('304 response should have no body', async () => {
319
+ const app = new Koa();
320
+ app.use(koaClassicServer(TEST_DIR, {
321
+ enableCaching: true
322
+ }));
323
+ const server = app.listen();
324
+ const request = supertest(server);
325
+
326
+ // First request
327
+ const res1 = await request.get('/test.txt');
328
+ expect(res1.status).toBe(200);
329
+ expect(res1.text).toBe('Test content for caching');
330
+ const bodySize1 = res1.text.length;
331
+
332
+ // Second request with ETag
333
+ const res2 = await request
334
+ .get('/test.txt')
335
+ .set('If-None-Match', res1.headers['etag']);
336
+
337
+ expect(res2.status).toBe(304);
338
+ expect(res2.text).toBe('');
339
+ expect(res2.text.length).toBe(0);
340
+
341
+ // Verify bandwidth saving
342
+ expect(bodySize1).toBeGreaterThan(0);
343
+ expect(res2.text.length).toBe(0);
344
+
345
+ server.close();
346
+ });
347
+
348
+ test('Should save bandwidth on multiple 304 responses', async () => {
349
+ const app = new Koa();
350
+ app.use(koaClassicServer(TEST_DIR, {
351
+ enableCaching: true
352
+ }));
353
+ const server = app.listen();
354
+ const request = supertest(server);
355
+
356
+ // First request
357
+ const res1 = await request.get('/test.txt');
358
+ const etag = res1.headers['etag'];
359
+ const originalSize = res1.text.length;
360
+
361
+ // Make 10 cached requests
362
+ let totalBytesSaved = 0;
363
+ for (let i = 0; i < 10; i++) {
364
+ const res = await request
365
+ .get('/test.txt')
366
+ .set('If-None-Match', etag);
367
+
368
+ expect(res.status).toBe(304);
369
+ totalBytesSaved += originalSize;
370
+ }
371
+
372
+ expect(totalBytesSaved).toBeGreaterThan(0);
373
+
374
+ server.close();
375
+ });
376
+ });
377
+
378
+ describe('Caching with different MIME types', () => {
379
+ beforeAll(() => {
380
+ // Create files with different types
381
+ fs.writeFileSync(path.join(TEST_DIR, 'test.html'), '<html><body>Test</body></html>');
382
+ fs.writeFileSync(path.join(TEST_DIR, 'test.json'), '{"test": "data"}');
383
+ fs.writeFileSync(path.join(TEST_DIR, 'test.css'), 'body { color: red; }');
384
+ fs.writeFileSync(path.join(TEST_DIR, 'test.js'), 'console.log("test");');
385
+ });
386
+
387
+ afterAll(() => {
388
+ fs.unlinkSync(path.join(TEST_DIR, 'test.html'));
389
+ fs.unlinkSync(path.join(TEST_DIR, 'test.json'));
390
+ fs.unlinkSync(path.join(TEST_DIR, 'test.css'));
391
+ fs.unlinkSync(path.join(TEST_DIR, 'test.js'));
392
+ });
393
+
394
+ test('HTML files should have cache headers', async () => {
395
+ const app = new Koa();
396
+ app.use(koaClassicServer(TEST_DIR, {
397
+ enableCaching: true
398
+ }));
399
+ const server = app.listen();
400
+ const request = supertest(server);
401
+
402
+ const res = await request.get('/test.html');
403
+ expect(res.status).toBe(200);
404
+ expect(res.headers['etag']).toBeDefined();
405
+ expect(res.headers['cache-control']).toContain('public');
406
+ expect(res.headers['content-type']).toContain('text/html');
407
+
408
+ server.close();
409
+ });
410
+
411
+ test('JSON files should have cache headers', async () => {
412
+ const app = new Koa();
413
+ app.use(koaClassicServer(TEST_DIR, {
414
+ enableCaching: true
415
+ }));
416
+ const server = app.listen();
417
+ const request = supertest(server);
418
+
419
+ const res = await request.get('/test.json');
420
+ expect(res.status).toBe(200);
421
+ expect(res.headers['etag']).toBeDefined();
422
+ expect(res.headers['content-type']).toContain('application/json');
423
+
424
+ server.close();
425
+ });
426
+
427
+ test('CSS files should have cache headers', async () => {
428
+ const app = new Koa();
429
+ app.use(koaClassicServer(TEST_DIR, {
430
+ enableCaching: true
431
+ }));
432
+ const server = app.listen();
433
+ const request = supertest(server);
434
+
435
+ const res = await request.get('/test.css');
436
+ expect(res.status).toBe(200);
437
+ expect(res.headers['etag']).toBeDefined();
438
+ expect(res.headers['content-type']).toContain('text/css');
439
+
440
+ server.close();
441
+ });
442
+
443
+ test('JavaScript files should have cache headers', async () => {
444
+ const app = new Koa();
445
+ app.use(koaClassicServer(TEST_DIR, {
446
+ enableCaching: true
447
+ }));
448
+ const server = app.listen();
449
+ const request = supertest(server);
450
+
451
+ const res = await request.get('/test.js');
452
+ expect(res.status).toBe(200);
453
+ expect(res.headers['etag']).toBeDefined();
454
+ expect(res.headers['content-type']).toContain('javascript');
455
+
456
+ server.close();
457
+ });
458
+ });
459
+
460
+ describe('Caching does not interfere with template rendering', () => {
461
+ test('Template files should not get cache headers during rendering', async () => {
462
+ const testFile = path.join(TEST_DIR, 'test-template.ejs');
463
+ fs.writeFileSync(testFile, '<html><body><%= name %></body></html>');
464
+
465
+ let renderCalled = false;
466
+
467
+ const app = new Koa();
468
+ app.use(koaClassicServer(TEST_DIR, {
469
+ enableCaching: true,
470
+ cacheMaxAge: 3600,
471
+ template: {
472
+ ext: ['ejs'],
473
+ render: async (ctx, next, filePath) => {
474
+ renderCalled = true;
475
+ ctx.body = '<html><body>Rendered</body></html>';
476
+ ctx.type = 'text/html';
477
+ }
478
+ }
479
+ }));
480
+ const server = app.listen();
481
+ const request = supertest(server);
482
+
483
+ const res = await request.get('/test-template.ejs');
484
+
485
+ expect(res.status).toBe(200);
486
+ expect(renderCalled).toBe(true);
487
+ expect(res.text).toBe('<html><body>Rendered</body></html>');
488
+
489
+ // Template rendering happens before caching logic,
490
+ // so cache headers should not be added by koaClassicServer
491
+ // (the template renderer controls caching)
492
+
493
+ fs.unlinkSync(testFile);
494
+ server.close();
495
+ });
496
+ });
497
+
498
+ describe('Concurrent requests with caching', () => {
499
+ test('Multiple concurrent requests should handle caching correctly', async () => {
500
+ const app = new Koa();
501
+ app.use(koaClassicServer(TEST_DIR, {
502
+ enableCaching: true
503
+ }));
504
+ const server = app.listen();
505
+ const request = supertest(server);
506
+
507
+ // Make 5 concurrent requests
508
+ const promises = Array.from({ length: 5 }, () =>
509
+ request.get('/test.txt')
510
+ );
511
+
512
+ const results = await Promise.all(promises);
513
+
514
+ // All should succeed
515
+ results.forEach(res => {
516
+ expect(res.status).toBe(200);
517
+ expect(res.headers['etag']).toBeDefined();
518
+ });
519
+
520
+ // All ETags should be identical (same file)
521
+ const etags = results.map(r => r.headers['etag']);
522
+ const uniqueEtags = new Set(etags);
523
+ expect(uniqueEtags.size).toBe(1);
524
+
525
+ server.close();
526
+ });
527
+
528
+ test('Concurrent 304 responses should work correctly', async () => {
529
+ const app = new Koa();
530
+ app.use(koaClassicServer(TEST_DIR, {
531
+ enableCaching: true
532
+ }));
533
+ const server = app.listen();
534
+ const request = supertest(server);
535
+
536
+ // First request to get ETag
537
+ const initial = await request.get('/test.txt');
538
+ const etag = initial.headers['etag'];
539
+
540
+ // Make 5 concurrent cached requests
541
+ const promises = Array.from({ length: 5 }, () =>
542
+ request.get('/test.txt').set('If-None-Match', etag)
543
+ );
544
+
545
+ const results = await Promise.all(promises);
546
+
547
+ // All should return 304
548
+ results.forEach(res => {
549
+ expect(res.status).toBe(304);
550
+ expect(res.text).toBe('');
551
+ });
552
+
553
+ server.close();
554
+ });
555
+ });
556
+ });
package/index.cjs CHANGED
@@ -353,6 +353,12 @@ module.exports = function koaClassicServer(
353
353
  return;
354
354
  }
355
355
  }
356
+ } else {
357
+ // BUGFIX: When caching is disabled, explicitly prevent browser caching
358
+ // Without these headers, browsers may use heuristic caching and serve stale content
359
+ ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
360
+ ctx.set('Pragma', 'no-cache'); // HTTP 1.0 compatibility
361
+ ctx.set('Expires', '0'); // Proxies
356
362
  }
357
363
 
358
364
  // Verify file is still readable (race condition protection)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-classic-server",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "High-performance Koa middleware for serving static files with Apache-like directory listing, HTTP caching, template engine support, and comprehensive security fixes",
5
5
  "main": "index.cjs",
6
6
  "exports": {