koa-classic-server 2.6.1 → 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 +20 -9
  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 +84 -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 +1096 -391
  39. package/index.mjs +1 -5
  40. package/package.json +4 -1
package/README.md CHANGED
@@ -554,9 +554,23 @@ Creates a Koa middleware for serving static files.
554
554
  redirect: 301 // HTTP redirect code (optional, default: 301)
555
555
  },
556
556
 
557
- // DEPRECATED (use new names above):
558
- // enableCaching: use browserCacheEnabled instead
559
- // cacheMaxAge: use browserCacheMaxAge instead
557
+ // Block files/dirs from listing and serving (HTTP 404)
558
+ // Dot-files (names starting with '.') are hidden by default.
559
+ // Dot-directories are visible by default.
560
+ hidden: {
561
+ dotFiles: {
562
+ default: 'hidden', // 'hidden' | 'visible' — system default: 'hidden'
563
+ whitelist: ['.well-known'], // Always visible — string (exact/glob) or RegExp
564
+ blacklist: [], // Always hidden — overrides whitelist
565
+ },
566
+ dotDirs: {
567
+ default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
568
+ whitelist: [],
569
+ blacklist: ['.git'],
570
+ },
571
+ alwaysHide: ['*.key', /secret/i], // Path-aware patterns (string glob or RegExp)
572
+ },
573
+
560
574
  }
561
575
  ```
562
576
 
@@ -566,7 +580,7 @@ Creates a Koa middleware for serving static files.
566
580
  |--------|------|---------|-------------|
567
581
  | `method` | Array | `['GET']` | Allowed HTTP methods |
568
582
  | `showDirContents` | Boolean | `true` | Show directory listing |
569
- | `index` | Array/String | `[]` | Index file patterns (array format recommended) |
583
+ | `index` | Array | `[]` | Index file patterns (strings, RegExp, or mixed) |
570
584
  | `urlPrefix` | String | `''` | URL path prefix |
571
585
  | `urlsReserved` | Array | `[]` | Reserved directory paths (first-level only) |
572
586
  | `template.render` | Function | `undefined` | Template rendering function |
@@ -576,8 +590,13 @@ Creates a Koa middleware for serving static files.
576
590
  | `useOriginalUrl` | Boolean | `true` | Use `ctx.originalUrl` (default) or `ctx.url` for URL resolution |
577
591
  | `hideExtension.ext` | String | - | Extension to hide (e.g. `'.ejs'`). Enables clean URL feature |
578
592
  | `hideExtension.redirect` | Number | `301` | HTTP redirect code for URLs with extension |
579
- | ~~`enableCaching`~~ | Boolean | `false` | **DEPRECATED**: Use `browserCacheEnabled` instead |
580
- | ~~`cacheMaxAge`~~ | Number | `3600` | **DEPRECATED**: Use `browserCacheMaxAge` instead |
593
+ | `hidden.dotFiles.default` | String | `'hidden'` | Default visibility for dot-files: `'hidden'` or `'visible'` |
594
+ | `hidden.dotFiles.whitelist` | Array | `[]` | Dot-file names always visible (string exact/glob or RegExp) |
595
+ | `hidden.dotFiles.blacklist` | Array | `[]` | Dot-file names always hidden — overrides whitelist |
596
+ | `hidden.dotDirs.default` | String | `'visible'` | Default visibility for dot-dirs: `'hidden'` or `'visible'` |
597
+ | `hidden.dotDirs.whitelist` | Array | `[]` | Dot-dir names always visible |
598
+ | `hidden.dotDirs.blacklist` | Array | `[]` | Dot-dir names always hidden — overrides whitelist |
599
+ | `hidden.alwaysHide` | Array | `[]` | Path-aware patterns (string glob or RegExp) for any file/dir. Secondary to whitelist/blacklist. |
581
600
 
582
601
  #### useOriginalUrl (Boolean, default: true)
583
602
 
@@ -774,7 +793,7 @@ npm run test:performance
774
793
  - ✅ 309 tests passing
775
794
  - ✅ Security tests (path traversal, XSS, race conditions)
776
795
  - ✅ EJS template integration tests
777
- - ✅ Index option tests (strings, arrays, RegExp)
796
+ - ✅ Index option tests (arrays, RegExp)
778
797
  - ✅ hideExtension tests (clean URLs, redirects, conflicts, validation)
779
798
  - ✅ Symlink tests (file, directory, broken, circular, indicators)
780
799
  - ✅ Performance benchmarks
@@ -844,20 +863,59 @@ npm run test:performance
844
863
 
845
864
  ## Migration Guide
846
865
 
866
+ ### From v2.x to v3.x
867
+
868
+ **Breaking Changes:**
869
+ - `index` option: String format removed — passing a non-empty string now throws an Error
870
+ - `cacheMaxAge` option: removed — use `browserCacheMaxAge`
871
+ - `enableCaching` option: removed — use `browserCacheEnabled`
872
+ - **Dot-files hidden by default** — `hidden.dotFiles.default` is `'hidden'` out of the box.
873
+ In v2.x, dot-files like `.env` were served normally. In v3.x they return 404 unless explicitly allowed.
874
+
875
+ **Migration:**
876
+
877
+ ```javascript
878
+ // v2.x (now throws in v3)
879
+ { index: 'index.html' }
880
+
881
+ // v3.x
882
+ { index: ['index.html'] }
883
+ ```
884
+
885
+ ```javascript
886
+ // v2.x — dot-files were served (no protection)
887
+ // v3.x — dot-files hidden by default (recommended)
888
+
889
+ // To restore v2.x behavior for dot-files:
890
+ {
891
+ hidden: { dotFiles: { default: 'visible' } }
892
+ }
893
+
894
+ // Recommended v3.x — hide dot-files but expose .well-known for ACME/Let's Encrypt:
895
+ {
896
+ hidden: {
897
+ dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
898
+ dotDirs: { default: 'hidden', whitelist: ['.well-known'] }
899
+ }
900
+ }
901
+ ```
902
+
903
+ ---
904
+
847
905
  ### From v1.x to v2.x
848
906
 
849
907
  **Breaking Changes:**
850
- - `index` option: String format deprecated (still works), use array format
908
+ - `index` option: String format deprecated (use array format)
851
909
 
852
910
  **Migration:**
853
911
 
854
912
  ```javascript
