koa-classic-server 2.0.0 → 2.1.2

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 (45) hide show
  1. package/README.md +553 -127
  2. package/__tests__/directory-sorting-links.test.js +135 -0
  3. package/__tests__/ejs.test.js +299 -0
  4. package/__tests__/performance.test.js +75 -6
  5. package/__tests__/publicWwwTest/ejs-templates/complex.ejs +56 -0
  6. package/__tests__/publicWwwTest/ejs-templates/index.ejs +30 -0
  7. package/__tests__/publicWwwTest/ejs-templates/simple.ejs +13 -0
  8. package/__tests__/publicWwwTest/ejs-templates/with-conditional.ejs +28 -0
  9. package/__tests__/publicWwwTest/ejs-templates/with-escaping.ejs +26 -0
  10. package/__tests__/publicWwwTest/ejs-templates/with-loop.ejs +16 -0
  11. package/{scripts → __tests__}/setup-benchmark.js +1 -1
  12. package/docs/CODE_REVIEW.md +298 -0
  13. package/docs/FLOW_DIAGRAM.md +952 -0
  14. package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +1734 -0
  15. package/docs/template-engine/esempi-incrementali.js +192 -0
  16. package/docs/template-engine/examples/esempio1-nessun-dato.ejs +12 -0
  17. package/docs/template-engine/examples/esempio2-una-variabile.ejs +11 -0
  18. package/docs/template-engine/examples/esempio3-piu-variabili.ejs +15 -0
  19. package/docs/template-engine/examples/esempio4-condizionale.ejs +18 -0
  20. package/docs/template-engine/examples/esempio5-loop.ejs +18 -0
  21. package/docs/template-engine/examples/index-esempi.html +181 -0
  22. package/docs/template-engine/examples/index.html +40 -0
  23. package/docs/template-engine/examples/test.ejs +64 -0
  24. package/index.cjs +186 -35
  25. package/package.json +9 -6
  26. package/CREATE_RELEASE.sh +0 -53
  27. package/publish-to-npm.sh +0 -65
  28. /package/{benchmark-results-baseline-v1.2.0.txt → __tests__/benchmark-results-baseline-v1.2.0.txt} +0 -0
  29. /package/{benchmark-results-optimized-v2.0.0.txt → __tests__/benchmark-results-optimized-v2.0.0.txt} +0 -0
  30. /package/{benchmark.js → __tests__/benchmark.js} +0 -0
  31. /package/{customTest → __tests__/customTest}/README.md +0 -0
  32. /package/{customTest → __tests__/customTest}/loadConfig.util.js +0 -0
  33. /package/{customTest → __tests__/customTest}/serversToLoad.util.js +0 -0
  34. /package/{demo-regex-index.js → __tests__/demo-regex-index.js} +0 -0
  35. /package/{test-regex-quick.js → __tests__/test-regex-quick.js} +0 -0
  36. /package/{BENCHMARKS.md → docs/BENCHMARKS.md} +0 -0
  37. /package/{CHANGELOG.md → docs/CHANGELOG.md} +0 -0
  38. /package/{DEBUG_REPORT.md → docs/DEBUG_REPORT.md} +0 -0
  39. /package/{DOCUMENTATION.md → docs/DOCUMENTATION.md} +0 -0
  40. /package/{EXAMPLES_INDEX_OPTION.md → docs/EXAMPLES_INDEX_OPTION.md} +0 -0
  41. /package/{INDEX_OPTION_PRIORITY.md → docs/INDEX_OPTION_PRIORITY.md} +0 -0
  42. /package/{OPTIMIZATION_HTTP_CACHING.md → docs/OPTIMIZATION_HTTP_CACHING.md} +0 -0
  43. /package/{PERFORMANCE_ANALYSIS.md → docs/PERFORMANCE_ANALYSIS.md} +0 -0
  44. /package/{PERFORMANCE_COMPARISON.md → docs/PERFORMANCE_COMPARISON.md} +0 -0
  45. /package/{noteExports.md → docs/noteExports.md} +0 -0
