koa-classic-server 2.6.1 → 3.0.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 (57) hide show
  1. package/CLAUDE.md +101 -0
  2. package/README.md +564 -591
  3. package/__tests__/benchmark-results-v3.0.0.txt +372 -0
  4. package/__tests__/benchmark.js +1 -1
  5. package/__tests__/caching-headers.test.js +30 -30
  6. package/__tests__/compression-fixtures/data.json +1 -0
  7. package/__tests__/compression-fixtures/large.txt +1 -0
  8. package/__tests__/compression-fixtures/small.txt +1 -0
  9. package/__tests__/compression.test.js +284 -0
  10. package/__tests__/customTest/serversToLoad.util.js +5 -5
  11. package/__tests__/demo-regex-index.js +4 -4
  12. package/__tests__/deprecation-warnings.test.js +71 -183
  13. package/__tests__/directory-sorting-links.test.js +1 -1
  14. package/__tests__/dt-unknown.test.js +39 -28
  15. package/__tests__/ejs.test.js +1 -1
  16. package/__tests__/hidden-fixtures/.dot-dir/inside.txt +1 -0
  17. package/__tests__/hidden-fixtures/.env +2 -0
  18. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +1 -0
  19. package/__tests__/hidden-fixtures/config/secrets/password.txt +1 -0
  20. package/__tests__/hidden-fixtures/data.key +1 -0
  21. package/__tests__/hidden-fixtures/file.secret +1 -0
  22. package/__tests__/hidden-fixtures/index.html +1 -0
  23. package/__tests__/hidden-fixtures/normal.txt +1 -0
  24. package/__tests__/hidden-fixtures/subdir/.env +1 -0
  25. package/__tests__/hidden-fixtures/subdir/regular.txt +1 -0
  26. package/__tests__/hidden-option.test.js +407 -0
  27. package/__tests__/hideExtension.test.js +70 -13
  28. package/__tests__/index-option.test.js +18 -16
  29. package/__tests__/index.test.js +14 -10
  30. package/__tests__/listing.test.js +437 -0
  31. package/__tests__/logger.test.js +232 -0
  32. package/__tests__/range-fixtures/sample.txt +1 -0
  33. package/__tests__/range.test.js +223 -0
  34. package/__tests__/security-headers.test.js +165 -0
  35. package/__tests__/security.test.js +148 -162
  36. package/__tests__/server-cache-fixtures/large.txt +1 -0
  37. package/__tests__/server-cache-fixtures/small.txt +1 -0
  38. package/__tests__/server-cache.test.js +594 -0
  39. package/__tests__/symlink.test.js +18 -15
  40. package/__tests__/template-timeout.test.js +321 -0
  41. package/docs/ACTION_PLAN.md +293 -0
  42. package/docs/CHANGELOG.md +289 -0
  43. package/docs/CODE_REVIEW.md +2 -0
  44. package/docs/DOCUMENTATION.md +259 -32
  45. package/docs/EXAMPLES_INDEX_OPTION.md +3 -3
  46. package/docs/FLOW_DIAGRAM.md +15 -13
  47. package/docs/INDEX_OPTION_PRIORITY.md +2 -2
  48. package/docs/OPTIMIZATION_HTTP_CACHING.md +2 -0
  49. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +864 -0
  50. package/docs/PERFORMANCE_COMPARISON.md +7 -7
  51. package/docs/security_improvement_for_V3.md +421 -0
  52. package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +5 -5
  53. package/docs/template-engine/esempi-incrementali.js +1 -1
  54. package/eslint.config.mjs +17 -0
  55. package/index.cjs +1507 -429
  56. package/index.mjs +1 -5
  57. package/package.json +9 -1
@@ -124,7 +124,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
124
124
  const app = new Koa();
125
125
  app.use(koaClassicServer(tmpDir, {
126
126
  index: ['index.html'],
127
- showDirContents: true
127
+ dirListing: { enabled: true }
128
128
  }));
129
129
  server = app.listen();
130
130
  request = supertest(server);
@@ -150,7 +150,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
150
150
  const app = new Koa();
151
151
  app.use(koaClassicServer(tmpDir, {
152
152
  index: ['index.ejs'],
153
- showDirContents: true
153
+ dirListing: { enabled: true }
154
154
  }));
155
155
  server = app.listen();
156
156
  request = supertest(server);