855
- // v1.x (deprecated)
913
+ // v1.x
856
914
  {
857
915
  index: 'index.html'
858
916
  }
859
917
 
860
- // v2.x (recommended)
918
+ // v2.x+
861
919
  {
862
920
  index: ['index.html']
863
921
  }
@@ -2,8 +2,8 @@
2
2
  * HTTP Caching Headers Test
3
3
  *
4
4
  * Tests to verify correct caching behavior:
5
- * - When enableCaching: true -> proper cache headers
6
- * - When enableCaching: false -> anti-cache headers
5
+ * - When browserCacheEnabled: true -> proper cache headers
6
+ * - When browserCacheEnabled: false -> anti-cache headers
7
7
  */
8
8
 
9
9
  const Koa = require('koa');
@@ -30,7 +30,7 @@ describe('HTTP Caching Headers', () => {
30
30
  }
31
31
  });
32
32
 
33
- describe('When caching is DISABLED (enableCaching: false)', () => {
33
+ describe('When caching is DISABLED (browserCacheEnabled: false)', () => {
34
34
  let app;
35
35
  let server;
36
36
  let request;
@@ -38,7 +38,7 @@ describe('HTTP Caching Headers', () => {
38
38
  beforeAll(() => {
39
39
  app = new Koa();
40
40
  app.use(koaClassicServer(TEST_DIR, {
41
- enableCaching: false
41
+ browserCacheEnabled: false
42
42
  }));
43
43
  server = app.listen();
44
44
  request = supertest(server);
@@ -89,7 +89,7 @@ describe('HTTP Caching Headers', () => {
89
89
  });
90
90
  });
91
91
 
92
- describe('When caching is ENABLED (enableCaching: true)', () => {
92
+ describe('When caching is ENABLED (browserCacheEnabled: true)', () => {
93
93
  let app;
94
94
  let server;
95
95
  let request;
@@ -97,8 +97,8 @@ describe('HTTP Caching Headers', () => {
97
97
  beforeAll(() => {
98
98
  app = new Koa();
99
99
  app.use(koaClassicServer(TEST_DIR, {
100
- enableCaching: true,
101
- cacheMaxAge: 3600
100
+ browserCacheEnabled: true,
101
+ browserCacheMaxAge: 3600
102
102
  }));
103
103
  server = app.listen();
104
104
  request = supertest(server);
@@ -173,7 +173,7 @@ describe('HTTP Caching Headers', () => {
173
173
 
174
174
  beforeAll(() => {
175
175
  app = new Koa();
176
- // No options provided - should default to enableCaching: false
176
+ // No options provided - should default to browserCacheEnabled: false
177
177
  app.use(koaClassicServer(TEST_DIR));
178
178
  server = app.listen();
179
179
  request = supertest(server);
@@ -193,12 +193,12 @@ describe('HTTP Caching Headers', () => {
193
193
  });
194
194
  });
195
195
 
196
- describe('Custom cacheMaxAge values', () => {
197
- test('Should respect custom cacheMaxAge: 7200', async () => {
196
+ describe('Custom browserCacheMaxAge values', () => {
197
+ test('Should respect custom browserCacheMaxAge: 7200', async () => {
198
198
  const app = new Koa();
199
199
  app.use(koaClassicServer(TEST_DIR, {
200
- enableCaching: true,
201
- cacheMaxAge: 7200
200
+ browserCacheEnabled: true,
201
+ browserCacheMaxAge: 7200
202
202
  }));
203
203
  const server = app.listen();
204
204
  const request = supertest(server);
@@ -211,11 +211,11 @@ describe('HTTP Caching Headers', () => {
211
211
  server.close();
212
212
  });
213
213
 
214
- test('Should respect custom cacheMaxAge: 0 (no browser cache)', async () => {
214
+ test('Should respect custom browserCacheMaxAge: 0 (no browser cache)', async () => {
215
215
  const app = new Koa();
216
216
  app.use(koaClassicServer(TEST_DIR, {
217
- enableCaching: true,
218
- cacheMaxAge: 0
217
+ browserCacheEnabled: true,
218
+ browserCacheMaxAge: 0
219
219
  }));
220
220
  const server = app.listen();
221
221
  const request = supertest(server);
@@ -230,11 +230,11 @@ describe('HTTP Caching Headers', () => {
230
230
  server.close();
231
231
  });
232
232
 
233
- test('Should respect custom cacheMaxAge: 86400 (1 day)', async () => {
233
+ test('Should respect custom browserCacheMaxAge: 86400 (1 day)', async () => {
234
234
  const app = new Koa();
235
235
  app.use(koaClassicServer(TEST_DIR, {
236
- enableCaching: true,
237
- cacheMaxAge: 86400
236
+ browserCacheEnabled: true,
237
+ browserCacheMaxAge: 86400
238
238
  }));
239
239
  const server = app.listen();
240
240
  const request = supertest(server);
@@ -255,7 +255,7 @@ describe('HTTP Caching Headers', () => {
255
255
 
256
256
  const app = new Koa();
257
257
  app.use(koaClassicServer(TEST_DIR, {
258
- enableCaching: true
258
+ browserCacheEnabled: true
259
259
  }));
260
260
  const server = app.listen();
261
261
  const request = supertest(server);
@@ -290,7 +290,7 @@ describe('HTTP Caching Headers', () => {
290
290
 
291
291
  const app = new Koa();
292
292
  app.use(koaClassicServer(TEST_DIR, {
293
- enableCaching: true
293
+ browserCacheEnabled: true
294
294
  }));
295
295
  const server = app.listen();
296
296
  const request = supertest(server);
@@ -318,7 +318,7 @@ describe('HTTP Caching Headers', () => {
318
318
  test('304 response should have no body', async () => {
319
319
  const app = new Koa();
320
320
  app.use(koaClassicServer(TEST_DIR, {
321
- enableCaching: true
321
+ browserCacheEnabled: true
322
322
  }));
323
323
  const server = app.listen();
324
324
  const request = supertest(server);
@@ -348,7 +348,7 @@ describe('HTTP Caching Headers', () => {
348
348
  test('Should save bandwidth on multiple 304 responses', async () => {
349
349
  const app = new Koa();
350
350
  app.use(koaClassicServer(TEST_DIR, {
351
- enableCaching: true
351
+ browserCacheEnabled: true
352
352
  }));
353
353
  const server = app.listen();
354
354
  const request = supertest(server);
@@ -394,7 +394,7 @@ describe('HTTP Caching Headers', () => {
394
394
  test('HTML files should have cache headers', async () => {
395
395
  const app = new Koa();
396
396
  app.use(koaClassicServer(TEST_DIR, {
397
- enableCaching: true
397
+ browserCacheEnabled: true
398
398
  }));
399
399
  const server = app.listen();
400
400
  const request = supertest(server);
@@ -411,7 +411,7 @@ describe('HTTP Caching Headers', () => {
411
411
  test('JSON files should have cache headers', async () => {
412
412
  const app = new Koa();
413
413
  app.use(koaClassicServer(TEST_DIR, {
414
- enableCaching: true
414
+ browserCacheEnabled: true
415
415
  }));
416
416
  const server = app.listen();
417
417
  const request = supertest(server);
@@ -427,7 +427,7 @@ describe('HTTP Caching Headers', () => {
427
427
  test('CSS files should have cache headers', async () => {
428
428
  const app = new Koa();
429
429
  app.use(koaClassicServer(TEST_DIR, {
430
- enableCaching: true
430
+ browserCacheEnabled: true
431
431
  }));
432
432
  const server = app.listen();
433
433
  const request = supertest(server);
@@ -443,7 +443,7 @@ describe('HTTP Caching Headers', () => {
443
443
  test('JavaScript files should have cache headers', async () => {
444
444
  const app = new Koa();
445
445
  app.use(koaClassicServer(TEST_DIR, {
446
- enableCaching: true
446
+ browserCacheEnabled: true
447
447
  }));
448
448
  const server = app.listen();
449
449
  const request = supertest(server);
@@ -466,8 +466,8 @@ describe('HTTP Caching Headers', () => {
466
466
 
467
467
  const app = new Koa();
468
468
  app.use(koaClassicServer(TEST_DIR, {
469
- enableCaching: true,
470
- cacheMaxAge: 3600,
469
+ browserCacheEnabled: true,
470
+ browserCacheMaxAge: 3600,
471
471
  template: {
472
472
  ext: ['ejs'],
473
473
  render: async (ctx, next, filePath) => {
@@ -499,7 +499,7 @@ describe('HTTP Caching Headers', () => {
499
499
  test('Multiple concurrent requests should handle caching correctly', async () => {
500
500
  const app = new Koa();
501
501
  app.use(koaClassicServer(TEST_DIR, {
502
- enableCaching: true
502
+ browserCacheEnabled: true
503
503
  }));
504
504
  const server = app.listen();
505
505
  const request = supertest(server);
@@ -528,7 +528,7 @@ describe('HTTP Caching Headers', () => {
528
528
  test('Concurrent 304 responses should work correctly', async () => {
529
529
  const app = new Koa();
530
530
  app.use(koaClassicServer(TEST_DIR, {
531
- enableCaching: true
531
+ browserCacheEnabled: true
532
532
  }));
533
533
  const server = app.listen();
534
534
  const request = supertest(server);
@@ -0,0 +1 @@
1
+ {"key":"value"}
@@ -0,0 +1 @@
1
+ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
@@ -0,0 +1 @@
1
+ tiny
@@ -0,0 +1,270 @@
1
+ const supertest = require('supertest');
2
+ const koaClassicServer = require('../index.cjs');
3
+ const Koa = require('koa');
4
+ const path = require('path');
5
+
6
+ const root = path.join(__dirname, 'compression-fixtures');
7
+
8
+ // Fixtures:
9
+ // large.txt — 2000 bytes of 'A' (text/plain, exceeds 1KB threshold)
10
+ // small.txt — 4 bytes of 'tiny' (text/plain, below 1KB threshold)
11
+ // data.json — 16 bytes '{"key":"value"}\n' (application/json, below threshold)
12
+
13
+ function createApp(opts = {}) {
14
+ const app = new Koa();
15
+ app.use(koaClassicServer(root, { showDirContents: false, ...opts }));
16
+ return app.listen();
17
+ }
18
+
19
+ // ─── Default behaviour (compression enabled, serverCache enabled) ─────────────
20
+
21
+ describe('Compression — default: br preferred', () => {
22
+ let server;
23
+ beforeAll(() => { server = createApp(); });
24
+ afterAll(() => server.close());
25
+
26
+ test('large.txt with Accept-Encoding: br → brotli compressed', async () => {
27
+ const res = await supertest(server)
28
+ .get('/large.txt')
29
+ .set('Accept-Encoding', 'br');
30
+ expect(res.status).toBe(200);
31
+ expect(res.headers['content-encoding']).toBe('br');
32
+ expect(res.headers['vary']).toBe('Accept-Encoding');
33
+ });
34
+
35
+ test('large.txt with Accept-Encoding: gzip → gzip compressed, body decompressed by supertest', async () => {
36
+ const res = await supertest(server)
37
+ .get('/large.txt')
38
+ .set('Accept-Encoding', 'gzip');
39
+ expect(res.status).toBe(200);
40
+ expect(res.headers['content-encoding']).toBe('gzip');
41
+ expect(res.headers['vary']).toBe('Accept-Encoding');
42
+ // supertest auto-decompresses gzip — res.text is the original content
43
+ expect(res.text).toBe('A'.repeat(2000));
44
+ });
45
+
46
+ test('large.txt with Accept-Encoding: br,gzip → br preferred (higher priority)', async () => {
47
+ const res = await supertest(server)
48
+ .get('/large.txt')
49
+ .set('Accept-Encoding', 'br, gzip');
50
+ expect(res.headers['content-encoding']).toBe('br');
51
+ });
52
+
53
+ // Use 'identity' to prevent supertest's default Accept-Encoding from triggering compression
54
+ test('large.txt with Accept-Encoding: identity → uncompressed', async () => {
55
+ const res = await supertest(server)
56
+ .get('/large.txt')
57
+ .set('Accept-Encoding', 'identity');
58
+ expect(res.status).toBe(200);
59
+ expect(res.headers['content-encoding']).toBeUndefined();
60
+ expect(res.headers['vary']).toBeUndefined();
61
+ expect(res.text).toBe('A'.repeat(2000));
62
+ });
63
+
64
+ test('Content-Length present on compressed response (serverCache)', async () => {
65
+ const res = await supertest(server)
66
+ .get('/large.txt')
67
+ .set('Accept-Encoding', 'gzip');
68
+ expect(res.headers['content-length']).toBeDefined();
69
+ expect(Number(res.headers['content-length'])).toBeGreaterThan(0);
70
+ });
71
+
72
+ test('Compressed Content-Length is smaller than original file size', async () => {
73
+ const res = await supertest(server)
74
+ .get('/large.txt')
75
+ .set('Accept-Encoding', 'gzip');
76
+ expect(Number(res.headers['content-length'])).toBeLessThan(2000);
77
+ });
78
+ });
79
+
80
+ // ─── Threshold: files below threshold are served uncompressed ────────────────
81
+
82
+ describe('Compression — threshold (default 1024 bytes)', () => {
83
+ let server;
84
+ beforeAll(() => { server = createApp(); });
85
+ afterAll(() => server.close());
86
+
87
+ test('small.txt (4 bytes) below threshold → no compression', async () => {
88
+ const res = await supertest(server)
89
+ .get('/small.txt')
90
+ .set('Accept-Encoding', 'br, gzip');
91
+ expect(res.status).toBe(200);
92
+ expect(res.headers['content-encoding']).toBeUndefined();
93
+ expect(res.text).toBe('tiny');
94
+ });
95
+
96
+ test('large.txt (2000 bytes) above threshold → compressed', async () => {
97
+ const res = await supertest(server)
98
+ .get('/large.txt')
99
+ .set('Accept-Encoding', 'gzip');
100
+ expect(res.headers['content-encoding']).toBe('gzip');
101
+ });
102
+
103
+ test('minSize: false → compress regardless of size', async () => {
104
+ const s = createApp({ compression: { minSize: false } });
105
+ const res = await supertest(s)
106
+ .get('/small.txt')
107
+ .set('Accept-Encoding', 'gzip');
108
+ s.close();
109
+ expect(res.headers['content-encoding']).toBe('gzip');
110
+ // supertest auto-decompresses gzip
111
+ expect(res.text).toBe('tiny');
112
+ });
113
+ });
114
+
115
+ // ─── compression: false shorthand ────────────────────────────────────────────
116
+
117
+ describe('Compression — disabled', () => {
118
+ let server;
119
+ beforeAll(() => { server = createApp({ compression: false }); });
120
+ afterAll(() => server.close());
121
+
122
+ test('compression: false → no compression on any file', async () => {
123
+ const res = await supertest(server)
124
+ .get('/large.txt')
125
+ .set('Accept-Encoding', 'br, gzip');
126
+ expect(res.status).toBe(200);
127
+ expect(res.headers['content-encoding']).toBeUndefined();
128
+ expect(res.text).toBe('A'.repeat(2000));
129
+ });
130
+ });
131
+
132
+ // ─── encodings configuration ──────────────────────────────────────────────────
133
+
134
+ describe('Compression — encodings configuration', () => {
135
+ test('encodings: [gzip] → no brotli even if client prefers br', async () => {
136
+ const s = createApp({ compression: { encodings: ['gzip'] } });
137
+ const res = await supertest(s)
138
+ .get('/large.txt')
139
+ .set('Accept-Encoding', 'br, gzip');
140
+ s.close();
141
+ expect(res.headers['content-encoding']).toBe('gzip');
142
+ });
143
+
144
+ test('encodings: [] → no compression', async () => {
145
+ const s = createApp({ compression: { encodings: [] } });
146
+ const res = await supertest(s)
147
+ .get('/large.txt')
148
+ .set('Accept-Encoding', 'br, gzip');
149
+ s.close();
150
+ expect(res.headers['content-encoding']).toBeUndefined();
151
+ });
152
+ });
153
+
154
+ // ─── mimeTypes configuration ──────────────────────────────────────────────────
155
+
156
+ describe('Compression — mimeTypes configuration', () => {
157
+ test('custom mimeTypes replaces default list', async () => {
158
+ // Only compress application/json; text/plain should not be compressed
159
+ const s = createApp({ compression: { mimeTypes: ['application/json'], threshold: false } });
160
+
161
+ const resTxt = await supertest(s)
162
+ .get('/large.txt')
163
+ .set('Accept-Encoding', 'gzip');
164
+ expect(resTxt.headers['content-encoding']).toBeUndefined();
165
+
166
+ s.close();
167
+ });
168
+ });
169
+
170
+ // ─── ETag encoding-specific ───────────────────────────────────────────────────
171
+
172
+ describe('Compression — encoding-specific ETag', () => {
173
+ let server;
174
+ beforeAll(() => { server = createApp({ browserCacheEnabled: true }); });
175
+ afterAll(() => server.close());
176
+
177
+ test('ETag for br response has -br suffix', async () => {
178
+ const res = await supertest(server)
179
+ .get('/large.txt')
180
+ .set('Accept-Encoding', 'br');
181
+ expect(res.headers['etag']).toMatch(/-br"$/);
182
+ });
183
+
184
+ test('ETag for gzip response has -gz suffix', async () => {
185
+ const res = await supertest(server)
186
+ .get('/large.txt')
187
+ .set('Accept-Encoding', 'gzip');
188
+ expect(res.headers['etag']).toMatch(/-gz"$/);
189
+ });
190
+
191
+ test('ETag for uncompressed response has no suffix', async () => {
192
+ // Use 'identity' to prevent supertest's default Accept-Encoding from triggering compression
193
+ const res = await supertest(server)
194
+ .get('/large.txt')
195
+ .set('Accept-Encoding', 'identity');
196
+ expect(res.headers['etag']).not.toMatch(/-(br|gz)"$/);
197
+ });
198
+
199
+ test('304 returned when If-None-Match matches encoding-specific ETag', async () => {
200
+ const first = await supertest(server)
201
+ .get('/large.txt')
202
+ .set('Accept-Encoding', 'gzip');
203
+ const etag = first.headers['etag'];
204
+
205
+ const second = await supertest(server)
206
+ .get('/large.txt')
207
+ .set('Accept-Encoding', 'gzip')
208
+ .set('If-None-Match', etag);
209
+ expect(second.status).toBe(304);
210
+ });
211
+
212
+ test('304 NOT returned when ETag suffix differs (br ETag sent with gzip request)', async () => {
213
+ const brRes = await supertest(server)
214
+ .get('/large.txt')
215
+ .set('Accept-Encoding', 'br');
216
+ const brEtag = brRes.headers['etag'];
217
+
218
+ // Send the br ETag but request gzip → ETag mismatch → 200
219
+ const gzipRes = await supertest(server)
220
+ .get('/large.txt')
221
+ .set('Accept-Encoding', 'gzip')
222
+ .set('If-None-Match', brEtag);
223
+ expect(gzipRes.status).toBe(200);
224
+ });
225
+ });
226
+
227
+ // ─── serverCache.compressedFile disabled (streaming mode) ────────────────────
228
+
229
+ describe('Compression — serverCache.compressedFile disabled (streaming)', () => {
230
+ let server;
231
+ beforeAll(() => {
232
+ server = createApp({ serverCache: { compressedFile: { enabled: false } } });
233
+ });
234
+ afterAll(() => server.close());
235
+
236
+ test('streaming: Content-Encoding set but no Content-Length', async () => {
237
+ const res = await supertest(server)
238
+ .get('/large.txt')
239
+ .set('Accept-Encoding', 'gzip');
240
+ expect(res.headers['content-encoding']).toBe('gzip');
241
+ // Streaming compressed responses use Transfer-Encoding: chunked → no Content-Length
242
+ expect(res.headers['content-length']).toBeUndefined();
243
+ });
244
+
245
+ test('streaming: response body is correctly decompressed', async () => {
246
+ const res = await supertest(server)
247
+ .get('/large.txt')
248
+ .set('Accept-Encoding', 'gzip');
249
+ // supertest auto-decompresses gzip — res.text is the original content
250
+ expect(res.text).toBe('A'.repeat(2000));
251
+ });
252
+ });
253
+
254
+ // ─── Compression does not apply to Range requests ────────────────────────────
255
+
256
+ describe('Compression — no compression on Range requests (HTTP 206)', () => {
257
+ let server;
258
+ beforeAll(() => { server = createApp(); });
259
+ afterAll(() => server.close());
260
+
261
+ test('Range request is served uncompressed even with Accept-Encoding', async () => {
262
+ const res = await supertest(server)
263
+ .get('/large.txt')
264
+ .set('Range', 'bytes=0-9')
265
+ .set('Accept-Encoding', 'br, gzip');
266
+ expect(res.status).toBe(206);
267
+ expect(res.headers['content-encoding']).toBeUndefined();
268
+ expect(res.text).toBe('A'.repeat(10));
269
+ });
270
+ });
@@ -32,7 +32,7 @@ const configurations = [
32
32
  urlPrefix: '/public',
33
33
  method: ['GET'],
34
34
  showDirContents: true,
35
- index: 'index.html',
35
+ index: ['index.html'],
36
36
  },
37
37
  },
38
38
  {