@@ -0,0 +1,952 @@
1
+ # Flow Diagram - koa-classic-server
2
+
3
+ ## Table of Contents
4
+ - [Overview](#overview)
5
+ - [Main Flow Diagram](#main-flow-diagram)
6
+ - [Initialization Phase](#initialization-phase)
7
+ - [Request Handling Phase](#request-handling-phase)
8
+ - [File Loading Flow](#file-loading-flow)
9
+ - [Directory Listing Flow](#directory-listing-flow)
10
+ - [Code Examples](#code-examples)
11
+
12
+ ---
13
+
14
+ ## Overview
15
+
16
+ **koa-classic-server** is a Koa middleware that serves static files with Apache2-like directory listing, template engine support, and HTTP caching optimization.
17
+
18
+ **Key Features:**
19
+ - Static file serving with streaming
20
+ - Directory listing with sortable columns (Name, Type, Size)
21
+ - Template engine integration (EJS, Pug, etc.)
22
+ - HTTP caching (ETag, Last-Modified, 304 responses)
23
+ - Security (path traversal protection, XSS protection)
24
+ - Async/await (non-blocking I/O)
25
+
26
+ ---
27
+
28
+ ## Main Flow Diagram
29
+
30
+ ```
31
+ ┌─────────────────────────────────────────────────────────────────┐
32
+ │ HTTP REQUEST RECEIVED │
33
+ └────────────────────────┬────────────────────────────────────────┘
34
+
35
+
36
+ ┌─────────────────────────────────────────────────────────────────┐
37
+ │ 1. METHOD CHECK │
38
+ │ Is method allowed? (default: GET) │
39
+ │ ├─ NO → await next() → EXIT │
40
+ │ └─ YES → Continue │
41
+ └────────────────────────┬────────────────────────────────────────┘
42
+
43
+
44
+ ┌─────────────────────────────────────────────────────────────────┐
45
+ │ 2. URL NORMALIZATION │
46
+ │ Remove trailing slash from URL │
47
+ │ Create URL object from ctx.href │
48
+ └────────────────────────┬────────────────────────────────────────┘
49
+
50
+
51
+ ┌─────────────────────────────────────────────────────────────────┐
52
+ │ 3. URL PREFIX CHECK │
53
+ │ Does URL match configured urlPrefix? │
54
+ │ ├─ NO → await next() → EXIT │
55
+ │ └─ YES → Continue │
56
+ └────────────────────────┬────────────────────────────────────────┘
57
+
58
+
59
+ ┌─────────────────────────────────────────────────────────────────┐
60
+ │ 4. RESERVED URL CHECK │
61
+ │ Is URL in reserved paths list? │
62
+ │ ├─ YES → await next() → EXIT │
63
+ │ └─ NO → Continue │
64
+ └────────────────────────┬────────────────────────────────────────┘
65
+
66
+
67
+ ┌─────────────────────────────────────────────────────────────────┐
68
+ │ 5. PATH TRAVERSAL PROTECTION │
69
+ │ Normalize path & verify it's within rootDir │
70
+ │ ├─ INVALID → 403 Forbidden → EXIT │
71
+ │ └─ VALID → Continue │
72
+ └────────────────────────┬────────────────────────────────────────┘
73
+
74
+
75
+ ┌─────────────────────────────────────────────────────────────────┐
76
+ │ 6. FILE/DIRECTORY EXISTS CHECK │
77
+ │ await fs.promises.stat(fullPath) │
78
+ │ ├─ ERROR → 404 Not Found → EXIT │
79
+ │ └─ OK → Continue │
80
+ └────────────────────────┬────────────────────────────────────────┘
81
+
82
+
83
+ ┌──────┴──────┐
84
+ │ │
85
+ ▼ ▼
86
+ ┌─────────────┐ ┌──────────┐
87
+ │ DIRECTORY │ │ FILE │
88
+ └──────┬──────┘ └────┬─────┘
89
+ │ │
90
+ │ └──────────────────┐
91
+ │ │
92
+ ▼ ▼
93
+ ┌──────────────────────────────┐ ┌────────────────────────────┐
94
+ │ DIRECTORY LISTING FLOW │ │ FILE LOADING FLOW │
95
+ │ (See detailed diagram) │ │ (See detailed diagram) │
96
+ └──────────────────────────────┘ └────────────────────────────┘
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Initialization Phase
102
+
103
+ This phase happens when you call `koaClassicServer(rootDir, options)` to create the middleware.
104
+
105
+ ```javascript
106
+ // index.cjs:25-102
107
+ module.exports = function koaClassicServer(rootDir, opts = {}) {
108
+ // 1. Validate rootDir
109
+ if (!rootDir || typeof rootDir !== 'string') {
110
+ throw new TypeError('rootDir must be a non-empty string');
111
+ }
112
+ if (!path.isAbsolute(rootDir)) {
113
+ throw new Error('rootDir must be an absolute path');
114
+ }
115
+
116
+ // 2. Normalize rootDir
117
+ const normalizedRootDir = path.resolve(rootDir);
118
+
119
+ // 3. Set default options
120
+ const options = opts || {};
121
+ options.template = opts.template || {};
122
+
123
+ // 4. Configure options with defaults
124
+ options.method = Array.isArray(options.method) ? options.method : ['GET'];
125
+ options.showDirContents = typeof options.showDirContents === 'boolean'
126
+ ? options.showDirContents
127
+ : true;
128
+
129
+ // 5. Normalize index option to array format
130
+ if (typeof options.index === 'string') {
131
+ options.index = options.index ? [options.index] : [];
132
+ }
133
+
134
+ // 6. Configure template engine
135
+ options.template.render = (options.template.render === undefined ||
136
+ typeof options.template.render === 'function')
137
+ ? options.template.render
138
+ : undefined;
139
+ options.template.ext = Array.isArray(options.template.ext)
140
+ ? options.template.ext
141
+ : [];
142
+
143
+ // 7. Configure HTTP caching
144
+ options.cacheMaxAge = typeof options.cacheMaxAge === 'number' &&
145
+ options.cacheMaxAge >= 0
146
+ ? options.cacheMaxAge
147
+ : 3600;
148
+ options.enableCaching = typeof options.enableCaching === 'boolean'
149
+ ? options.enableCaching
150
+ : true;
151
+
152
+ // 8. Return Koa middleware function
153
+ return async (ctx, next) => {
154
+ // Request handling phase starts here...
155
+ };
156
+ }
157
+ ```
158
+
159
+ **Flow:**
160
+ ```
161
+ START
162
+
163
+ ├─> Validate rootDir (must be absolute path string)
164
+
165
+ ├─> Normalize rootDir with path.resolve()
166
+
167
+ ├─> Set default options:
168
+ │ ├─ method: ['GET']
169
+ │ ├─ showDirContents: true
170
+ │ ├─ index: [] (array format)
171
+ │ ├─ urlPrefix: ""
172
+ │ ├─ urlsReserved: []
173
+ │ ├─ template.render: undefined
174
+ │ ├─ template.ext: []
175
+ │ ├─ cacheMaxAge: 3600 (1 hour)
176
+ │ └─ enableCaching: true
177
+
178
+ └─> Return async middleware function
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Request Handling Phase
184
+
185
+ This phase processes each incoming HTTP request.
186
+
187
+ ### 1. Method Check
188
+ ```javascript
189
+ // index.cjs:104-108
190
+ if (!options.method.includes(ctx.method)) {
191
+ await next(); // Not our method, pass to next middleware
192
+ return;
193
+ }
194
+ ```
195
+
196
+ **Flow:**
197
+ ```
198
+ Request Method = GET, POST, etc.
199
+
200
+ ├─ Is method in options.method array?
201
+ │ ├─ NO → Call next middleware (await next())
202
+ │ └─ YES → Continue processing
203
+ ```
204
+
205
+ ### 2. URL Normalization
206
+ ```javascript
207
+ // index.cjs:110-116
208
+ let pageHref = '';
209
+ if (ctx.href.charAt(ctx.href.length - 1) === '/') {
210
+ pageHref = new URL(ctx.href.slice(0, -1));
211
+ } else {
212
+ pageHref = new URL(ctx.href);
213
+ }
214
+ ```
215
+
216
+ **Flow:**
217
+ ```
218
+ Original URL: http://localhost:3000/path/to/file/
219
+
220
+ ├─> Remove trailing slash
221
+
222
+ └─> Result: http://localhost:3000/path/to/file
223
+ ```
224
+
225
+ ### 3. URL Prefix Check
226
+ ```javascript
227
+ // index.cjs:118-127
228
+ const a_pathname = pageHref.pathname.split("/");
229
+ const a_urlPrefix = options.urlPrefix.split("/");
230
+
231
+ for (const key in a_urlPrefix) {
232
+ if (a_urlPrefix[key] !== a_pathname[key]) {
233
+ await next(); // Prefix doesn't match
234
+ return;
235
+ }
236
+ }
237
+ ```
238
+
239
+ **Example:**
240
+ ```
241
+ options.urlPrefix = "/api/static"
242
+ Request pathname = "/api/static/file.txt"
243
+
244
+ Split urlPrefix: ["", "api", "static"]
245
+ Split pathname: ["", "api", "static", "file.txt"]
246
+
247
+ Compare:
248
+ [0] "" === "" ✓
249
+ [1] "api" === "api" ✓
250
+ [2] "static" === "static" ✓
251
+
252
+ → Match! Continue processing
253
+ ```
254
+
255
+ ### 4. Reserved URLs Check
256
+ ```javascript
257
+ // index.cjs:138-147
258
+ if (Array.isArray(options.urlsReserved) && options.urlsReserved.length > 0) {
259
+ const a_pathnameOutPrefix = pageHrefOutPrefix.pathname.split("/");
260
+ for (const value of options.urlsReserved) {
261
+ if (a_pathnameOutPrefix[1] === value.substring(1)) {
262
+ await next(); // Reserved URL, pass to another handler
263
+ return;
264
+ }
265
+ }
266
+ }
267
+ ```
268
+
269
+ **Example:**
270
+ ```
271
+ options.urlsReserved = ["/admin", "/api"]
272
+ Request pathname = "/admin/users"
273
+
274
+ Split: ["", "admin", "users"]
275
+ Check: a_pathnameOutPrefix[1] === "admin"
276
+
277
+ → Match! Call next middleware (reserved for other handlers)
278
+ ```
279
+
280
+ ### 5. Path Traversal Protection
281
+ ```javascript
282
+ // index.cjs:149-167
283
+ let requestedPath = "";
284
+ if (pageHrefOutPrefix.pathname === "/") {
285
+ requestedPath = "";
286
+ } else {
287
+ requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
288
+ }
289
+
290
+ // Normalize path and prevent path traversal
291
+ const normalizedPath = path.normalize(requestedPath);
292
+ const fullPath = path.join(normalizedRootDir, normalizedPath);
293
+
294
+ // Security check: ensure resolved path is within rootDir
295
+ if (!fullPath.startsWith(normalizedRootDir)) {
296
+ ctx.status = 403;
297
+ ctx.body = 'Forbidden';
298
+ return;
299
+ }
300
+ ```
301
+
302
+ **Security Example:**
303
+ ```
304
+ rootDir = "/var/www/public"
305
+ Request = "/../../../etc/passwd"
306
+
307
+ normalize("/../../../etc/passwd") → "../../../etc/passwd"
308
+ join("/var/www/public", "../../../etc/passwd") → "/var/etc/passwd"
309
+
310
+ Check: "/var/etc/passwd".startsWith("/var/www/public") → FALSE
311
+
312
+ → 403 Forbidden (Path Traversal Attack Blocked!)
313
+ ```
314
+
315
+ ### 6. File/Directory Exists Check
316
+ ```javascript
317
+ // index.cjs:171-180
318
+ let stat;
319
+ try {
320
+ stat = await fs.promises.stat(toOpen);
321
+ } catch (error) {
322
+ ctx.status = 404;
323
+ ctx.body = requestedUrlNotFound();
324
+ return;
325
+ }
326
+ ```
327
+
328
+ **Flow:**
329
+ ```
330
+ await fs.promises.stat(fullPath)
331
+
332
+ ├─ SUCCESS → stat object (isFile(), isDirectory(), size, mtime)
333
+
334
+ └─ ERROR → 404 Not Found
335
+ ```
336
+
337
+ ### 7. Route to File or Directory Handler
338
+ ```javascript
339
+ // index.cjs:182-207
340
+ if (stat.isDirectory()) {
341
+ // Directory handling
342
+ if (options.showDirContents) {
343
+ // Look for index file first
344
+ if (options.index && options.index.length > 0) {
345
+ const indexFile = await findIndexFile(toOpen, options.index);
346
+ if (indexFile) {
347
+ await loadFile(indexPath, indexFile.stat);
348
+ return;
349
+ }
350
+ }
351
+ // No index file, show directory listing
352
+ ctx.body = await show_dir(toOpen, ctx);
353
+ } else {
354
+ ctx.status = 404;
355
+ ctx.body = requestedUrlNotFound();
356
+ }
357
+ return;
358
+ } else {
359
+ // File handling
360
+ await loadFile(toOpen, stat);
361
+ return;
362
+ }
363
+ ```
364
+
365
+ **Flow:**
366
+ ```
367
+ stat.isDirectory()?
368
+
369
+ ├─ YES (Directory)
370
+ │ │
371
+ │ ├─> showDirContents = true?
372
+ │ │ │
373
+ │ │ ├─> Has index option?
374
+ │ │ │ │
375
+ │ │ │ ├─> findIndexFile()
376
+ │ │ │ │ │
377
+ │ │ │ │ ├─ Found → loadFile(index)
378
+ │ │ │ │ └─ Not found → show_dir()
379
+ │ │ │ │
380
+ │ │ │ └─> No index → show_dir()
381
+ │ │ │
382
+ │ │ └─> showDirContents = false → 404 Not Found
383
+ │ │
384
+ │ └─ NO (File) → loadFile()
385
+ ```
386
+
387
+ ---
388
+
389
+ ## File Loading Flow
390
+
391
+ Handles serving individual files with caching, template rendering, and streaming.
392
+
393
+ ```
394
+ ┌─────────────────────────────────────────────────────────────────┐
395
+ │ loadFile(toOpen, fileStat) │
396
+ └────────────────────────┬────────────────────────────────────────┘
397
+
398
+
399
+ ┌─────────────────────────────────────────────────────────────────┐
400
+ │ 1. Get file stat (if not provided) │
401
+ │ await fs.promises.stat(toOpen) │
402
+ │ ├─ ERROR → 404 Not Found → EXIT │
403
+ │ └─ OK → Continue │
404
+ └────────────────────────┬────────────────────────────────────────┘
405
+
406
+
407
+ ┌─────────────────────────────────────────────────────────────────┐
408
+ │ 2. Check if file is a template │
409
+ │ fileExt in options.template.ext? │
410
+ │ ├─ YES → Call template.render(ctx, next, toOpen) │
411
+ │ │ ├─ SUCCESS → EXIT (template rendered) │
412
+ │ │ └─ ERROR → 500 Internal Server Error → EXIT │
413
+ │ └─ NO → Continue (serve as static file) │
414
+ └────────────────────────┬────────────────────────────────────────┘
415
+
416
+
417
+ ┌─────────────────────────────────────────────────────────────────┐
418
+ │ 3. HTTP Caching (if enableCaching = true) │
419
+ │ Generate ETag: "mtime-size" │
420
+ │ Set Last-Modified: mtime.toUTCString() │
421
+ │ Set Cache-Control: public, max-age=3600 │
422
+ └────────────────────────┬────────────────────────────────────────┘
423
+
424
+
425
+ ┌─────────────────────────────────────────────────────────────────┐
426
+ │ 4. Conditional Request Check │
427
+ │ Client sent If-None-Match header? │
428
+ │ ├─ YES → clientEtag === serverEtag? │
429
+ │ │ ├─ YES → 304 Not Modified → EXIT │
430
+ │ │ └─ NO → Continue │
431
+ │ └─ NO → Continue │
432
+ │ │
433
+ │ Client sent If-Modified-Since header? │
434
+ │ ├─ YES → fileDate <= clientDate? │
435
+ │ │ ├─ YES → 304 Not Modified → EXIT │
436
+ │ │ └─ NO → Continue │
437
+ │ └─ NO → Continue │
438
+ └────────────────────────┬────────────────────────────────────────┘
439
+
440
+
441
+ ┌─────────────────────────────────────────────────────────────────┐
442
+ │ 5. File Access Check (Race Condition Protection) │
443
+ │ await fs.promises.access(toOpen, fs.constants.R_OK) │
444
+ │ ├─ ERROR → 404 Not Found → EXIT │
445
+ │ └─ OK → Continue │
446
+ └────────────────────────┬────────────────────────────────────────┘
447
+
448
+
449
+ ┌─────────────────────────────────────────────────────────────────┐
450
+ │ 6. Stream File to Client │
451
+ │ - Get MIME type │
452
+ │ - Create read stream │
453
+ │ - Set Content-Type, Content-Length, Content-Disposition │
454
+ │ - ctx.body = stream │
455
+ │ → EXIT (file streaming to client) │
456
+ └─────────────────────────────────────────────────────────────────┘
457
+ ```
458
+
459
+ ### Code Example: Template Rendering
460
+ ```javascript
461
+ // index.cjs:296-311
462
+ if (options.template.ext.length > 0 && options.template.render) {
463
+ const fileExt = path.extname(toOpen).slice(1); // Remove leading dot
464
+
465
+ if (fileExt && options.template.ext.includes(fileExt)) {
466
+ try {
467
+ await options.template.render(ctx, next, toOpen);
468
+ return;
469
+ } catch (error) {
470
+ console.error('Template rendering error:', error);
471
+ ctx.status = 500;
472
+ ctx.body = 'Internal Server Error - Template Rendering Failed';
473
+ return;
474
+ }
475
+ }
476
+ }
477
+ ```
478
+
479
+ **Example:**
480
+ ```
481
+ File: /public/index.ejs
482
+ options.template.ext = ["ejs", "EJS"]
483
+
484
+ fileExt = "ejs"
485
+ fileExt in template.ext? → YES
486
+
487
+ → Call template.render(ctx, next, "/public/index.ejs")
488
+ → EJS renders HTML
489
+ → ctx.body = rendered HTML
490
+ ```
491
+
492
+ ### Code Example: HTTP Caching
493
+ ```javascript
494
+ // index.cjs:313-350
495
+ if (options.enableCaching) {
496
+ // Generate ETag
497
+ const etag = `"${fileStat.mtime.getTime()}-${fileStat.size}"`;
498
+
499
+ // Format Last-Modified
500
+ const lastModified = fileStat.mtime.toUTCString();
501
+
502
+ // Set headers
503
+ ctx.set('ETag', etag);
504
+ ctx.set('Last-Modified', lastModified);
505
+ ctx.set('Cache-Control', `public, max-age=${options.cacheMaxAge}, must-revalidate`);
506
+
507
+ // Check If-None-Match (ETag validation)
508
+ const clientEtag = ctx.get('If-None-Match');
509
+ if (clientEtag && clientEtag === etag) {
510
+ ctx.status = 304; // Not Modified
511
+ return;
512
+ }
513
+
514
+ // Check If-Modified-Since (date validation)
515
+ const clientModifiedSince = ctx.get('If-Modified-Since');
516
+ if (clientModifiedSince) {
517
+ const clientDate = new Date(clientModifiedSince);
518
+ const fileDate = new Date(fileStat.mtime);
519
+
520
+ if (fileDate.getTime() <= clientDate.getTime()) {
521
+ ctx.status = 304; // Not Modified
522
+ return;
523
+ }
524
+ }
525
+ }
526
+ ```
527
+
528
+ **Caching Flow:**
529
+ ```
530
+ First Request:
531
+ Client → GET /file.txt
532
+ Server → 200 OK
533
+ ETag: "1699887654321-1024"
534
+ Last-Modified: Mon, 13 Nov 2023 10:20:54 GMT
535
+ Cache-Control: public, max-age=3600
536
+ [file content]
537
+
538
+ Second Request (file unchanged):
539
+ Client → GET /file.txt
540
+ If-None-Match: "1699887654321-1024"
541
+ Server → 304 Not Modified
542
+ (no body sent - saves bandwidth!)
543
+ ```
544
+
545
+ ---
546
+
547
+ ## Directory Listing Flow
548
+
549
+ Generates Apache2-like directory listing with sortable columns.
550
+
551
+ ```
552
+ ┌─────────────────────────────────────────────────────────────────┐
553
+ │ show_dir(toOpen, ctx) │
554
+ └────────────────────────┬────────────────────────────────────────┘
555
+
556
+
557
+ ┌─────────────────────────────────────────────────────────────────┐
558
+ │ 1. Read directory contents │
559
+ │ await fs.promises.readdir(toOpen, {withFileTypes: true}) │
560
+ │ ├─ ERROR → 500 Internal Server Error → EXIT │
561
+ │ └─ OK → Continue with dir entries │
562
+ └────────────────────────┬────────────────────────────────────────┘
563
+
564
+
565
+ ┌─────────────────────────────────────────────────────────────────┐
566
+ │ 2. Get sorting parameters from query string │
567
+ │ sortBy = ctx.query.sort || 'name' // name, type, size │
568
+ │ sortOrder = ctx.query.order || 'asc' // asc, desc │
569
+ └────────────────────────┬────────────────────────────────────────┘
570
+
571
+
572
+ ┌─────────────────────────────────────────────────────────────────┐
573
+ │ 3. Build HTML header │
574
+ │ - Page title │
575
+ │ - CSS styles │
576
+ │ - Create sortable column headers (Name, Type, Size) │
577
+ │ - Show sort indicators (↑↓) │
578
+ └────────────────────────┬────────────────────────────────────────┘
579
+
580
+
581
+ ┌─────────────────────────────────────────────────────────────────┐
582
+ │ 4. Collect item data for each directory entry │
583
+ │ For each file/directory: │
584
+ │ - Get name │
585
+ │ - Get type (1=file, 2=directory, 3=symlink) │
586
+ │ - Get MIME type │
587
+ │ - Get file size (await fs.promises.stat) │
588
+ │ - Store in items array │
589
+ └────────────────────────┬────────────────────────────────────────┘
590
+
591
+
592
+ ┌─────────────────────────────────────────────────────────────────┐
593
+ │ 5. Sort items array based on sortBy and sortOrder │
594
+ │ sortBy = 'name' → a.name.localeCompare(b.name) │
595
+ │ sortBy = 'type' → directories first, then by MIME type │
596
+ │ sortBy = 'size' → directories first, then by bytes │
597
+ │ │
598
+ │ sortOrder = 'desc' → reverse comparison │
599
+ └────────────────────────┬────────────────────────────────────────┘
600
+
601
+
602
+ ┌─────────────────────────────────────────────────────────────────┐
603
+ │ 6. Generate HTML table rows from sorted items │
604
+ │ For each item: │
605
+ │ - Escape HTML (XSS protection) │
606
+ │ - Create clickable link │
607
+ │ - Add icon (📁 for directories, 📄 for files) │
608
+ │ - Show MIME type │
609
+ │ - Show formatted size │
610
+ └────────────────────────┬────────────────────────────────────────┘
611
+
612
+
613
+ ┌─────────────────────────────────────────────────────────────────┐
614
+ │ 7. Close HTML tags and return HTML string │
615
+ │ → EXIT (directory listing displayed) │
616
+ └─────────────────────────────────────────────────────────────────┘
617
+ ```
618
+
619
+ ### Code Example: Reading Directory
620
+ ```javascript
621
+ // index.cjs:401-418
622
+ async function show_dir(toOpen, ctx) {
623
+ let dir;
624
+ try {
625
+ dir = await fs.promises.readdir(toOpen, { withFileTypes: true });
626
+ } catch (error) {
627
+ console.error('Directory read error:', error);
628
+ ctx.status = 500;
629
+ ctx.body = 'Error reading directory';
630
+ return;
631
+ }
632
+
633
+ if (dir.length === 0) {
634
+ return `
635
+ <!DOCTYPE html>
636
+ <html><head><title>Empty Directory</title></head>
637
+ <body><h1>Empty Directory</h1></body></html>
638
+ `;
639
+ }
640
+
641
+ // Continue processing...
642
+ }
643
+ ```
644
+
645
+ ### Code Example: Sorting Logic
646
+ ```javascript
647
+ // index.cjs:524-552
648
+ items.sort((a, b) => {
649
+ let comparison = 0;
650
+
651
+ if (sortBy === 'name') {
652
+ // Alphabetical sort
653
+ comparison = a.name.localeCompare(b.name);
654
+ } else if (sortBy === 'type') {
655
+ // Directories first, then by MIME type
656
+ if (a.type === 2 && b.type !== 2) {
657
+ comparison = -1; // a is directory, b is not
658
+ } else if (a.type !== 2 && b.type === 2) {
659
+ comparison = 1; // b is directory, a is not
660
+ } else {
661
+ comparison = a.mimeType.localeCompare(b.mimeType);
662
+ }
663
+ } else if (sortBy === 'size') {
664
+ // Directories first, then by file size
665
+ if (a.type === 2 && b.type !== 2) {
666
+ comparison = -1;
667
+ } else if (a.type !== 2 && b.type === 2) {
668
+ comparison = 1;
669
+ } else {
670
+ comparison = a.sizeBytes - b.sizeBytes;
671
+ }
672
+ }
673
+
674
+ // Apply sort order (asc/desc)
675
+ return sortOrder === 'desc' ? -comparison : comparison;
676
+ });
677
+ ```
678
+
679
+ **Sorting Examples:**
680
+
681
+ **1. Sort by Name (ascending):**
682
+ ```
683
+ URL: /?sort=name&order=asc
684
+
685
+ Before: [zebra.txt, apple.txt, banana.txt]
686
+ After: [apple.txt, banana.txt, zebra.txt]
687
+ ```
688
+
689
+ **2. Sort by Type (ascending):**
690
+ ```
691
+ URL: /?sort=type&order=asc
692
+
693
+ Before: [file.txt (text/plain), image.jpg (image/jpeg), docs/ (directory)]
694
+ After: [docs/ (directory), image.jpg (image/jpeg), file.txt (text/plain)]
695
+
696
+ (Directories always first when sorting by type)
697
+ ```
698
+
699
+ **3. Sort by Size (descending):**
700
+ ```
701
+ URL: /?sort=size&order=desc
702
+
703
+ Before: [small.txt (1 KB), large.zip (10 MB), docs/ (directory)]
704
+ After: [docs/ (-), large.zip (10 MB), small.txt (1 KB)]
705
+
706
+ (Directories always first, then largest to smallest)
707
+ ```
708
+
709
+ ### Code Example: XSS Protection
710
+ ```javascript
711
+ // index.cjs:586-598
712
+ function escapeHtml(unsafe) {
713
+ if (typeof unsafe !== 'string') {
714
+ return unsafe;
715
+ }
716
+ return unsafe
717
+ .replace(/&/g, "&amp;")
718
+ .replace(/</g, "&lt;")
719
+ .replace(/>/g, "&gt;")
720
+ .replace(/"/g, "&quot;")
721
+ .replace(/'/g, "&#039;");
722
+ }
723
+
724
+ // Usage in HTML generation
725
+ const escapedName = escapeHtml(item.name);
726
+ parts.push(`<a href="${escapeHtml(item.itemUri)}">${escapedName}</a>`);
727
+ ```
728
+
729
+ **Security Example:**
730
+ ```
731
+ Malicious filename: <script>alert('XSS')</script>.txt
732
+
733
+ Without escaping:
734
+ <a href="/files/<script>alert('XSS')</script>.txt">
735
+ → XSS Attack! Script executes in browser
736
+
737
+ With escaping:
738
+ <a href="/files/&lt;script&gt;alert('XSS')&lt;/script&gt;.txt">
739
+ → Safe! Displays as text, doesn't execute
740
+ ```
741
+
742
+ ---
743
+
744
+ ## Code Examples
745
+
746
+ ### Complete Usage Example
747
+
748
+ ```javascript
749
+ const Koa = require('koa');
750
+ const koaClassicServer = require('koa-classic-server');
751
+ const ejs = require('ejs');
752
+
753
+ const app = new Koa();
754
+
755
+ // Configure static file server with all features
756
+ app.use(koaClassicServer('/var/www/public', {
757
+ // Only handle GET requests
758
+ method: ['GET'],
759
+
760
+ // Show directory listings
761
+ showDirContents: true,
762
+
763
+ // Look for index files (priority order)
764
+ index: ['index.html', 'index.htm', /index\.[eE][jJ][sS]/],
765
+
766
+ // Serve under /static prefix
767
+ urlPrefix: '/static',
768
+
769
+ // Reserve /api for other handlers
770
+ urlsReserved: ['/api', '/admin'],
771
+
772
+ // Template engine (EJS)
773
+ template: {
774
+ ext: ['ejs', 'EJS'],
775
+ render: async (ctx, next, filePath) => {
776
+ ctx.body = await ejs.renderFile(filePath, {
777
+ user: 'John Doe',
778
+ items: ['A', 'B', 'C']
779
+ });
780
+ ctx.type = 'text/html';
781
+ }
782
+ },
783
+
784
+ // HTTP caching (1 hour)
785
+ cacheMaxAge: 3600,
786
+ enableCaching: true
787
+ }));
788
+
789
+ app.listen(3000);
790
+ ```
791
+
792
+ **Flow for this configuration:**
793
+
794
+ ```
795
+ Request: GET http://localhost:3000/static/docs/index.ejs
796
+
797
+ 1. Method check: GET ✓
798
+ 2. URL prefix: /static ✓
799
+ 3. Reserved URLs: /docs not in [/api, /admin] ✓
800
+ 4. Path traversal: /var/www/public/docs/index.ejs is safe ✓
801
+ 5. File exists: ✓
802
+ 6. Is directory? NO (it's a file)
803
+ 7. Is template? .ejs in ["ejs", "EJS"] ✓
804
+ 8. Call template.render()
805
+ → EJS renders with data {user: 'John Doe', items: ['A', 'B', 'C']}
806
+ → ctx.body = rendered HTML
807
+ 9. Send response to client
808
+ ```
809
+
810
+ ### Example: Directory Listing with Sorting
811
+
812
+ ```
813
+ Request: GET http://localhost:3000/docs/?sort=size&order=desc
814
+
815
+ HTML Response:
816
+ ┌───────────────────────────────────────────────────────┐
817
+ │ Index of /docs/ │
818
+ ├────────────────────┬──────────────┬───────────────────┤
819
+ │ Name ↑ │ Type ↑ │ Size ↓ │
820
+ ├────────────────────┼──────────────┼───────────────────┤
821
+ │ 📁 images/ │ directory │ - │
822
+ │ 📄 guide.pdf │ application/ │ 2.5 MB │
823
+ │ │ pdf │ │
824
+ │ 📄 readme.txt │ text/plain │ 1.2 KB │
825
+ └────────────────────┴──────────────┴───────────────────┘
826
+
827
+ Click "Size ↓" to toggle between ascending/descending
828
+ Click "Name" to sort alphabetically
829
+ Click "Type" to sort by MIME type
830
+ ```
831
+
832
+ ---
833
+
834
+ ## Performance Optimizations
835
+
836
+ ### 1. Async/Await (Non-blocking I/O)
837
+ ```javascript
838
+ // ❌ BAD (blocking)
839
+ const files = fs.readdirSync(dir); // Blocks event loop!
840
+
841
+ // ✅ GOOD (non-blocking)
842
+ const files = await fs.promises.readdir(dir); // Event loop continues
843
+ ```
844
+
845
+ ### 2. String Concatenation → Array Join
846
+ ```javascript
847
+ // ❌ BAD (slow, memory-intensive)
848
+ let html = "";
849
+ html += "<html>";
850
+ html += "<body>";
851
+ html += "</body>";
852
+ html += "</html>";
853
+
854
+ // ✅ GOOD (30-40% less memory)
855
+ const parts = [];
856
+ parts.push("<html>");
857
+ parts.push("<body>");
858
+ parts.push("</body>");
859
+ parts.push("</html>");
860
+ const html = parts.join("");
861
+ ```
862
+
863
+ ### 3. HTTP Caching (80-95% bandwidth reduction)
864
+ ```javascript
865
+ // Client sends:
866
+ GET /file.txt
867
+ If-None-Match: "1699887654321-1024"
868
+
869
+ // Server responds:
870
+ HTTP/1.1 304 Not Modified
871
+ (No body sent - file unchanged)
872
+
873
+ // Bandwidth saved: 100% of file size!
874
+ ```
875
+
876
+ ### 4. Single stat() Call
877
+ ```javascript
878
+ // ❌ BAD (double stat call)
879
+ if (fs.existsSync(file)) { // 1st stat
880
+ const stat = fs.statSync(file); // 2nd stat
881
+ }
882
+
883
+ // ✅ GOOD (single stat call)
884
+ try {
885
+ const stat = await fs.promises.stat(file); // Only 1 stat
886
+ // Use stat object
887
+ } catch (error) {
888
+ // File doesn't exist
889
+ }
890
+ ```
891
+
892
+ ---
893
+
894
+ ## Security Features
895
+
896
+ ### 1. Path Traversal Protection
897
+ ```javascript
898
+ const normalizedPath = path.normalize(requestedPath);
899
+ const fullPath = path.join(normalizedRootDir, normalizedPath);
900
+
901
+ if (!fullPath.startsWith(normalizedRootDir)) {
902
+ ctx.status = 403;
903
+ ctx.body = 'Forbidden';
904
+ return;
905
+ }
906
+ ```
907
+
908
+ ### 2. XSS Protection
909
+ ```javascript
910
+ function escapeHtml(unsafe) {
911
+ return unsafe
912
+ .replace(/&/g, "&amp;")
913
+ .replace(/</g, "&lt;")
914
+ .replace(/>/g, "&gt;")
915
+ .replace(/"/g, "&quot;")
916
+ .replace(/'/g, "&#039;");
917
+ }
918
+ ```
919
+
920
+ ### 3. Race Condition Protection
921
+ ```javascript
922
+ // Verify file is still readable before streaming
923
+ try {
924
+ await fs.promises.access(toOpen, fs.constants.R_OK);
925
+ } catch (error) {
926
+ ctx.status = 404;
927
+ return;
928
+ }
929
+ ```
930
+
931
+ ### 4. Proper Content-Disposition
932
+ ```javascript
933
+ const filename = path.basename(toOpen);
934
+ const safeFilename = filename.replace(/"/g, '\\"'); // Escape quotes
935
+ ctx.response.set("content-disposition", `inline; filename="${safeFilename}"`);
936
+ ```
937
+
938
+ ---
939
+
940
+ ## Summary
941
+
942
+ **koa-classic-server** is a production-ready static file server middleware with:
943
+
944
+ - ✅ **7-step request validation** (method, prefix, security, etc.)
945
+ - ✅ **Apache2-like directory listing** with sortable columns
946
+ - ✅ **Template engine support** (EJS, Pug, Nunjucks, etc.)
947
+ - ✅ **HTTP caching** (ETag, Last-Modified, 304 responses)
948
+ - ✅ **Security** (path traversal, XSS, race conditions)
949
+ - ✅ **Performance** (async/await, array join, single stat calls)
950
+ - ✅ **146 passing tests** (comprehensive test coverage)
951
+
952
+ **Total middleware flow: 7 validation steps → 2 handlers (file/directory) → optimized delivery**