@@ -161,8 +161,9 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
161
161
  test('GET / serves symlinked index.ejs, not directory listing', async () => {
162
162
  const res = await request.get('/');
163
163
  expect(res.status).toBe(200);
164
- expect(res.text).toContain('EJS via Symlink');
165
- expect(res.text).not.toContain('Index of');
164
+ const body = res.text !== undefined ? res.text : res.body.toString('utf8');
165
+ expect(body).toContain('EJS via Symlink');
166
+ expect(body).not.toContain('Index of');
166
167
  });
167
168
  });
168
169
 
@@ -176,7 +177,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
176
177
  const app = new Koa();
177
178
  app.use(koaClassicServer(tmpDir, {
178
179
  index: [],
179
- showDirContents: true
180
+ dirListing: { enabled: true }
180
181
  }));
181
182
  server = app.listen();
182
183
  request = supertest(server);
@@ -187,7 +188,8 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
187
188
  test('GET /index.ejs via symlink returns 200', async () => {
188
189
  const res = await request.get('/index.ejs');
189
190
  expect(res.status).toBe(200);
190
- expect(res.text).toContain('EJS via Symlink');
191
+ const body = res.text !== undefined ? res.text : res.body.toString('utf8');
192
+ expect(body).toContain('EJS via Symlink');
191
193
  });
192
194
 
