koa-classic-server 2.1.2 โ 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.
- package/.github/workflows/npm-publish.yml +98 -0
- package/README.md +16 -5
- package/__tests__/caching-headers.test.js +556 -0
- package/docs/CHANGELOG.md +78 -0
- package/index.cjs +14 -2
- package/package.json +5 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# This workflow will run tests using node and then publish a package to npm when a release is published
|
|
2
|
+
# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
|
|
3
|
+
|
|
4
|
+
name: Publish to npm
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
# Prevent multiple concurrent publish workflows
|
|
11
|
+
concurrency:
|
|
12
|
+
group: npm-publish-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
# Set permissions for the workflow
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
id-token: write # Required for npm provenance
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
build:
|
|
22
|
+
name: Build and Test
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
timeout-minutes: 10
|
|
25
|
+
|
|
26
|
+
steps:
|
|
27
|
+
- name: Checkout code
|
|
28
|
+
uses: actions/checkout@v4
|
|
29
|
+
|
|
30
|
+
- name: Setup Node.js
|
|
31
|
+
uses: actions/setup-node@v4
|
|
32
|
+
with:
|
|
33
|
+
node-version: 20
|
|
34
|
+
cache: 'npm'
|
|
35
|
+
|
|
36
|
+
- name: Install dependencies
|
|
37
|
+
run: npm ci
|
|
38
|
+
|
|
39
|
+
- name: Run tests
|
|
40
|
+
run: npm test --if-present
|
|
41
|
+
|
|
42
|
+
- name: Build package
|
|
43
|
+
run: npm run build --if-present
|
|
44
|
+
|
|
45
|
+
- name: Cache build artifacts
|
|
46
|
+
uses: actions/cache/save@v4
|
|
47
|
+
with:
|
|
48
|
+
path: |
|
|
49
|
+
node_modules
|
|
50
|
+
dist
|
|
51
|
+
build
|
|
52
|
+
key: build-${{ github.sha }}
|
|
53
|
+
|
|
54
|
+
publish-npm:
|
|
55
|
+
name: Publish to npm Registry
|
|
56
|
+
needs: build
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
timeout-minutes: 10
|
|
59
|
+
|
|
60
|
+
steps:
|
|
61
|
+
- name: Checkout code
|
|
62
|
+
uses: actions/checkout@v4
|
|
63
|
+
|
|
64
|
+
- name: Setup Node.js
|
|
65
|
+
uses: actions/setup-node@v4
|
|
66
|
+
with:
|
|
67
|
+
node-version: 20
|
|
68
|
+
registry-url: https://registry.npmjs.org/
|
|
69
|
+
cache: 'npm'
|
|
70
|
+
|
|
71
|
+
- name: Restore build artifacts
|
|
72
|
+
uses: actions/cache/restore@v4
|
|
73
|
+
with:
|
|
74
|
+
path: |
|
|
75
|
+
node_modules
|
|
76
|
+
dist
|
|
77
|
+
build
|
|
78
|
+
key: build-${{ github.sha }}
|
|
79
|
+
|
|
80
|
+
- name: Verify package version matches release tag
|
|
81
|
+
run: |
|
|
82
|
+
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
|
83
|
+
RELEASE_TAG=${GITHUB_REF#refs/tags/}
|
|
84
|
+
# Remove 'v' prefix if present in tag
|
|
85
|
+
RELEASE_VERSION=${RELEASE_TAG#v}
|
|
86
|
+
|
|
87
|
+
echo "Package version: $PACKAGE_VERSION"
|
|
88
|
+
echo "Release version: $RELEASE_VERSION"
|
|
89
|
+
|
|
90
|
+
if [ "$PACKAGE_VERSION" != "$RELEASE_VERSION" ]; then
|
|
91
|
+
echo "Error: Package version ($PACKAGE_VERSION) does not match release tag ($RELEASE_VERSION)"
|
|
92
|
+
exit 1
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
- name: Publish to npm with provenance
|
|
96
|
+
run: npm publish --provenance --access public
|
|
97
|
+
env:
|
|
98
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
CHANGED
|
@@ -8,9 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
## ๐ Version 2.1.
|
|
11
|
+
## ๐ Version 2.1.3 - Configuration Update
|
|
12
12
|
|
|
13
|
-
Version 2.1.
|
|
13
|
+
Version 2.1.3 updates the default caching behavior for better development experience while maintaining production-ready performance.
|
|
14
|
+
|
|
15
|
+
### What's New in 2.1.3
|
|
16
|
+
|
|
17
|
+
โ
**Development-Friendly Defaults** - `enableCaching` now defaults to `false` for easier development
|
|
18
|
+
โ
**Production Guidance** - Clear documentation on enabling caching for production environments
|
|
19
|
+
โ
**Enhanced Documentation** - Comprehensive notes on caching configuration and recommendations
|
|
14
20
|
|
|
15
21
|
### What's New in 2.1.2
|
|
16
22
|
|
|
@@ -228,10 +234,14 @@ app.use(koaClassicServer(__dirname + '/public', {
|
|
|
228
234
|
}));
|
|
229
235
|
```
|
|
230
236
|
|
|
231
|
-
**
|
|
237
|
+
**โ ๏ธ Important: Production Recommendation**
|
|
238
|
+
|
|
239
|
+
The default value for `enableCaching` is `false` to facilitate development (where you want changes to be immediately visible). **For production environments, it is strongly recommended to set `enableCaching: true`** to benefit from:
|
|
240
|
+
|
|
232
241
|
- 80-95% bandwidth reduction
|
|
233
242
|
- 304 Not Modified responses for unchanged files
|
|
234
243
|
- Faster page loads for returning visitors
|
|
244
|
+
- Reduced server load
|
|
235
245
|
|
|
236
246
|
**See details:** [HTTP Caching Optimization โ](./docs/OPTIMIZATION_HTTP_CACHING.md)
|
|
237
247
|
|
|
@@ -357,7 +367,8 @@ Creates a Koa middleware for serving static files.
|
|
|
357
367
|
},
|
|
358
368
|
|
|
359
369
|
// HTTP caching configuration
|
|
360
|
-
|
|
370
|
+
// NOTE: Default is false for development. Set to true in production for better performance!
|
|
371
|
+
enableCaching: false, // Enable ETag & Last-Modified (default: false)
|
|
361
372
|
cacheMaxAge: 3600, // Cache-Control max-age in seconds (default: 3600 = 1 hour)
|
|
362
373
|
}
|
|
363
374
|
```
|
|
@@ -373,7 +384,7 @@ Creates a Koa middleware for serving static files.
|
|
|
373
384
|
| `urlsReserved` | Array | `[]` | Reserved directory paths (first-level only) |
|
|
374
385
|
| `template.render` | Function | `undefined` | Template rendering function |
|
|
375
386
|
| `template.ext` | Array | `[]` | Extensions for template rendering |
|
|
376
|
-
| `enableCaching` | Boolean | `
|
|
387
|
+
| `enableCaching` | Boolean | `false` | Enable HTTP caching headers (recommended: `true` in production) |
|
|
377
388
|
| `cacheMaxAge` | Number | `3600` | Cache duration in seconds |
|
|
378
389
|
|
|
379
390
|
---
|
|
@@ -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/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,84 @@ All notable changes to koa-classic-server will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.1.3] - 2025-11-25
|
|
9
|
+
|
|
10
|
+
### ๐ง Configuration Changes
|
|
11
|
+
|
|
12
|
+
#### Changed Default Caching Behavior
|
|
13
|
+
- **Change**: `enableCaching` default value changed from `true` to `false`
|
|
14
|
+
- **Rationale**: Better development experience - changes are immediately visible without cache invalidation
|
|
15
|
+
- **Production Impact**: **Users should explicitly set `enableCaching: true` in production environments**
|
|
16
|
+
- **Benefits in Production**:
|
|
17
|
+
- 80-95% bandwidth reduction
|
|
18
|
+
- Faster page loads with 304 Not Modified responses
|
|
19
|
+
- Reduced server load
|
|
20
|
+
- **Code**: `index.cjs:107`
|
|
21
|
+
|
|
22
|
+
### ๐ Documentation Improvements
|
|
23
|
+
|
|
24
|
+
#### Enhanced Caching Documentation
|
|
25
|
+
- Added comprehensive production recommendations in README.md
|
|
26
|
+
- Added inline code comments explaining the default behavior
|
|
27
|
+
- Clear guidance on when to enable caching (development vs production)
|
|
28
|
+
- **Files**: `README.md`, `index.cjs`
|
|
29
|
+
|
|
30
|
+
### โ ๏ธ Migration Notice
|
|
31
|
+
|
|
32
|
+
**IMPORTANT**: If you are upgrading from 2.1.2 or earlier and rely on HTTP caching:
|
|
33
|
+
|
|
34
|
+
```javascript
|
|
35
|
+
// You must now explicitly enable caching in production
|
|
36
|
+
app.use(koaClassicServer(__dirname + '/public', {
|
|
37
|
+
enableCaching: true // โ Add this for production environments
|
|
38
|
+
}));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Development**: No changes needed - the new default (`false`) is better for development.
|
|
42
|
+
|
|
43
|
+
**Production**: Explicitly set `enableCaching: true` to maintain previous behavior and performance benefits.
|
|
44
|
+
|
|
45
|
+
### ๐ฆ Package Changes
|
|
46
|
+
|
|
47
|
+
- **Version**: `2.1.2` โ `2.1.3`
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## [2.1.2] - 2025-11-24
|
|
52
|
+
|
|
53
|
+
### ๐จ Features
|
|
54
|
+
|
|
55
|
+
#### Sortable Directory Columns
|
|
56
|
+
- Apache2-like directory listing with clickable column headers
|
|
57
|
+
- Sort by Name, Type, or Size (ascending/descending)
|
|
58
|
+
- Fixed navigation bug after sorting
|
|
59
|
+
|
|
60
|
+
#### File Size Display
|
|
61
|
+
- Human-readable file sizes (B, KB, MB, GB, TB)
|
|
62
|
+
- Proper formatting and precision
|
|
63
|
+
|
|
64
|
+
#### HTTP Caching
|
|
65
|
+
- ETag and Last-Modified headers
|
|
66
|
+
- 304 Not Modified responses
|
|
67
|
+
- 80-95% bandwidth reduction
|
|
68
|
+
|
|
69
|
+
### ๐งช Testing
|
|
70
|
+
- 153 tests passing
|
|
71
|
+
- Comprehensive test coverage
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## [2.1.1] - 2025-11-23
|
|
76
|
+
|
|
77
|
+
### ๐ Production Release
|
|
78
|
+
|
|
79
|
+
- Async/await implementation
|
|
80
|
+
- Non-blocking I/O
|
|
81
|
+
- Performance optimizations
|
|
82
|
+
- Flow documentation
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
8
86
|
## [1.2.0] - 2025-11-17
|
|
9
87
|
|
|
10
88
|
### ๐ SECURITY & BUG FIX RELEASE
|
package/index.cjs
CHANGED
|
@@ -45,7 +45,10 @@ module.exports = function koaClassicServer(
|
|
|
45
45
|
ext: [], // File extensions to process with template.render
|
|
46
46
|
},
|
|
47
47
|
cacheMaxAge: 3600, // Cache-Control max-age in seconds (default: 1 hour)
|
|
48
|
-
enableCaching:
|
|
48
|
+
enableCaching: false, // Enable HTTP caching headers (ETag, Last-Modified)
|
|
49
|
+
// NOTE: Default is false for development.
|
|
50
|
+
// In production, it's recommended to set enableCaching: true
|
|
51
|
+
// to reduce bandwidth usage and improve performance.
|
|
49
52
|
}
|
|
50
53
|
*/
|
|
51
54
|
) {
|
|
@@ -97,8 +100,11 @@ module.exports = function koaClassicServer(
|
|
|
97
100
|
options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
|
|
98
101
|
|
|
99
102
|
// OPTIMIZATION: HTTP Caching options
|
|
103
|
+
// NOTE: Default enableCaching is false for development environments.
|
|
104
|
+
// For production deployments, it's strongly recommended to enable caching
|
|
105
|
+
// by setting enableCaching: true to benefit from reduced bandwidth and improved performance.
|
|
100
106
|
options.cacheMaxAge = typeof options.cacheMaxAge === 'number' && options.cacheMaxAge >= 0 ? options.cacheMaxAge : 3600;
|
|
101
|
-
options.enableCaching = typeof options.enableCaching === 'boolean' ? options.enableCaching :
|
|
107
|
+
options.enableCaching = typeof options.enableCaching === 'boolean' ? options.enableCaching : false;
|
|
102
108
|
|
|
103
109
|
return async (ctx, next) => {
|
|
104
110
|
// Check if method is allowed
|
|
@@ -347,6 +353,12 @@ module.exports = function koaClassicServer(
|
|
|
347
353
|
return;
|
|
348
354
|
}
|
|
349
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
|
|
350
362
|
}
|
|
351
363
|
|
|
352
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
|
+
"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": {
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
],
|
|
30
30
|
"author": "Italo Paesano",
|
|
31
31
|
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/italopaesano/koa-classic-server"
|
|
35
|
+
},
|
|
32
36
|
"dependencies": {
|
|
33
37
|
"mime-types": "^2.1.35"
|
|
34
38
|
},
|