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.
- package/README.md +68 -10
- package/__tests__/caching-headers.test.js +30 -30
- package/__tests__/compression-fixtures/data.json +1 -0
- package/__tests__/compression-fixtures/large.txt +1 -0
- package/__tests__/compression-fixtures/small.txt +1 -0
- package/__tests__/compression.test.js +270 -0
- package/__tests__/customTest/serversToLoad.util.js +1 -1
- package/__tests__/deprecation-warnings.test.js +71 -183
- package/__tests__/dt-unknown.test.js +20 -9
- package/__tests__/hidden-fixtures/.dot-dir/inside.txt +1 -0
- package/__tests__/hidden-fixtures/.env +2 -0
- package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +1 -0
- package/__tests__/hidden-fixtures/config/secrets/password.txt +1 -0
- package/__tests__/hidden-fixtures/data.key +1 -0
- package/__tests__/hidden-fixtures/file.secret +1 -0
- package/__tests__/hidden-fixtures/index.html +1 -0
- package/__tests__/hidden-fixtures/normal.txt +1 -0
- package/__tests__/hidden-fixtures/subdir/.env +1 -0
- package/__tests__/hidden-fixtures/subdir/regular.txt +1 -0
- package/__tests__/hidden-option.test.js +422 -0
- package/__tests__/index-option.test.js +18 -16
- package/__tests__/index.test.js +8 -4
- package/__tests__/range-fixtures/sample.txt +1 -0
- package/__tests__/range.test.js +223 -0
- package/__tests__/security-headers.test.js +153 -0
- package/__tests__/security.test.js +145 -159
- package/__tests__/server-cache-fixtures/large.txt +1 -0
- package/__tests__/server-cache-fixtures/small.txt +1 -0
- package/__tests__/server-cache.test.js +423 -0
- package/__tests__/symlink.test.js +8 -5
- package/docs/ACTION_PLAN.md +293 -0
- package/docs/CHANGELOG.md +84 -0
- package/docs/EXAMPLES_INDEX_OPTION.md +2 -2
- package/docs/FLOW_DIAGRAM.md +13 -13
- package/docs/OPTIMIZATION_ROADMAP_for_V3.md +864 -0
- package/docs/PERFORMANCE_COMPARISON.md +7 -7
- package/eslint.config.mjs +17 -0
- package/index.cjs +1096 -391
- package/index.mjs +1 -5
- 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
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
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
|
|
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
|
-
|
|
|
580
|
-
|
|
|
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 (
|
|
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 (
|
|
908
|
+
- `index` option: String format deprecated (use array format)
|
|
851
909
|
|
|
852
910
|
**Migration:**
|
|
853
911
|
|
|
854
912
|
```javascript
|
|
855
|
-
// v1.x
|
|
913
|
+
// v1.x
|
|
856
914
|
{
|
|
857
915
|
index: 'index.html'
|
|
858
916
|
}
|
|
859
917
|
|
|
860
|
-
// v2.x
|
|
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
|
|
6
|
-
* - When
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
197
|
-
test('Should respect custom
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
233
|
+
test('Should respect custom browserCacheMaxAge: 86400 (1 day)', async () => {
|
|
234
234
|
const app = new Koa();
|
|
235
235
|
app.use(koaClassicServer(TEST_DIR, {
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|