193
195
  test('GET /linked-style.css via symlink returns 200 with correct mime', async () => {
@@ -208,7 +210,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
208
210
  const app = new Koa();
209
211
  app.use(koaClassicServer(tmpDir, {
210
212
  index: ['index.ejs'],
211
- showDirContents: true,
213
+ dirListing: { enabled: true },
212
214
  template: {
213
215
  ext: ['ejs'],
214
216
  render: async (ctx, next, filePath) => {
@@ -242,7 +244,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
242
244
  const app = new Koa();
243
245
  app.use(koaClassicServer(tmpDir, {
244
246
  index: [],
245
- showDirContents: true
247
+ dirListing: { enabled: true }
246
248
  }));
247
249
  server = app.listen();
248
250
  request = supertest(server);
@@ -273,7 +275,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
273
275
  const app = new Koa();
274
276
  app.use(koaClassicServer(tmpDir, {
275
277
  index: [],
276
- showDirContents: true
278
+ dirListing: { enabled: true }
277
279
  }));
278
280
  server = app.listen();
279
281
  request = supertest(server);
@@ -299,7 +301,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
299
301
  const app = new Koa();
300
302
  app.use(koaClassicServer(tmpDir, {
301
303
  index: [],
302
- showDirContents: true
304
+ dirListing: { enabled: true }
303
305
  }));
304
306
  server = app.listen();
305
307
  request = supertest(server);
@@ -328,7 +330,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
328
330
  const app = new Koa();
329
331
  app.use(koaClassicServer(tmpDir, {
330
332
  index: ['index.html'],
331
- showDirContents: true
333
+ dirListing: { enabled: true }
332
334
  }));
333
335
  server = app.listen();
334
336
  request = supertest(server);
@@ -354,7 +356,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
354
356
  const app = new Koa();
355
357
  app.use(koaClassicServer(tmpDir, {
356
358
  index: [],
357
- showDirContents: true
359
+ dirListing: { enabled: true }
358
360
  }));
359
361
  server = app.listen();
360
362
  request = supertest(server);
@@ -420,7 +422,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
420
422
  const app = new Koa();
421
423
  app.use(koaClassicServer(tmpDir, {
422
424
  index: [/index\.[eE][jJ][sS]/],
423
- showDirContents: true
425
+ dirListing: { enabled: true }
424
426
  }));
425
427
  server = app.listen();
426
428
  request = supertest(server);
@@ -431,8 +433,9 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
431
433
  test('GET / finds symlinked index.ejs via RegExp pattern', async () => {
432
434
  const res = await request.get('/');
433
435
  expect(res.status).toBe(200);
434
- expect(res.text).toContain('EJS via Symlink');
435
- expect(res.text).not.toContain('Index of');
436
+ const body = res.text !== undefined ? res.text : res.body.toString('utf8');
437
+ expect(body).toContain('EJS via Symlink');
438
+ expect(body).not.toContain('Index of');
436
439
  });
437
440
  });
438
441
  });
@@ -0,0 +1,321 @@
1
+ const Koa = require('koa');
2
+ const koaClassicServer = require('../index.cjs');
3
+ const supertest = require('supertest');
4
+ const path = require('path');
5
+
6
+ const ROOT = path.join(__dirname, 'publicWwwTest');
7
+ const TEMPLATE_URL = '/ejs-templates/simple.ejs';
8
+
9
+ function buildServer(renderFn, opts = {}) {
10
+ const app = new Koa();
11
+ app.silent = true;
12
+ app.use(koaClassicServer(ROOT, {
13
+ method: ['GET'],
14
+ hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'visible' } },
15
+ template: {
16
+ ext: ['ejs'],
17
+ render: renderFn,
18
+ ...(opts.renderTimeout !== undefined ? { renderTimeout: opts.renderTimeout } : {})
19
+ }
20
+ }));
21
+ const server = app.listen();
22
+ return { server, request: supertest(server) };
23
+ }
24
+
25
+ // Sleep that does not keep the event loop alive — used to simulate
26
+ // non-cooperative long-running work without leaving orphan timers behind.
27
+ function unrefSleep(ms) {
28
+ return new Promise(resolve => {
29
+ const t = setTimeout(resolve, ms);
30
+ if (typeof t.unref === 'function') t.unref();
31
+ });
32
+ }
33
+
34
+ describe('template.renderTimeout', () => {
35
+ let consoleErrorSpy;
36
+
37
+ beforeEach(() => {
38
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
39
+ });
40
+
41
+ afterEach(() => {
42
+ consoleErrorSpy.mockRestore();
43
+ });
44
+
45
+ describe('Factory validation', () => {
46
+ test('rejects negative renderTimeout', () => {
47
+ expect(() => koaClassicServer(ROOT, {
48
+ template: { ext: ['ejs'], render: () => {}, renderTimeout: -1 }
49
+ })).toThrow(/renderTimeout must be a finite number/);
50
+ });
51
+
52
+ test('rejects non-number renderTimeout', () => {
53
+ expect(() => koaClassicServer(ROOT, {
54
+ template: { ext: ['ejs'], render: () => {}, renderTimeout: '5000' }
55
+ })).toThrow(/renderTimeout must be a finite number/);
56
+ });
57
+
58
+ test('rejects NaN renderTimeout', () => {
59
+ expect(() => koaClassicServer(ROOT, {
60
+ template: { ext: ['ejs'], render: () => {}, renderTimeout: NaN }
61
+ })).toThrow(/renderTimeout must be a finite number/);
62
+ });
63
+
64
+ test('rejects Infinity renderTimeout', () => {
65
+ expect(() => koaClassicServer(ROOT, {
66
+ template: { ext: ['ejs'], render: () => {}, renderTimeout: Infinity }
67
+ })).toThrow(/renderTimeout must be a finite number/);
68
+ });
69
+
70
+ test('accepts 0 (disabled)', () => {
71
+ expect(() => koaClassicServer(ROOT, {
72
+ template: { ext: ['ejs'], render: () => {}, renderTimeout: 0 }
73
+ })).not.toThrow();
74
+ });
75
+
76
+ test('accepts positive integer', () => {
77
+ expect(() => koaClassicServer(ROOT, {
78
+ template: { ext: ['ejs'], render: () => {}, renderTimeout: 1000 }
79
+ })).not.toThrow();
80
+ });
81
+
82
+ test('defaults to 30000 when undefined', () => {
83
+ const opts = { template: { ext: ['ejs'], render: () => {} } };
84
+ koaClassicServer(ROOT, opts);
85
+ expect(opts.template.renderTimeout).toBe(30000);
86
+ });
87
+ });
88
+
89
+ describe('Successful render within timeout', () => {
90
+ let env;
91
+ afterEach(() => env && env.server.close());
92
+
93
+ test('returns 200 when render completes before timeout', async () => {
94
+ env = buildServer(async (ctx) => {
95
+ await new Promise(r => setTimeout(r, 20));
96
+ ctx.type = 'text/html';
97
+ ctx.body = '<p>ok</p>';
98
+ }, { renderTimeout: 500 });
99
+
100
+ const res = await env.request.get(TEMPLATE_URL);
101
+ expect(res.status).toBe(200);
102
+ expect(res.text).toContain('ok');
103
+ });
104
+ });
105
+
106
+ describe('Timeout behaviour', () => {
107
+ let env;
108
+ afterEach(() => env && env.server.close());
109
+
110
+ test('returns 504 when render exceeds timeout', async () => {
111
+ env = buildServer(async () => {
112
+ await unrefSleep(5000);
113
+ }, { renderTimeout: 100 });
114
+
115
+ const res = await env.request.get(TEMPLATE_URL);
116
+ expect(res.status).toBe(504);
117
+ expect(res.text).toContain('Gateway Timeout');
118
+ expect(res.text).toContain('took too long to render');
119
+ });
120
+
121
+ test('504 response carries security headers', async () => {
122
+ env = buildServer(async () => {
123
+ await unrefSleep(5000);
124
+ }, { renderTimeout: 50 });
125
+
126
+ const res = await env.request.get(TEMPLATE_URL);
127
+ expect(res.status).toBe(504);
128
+ expect(res.headers['content-security-policy']).toMatch(/default-src 'none'/);
129
+ expect(res.headers['x-content-type-options']).toBe('nosniff');
130
+ expect(res.headers['x-frame-options']).toBe('DENY');
131
+ });
132
+
133
+ test('renderTimeout: 0 disables the timer (render is allowed to run long)', async () => {
134
+ env = buildServer(async (ctx) => {
135
+ await new Promise(r => setTimeout(r, 200));
136
+ ctx.type = 'text/html';
137
+ ctx.body = '<p>slow but ok</p>';
138
+ }, { renderTimeout: 0 });
139
+
140
+ const res = await env.request.get(TEMPLATE_URL);
141
+ expect(res.status).toBe(200);
142
+ expect(res.text).toContain('slow but ok');
143
+ });
144
+ });
145
+
146
+ describe('Render argument contract', () => {
147
+ let env;
148
+ afterEach(() => env && env.server.close());
149
+
150
+ test('render is called with (ctx, next, filePath, rawBuffer, signal) in that order', async () => {
151
+ let received;
152
+ env = buildServer(async (...args) => {
153
+ received = args;
154
+ args[0].body = 'ok';
155
+ }, { renderTimeout: 1000 });
156
+
157
+ await env.request.get(TEMPLATE_URL);
158
+
159
+ expect(received).toHaveLength(5);
160
+ // ctx: Koa context — must expose req/res/state
161
+ expect(received[0]).toBeDefined();
162
+ expect(received[0].req).toBeDefined();
163
+ expect(received[0].res).toBeDefined();
164
+ expect(received[0].state).toBeDefined();
165
+ // next: function (downstream middleware)
166
+ expect(typeof received[1]).toBe('function');
167
+ // filePath: absolute path to the requested file
168
+ expect(typeof received[2]).toBe('string');
169
+ expect(path.isAbsolute(received[2])).toBe(true);
170
+ expect(received[2]).toMatch(/simple\.ejs$/);
171
+ // rawBuffer: Buffer or null depending on serverCache.rawFile state
172
+ expect(received[3] === null || Buffer.isBuffer(received[3])).toBe(true);
173
+ // signal: AbortSignal
174
+ expect(received[4]).toBeInstanceOf(AbortSignal);
175
+ });
176
+ });
177
+
178
+ describe('AbortSignal contract', () => {
179
+ let env;
180
+ afterEach(() => env && env.server.close());
181
+
182
+ test('passes an AbortSignal as 5th argument', async () => {
183
+ let receivedSignal;
184
+ env = buildServer(async (ctx, _next, _path, _buf, signal) => {
185
+ receivedSignal = signal;
186
+ ctx.body = 'ok';
187
+ }, { renderTimeout: 1000 });
188
+
189
+ await env.request.get(TEMPLATE_URL);
190
+ expect(receivedSignal).toBeInstanceOf(AbortSignal);
191
+ expect(receivedSignal.aborted).toBe(false);
192
+ });
193
+
194
+ test('signal is NOT aborted after a successful render completes', async () => {
195
+ let signalAtEnd;
196
+ let signalRef;
197
+ env = buildServer(async (ctx, _next, _path, _buf, signal) => {
198
+ signalRef = signal;
199
+ await new Promise(r => setTimeout(r, 20));
200
+ signalAtEnd = signal.aborted;
201
+ ctx.type = 'text/html';
202
+ ctx.body = '<p>done</p>';
203
+ }, { renderTimeout: 1000 });
204
+
205
+ const res = await env.request.get(TEMPLATE_URL);
206
+ expect(res.status).toBe(200);
207
+ // Signal must not be aborted at the end of a successful render
208
+ expect(signalAtEnd).toBe(false);
209
+ // ...and must remain non-aborted after the handler finishes
210
+ // (i.e. our cleanup must not abort it as a side-effect)
211
+ await new Promise(r => setTimeout(r, 50));
212
+ expect(signalRef.aborted).toBe(false);
213
+ });
214
+
215
+ test('signal aborts when render times out', async () => {
216
+ let signalAtTimeout = null;
217
+ env = buildServer(async (ctx, _next, _path, _buf, signal) => {
218
+ await unrefSleep(300);
219
+ signalAtTimeout = signal.aborted;
220
+ }, { renderTimeout: 50 });
221
+
222
+ const res = await env.request.get(TEMPLATE_URL);
223
+ expect(res.status).toBe(504);
224
+ await new Promise(r => setTimeout(r, 400));
225
+ expect(signalAtTimeout).toBe(true);
226
+ });
227
+
228
+ test('cooperative render that honours signal terminates early', async () => {
229
+ const renderDuration = jest.fn();
230
+ env = buildServer(async (ctx, _next, _path, _buf, signal) => {
231
+ const start = Date.now();
232
+ try {
233
+ await new Promise((resolve, reject) => {
234
+ const t = setTimeout(resolve, 5000);
235
+ signal.addEventListener('abort', () => {
236
+ clearTimeout(t);
237
+ reject(new Error('aborted'));
238
+ });
239
+ });
240
+ } finally {
241
+ renderDuration(Date.now() - start);
242
+ }
243
+ }, { renderTimeout: 50 });
244
+
245
+ const res = await env.request.get(TEMPLATE_URL);
246
+ expect(res.status).toBe(504);
247
+ await new Promise(r => setTimeout(r, 200));
248
+ const elapsed = renderDuration.mock.calls[0]?.[0] ?? 9999;
249
+ expect(elapsed).toBeLessThan(1000);
250
+ });
251
+ });
252
+
253
+ describe('Error handling integrity', () => {
254
+ let env;
255
+ afterEach(() => env && env.server.close());
256
+
257
+ test('late rejection after timeout does not crash the process', async () => {
258
+ const unhandledHandler = jest.fn();
259
+ process.on('unhandledRejection', unhandledHandler);
260
+
261
+ env = buildServer(async () => {
262
+ await new Promise(r => setTimeout(r, 100));
263
+ throw new Error('late failure');
264
+ }, { renderTimeout: 30 });
265
+
266
+ const res = await env.request.get(TEMPLATE_URL);
267
+ expect(res.status).toBe(504);
268
+
269
+ await new Promise(r => setTimeout(r, 250));
270
+ process.off('unhandledRejection', unhandledHandler);
271
+ expect(unhandledHandler).not.toHaveBeenCalled();
272
+ });
273
+
274
+ test('synchronous render returning rejected promise still produces 500', async () => {
275
+ env = buildServer(() => Promise.reject(new Error('boom')), { renderTimeout: 1000 });
276
+
277
+ const res = await env.request.get(TEMPLATE_URL);
278
+ expect(res.status).toBe(500);
279
+ expect(res.text).toContain('Internal Server Error');
280
+ });
281
+
282
+ test('synchronous throw in render produces 500', async () => {
283
+ env = buildServer(() => { throw new Error('sync boom'); }, { renderTimeout: 1000 });
284
+
285
+ const res = await env.request.get(TEMPLATE_URL);
286
+ expect(res.status).toBe(500);
287
+ expect(res.text).toContain('Internal Server Error');
288
+ });
289
+ });
290
+
291
+ describe('Client disconnect', () => {
292
+ let env;
293
+ afterEach(() => env && env.server.close());
294
+
295
+ test('signal aborts when client closes the connection before render finishes', async () => {
296
+ let abortObserved = false;
297
+ env = buildServer(async (ctx, _next, _path, _buf, signal) => {
298
+ signal.addEventListener('abort', () => { abortObserved = true; });
299
+ await unrefSleep(1000);
300
+ ctx.body = 'late';
301
+ }, { renderTimeout: 0 });
302
+
303
+ const addr = env.server.address();
304
+ const http = require('http');
305
+ await new Promise((resolve) => {
306
+ const req = http.request({
307
+ host: addr.address === '::' ? '127.0.0.1' : addr.address,
308
+ port: addr.port,
309
+ path: TEMPLATE_URL,
310
+ method: 'GET'
311
+ }, () => {});
312
+ req.on('error', () => {});
313
+ req.end();
314
+ setTimeout(() => { req.destroy(); resolve(); }, 50);
315
+ });
316
+
317
+ await new Promise(r => setTimeout(r, 100));
318
+ expect(abortObserved).toBe(true);
319
+ });
320
+ });
321
+ });