koa-classic-server 2.4.0 → 2.5.1

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/koa-classic-server.svg)](https://www.npmjs.com/package/koa-classic-server)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![Tests](https://img.shields.io/badge/tests-214%20passing-brightgreen.svg)]()
7
+ [![Tests](https://img.shields.io/badge/tests-309%20passing-brightgreen.svg)]()
8
8
 
9
9
  ---
10
10
 
@@ -26,8 +26,9 @@ The 2.X series brings major performance improvements, enhanced security, and pow
26
26
  ✅ **Enhanced Index Option** - Array format with RegExp support
27
27
  ✅ **Template Engine Support** - EJS, Pug, Handlebars, Nunjucks, and more
28
28
  ✅ **Enterprise Security** - Path traversal, XSS, race condition protection
29
+ ✅ **Clean URLs** - Hide file extensions with `hideExtension` (mod_rewrite-like behavior)
29
30
  ✅ **Symlink Support** - Full symbolic link support (NixOS, Docker, npm link, Capistrano)
30
- ✅ **Comprehensive Testing** - 214 tests passing with extensive coverage
31
+ ✅ **Comprehensive Testing** - 309 tests passing with extensive coverage
31
32
  ✅ **Complete Documentation** - Detailed guides and examples
32
33
 
33
34
  [See full changelog →](./docs/CHANGELOG.md)
@@ -50,7 +51,8 @@ The 2.X series brings major performance improvements, enhanced security, and pow
50
51
  - ⚙️ **Highly Configurable** - URL prefixes, reserved paths, index files
51
52
  - 🚀 **High Performance** - Async/await, non-blocking I/O, optimized algorithms
52
53
  - 🔗 **Symlink Support** - Transparent symlink resolution with directory listing indicators
53
- - 🧪 **Well-Tested** - 214 passing tests with comprehensive coverage
54
+ - 🌐 **Clean URLs** - Hide file extensions for SEO-friendly URLs via `hideExtension`
55
+ - 🧪 **Well-Tested** - 309 passing tests with comprehensive coverage
54
56
  - 📦 **Dual Module Support** - CommonJS and ES Modules
55
57
 
56
58
  ---
@@ -218,7 +220,177 @@ app.use(koaClassicServer(__dirname + '/views', {
218
220
 
219
221
  **See complete guide:** [Template Engine Documentation →](./docs/template-engine/TEMPLATE_ENGINE_GUIDE.md)
220
222
 
221
- ### 6. With HTTP Caching
223
+ ### 6. Clean URLs with hideExtension
224
+
225
+ Hide file extensions from URLs, similar to Apache's `mod_rewrite`:
226
+
227
+ ```javascript
228
+ const ejs = require('ejs');
229
+
230
+ app.use(koaClassicServer(__dirname + '/public', {
231
+ showDirContents: true,
232
+ index: ['index.ejs'],
233
+ hideExtension: {
234
+ ext: '.ejs', // Extension to hide (required)
235
+ redirect: 301 // HTTP redirect code (optional, default: 301)
236
+ },
237
+ template: {
238
+ ext: ['ejs'],
239
+ render: async (ctx, next, filePath) => {
240
+ ctx.body = await ejs.renderFile(filePath, data);
241
+ ctx.type = 'text/html';
242
+ }
243
+ }
244
+ }));
245
+ ```
246
+
247
+ **URL Behavior:**
248
+
249
+ | Request URL | Action | Result |
250
+ |-------------|--------|--------|
251
+ | `/about` | Resolves `about.ejs` | Serves file (200) |
252
+ | `/blog/article` | Resolves `blog/article.ejs` | Serves file (200) |
253
+ | `/about.ejs` | Redirect | 301 → `/about` |
254
+ | `/about.ejs?lang=it` | Redirect | 301 → `/about?lang=it` |
255
+ | `/index.ejs` | Redirect | 301 → `/` |
256
+ | `/section/index.ejs` | Redirect | 301 → `/section/` |
257
+ | `/style.css` | No interference | Normal flow |
258
+ | `/about/` | No interference | Shows directory listing |
259
+
260
+ **Conflict Resolution:**
261
+
262
+ - **Directory vs file**: When both `about/` directory and `about.ejs` file exist, `/about` serves the file. Use `/about/` to access the directory.
263
+ - **Extensionless vs extension**: When both `about` (no ext) and `about.ejs` exist, `/about` always serves `about.ejs`. The extensionless file becomes unreachable.
264
+
265
+ > **Note**: This conflict resolution behavior differs from Apache/Nginx, where directories typically take priority over files with the same base name.
266
+
267
+ ### 7. URL Rewriting Support (useOriginalUrl)
268
+
269
+ When using URL rewriting middleware (i18n, routing), set `useOriginalUrl: false` so koa-classic-server resolves files from the rewritten URL instead of the original one:
270
+
271
+ ```javascript
272
+ const Koa = require('koa');
273
+ const koaClassicServer = require('koa-classic-server');
274
+
275
+ const app = new Koa();
276
+
277
+ // i18n middleware: strips language prefix and rewrites ctx.url
278
+ app.use(async (ctx, next) => {
279
+ const langMatch = ctx.path.match(/^\/(it|en|fr|de|es)\//);
280
+ if (langMatch) {
281
+ ctx.state.lang = langMatch[1]; // Save language for templates
282
+ ctx.url = ctx.path.replace(/^\/(it|en|fr|de|es)/, '') + ctx.search;
283
+ }
284
+ await next();
285
+ });
286
+
287
+ // Serve files using the rewritten URL
288
+ app.use(koaClassicServer(__dirname + '/public', {
289
+ useOriginalUrl: false // Use ctx.url (rewritten) instead of ctx.originalUrl
290
+ }));
291
+
292
+ app.listen(3000);
293
+ ```
294
+
295
+ **How it works:**
296
+
297
+ | Request | `ctx.originalUrl` | `ctx.url` (rewritten) | File resolved |
298
+ |---------|-------------------|----------------------|---------------|
299
+ | `/it/page.html` | `/it/page.html` | `/page.html` | `public/page.html` |
300
+ | `/en/page.html` | `/en/page.html` | `/page.html` | `public/page.html` |
301
+ | `/page.html` | `/page.html` | `/page.html` | `public/page.html` |
302
+
303
+ - With `useOriginalUrl: true` (default): the server would look for `public/it/page.html` (which doesn't exist)
304
+ - With `useOriginalUrl: false`: the server looks for `public/page.html` (correct)
305
+
306
+ ### 8. Advanced hideExtension Scenarios
307
+
308
+ #### Recommended file structure
309
+
310
+ ```
311
+ views/
312
+ ├── index.ejs ← / (home page)
313
+ ├── about.ejs ← /about
314
+ ├── contact.ejs ← /contact
315
+ ├── blog/
316
+ │ ├── index.ejs ← /blog/
317
+ │ ├── first-post.ejs ← /blog/first-post
318
+ │ └── second-post.ejs ← /blog/second-post
319
+ ├── docs/
320
+ │ ├── index.ejs ← /docs/
321
+ │ ├── getting-started.ejs ← /docs/getting-started
322
+ │ └── api-reference.ejs ← /docs/api-reference
323
+ └── assets/
324
+ ├── style.css ← /assets/style.css (served normally)
325
+ └── script.js ← /assets/script.js (served normally)
326
+ ```
327
+
328
+ #### hideExtension with i18n middleware
329
+
330
+ Combine `hideExtension` with `useOriginalUrl: false` for multilingual sites with clean URLs:
331
+
332
+ ```javascript
333
+ const Koa = require('koa');
334
+ const koaClassicServer = require('koa-classic-server');
335
+ const ejs = require('ejs');
336
+
337
+ const app = new Koa();
338
+
339
+ // i18n middleware: /it/about → ctx.url = /about, ctx.state.lang = 'it'
340
+ app.use(async (ctx, next) => {
341
+ const langMatch = ctx.path.match(/^\/(it|en|fr)\//);
342
+ if (langMatch) {
343
+ ctx.state.lang = langMatch[1];
344
+ ctx.url = ctx.path.replace(/^\/(it|en|fr)/, '') + ctx.search;
345
+ } else {
346
+ ctx.state.lang = 'en'; // Default language
347
+ }
348
+ await next();
349
+ });
350
+
351
+ app.use(koaClassicServer(__dirname + '/views', {
352
+ index: ['index.ejs'],
353
+ useOriginalUrl: false, // Resolve files from rewritten URL
354
+ hideExtension: {
355
+ ext: '.ejs',
356
+ redirect: 301
357
+ },
358
+ template: {
359
+ ext: ['ejs'],
360
+ render: async (ctx, next, filePath) => {
361
+ ctx.body = await ejs.renderFile(filePath, { lang: ctx.state.lang });
362
+ ctx.type = 'text/html';
363
+ }
364
+ }
365
+ }));
366
+
367
+ app.listen(3000);
368
+ ```
369
+
370
+ **Result:**
371
+
372
+ | Request | Rewritten URL | File resolved | Redirect target |
373
+ |---------|---------------|---------------|-----------------|
374
+ | `/it/about` | `/about` | `views/about.ejs` | — |
375
+ | `/en/blog/first-post` | `/blog/first-post` | `views/blog/first-post.ejs` | — |
376
+ | `/it/about.ejs` | `/about.ejs` | — | 301 → `/it/about` (preserves `/it/` prefix) |
377
+
378
+ > **Note**: Redirects always use `ctx.originalUrl` to preserve the language prefix, regardless of the `useOriginalUrl` setting.
379
+
380
+ #### Temporary redirect (302)
381
+
382
+ Use `redirect: 302` instead of 301 when the URL mapping may change (staging, A/B testing, or during migration):
383
+
384
+ ```javascript
385
+ hideExtension: {
386
+ ext: '.ejs',
387
+ redirect: 302 // Temporary redirect — browsers won't cache it
388
+ }
389
+ ```
390
+
391
+ > **When to use 302**: A 301 (permanent) tells browsers and search engines to cache the redirect. Use 302 (temporary) during development, staging, or when you're not yet sure the clean URL structure is final.
392
+
393
+ ### 9. With HTTP Caching
222
394
 
223
395
  Enable aggressive caching for static files:
224
396
 
@@ -240,7 +412,7 @@ The default value for `browserCacheEnabled` is `false` to facilitate development
240
412
 
241
413
  **See details:** [HTTP Caching Optimization →](./docs/OPTIMIZATION_HTTP_CACHING.md)
242
414
 
243
- ### 7. Multiple Index Files with Priority
415
+ ### 10. Multiple Index Files with Priority
244
416
 
245
417
  Search for multiple index files with custom order:
246
418
 
@@ -257,7 +429,7 @@ app.use(koaClassicServer(__dirname + '/public', {
257
429
 
258
430
  **See details:** [Index Option Priority →](./docs/INDEX_OPTION_PRIORITY.md)
259
431
 
260
- ### 8. Complete Production Example
432
+ ### 11. Complete Production Example
261
433
 
262
434
  Real-world configuration for production:
263
435
 
@@ -280,9 +452,15 @@ app.use(koaClassicServer(path.join(__dirname, 'public'), {
280
452
  browserCacheMaxAge: 86400, // 24 hours
281
453
  }));
282
454
 
283
- // Serve dynamic templates
455
+ // Serve dynamic templates with clean URLs
284
456
  app.use(koaClassicServer(path.join(__dirname, 'views'), {
285
457
  showDirContents: false,
458
+ index: ['index.ejs'],
459
+ useOriginalUrl: false, // Use ctx.url (for i18n or routing middleware)
460
+ hideExtension: {
461
+ ext: '.ejs', // /about → serves about.ejs
462
+ redirect: 301 // /about.ejs → 301 redirect to /about
463
+ },
286
464
  template: {
287
465
  ext: ['ejs'],
288
466
  render: async (ctx, next, filePath) => {
@@ -370,6 +548,12 @@ Creates a Koa middleware for serving static files.
370
548
  useOriginalUrl: true, // Use ctx.originalUrl (default) or ctx.url
371
549
  // Set false for URL rewriting middleware (i18n, routing)
372
550
 
551
+ // Clean URLs - hide file extension from URLs (mod_rewrite-like)
552
+ hideExtension: {
553
+ ext: '.ejs', // Extension to hide (required, must start with '.')
554
+ redirect: 301 // HTTP redirect code (optional, default: 301)
555
+ },
556
+
373
557
  // DEPRECATED (use new names above):
374
558
  // enableCaching: use browserCacheEnabled instead
375
559
  // cacheMaxAge: use browserCacheMaxAge instead
@@ -390,6 +574,8 @@ Creates a Koa middleware for serving static files.
390
574
  | `browserCacheEnabled` | Boolean | `false` | Enable browser HTTP caching headers (recommended: `true` in production) |
391
575
  | `browserCacheMaxAge` | Number | `3600` | Browser cache duration in seconds |
392
576
  | `useOriginalUrl` | Boolean | `true` | Use `ctx.originalUrl` (default) or `ctx.url` for URL resolution |
577
+ | `hideExtension.ext` | String | - | Extension to hide (e.g. `'.ejs'`). Enables clean URL feature |
578
+ | `hideExtension.redirect` | Number | `301` | HTTP redirect code for URLs with extension |
393
579
  | ~~`enableCaching`~~ | Boolean | `false` | **DEPRECATED**: Use `browserCacheEnabled` instead |
394
580
  | ~~`cacheMaxAge`~~ | Number | `3600` | **DEPRECATED**: Use `browserCacheMaxAge` instead |
395
581
 
@@ -585,10 +771,11 @@ npm run test:performance
585
771
  ```
586
772
 
587
773
  **Test Coverage:**
588
- - ✅ 214 tests passing
774
+ - ✅ 309 tests passing
589
775
  - ✅ Security tests (path traversal, XSS, race conditions)
590
776
  - ✅ EJS template integration tests
591
777
  - ✅ Index option tests (strings, arrays, RegExp)
778
+ - ✅ hideExtension tests (clean URLs, redirects, conflicts, validation)
592
779
  - ✅ Symlink tests (file, directory, broken, circular, indicators)
593
780
  - ✅ Performance benchmarks
594
781
  - ✅ Directory sorting tests
@@ -0,0 +1,550 @@
1
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
2
+ //
3
+ // TEST FOR hideExtension OPTION
4
+ // This test verifies that the hideExtension option works correctly:
5
+ // - Clean URL resolution (URL without extension → file with extension)
6
+ // - Redirect from URL with extension to clean URL
7
+ // - Query string preservation
8
+ // - Conflict resolution (directory vs file, extensionless vs extension)
9
+ // - Input validation
10
+ // - Interaction with existing options (urlsReserved, useOriginalUrl, template)
11
+ //
12
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
13
+
14
+ const supertest = require('supertest');
15
+ const koaClassicServer = require('../index.cjs');
16
+ const Koa = require('koa');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const ejs = require('ejs');
20
+
21
+ const rootDir = path.join(__dirname, 'publicWwwTest', 'hideext-test');
22
+
23
+ describe('hideExtension option tests', () => {
24
+
25
+ // ==========================================
26
+ // Clean URL Resolution
27
+ // ==========================================
28
+ describe('Clean URL resolution', () => {
29
+ let app, server, request;
30
+
31
+ beforeAll(() => {
32
+ app = new Koa();
33
+ app.use(koaClassicServer(rootDir, {
34
+ showDirContents: true,
35
+ index: ['index.ejs'],
36
+ hideExtension: { ext: '.ejs' },
37
+ template: {
38
+ ext: ['ejs'],
39
+ render: async (ctx, next, filePath) => {
40
+ const content = await fs.promises.readFile(filePath, 'utf-8');
41
+ ctx.type = 'text/html';
42
+ ctx.body = content;
43
+ }
44
+ }
45
+ }));
46
+ server = app.listen();
47
+ request = supertest(server);
48
+ });
49
+
50
+ afterAll(() => { server.close(); });
51
+
52
+ test('/about serves about.ejs', async () => {
53
+ const response = await request.get('/about');
54
+ expect(response.status).toBe(200);
55
+ expect(response.text).toContain('About Page');
56
+ });
57
+
58
+ test('/blog/articolo serves blog/articolo.ejs (multi-level path)', async () => {
59
+ const response = await request.get('/blog/articolo');
60
+ expect(response.status).toBe(200);
61
+ expect(response.text).toContain('Blog Article');
62
+ });
63
+
64
+ test('/style.css serves style.css (no interference with other extensions)', async () => {
65
+ const response = await request.get('/style.css');
66
+ expect(response.status).toBe(200);
67
+ expect(response.text).toContain('body { color: red; }');
68
+ });
69
+
70
+ test('/ serves the index file via existing index flow', async () => {
71
+ const response = await request.get('/');
72
+ expect(response.status).toBe(200);
73
+ expect(response.text).toContain('Home Page');
74
+ });
75
+ });
76
+
77
+ // ==========================================
78
+ // Redirect URL with extension → clean URL
79
+ // ==========================================
80
+ describe('Redirect URL with extension to clean URL', () => {
81
+ let app, server, request;
82
+
83
+ beforeAll(() => {
84
+ app = new Koa();
85
+ app.use(koaClassicServer(rootDir, {
86
+ showDirContents: true,
87
+ index: ['index.ejs'],
88
+ hideExtension: { ext: '.ejs' },
89
+ template: {
90
+ ext: ['ejs'],
91
+ render: async (ctx, next, filePath) => {
92
+ const content = await fs.promises.readFile(filePath, 'utf-8');
93
+ ctx.type = 'text/html';
94
+ ctx.body = content;
95
+ }
96
+ }
97
+ }));
98
+ server = app.listen();
99
+ request = supertest(server);
100
+ });
101
+
102
+ afterAll(() => { server.close(); });
103
+
104
+ test('/about.ejs → redirect 301 to /about', async () => {
105
+ const response = await request.get('/about.ejs');
106
+ expect(response.status).toBe(301);
107
+ expect(response.headers.location).toBe('/about');
108
+ });
109
+
110
+ test('/blog/articolo.ejs → redirect 301 to /blog/articolo', async () => {
111
+ const response = await request.get('/blog/articolo.ejs');
112
+ expect(response.status).toBe(301);
113
+ expect(response.headers.location).toBe('/blog/articolo');
114
+ });
115
+
116
+ test('/about.ejs?lang=it → redirect 301 to /about?lang=it (preserves query string)', async () => {
117
+ const response = await request.get('/about.ejs?lang=it');
118
+ expect(response.status).toBe(301);
119
+ expect(response.headers.location).toBe('/about?lang=it');
120
+ });
121
+
122
+ test('/index.ejs → redirect to /', async () => {
123
+ const response = await request.get('/index.ejs');
124
+ expect(response.status).toBe(301);
125
+ expect(response.headers.location).toBe('/');
126
+ });
127
+
128
+ test('/sezione/index.ejs → redirect to /sezione/', async () => {
129
+ const response = await request.get('/sezione/index.ejs');
130
+ expect(response.status).toBe(301);
131
+ expect(response.headers.location).toBe('/sezione/');
132
+ });
133
+ });
134
+
135
+ // ==========================================
136
+ // Custom redirect code (302)
137
+ // ==========================================
138
+ describe('Custom redirect code', () => {
139
+ let app, server, request;
140
+
141
+ beforeAll(() => {
142
+ app = new Koa();
143
+ app.use(koaClassicServer(rootDir, {
144
+ showDirContents: true,
145
+ index: ['index.ejs'],
146
+ hideExtension: { ext: '.ejs', redirect: 302 }
147
+ }));
148
+ server = app.listen();
149
+ request = supertest(server);
150
+ });
151
+
152
+ afterAll(() => { server.close(); });
153
+
154
+ test('/about.ejs → redirect 302 to /about', async () => {
155
+ const response = await request.get('/about.ejs');
156
+ expect(response.status).toBe(302);
157
+ expect(response.headers.location).toBe('/about');
158
+ });
159
+ });
160
+
161
+ // ==========================================
162
+ // Directory/file conflict (showDirContents: true)
163
+ // ==========================================
164
+ describe('Directory/file conflict with showDirContents: true', () => {
165
+ let app, server, request;
166
+
167
+ beforeAll(() => {
168
+ app = new Koa();
169
+ app.use(koaClassicServer(rootDir, {
170
+ showDirContents: true,
171
+ index: ['index.html'],
172
+ hideExtension: { ext: '.ejs' },
173
+ template: {
174
+ ext: ['ejs'],
175
+ render: async (ctx, next, filePath) => {
176
+ const content = await fs.promises.readFile(filePath, 'utf-8');
177
+ ctx.type = 'text/html';
178
+ ctx.body = content;
179
+ }
180
+ }
181
+ }));
182
+ server = app.listen();
183
+ request = supertest(server);
184
+ });
185
+
186
+ afterAll(() => { server.close(); });
187
+
188
+ test('/about serves about.ejs (file wins over directory)', async () => {
189
+ const response = await request.get('/about');
190
+ expect(response.status).toBe(200);
191
+ expect(response.text).toContain('About Page');
192
+ });
193
+
194
+ test('/about/ shows the directory index or directory contents', async () => {
195
+ const response = await request.get('/about/');
196
+ expect(response.status).toBe(200);
197
+ // The about/ directory has index.html, so it's served as the index file
198
+ expect(response.text).toContain('About Directory Index');
199
+ });
200
+ });
201
+
202
+ // ==========================================
203
+ // Trailing slash without directory → 404
204
+ // ==========================================
205
+ describe('Trailing slash without directory', () => {
206
+ let app, server, request;
207
+
208
+ beforeAll(() => {
209
+ app = new Koa();
210
+ app.use(koaClassicServer(rootDir, {
211
+ showDirContents: true,
212
+ hideExtension: { ext: '.ejs' }
213
+ }));
214
+ server = app.listen();
215
+ request = supertest(server);
216
+ });
217
+
218
+ afterAll(() => { server.close(); });
219
+
220
+ test('/nonexistent/ returns 404', async () => {
221
+ const response = await request.get('/nonexistent/');
222
+ expect(response.status).toBe(404);
223
+ });
224
+ });
225
+
226
+ // ==========================================
227
+ // File without extension vs .ejs conflict
228
+ // ==========================================
229
+ describe('File without extension vs .ejs conflict', () => {
230
+ let app, server, request;
231
+
232
+ beforeAll(() => {
233
+ app = new Koa();
234
+ app.use(koaClassicServer(rootDir, {
235
+ showDirContents: true,
236
+ hideExtension: { ext: '.ejs' },
237
+ template: {
238
+ ext: ['ejs'],
239
+ render: async (ctx, next, filePath) => {
240
+ const content = await fs.promises.readFile(filePath, 'utf-8');
241
+ ctx.type = 'text/html';
242
+ ctx.body = content;
243
+ }
244
+ }
245
+ }));
246
+ server = app.listen();
247
+ request = supertest(server);
248
+ });
249
+
250
+ afterAll(() => { server.close(); });
251
+
252
+ test('/conflict-test/pagina serves pagina.ejs (ejs wins over extensionless file)', async () => {
253
+ const response = await request.get('/conflict-test/pagina');
254
+ expect(response.status).toBe(200);
255
+ expect(response.text).toContain('Pagina EJS');
256
+ });
257
+ });
258
+
259
+ // ==========================================
260
+ // Interaction with urlsReserved
261
+ // ==========================================
262
+ describe('Interaction with urlsReserved', () => {
263
+ let app, server, request;
264
+
265
+ beforeAll(() => {
266
+ app = new Koa();
267
+
268
+ // Add a "next" middleware to catch reserved URLs
269
+ app.use(async (ctx, next) => {
270
+ await next();
271
+ if (ctx.status === 404 && ctx._passedToNext) {
272
+ ctx.status = 200;
273
+ ctx.body = 'RESERVED';
274
+ }
275
+ });
276
+
277
+ const middleware = koaClassicServer(rootDir, {
278
+ showDirContents: true,
279
+ index: ['index.ejs'],
280
+ urlsReserved: ['/blog'],
281
+ hideExtension: { ext: '.ejs' },
282
+ template: {
283
+ ext: ['ejs'],
284
+ render: async (ctx, next, filePath) => {
285
+ const content = await fs.promises.readFile(filePath, 'utf-8');
286
+ ctx.type = 'text/html';
287
+ ctx.body = content;
288
+ }
289
+ }
290
+ });
291
+
292
+ // Wrap middleware to track next() calls
293
+ app.use(async (ctx, next) => {
294
+ const originalNext = next;
295
+ await middleware(ctx, async () => {
296
+ ctx._passedToNext = true;
297
+ await originalNext();
298
+ });
299
+ });
300
+
301
+ server = app.listen();
302
+ request = supertest(server);
303
+ });
304
+
305
+ afterAll(() => { server.close(); });
306
+
307
+ test('/blog is reserved (passed to next middleware)', async () => {
308
+ const response = await request.get('/blog');
309
+ // blog is a directory and is reserved, so it passes to next
310
+ expect(response.text).toBe('RESERVED');
311
+ });
312
+
313
+ test('/about still resolves about.ejs normally', async () => {
314
+ const response = await request.get('/about');
315
+ expect(response.status).toBe(200);
316
+ expect(response.text).toContain('About Page');
317
+ });
318
+ });
319
+
320
+ // ==========================================
321
+ // Interaction with useOriginalUrl
322
+ // ==========================================
323
+ describe('Interaction with useOriginalUrl', () => {
324
+ let app, server, request;
325
+
326
+ beforeAll(() => {
327
+ app = new Koa();
328
+
329
+ // i18n middleware that rewrites URLs
330
+ app.use(async (ctx, next) => {
331
+ if (ctx.path.match(/^\/it\//)) {
332
+ ctx.url = ctx.path.replace(/^\/it/, '');
333
+ }
334
+ await next();
335
+ });
336
+
337
+ app.use(koaClassicServer(rootDir, {
338
+ showDirContents: true,
339
+ useOriginalUrl: false,
340
+ hideExtension: { ext: '.ejs' },
341
+ template: {
342
+ ext: ['ejs'],
343
+ render: async (ctx, next, filePath) => {
344
+ const content = await fs.promises.readFile(filePath, 'utf-8');
345
+ ctx.type = 'text/html';
346
+ ctx.body = content;
347
+ }
348
+ }
349
+ }));
350
+
351
+ server = app.listen();
352
+ request = supertest(server);
353
+ });
354
+
355
+ afterAll(() => { server.close(); });
356
+
357
+ test('redirect uses ctx.originalUrl (preserves /it/ prefix)', async () => {
358
+ const response = await request.get('/it/about.ejs');
359
+ expect(response.status).toBe(301);
360
+ // Redirect should use originalUrl: /it/about (not /about)
361
+ expect(response.headers.location).toBe('/it/about');
362
+ });
363
+
364
+ test('clean URL resolves through rewritten URL', async () => {
365
+ const response = await request.get('/it/about');
366
+ expect(response.status).toBe(200);
367
+ expect(response.text).toContain('About Page');
368
+ });
369
+ });
370
+
371
+ // ==========================================
372
+ // Case-sensitive extension matching
373
+ // ==========================================
374
+ describe('Case-sensitive extension matching', () => {
375
+ let app, server, request;
376
+
377
+ beforeAll(() => {
378
+ app = new Koa();
379
+ app.use(koaClassicServer(rootDir, {
380
+ showDirContents: true,
381
+ hideExtension: { ext: '.ejs' }
382
+ }));
383
+ server = app.listen();
384
+ request = supertest(server);
385
+ });
386
+
387
+ afterAll(() => { server.close(); });
388
+
389
+ test('/about.EJS is not handled by hideExtension (case-sensitive)', async () => {
390
+ const response = await request.get('/about.EJS');
391
+ // The file about.EJS exists, should be served normally (not redirected)
392
+ expect(response.status).toBe(200);
393
+ // Should NOT be a redirect
394
+ expect(response.status).not.toBe(301);
395
+ });
396
+ });
397
+
398
+ // ==========================================
399
+ // URLs with different extensions (no interference)
400
+ // ==========================================
401
+ describe('URLs with different extensions', () => {
402
+ let app, server, request;
403
+
404
+ beforeAll(() => {
405
+ app = new Koa();
406
+ app.use(koaClassicServer(rootDir, {
407
+ showDirContents: true,
408
+ hideExtension: { ext: '.ejs' }
409
+ }));
410
+ server = app.listen();
411
+ request = supertest(server);
412
+ });
413
+
414
+ afterAll(() => { server.close(); });
415
+
416
+ test('/file.ejs.bak is not interfered with', async () => {
417
+ const response = await request.get('/file.ejs.bak');
418
+ // .bak is the extension, not .ejs - should be served normally
419
+ expect(response.status).toBe(200);
420
+ expect(response.status).not.toBe(301);
421
+ });
422
+
423
+ test('/photo.txt is not interfered with', async () => {
424
+ const response = await request.get('/photo.txt');
425
+ expect(response.status).toBe(200);
426
+ expect(response.status).not.toBe(301);
427
+ });
428
+
429
+ test('/style.css is not interfered with', async () => {
430
+ const response = await request.get('/style.css');
431
+ expect(response.status).toBe(200);
432
+ expect(response.status).not.toBe(301);
433
+ });
434
+ });
435
+
436
+ // ==========================================
437
+ // Template engine integration
438
+ // ==========================================
439
+ describe('Template engine integration', () => {
440
+ let app, server, request;
441
+
442
+ beforeAll(() => {
443
+ app = new Koa();
444
+ app.use(koaClassicServer(rootDir, {
445
+ showDirContents: true,
446
+ index: ['index.ejs'],
447
+ hideExtension: { ext: '.ejs' },
448
+ template: {
449
+ ext: ['ejs'],
450
+ render: async (ctx, next, filePath) => {
451
+ const templateContent = await fs.promises.readFile(filePath, 'utf-8');
452
+ const html = ejs.render(templateContent, { title: 'Test Title' });
453
+ ctx.type = 'text/html';
454
+ ctx.body = html;
455
+ }
456
+ }
457
+ }));
458
+ server = app.listen();
459
+ request = supertest(server);
460
+ });
461
+
462
+ afterAll(() => { server.close(); });
463
+
464
+ test('file resolved via hideExtension passes correctly to template engine', async () => {
465
+ const response = await request.get('/about');
466
+ expect(response.status).toBe(200);
467
+ expect(response.type).toBe('text/html');
468
+ expect(response.text).toContain('About Page');
469
+ });
470
+ });
471
+
472
+ // ==========================================
473
+ // Input Validation
474
+ // ==========================================
475
+ describe('Input validation', () => {
476
+
477
+ test('hideExtension: true → throws Error', () => {
478
+ expect(() => {
479
+ koaClassicServer(rootDir, {
480
+ hideExtension: true
481
+ });
482
+ }).toThrow();
483
+ });
484
+
485
+ test('hideExtension: {} → throws Error (missing ext)', () => {
486
+ expect(() => {
487
+ koaClassicServer(rootDir, {
488
+ hideExtension: {}
489
+ });
490
+ }).toThrow();
491
+ });
492
+
493
+ test('hideExtension: { ext: "" } → throws Error (empty ext)', () => {
494
+ expect(() => {
495
+ koaClassicServer(rootDir, {
496
+ hideExtension: { ext: '' }
497
+ });
498
+ }).toThrow();
499
+ });
500
+
501
+ test('hideExtension: { ext: "ejs" } → warning + normalizes to ".ejs"', () => {
502
+ const originalWarn = console.warn;
503
+ const warnings = [];
504
+ console.warn = (...args) => { warnings.push(args); };
505
+
506
+ try {
507
+ const middleware = koaClassicServer(rootDir, {
508
+ hideExtension: { ext: 'ejs' }
509
+ });
510
+ // Should not throw
511
+ expect(middleware).toBeDefined();
512
+ // Should have issued a warning
513
+ expect(warnings.length).toBeGreaterThan(0);
514
+ expect(warnings[0][1]).toContain('hideExtension.ext should start with a dot');
515
+ } finally {
516
+ console.warn = originalWarn;
517
+ }
518
+ });
519
+
520
+ test('hideExtension: { ext: ".ejs", redirect: "abc" } → throws Error (redirect not numeric)', () => {
521
+ expect(() => {
522
+ koaClassicServer(rootDir, {
523
+ hideExtension: { ext: '.ejs', redirect: 'abc' }
524
+ });
525
+ }).toThrow();
526
+ });
527
+
528
+ test('hideExtension: { ext: ".ejs" } → valid, default redirect 301', () => {
529
+ expect(() => {
530
+ koaClassicServer(rootDir, {
531
+ hideExtension: { ext: '.ejs' }
532
+ });
533
+ }).not.toThrow();
534
+ });
535
+
536
+ test('hideExtension: { ext: ".ejs", redirect: 302 } → valid', () => {
537
+ expect(() => {
538
+ koaClassicServer(rootDir, {
539
+ hideExtension: { ext: '.ejs', redirect: 302 }
540
+ });
541
+ }).not.toThrow();
542
+ });
543
+
544
+ test('hideExtension: undefined → feature disabled (no error)', () => {
545
+ expect(() => {
546
+ koaClassicServer(rootDir, {});
547
+ }).not.toThrow();
548
+ });
549
+ });
550
+ });
@@ -0,0 +1 @@
1
+ <h1>About Directory Index</h1>
@@ -0,0 +1 @@
1
+ <h1>About UPPERCASE</h1>
@@ -0,0 +1,2 @@
1
+ <h1>About Page</h1>
2
+ <p>This is the about page rendered from about.ejs</p>
@@ -0,0 +1,2 @@
1
+ <h1>Blog Article</h1>
2
+ <p>This is a blog article rendered from blog/articolo.ejs</p>
@@ -0,0 +1 @@
1
+ plain pagina file
@@ -0,0 +1 @@
1
+ <h1>Pagina EJS</h1>
@@ -0,0 +1 @@
1
+ backup file
@@ -0,0 +1,2 @@
1
+ <h1>Home Page</h1>
2
+ <p>This is the index page</p>
@@ -0,0 +1 @@
1
+ This is a photo description file
@@ -0,0 +1 @@
1
+ <h1>Sezione Index</h1>
@@ -0,0 +1 @@
1
+ body { color: red; }
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,75 @@ 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.5.1] - 2026-03-01
9
+
10
+ ### 📝 Documentation
11
+
12
+ - Added dedicated usage example for `useOriginalUrl` (Section 7) with realistic i18n middleware scenario (/it/, /en/, /fr/)
13
+ - Added "Advanced hideExtension Scenarios" section (Section 8):
14
+ - Recommended file/directory structure (ASCII tree)
15
+ - Combined `hideExtension` + i18n middleware example with `useOriginalUrl: false`
16
+ - Temporary redirect (302) variant with guidance on 301 vs 302 usage
17
+ - Added `hideExtension` and `useOriginalUrl` to the Complete Production Example (Section 11)
18
+
19
+ ### 📦 Package Changes
20
+ - **Version**: `2.5.0` → `2.5.1`
21
+ - **Semver**: Patch version bump (documentation only, no code changes)
22
+
23
+ ---
24
+
25
+ ## [2.5.0] - 2026-02-28
26
+
27
+ ### ✨ New Feature
28
+
29
+ #### hideExtension - Clean URLs (mod_rewrite-like)
30
+ - **New Option**: `hideExtension: { ext: '.ejs', redirect: 301 }`
31
+ - **Purpose**: Hide file extensions from URLs for SEO-friendly clean URLs
32
+ - **Clean URL Resolution**: `/about` → serves `about.ejs` (when file exists)
33
+ - **Extension Redirect**: `/about.ejs` → 301 redirect to `/about` (preserves query string)
34
+ - **Index File Redirect**: `/index.ejs` → redirect to `/`, `/section/index.ejs` → redirect to `/section/`
35
+ - **Conflict Resolution**: `.ejs` file wins over both directories and extensionless files with same base name
36
+ - **Case-Sensitive**: Extension matching is case-sensitive (`.ejs` ≠ `.EJS`)
37
+ - **No Interference**: URLs with other extensions (`.css`, `.png`, etc.) pass through normally
38
+ - **Trailing Slash**: `/about/` always means directory, never resolves to file
39
+ - **Redirect uses `ctx.originalUrl`**: Preserves URL prefixes from upstream middleware (i18n, routing)
40
+
41
+ #### Input Validation
42
+ - `hideExtension: true` → throws Error (must be an object)
43
+ - `hideExtension: {}` → throws Error (missing `ext`)
44
+ - `hideExtension: { ext: '' }` → throws Error (empty ext)
45
+ - `hideExtension: { ext: 'ejs' }` → warning + auto-normalizes to `.ejs`
46
+ - `hideExtension: { ext: '.ejs', redirect: 'abc' }` → throws Error (redirect must be number)
47
+
48
+ #### Integration with Existing Options
49
+ - **urlsReserved**: Checked before `hideExtension`, no interference
50
+ - **urlPrefix**: `hideExtension` works on path after prefix removal
51
+ - **useOriginalUrl**: Resolution follows setting; redirect always uses `ctx.originalUrl`
52
+ - **template**: Resolved files pass through template engine normally
53
+ - **method**: `hideExtension` only applies to allowed HTTP methods
54
+
55
+ ### 🧪 Testing
56
+ - Added `__tests__/hideExtension.test.js` with 31 tests covering:
57
+ - Clean URL resolution (single and multi-level paths)
58
+ - Extension redirect (301/302, query string preservation)
59
+ - Directory/file conflict resolution
60
+ - Trailing slash behavior
61
+ - Extensionless file conflict
62
+ - Index file redirect (`/index.ejs` → `/`)
63
+ - `urlsReserved` interaction
64
+ - `useOriginalUrl` interaction (redirect preserves prefix)
65
+ - Case-sensitive matching
66
+ - No interference with other extensions
67
+ - Template engine integration
68
+ - Input validation (7 validation tests)
69
+ - All 278 existing tests still pass (zero regressions)
70
+
71
+ ### 📦 Package Changes
72
+ - **Version**: `2.4.0` → `2.5.0`
73
+ - **Semver**: Minor version bump (new feature, backward compatible)
74
+
75
+ ---
76
+
8
77
  ## [2.4.0] - 2026-02-28
9
78
 
10
79
  ### 🐛 Bug Fix
package/index.cjs CHANGED
@@ -51,6 +51,10 @@ module.exports = function koaClassicServer(
51
51
  // to reduce bandwidth usage and improve performance.
52
52
  useOriginalUrl: true, // Use ctx.originalUrl (default) or ctx.url
53
53
  // Set false for URL rewriting middleware (i18n, routing)
54
+ hideExtension: { // Hide file extension from URLs (clean URLs like mod_rewrite)
55
+ ext: '.ejs', // Extension to hide (required, string, case-sensitive, must start with '.')
56
+ redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
57
+ },
54
58
 
55
59
  // DEPRECATED OPTIONS (maintained for backward compatibility):
56
60
  // cacheMaxAge: use browserCacheMaxAge instead
@@ -138,6 +142,35 @@ module.exports = function koaClassicServer(
138
142
  options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
139
143
  options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
140
144
 
145
+ // Validate and normalize hideExtension option
146
+ if (options.hideExtension !== undefined && options.hideExtension !== null) {
147
+ if (typeof options.hideExtension !== 'object' || Array.isArray(options.hideExtension)) {
148
+ throw new Error('[koa-classic-server] hideExtension must be an object with an "ext" property. Example: { ext: ".ejs" }');
149
+ }
150
+ if (!options.hideExtension.ext || typeof options.hideExtension.ext !== 'string') {
151
+ throw new Error('[koa-classic-server] hideExtension.ext is required and must be a non-empty string. Example: { ext: ".ejs" }');
152
+ }
153
+ // Normalize ext: add leading dot if missing
154
+ if (!options.hideExtension.ext.startsWith('.')) {
155
+ console.warn(
156
+ '\x1b[33m%s\x1b[0m',
157
+ '[koa-classic-server] WARNING: hideExtension.ext should start with a dot.\n' +
158
+ ` Current usage: ext: "${options.hideExtension.ext}"\n` +
159
+ ` Corrected to: ext: ".${options.hideExtension.ext}"\n` +
160
+ ' Please update your configuration.'
161
+ );
162
+ options.hideExtension.ext = '.' + options.hideExtension.ext;
163
+ }
164
+ // Validate redirect code
165
+ if (options.hideExtension.redirect !== undefined) {
166
+ if (typeof options.hideExtension.redirect !== 'number') {
167
+ throw new Error('[koa-classic-server] hideExtension.redirect must be a number (e.g. 301, 302). Got: ' + typeof options.hideExtension.redirect);
168
+ }
169
+ } else {
170
+ options.hideExtension.redirect = 301;
171
+ }
172
+ }
173
+
141
174
  /**
142
175
  * Returns true if dirent is a regular file or a symlink pointing to a regular file.
143
176
  * Uses fs.promises.stat (which follows symlinks) only when dirent.isSymbolicLink() is true.
@@ -242,6 +275,68 @@ module.exports = function koaClassicServer(
242
275
 
243
276
  let toOpen = fullPath;
244
277
 
278
+ // hideExtension logic: redirect URLs with extension and resolve clean URLs
279
+ // Track if original URL had trailing slash (stripped by pageHref construction above)
280
+ const originalUrlPath = new URL(ctx.protocol + '://' + ctx.host + urlToUse).pathname;
281
+ const hadTrailingSlash = originalUrlPath.length > 1 && originalUrlPath.endsWith('/');
282
+
283
+ if (options.hideExtension) {
284
+ const hideExt = options.hideExtension.ext;
285
+ const hideRedirect = options.hideExtension.redirect;
286
+
287
+ // Check if URL ends with the configured extension → redirect to clean URL
288
+ // Use the original path (before trailing slash stripping) for accurate matching
289
+ const pathForExtCheck = hadTrailingSlash ? originalUrlPath.slice(0, -1) : requestedPath;
290
+ if (pathForExtCheck.endsWith(hideExt)) {
291
+ // Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
292
+ const originalUrlObj = new URL(ctx.protocol + '://' + ctx.host + ctx.originalUrl);
293
+ let redirectPath = originalUrlObj.pathname;
294
+
295
+ // Remove the extension from the path
296
+ redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
297
+
298
+ // Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
299
+ const baseName = path.basename(redirectPath);
300
+ // Check if the remaining path points to an index file
301
+ if (options.index && options.index.length > 0) {
302
+ for (const pattern of options.index) {
303
+ if (typeof pattern === 'string' && (baseName + hideExt) === pattern) {
304
+ // Redirect to the directory (with trailing slash)
305
+ redirectPath = redirectPath.slice(0, redirectPath.length - baseName.length);
306
+ break;
307
+ }
308
+ }
309
+ }
310
+
311
+ // Preserve query string
312
+ const redirectUrl = redirectPath + (originalUrlObj.search || '');
313
+
314
+ ctx.status = hideRedirect;
315
+ ctx.redirect(redirectUrl);
316
+ return;
317
+ }
318
+
319
+ // Check if URL has no extension → try adding the configured extension
320
+ // Skip if original URL had trailing slash (trailing slash = directory intent)
321
+ const extOfRequested = path.extname(requestedPath);
322
+ if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
323
+ const pathWithExt = fullPath + hideExt;
324
+
325
+ // Security check: ensure resolved path is still within rootDir
326
+ if (pathWithExt.startsWith(normalizedRootDir)) {
327
+ try {
328
+ const statWithExt = await fs.promises.stat(pathWithExt);
329
+ if (statWithExt.isFile()) {
330
+ // File with extension exists, serve it
331
+ toOpen = pathWithExt;
332
+ }
333
+ } catch {
334
+ // File with extension doesn't exist, continue normal flow
335
+ }
336
+ }
337
+ }
338
+ }
339
+
245
340
  // OPTIMIZATION: Check if file/directory exists (async, non-blocking)
246
341
  let stat;
247
342
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-classic-server",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
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": {
@@ -1,26 +0,0 @@
1
- {
2
- // Use IntelliSense to learn about possible attributes.
3
- // Hover to view descriptions of existing attributes.
4
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5
- "version": "0.2.0",
6
- "configurations": [
7
- {
8
- "name": "Attach by Process ID",
9
- "processId": "${command:PickProcess}",
10
- "request": "attach",
11
- "skipFiles": [
12
- "<node_internals>/**"
13
- ],
14
- "type": "node"
15
- },
16
- {
17
- "type": "node",
18
- "request": "launch",
19
- "name": "Launch Program",
20
- "skipFiles": [
21
- "<node_internals>/**"
22
- ],
23
- "program": "${workspaceFolder}/index.cjs"
24
- }
25
- ]
26
- }
@@ -1,41 +0,0 @@
1
- {
2
- "version": "0.2.0",
3
- "configurations": [
4
- {
5
- "name": "Attach by Process ID",
6
- "processId": "${command:PickProcess}",
7
- "request": "attach",
8
- "skipFiles": [
9
- "<node_internals>/**"
10
- ],
11
- "type": "node"
12
- },
13
- {
14
- "type": "node",
15
- "request": "launch",
16
- "name": "Launch Program",
17
- "skipFiles": [
18
- "<node_internals>/**"
19
- ],
20
- "program": "${workspaceFolder}/index.cjs"
21
- },
22
- {
23
- "name": "Debug Jest Tests",
24
- "type": "node",
25
- "request": "launch",
26
- "runtimeExecutable": "node",
27
- "runtimeArgs": [
28
- "--inspect-brk",
29
- "${workspaceFolder}/node_modules/jest/bin/jest.js",
30
- "--runInBand"
31
- ],
32
- "port": 9229,
33
- "console": "integratedTerminal",
34
- "internalConsoleOptions": "neverOpen",
35
- "skipFiles": [
36
- "<node_internals>/**"
37
- ]
38
- }
39
- ]
40
- }
41
-
File without changes