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
@@ -0,0 +1,864 @@
1
+ # Performance Optimization Roadmap — koa-classic-server v3.0.0
2
+
3
+ **File:** `docs/OPTIMIZATION_ROADMAP_for_V3.md`
4
+ **Data analisi:** 2026-04-14
5
+ **Versione sorgente analizzata:** branch `claude/koa-v3-preparation-rVXMD`
6
+ **File analizzato:** `index.cjs` (1544 righe)
7
+
8
+ ---
9
+
10
+ ## Indice
11
+
12
+ 1. [Introduzione](#1-introduzione)
13
+ - 1.1 Scopo del documento
14
+ - 1.2 Come usare questo roadmap
15
+ - 1.3 Legenda (impatto / difficoltà / rischio)
16
+ 2. [Riepilogo generale](#2-riepilogo-generale)
17
+ - 2.1 Tabella dei 15 punti
18
+ - 2.2 Mappa visiva fase → punti → impatto
19
+ 3. [FASE 1 — Precomputation al factory time](#3-fase-1--precomputation-al-factory-time)
20
+ - [x] 3.1 #2 — `urlPrefix.split()` precompilato
21
+ - [x] 3.2 #11 — `require('stream')` → top-level
22
+ - [x] 3.3 #12 — `Math.log(1024)` → costante di modulo
23
+ - [x] 3.4 #14 — `for...in` su array → loop indicizzato
24
+ - [x] 3.5 #15 — `pageHref.origin+pathname` estratto prima del loop
25
+ 4. [FASE 2 — Riduzione costruzioni new URL()](#4-fase-2--riduzione-costruzioni-new-url)
26
+ - [x] 4.1 #1a — `new URL()` a riga 744 incondizionata
27
+ - [x] 4.2 #1b — Estrazione `_origin` per evitare concatenazioni ripetute
28
+ 5. [FASE 3 — Helper puri → scope modulo + string single-pass](#5-fase-3--helper-puri--scope-modulo--string-single-pass)
29
+ - [x] 5.1 #8 — `escapeHtml` e `formatSize` → scope modulo
30
+ - [x] 5.2 #9 — `escapeHtml`: 5 `replace()` → regex single-pass + lookup table
31
+ - [x] 5.3 #10 — HTML 404 → costante pre-calcolata
32
+ - [x] 5.4 #13 — `item[sy_type]` Symbol hack → API dirent ufficiale
33
+ 6. [FASE 4 — Strutture dati: Array → Set](#6-fase-4--strutture-dati-array--set)
34
+ - [x] 6.1 #7 — `mimeTypes` `Array.includes()` O(n) → `Set.has()` O(1)
35
+ 7. [FASE 5 — Directory listing: I/O parallelo](#7-fase-5--directory-listing-io-parallelo)
36
+ - [x] 7.1 #4 — Eliminare doppia `stat()` per symlink
37
+ - [x] 7.2 #3 — Loop `for...of` `await` → `Promise.all` per stat parallele
38
+ 8. [FASE 6 — findIndexFile fast-path](#8-fase-6--findindexfile-fast-path)
39
+ - [x] 8.1 #5 — `stat()` diretto per pattern stringa, `readdir` solo per RegExp
40
+ 9. [FASE 7 — LFU cache: eviction O(1)](#9-fase-7--lfu-cache-eviction-o1)
41
+ - [x] 9.1 #6 — Struttura LFU classica con bucket di frequenza
42
+ 10. [Stima impatto complessivo atteso](#10-stima-impatto-complessivo-atteso)
43
+ - 10.1 File serving (rawFile cache warm)
44
+ - 10.2 Directory listing su disco locale
45
+ - 10.3 Directory listing su NFS/SMB
46
+ - 10.4 High-throughput request rate
47
+ 11. [Note implementative e ordine consigliato](#11-note-implementative-e-ordine-consigliato)
48
+
49
+ ---
50
+
51
+ ## 1. Introduzione
52
+
53
+ ### 1.1 Scopo del documento
54
+
55
+ Questo documento raccoglie tutte le opportunità di ottimizzazione delle performance
56
+ identificate nel sorgente di koa-classic-server durante la preparazione della v3.0.0.
57
+ Le ottimizzazioni sono organizzate in 7 fasi ordinate per facilità di implementazione
58
+ e impatto, con checkbox per tracciare l'avanzamento.
59
+
60
+ ### 1.2 Come usare questo roadmap
61
+
62
+ - `[ ]` = da fare
63
+ - `[x]` = completato
64
+ - Ogni punto riporta: descrizione del problema, riga sorgente, causa root, fix proposto
65
+ - Le fasi sono indipendenti tra loro e possono essere eseguite in qualsiasi ordine,
66
+ ma l'ordine proposto minimizza il rischio di regressioni
67
+
68
+ ### 1.3 Legenda
69
+
70
+ | Simbolo | Significato |
71
+ |---------|-------------|
72
+ | 🔴 Alto | Impatto misurabile su ogni richiesta o su listing di directory |
73
+ | 🟡 Medio | Impatto visibile sotto carico o con directory grandi |
74
+ | 🟢 Basso | Micro-ottimizzazione, guadagno marginale |
75
+ | ⚙️ Qualità | Migliora robustezza/leggibilità anche senza impatto diretto |
76
+ | ★ Difficoltà Bassa | < 1 ora, modifica localizzata, nessun rischio architetturale |
77
+ | ★★ Difficoltà Media | Richiede refactor di una funzione, test di verifica necessari |
78
+ | ★★★ Difficoltà Alta | Cambiamento algoritmico, struttura dati nuova, test estensivi |
79
+
80
+ ---
81
+
82
+ ## 2. Riepilogo generale
83
+
84
+ ### 2.1 Tabella dei 15 punti
85
+
86
+ | # | Ottimizzazione | Riga/i | Fase | Impatto | Difficoltà | Rischio |
87
+ |---|---------------|--------|------|---------|------------|---------|
88
+ | 1a | `new URL()` a riga 744 — incondizionata | 744 | 2 | 🔴 Alto | ★ | Basso |
89
+ | 1b | `ctx.protocol+'://'+ctx.host` ripetuto 3+ volte | 660,744,756 | 2 | 🔴 Alto | ★ | Basso |
90
+ | 2 | `urlPrefix.split("/")` ad ogni richiesta | 670 | 1 | 🔴 Alto | ★ | Basso |
91
+ | 3 | `show_dir`: stat serializzate (`await` in `for...of`) | 1371–1430 | 5 | 🔴 Alto | ★★ | Basso |
92
+ | 4 | `show_dir`: doppia `stat()` per symlink | 1396–1420 | 5 | 🔴 Alto | ★ | Basso |
93
+ | 5 | `findIndexFile`: readdir + stat tutti + stat secondo | 859–908 | 6 | 🟡 Medio | ★★ | Medio |
94
+ | 6 | LFU eviction: scansione lineare O(n) | 577–597 | 7 | 🟡 Medio | ★★★ | Medio |
95
+ | 7 | `mimeTypes.includes()`: Array O(n) vs Set O(1) | 1105 | 4 | 🟡 Medio | ★ | Basso |
96
+ | 8 | `escapeHtml`, `formatSize` ricreate ad ogni richiesta | 1280,1532 | 3 | 🟡 Medio | ★ | Basso |
97
+ | 9 | `escapeHtml`: 5 `.replace()` in catena | 1536–1541 | 3 | 🟡 Medio | ★ | Basso |
98
+ | 10 | `requestedUrlNotFound()` rigenera HTML ad ogni 404 | 917–933 | 3 | 🟢 Basso | ★ | Basso |
99
+ | 11 | `require('stream')` dentro l'handler | 1234 | 1 | 🟢 Basso | ★ | Basso |
100
+ | 12 | `formatSize`: `Math.log(1024)` ricalcolato ad ogni call | 1285 | 1 | 🟢 Basso | ★ | Basso |
101
+ | 13 | `item[sy_type]` Symbol interno invece di API dirent | 1366–1373 | 3 | ⚙️ Qualità | ★ | Basso |
102
+ | 14 | `for...in` su array invece di loop indicizzato | 672 | 1 | 🟢 Basso | ★ | Basso |
103
+ | 15 | `pageHref.origin+pathname` ricalcolato nel loop | 1383–1387 | 1 | 🟢 Basso | ★ | Basso |
104
+
105
+ ### 2.2 Mappa fase → punti → impatto
106
+
107
+ ```
108
+ FASE 1 ── Precomputation ──────── #2 #11 #12 #14 #15 ── ★ ── Medio
109
+ FASE 2 ── URL reduction ──────── #1a #1b ──────────── ★ ── Alto
110
+ FASE 3 ── Helper scope+string ── #8 #9 #10 #13 ──── ★ ── Medio
111
+ FASE 4 ── Array → Set ─────────── #7 ─────────────────── ★ ── Medio
112
+ FASE 5 ── I/O parallelo ────────── #4 #3 ─────────────── ★★ ── Alto ◄ impatto massimo
113
+ FASE 6 ── findIndexFile ────────── #5 ─────────────────── ★★ ── Medio
114
+ FASE 7 ── LFU O(1) ─────────────── #6 ─────────────────── ★★★ ── Medio
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 3. FASE 1 — Precomputation al factory time
120
+
121
+ **Concetto:** tutto ciò che viene calcolato inutilmente ad ogni richiesta
122
+ ma il cui valore non cambia mai dopo l'inizializzazione del middleware.
123
+ Queste modifiche sono le più sicure: localizzate, senza effetti collaterali,
124
+ verificabili con i test esistenti senza aggiungerne di nuovi.
125
+
126
+ ---
127
+
128
+ ### [x] 3.1 — #2: `urlPrefix.split("/")` precompilato
129
+
130
+ **Riga:** `670`
131
+ **Problema:**
132
+ ```js
133
+ // ATTUALE — eseguito ad ogni richiesta
134
+ const a_urlPrefix = options.urlPrefix.split("/");
135
+ ```
136
+
137
+ `options.urlPrefix` non cambia mai dopo la factory. La chiamata `.split()` alloca
138
+ un nuovo Array ad ogni richiesta.
139
+
140
+ **Fix:**
141
+ ```js
142
+ // In factory (una volta sola):
143
+ const _urlPrefixParts = options.urlPrefix.split("/");
144
+
145
+ // Nell'handler — sostituire a_urlPrefix con _urlPrefixParts
146
+ ```
147
+
148
+ ---
149
+
150
+ ### [x] 3.2 — #11: `require('stream')` → top-level
151
+
152
+ **Riga:** `1234`
153
+ **Problema:**
154
+ ```js
155
+ // ATTUALE — dentro il corpo della richiesta, ramo streaming+rawBuffer
156
+ const { Readable } = require('stream');
157
+ ```
158
+
159
+ Node.js cache i moduli, ma la chiamata `require()` è comunque una Map lookup +
160
+ destructuring ad ogni esecuzione del branch. Appartiene al top del file.
161
+
162
+ **Fix:**
163
+ ```js
164
+ // Aggiungere in cima al file, dopo gli altri require:
165
+ const { Readable } = require('stream');
166
+ ```
167
+
168
+ ---
169
+
170
+ ### [x] 3.3 — #12: `Math.log(1024)` → costante di modulo
171
+
172
+ **Riga:** `1285`
173
+ **Problema:**
174
+ ```js
175
+ function formatSize(bytes) {
176
+ const k = 1024;
177
+ const i = Math.floor(Math.log(bytes) / Math.log(k)); // Math.log(k) ogni volta
178
+ ```
179
+
180
+ `Math.log(1024)` è una costante pura. Viene ricalcolata ad ogni chiamata a
181
+ `formatSize()`, che viene invocata per ogni entry visibile in ogni listing.
182
+
183
+ **Fix:**
184
+ ```js
185
+ // A scope di modulo:
186
+ const _LOG_1024 = Math.log(1024);
187
+
188
+ // In formatSize:
189
+ const i = Math.floor(Math.log(bytes) / _LOG_1024);
190
+ ```
191
+
192
+ ---
193
+
194
+ ### [x] 3.4 — #14: `for...in` su array → loop indicizzato
195
+
196
+ **Riga:** `672`
197
+ **Problema:**
198
+ ```js
199
+ // ATTUALE
200
+ for (const key in a_urlPrefix) {
201
+ if (a_urlPrefix[key] !== a_pathname[key]) {
202
+ ```
203
+
204
+ `for...in` su Array itera sulle chiavi come stringhe (`"0"`, `"1"`, ...) e in
205
+ teoria include proprietà prototipo non standard. La semantica corretta per
206
+ iterare un array con indice è il loop indicizzato.
207
+
208
+ **Fix:**
209
+ ```js
210
+ for (let i = 0; i < _urlPrefixParts.length; i++) {
211
+ if (_urlPrefixParts[i] !== a_pathname[i]) {
212
+ await next();
213
+ return;
214
+ }
215
+ }
216
+ ```
217
+
218
+ > Dipende dal completamento del punto 3.1 — `_urlPrefixParts` già precompilato.
219
+
220
+ ---
221
+
222
+ ### [x] 3.5 — #15: `pageHref.origin + pageHref.pathname` estratto prima del loop
223
+
224
+ **Righe:** `1383–1387`
225
+ **Problema:**
226
+ ```js
227
+ // ATTUALE — dentro il for...of, ri-calcolata per ogni item
228
+ const baseUrl = pageHref.origin + pageHref.pathname;
229
+ if (baseUrl === pageHref.origin + options.urlPrefix + "/" || ...) {
230
+ ```
231
+
232
+ La stringa `pageHref.origin + pageHref.pathname` è identica per tutti gli item
233
+ della stessa directory listing. Viene ricostruita (e confrontata con altre
234
+ concatenazioni) per ogni elemento del loop.
235
+
236
+ > **Nota:** c'è anche un conflitto di nome — `baseUrl` alla riga 1383 ombreggia
237
+ > `baseUrl` dichiarata alla riga 1322 per i link di sorting.
238
+
239
+ **Fix:**
240
+ ```js
241
+ // Prima del for...of, dopo il calcolo di dirRelPath:
242
+ const _listingBaseUrl = pageHref.origin + pageHref.pathname;
243
+ const _listingOriginPrefix = pageHref.origin + options.urlPrefix;
244
+
245
+ // Nel loop, sostituire le concatenazioni ripetute con le variabili pre-calcolate.
246
+ // Rinominare anche la variabile locale di sorting in sortBaseUrl per eliminare
247
+ // il conflitto di nome.
248
+ ```
249
+
250
+ ---
251
+
252
+ ## 4. FASE 2 — Riduzione costruzioni `new URL()`
253
+
254
+ **Concetto:** il costruttore `URL` è significativamente più costoso di operazioni
255
+ stringa equivalenti. Fa parsing completo, validazione RFC 3986, canonicalizzazione
256
+ e allocazione di un oggetto con molte proprietà. Ogni richiesta subisce almeno
257
+ una costruzione; in certi path ne subisce 3–4.
258
+
259
+ ---
260
+
261
+ ### [x] 4.1 — #1a: `new URL()` a riga 744 — incondizionata anche senza `hideExtension`
262
+
263
+ **Riga:** `744`
264
+ **Problema:**
265
+ ```js
266
+ // ATTUALE — eseguito su OGNI richiesta, indipendentemente da hideExtension
267
+ const originalUrlPath = new URL(ctx.protocol + '://' + ctx.host + urlToUse).pathname;
268
+ const hadTrailingSlash = originalUrlPath.length > 1 && originalUrlPath.endsWith('/');
269
+ ```
270
+
271
+ Questo URL viene costruito esclusivamente per determinare `hadTrailingSlash`,
272
+ informazione usata solo dentro il blocco `if (options.hideExtension)`.
273
+ Se `hideExtension` non è configurato (caso più comune), l'intera costruzione
274
+ è sprecata.
275
+
276
+ **Fix:**
277
+ ```js
278
+ // Spostare le due righe DENTRO il blocco:
279
+ if (options.hideExtension) {
280
+ const originalUrlPath = new URL(ctx.protocol + '://' + ctx.host + urlToUse).pathname;
281
+ const hadTrailingSlash = originalUrlPath.length > 1 && originalUrlPath.endsWith('/');
282
+ // ... resto della logica hideExtension
283
+ }
284
+ ```
285
+
286
+ **Alternativa zero-URL per il trailing slash:**
287
+ ```js
288
+ // Senza costruire URL, direttamente sulla stringa:
289
+ const rawPath = urlToUse.split('?')[0]; // rimuove query string
290
+ const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
291
+ ```
292
+
293
+ ---
294
+
295
+ ### [x] 4.2 — #1b: `ctx.protocol + '://' + ctx.host` ripetuto 3+ volte
296
+
297
+ **Righe:** `660, 744, 756`
298
+ **Problema:**
299
+ ```js
300
+ const fullUrl = ctx.protocol + '://' + ctx.host + urlToUse; // riga 660
301
+ const originalUrlPath = new URL(ctx.protocol + '://' + ctx.host + urlToUse)...; // riga 744
302
+ const originalUrlObj = new URL(ctx.protocol + '://' + ctx.host + ctx.originalUrl); // riga 756
303
+ ```
304
+
305
+ La stringa base `ctx.protocol + '://' + ctx.host` viene concatenata 3 volte
306
+ per richiesta, ognuna producendo una stringa temporanea intermedia.
307
+
308
+ **Fix:**
309
+ ```js
310
+ // All'inizio dell'handler, una sola volta:
311
+ const _origin = ctx.protocol + '://' + ctx.host;
312
+
313
+ // Poi ovunque:
314
+ const fullUrl = _origin + urlToUse;
315
+ const originalUrlObj = new URL(_origin + ctx.originalUrl);
316
+ // ecc.
317
+ ```
318
+
319
+ ---
320
+
321
+ ## 5. FASE 3 — Helper puri → scope modulo + string single-pass
322
+
323
+ **Concetto:** funzioni che non catturano variabili di closure ma sono dichiarate
324
+ dentro l'handler (o dentro `show_dir` che è dentro l'handler), venendo ricreate
325
+ come nuovi oggetti-funzione ad ogni richiesta o ad ogni listing. Spostare le
326
+ funzioni pure a scope di modulo elimina questa allocazione sistematica.
327
+
328
+ ---
329
+
330
+ ### [x] 5.1 — #8: `escapeHtml` e `formatSize` → scope di modulo
331
+
332
+ **Righe:** `1280` (formatSize), `1532` (escapeHtml)
333
+ **Problema:** entrambe le funzioni sono dichiarate dentro la closure dell'handler.
334
+ Non usano alcuna variabile di closure: sono funzioni pure che dipendono solo
335
+ dai propri argomenti.
336
+
337
+ `escapeHtml` viene chiamata per ogni entry di ogni listing. Se una directory
338
+ ha 200 file, la funzione viene creata una volta per listing ma chiamata 200+
339
+ volte con un oggetto-funzione nato e morto insieme alla richiesta.
340
+
341
+ **Fix:** spostare entrambe le definizioni a livello di `module.exports`
342
+ (scope di modulo), prima della `return async (ctx, next) => {`.
343
+
344
+ ---
345
+
346
+ ### [x] 5.2 — #9: `escapeHtml` single-pass con lookup table
347
+
348
+ **Righe:** `1536–1541`
349
+ **Problema:**
350
+ ```js
351
+ // ATTUALE — 5 passaggi sulla stringa, 4 stringhe intermedie allocate
352
+ return unsafe
353
+ .replace(/&/g, "&amp;")
354
+ .replace(/</g, "&lt;")
355
+ .replace(/>/g, "&gt;")
356
+ .replace(/"/g, "&quot;")
357
+ .replace(/'/g, "&#039;");
358
+ ```
359
+
360
+ **Fix:**
361
+ ```js
362
+ // A scope di modulo (una sola volta):
363
+ const _HTML_ESCAPE_MAP = {
364
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
365
+ };
366
+ const _HTML_ESCAPE_RE = /[&<>"']/g;
367
+
368
+ // Funzione:
369
+ function escapeHtml(unsafe) {
370
+ if (typeof unsafe !== 'string') return unsafe;
371
+ return unsafe.replace(_HTML_ESCAPE_RE, c => _HTML_ESCAPE_MAP[c]);
372
+ }
373
+ ```
374
+
375
+ Un solo passaggio, una sola stringa allocata, regex compilata una volta.
376
+
377
+ ---
378
+
379
+ ### [x] 5.3 — #10: HTML 404 → costante pre-calcolata
380
+
381
+ **Righe:** `917–933`
382
+ **Problema:**
383
+ ```js
384
+ function requestedUrlNotFound() {
385
+ return `
386
+ <!DOCTYPE html>
387
+ <html>...
388
+ </html>
389
+ `;
390
+ }
391
+ ```
392
+
393
+ Il template literal produce ogni volta una stringa identica. La funzione viene
394
+ chiamata ad ogni 404, ad ogni hidden check, ad ogni path traversal bloccato.
395
+
396
+ **Fix:**
397
+ ```js
398
+ // A scope di factory (calcolato una volta):
399
+ const _NOT_FOUND_HTML = `<!DOCTYPE html>
400
+ <html>
401
+ <head>
402
+ <meta charset="UTF-8">
403
+ <title>URL not found</title>
404
+ </head>
405
+ <body>
406
+ <h1>Not Found</h1>
407
+ <h3>The requested URL was not found on this server.</h3>
408
+ </body>
409
+ </html>`;
410
+
411
+ // sendNotFound diventa:
412
+ function sendNotFound(ctx) {
413
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
414
+ ctx.status = 404;
415
+ ctx.body = _NOT_FOUND_HTML;
416
+ }
417
+ ```
418
+
419
+ ---
420
+
421
+ ### [x] 5.4 — #13: `item[sy_type]` Symbol interno → API dirent ufficiale
422
+
423
+ **Righe:** `1366–1373`
424
+ **Problema:**
425
+ ```js
426
+ // ATTUALE — accesso a Symbol interno non documentato
427
+ let a_sy = Object.getOwnPropertySymbols(dir[0]);
428
+ const sy_type = a_sy[0];
429
+ // ...
430
+ const type = item[sy_type]; // usato per ogni item
431
+ ```
432
+
433
+ Questa tecnica legge la proprietà numerica del tipo dirent (`0=DT_UNKNOWN`,
434
+ `1=file`, `2=dir`, `3=symlink`) tramite un Symbol privato di Node.js che
435
+ potrebbe cambiare in versioni future. L'API pubblica equivalente esiste:
436
+ `dirent.isFile()`, `dirent.isDirectory()`, `dirent.isSymbolicLink()`.
437
+
438
+ **Fix:**
439
+ ```js
440
+ // Helper a scope di modulo:
441
+ function getDirentType(dirent) {
442
+ if (dirent.isFile()) return 1;
443
+ if (dirent.isDirectory()) return 2;
444
+ if (dirent.isSymbolicLink()) return 3;
445
+ return 0; // DT_UNKNOWN
446
+ }
447
+
448
+ // Nel loop show_dir, sostituire:
449
+ // const type = item[sy_type];
450
+ // con:
451
+ const type = getDirentType(item);
452
+ ```
453
+
454
+ Eliminare anche le righe `Object.getOwnPropertySymbols` e `sy_type`.
455
+
456
+ ---
457
+
458
+ ## 6. FASE 4 — Strutture dati: Array → Set
459
+
460
+ **Concetto:** sostituire ricerche lineari O(n) con lookup O(1) dove il set
461
+ di valori è fisso e costruito una volta sola all'inizializzazione.
462
+
463
+ ---
464
+
465
+ ### [x] 6.1 — #7: `mimeTypes` Array → Set
466
+
467
+ **Riga:** `1105`
468
+ **Problema:**
469
+ ```js
470
+ // ATTUALE — Array.includes() O(n), chiamato ad ogni richiesta non-Range
471
+ const isCompressibleMime = compressionConfig.mimeTypes.includes(mimeType);
472
+ ```
473
+
474
+ La lista default ha 11 MIME type. Worst case: 11 confronti string ad ogni
475
+ richiesta che potrebbe essere compressa.
476
+
477
+ **Fix:** in `normalizeCompressionConfig()`, restituire un `Set` invece di un `Array`:
478
+ ```js
479
+ // ATTUALE
480
+ return { enabled, encodings, minSize, mimeTypes };
481
+
482
+ // FIX
483
+ return { enabled, encodings, minSize, mimeTypes: new Set(mimeTypes) };
484
+
485
+ // Nell'handler, aggiornare la call-site:
486
+ const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
487
+ ```
488
+
489
+ > **Attenzione:** aggiornare anche il test `compression.test.js` se verifica
490
+ > il tipo di `mimeTypes` direttamente.
491
+
492
+ ---
493
+
494
+ ## 7. FASE 5 — Directory listing: I/O parallelo
495
+
496
+ **Concetto:** il bottleneck più impattante del middleware. Le `stat` del filesystem
497
+ durante la generazione del listing vengono eseguite in modo sequenziale (`await`
498
+ in `for...of`), serializzando operazioni che potrebbero essere tutte concorrenti.
499
+ Su NFS o filesystem di rete l'impatto è ordini di grandezza.
500
+
501
+ ---
502
+
503
+ ### [x] 7.1 — #4: Eliminare doppia `stat()` per symlink — riutilizzare `realStat`
504
+
505
+ **Righe:** `1396–1420`
506
+ **Problema:** per ogni entry che è un symlink o `DT_UNKNOWN`, la funzione fa
507
+ due chiamate `stat` al filesystem separate sullo stesso path:
508
+ ```js
509
+ // STAT #1 — per determinare il tipo effettivo (riga 1396)
510
+ const realStat = await fs.promises.stat(itemPath);
511
+ if (realStat.isFile()) effectiveType = 1;
512
+ // realStat.size è disponibile ma IGNORATO
513
+
514
+ // ... hidden check ...
515
+
516
+ // STAT #2 — per ottenere la size (riga 1420) — RIDONDANTE per symlink
517
+ const itemStat = await fs.promises.stat(itemPath);
518
+ if (effectiveType === 1) {
519
+ sizeBytes = itemStat.size;
520
+ }
521
+ ```
522
+
523
+ **Fix:** conservare `realStat` e riutilizzarlo per la size:
524
+ ```js
525
+ let cachedStat = null;
526
+
527
+ if (type === 3 || type === 0) {
528
+ try {
529
+ cachedStat = await fs.promises.stat(itemPath); // conservato
530
+ if (cachedStat.isFile()) effectiveType = 1;
531
+ else if (cachedStat.isDirectory()) effectiveType = 2;
532
+ } catch { ... }
533
+ }
534
+
535
+ // Per la size: riutilizzare cachedStat se disponibile
536
+ if (!isBrokenSymlink) {
537
+ try {
538
+ const itemStat = cachedStat || await fs.promises.stat(itemPath);
539
+ if (effectiveType === 1) {
540
+ sizeBytes = itemStat.size;
541
+ sizeStr = formatSize(sizeBytes);
542
+ }
543
+ } catch { sizeStr = '-'; }
544
+ }
545
+ ```
546
+
547
+ ---
548
+
549
+ ### [x] 7.2 — #3: Loop `for...of` con `await` → `Promise.all` per stat parallele
550
+
551
+ **Righe:** `1371–1430`
552
+ **Problema:** il loop raccoglie i dati di ogni entry in modo sequenziale.
553
+ Ogni `await fs.promises.stat()` blocca l'iterazione finché il filesystem
554
+ non risponde. Con N file:
555
+
556
+ - Disco locale (`stat` ≈ 0.3 ms): 100 file → ~30 ms sequenziali vs ~2 ms paralleli
557
+ - NFS (`stat` ≈ 15 ms): 100 file → ~1500 ms sequenziali vs ~20 ms paralleli
558
+
559
+ **Struttura del fix:** separare la raccolta dati (tutte le stat in parallelo)
560
+ dalla generazione HTML (puramente sincrona):
561
+
562
+ ```js
563
+ // FASE A: raccogliere tutti i dati in parallelo
564
+ const rawItems = await Promise.all(
565
+ dir.map(async (item) => {
566
+ const type = getDirentType(item); // dopo fix #13
567
+ if (type !== 0 && type !== 1 && type !== 2 && type !== 3) return null;
568
+
569
+ const s_name = item.name;
570
+ const itemPath = path.join(toOpen, s_name);
571
+
572
+ let effectiveType = type;
573
+ let isBrokenSymlink = false;
574
+ let cachedStat = null;
575
+
576
+ // Stat #1: risolvi symlink / DT_UNKNOWN
577
+ if (type === 3 || type === 0) {
578
+ try {
579
+ cachedStat = await fs.promises.stat(itemPath);
580
+ if (cachedStat.isFile()) effectiveType = 1;
581
+ else if (cachedStat.isDirectory()) effectiveType = 2;
582
+ } catch {
583
+ if (type === 3) isBrokenSymlink = true;
584
+ else return null;
585
+ }
586
+ }
587
+
588
+ // Hidden check: scarta subito le entry nascoste
589
+ const itemIsDir = effectiveType === 2;
590
+ const itemRelPath = dirRelPath ? dirRelPath + '/' + s_name : s_name;
591
+ if (isHiddenEntry(s_name, itemRelPath, itemIsDir)) return null;
592
+
593
+ // Stat #2: size (riutilizza cachedStat se già disponibile)
594
+ let sizeBytes = 0;
595
+ let sizeStr = '-';
596
+ if (!isBrokenSymlink) {
597
+ try {
598
+ const itemStat = cachedStat || await fs.promises.stat(itemPath);
599
+ if (effectiveType === 1) {
600
+ sizeBytes = itemStat.size;
601
+ sizeStr = formatSize(sizeBytes);
602
+ }
603
+ } catch { sizeStr = '-'; }
604
+ }
605
+
606
+ return { name: s_name, type, effectiveType, isBrokenSymlink,
607
+ sizeBytes, sizeStr, itemPath };
608
+ })
609
+ );
610
+
611
+ // FASE B: filtrare null e calcolare campi derivati (pura, sincrona)
612
+ const items = rawItems
613
+ .filter(Boolean)
614
+ .map(item => ({
615
+ ...item,
616
+ isSymlink: item.type === 3,
617
+ mimeType: item.effectiveType === 2
618
+ ? 'DIR'
619
+ : (mime.lookup(item.itemPath) || 'unknown'),
620
+ itemUri: buildItemUri(item.name),
621
+ isReserved: /* ... */
622
+ }));
623
+
624
+ // FASE C: sort + HTML generation (invariati)
625
+ ```
626
+
627
+ > **Attenzione:** `Promise.all` esegue tutte le stat concorrentemente, ma le
628
+ > entry risultanti sono nell'ordine originale del `readdir`. Il sort successivo
629
+ > rimane invariato.
630
+
631
+ ---
632
+
633
+ ## 8. FASE 6 — `findIndexFile` fast-path
634
+
635
+ **Concetto:** il caso di gran lunga più comune è un pattern stringa (es. `"index.html"`).
636
+ Invece di leggere tutti i file della directory, verificare e poi cercare, è più
637
+ efficiente tentare una `stat` diretta sul file candidato.
638
+
639
+ ---
640
+
641
+ ### [x] 8.1 — #5: `stat()` diretto per pattern stringa, `readdir` solo per RegExp
642
+
643
+ **Righe:** `859–908`
644
+ **Problema attuale:**
645
+
646
+ 1. `readdir()` di tutti i file della directory
647
+ 2. `Promise.all` con `isFileOrSymlinkToFile` su tutti i file (`stat` per symlink)
648
+ 3. Filtraggio + ricerca del pattern nella lista
649
+ 4. Seconda `stat()` sul file trovato per restituire `fileStat`
650
+
651
+ Per una directory con 500 file e pattern `"index.html"`, questo compie centinaia
652
+ di operazioni inutili.
653
+
654
+ **Fix — fast-path per pattern stringa:**
655
+ ```js
656
+ async function findIndexFile(dirPath, indexPatterns) {
657
+ for (const pattern of indexPatterns) {
658
+
659
+ // FAST PATH: pattern stringa → stat() diretto, nessun readdir
660
+ if (typeof pattern === 'string') {
661
+ const candidate = path.join(dirPath, pattern);
662
+ try {
663
+ const fileStat = await fs.promises.stat(candidate);
664
+ if (fileStat.isFile()) {
665
+ return { name: pattern, stat: fileStat };
666
+ }
667
+ } catch {
668
+ continue; // file non esiste, prova il pattern successivo
669
+ }
670
+ }
671
+
672
+ // SLOW PATH: pattern RegExp → readdir necessario (comportamento attuale)
673
+ if (pattern instanceof RegExp) {
674
+ try {
675
+ const files = await fs.promises.readdir(dirPath, { withFileTypes: true });
676
+ // ... logica attuale per RegExp ...
677
+ } catch (error) {
678
+ console.error('Error finding index file:', error);
679
+ }
680
+ }
681
+ }
682
+ return null;
683
+ }
684
+ ```
685
+
686
+ > Se tutti i pattern sono stringhe (caso tipico), `readdir` non viene mai chiamato.
687
+ > Se l'array misto contiene prima stringhe poi RegExp, le stringhe sono risolte
688
+ > con O(1) `stat` per pattern e il RegExp usa `readdir` solo se nessuna stringa
689
+ > ha trovato corrispondenza.
690
+
691
+ ---
692
+
693
+ ## 9. FASE 7 — LFU cache: eviction O(1)
694
+
695
+ **Concetto:** l'implementazione attuale di `evictLFU` scansiona l'intera Map
696
+ per trovare l'entry con hits minimo. Per cache grandi con frequente pressure
697
+ sul `maxSize`, questo degrada a O(n) per eviction, O(n²) ammortizzato.
698
+
699
+ ---
700
+
701
+ ### [x] 9.1 — #6: Struttura LFU classica con bucket di frequenza
702
+
703
+ **Righe:** `577–597`
704
+ **Problema attuale:**
705
+ ```js
706
+ function evictLFU(cache, ...) {
707
+ let minHits = Infinity, minKey = null;
708
+ for (const [key, entry] of cache) { // O(n) scan
709
+ if (entry.hits < minHits) { minKey = key; }
710
+ }
711
+ // evict minKey
712
+ }
713
+ ```
714
+
715
+ **Struttura dati proposta:** LFU classico O(1) con:
716
+ - `freqMap`: `Map<frequency, Set<key>>` — bucket per frequenza
717
+ - `keyFreq`: `Map<key, frequency>` — frequenza corrente di ogni key
718
+ - `minFreq`: numero intero — frequenza minima attuale
719
+
720
+ ```js
721
+ class LFUCache {
722
+ constructor(maxSize) {
723
+ this.maxSize = maxSize; // in bytes
724
+ this.currentSize = 0;
725
+ this.keyMap = new Map(); // key → { buffer, mtime, size, freq }
726
+ this.freqMap = new Map(); // freq → Set<key>
727
+ this.minFreq = 0;
728
+ }
729
+
730
+ get(key) {
731
+ if (!this.keyMap.has(key)) return undefined;
732
+ this._incrementFreq(key);
733
+ return this.keyMap.get(key);
734
+ }
735
+
736
+ set(key, entry) {
737
+ while (this.currentSize + entry.buffer.length > this.maxSize
738
+ && this.keyMap.size > 0) {
739
+ this._evictOne();
740
+ }
741
+ if (this.currentSize + entry.buffer.length > this.maxSize) return;
742
+
743
+ this.keyMap.set(key, { ...entry, freq: 1 });
744
+ this._addToFreqBucket(key, 1);
745
+ this.currentSize += entry.buffer.length;
746
+ if (this.minFreq > 1) this.minFreq = 1;
747
+ }
748
+
749
+ delete(key) {
750
+ if (!this.keyMap.has(key)) return;
751
+ const { freq, buffer } = this.keyMap.get(key);
752
+ this.currentSize -= buffer.length;
753
+ this.keyMap.delete(key);
754
+ this.freqMap.get(freq)?.delete(key);
755
+ }
756
+
757
+ _incrementFreq(key) {
758
+ const entry = this.keyMap.get(key);
759
+ const oldFreq = entry.freq;
760
+ const newFreq = oldFreq + 1;
761
+ entry.freq = newFreq;
762
+ this.freqMap.get(oldFreq).delete(key);
763
+ if (this.freqMap.get(oldFreq).size === 0) {
764
+ this.freqMap.delete(oldFreq);
765
+ if (this.minFreq === oldFreq) this.minFreq = newFreq;
766
+ }
767
+ this._addToFreqBucket(key, newFreq);
768
+ }
769
+
770
+ _addToFreqBucket(key, freq) {
771
+ if (!this.freqMap.has(freq)) this.freqMap.set(freq, new Set());
772
+ this.freqMap.get(freq).add(key);
773
+ }
774
+
775
+ _evictOne() {
776
+ const bucket = this.freqMap.get(this.minFreq);
777
+ if (!bucket || bucket.size === 0) return;
778
+ const evictKey = bucket.values().next().value; // FIFO a parità di freq
779
+ this.delete(evictKey);
780
+ }
781
+ }
782
+ ```
783
+
784
+ **Integrazione:** sostituire `_rawFileCache` (Map) e `_compressedFileCache` (Map)
785
+ con istanze di `LFUCache`. Adattare i siti di chiamata per usare
786
+ `cache.get(key)`, `cache.set(key, entry)`, `cache.delete(key)`.
787
+ La funzione globale `evictLFU` diventa obsoleta e può essere rimossa.
788
+
789
+ > **Prerequisito:** aggiornare i test in `server-cache.test.js` che verificano
790
+ > il comportamento LFU, in particolare il test `"all files return correct content
791
+ > even when eviction occurs"` — la semantica rimane identica, solo l'implementazione
792
+ > interna cambia.
793
+
794
+ ---
795
+
796
+ ## 10. Stima impatto complessivo atteso
797
+
798
+ ### 10.1 File serving (rawFile cache warm)
799
+
800
+ Già ottimale: risposta da buffer in memoria, zero disk I/O.
801
+ Le fasi 1 e 2 riducono l'overhead del routing di ~5–10% in termini di allocazioni.
802
+
803
+ ### 10.2 Directory listing su disco locale (ext4/btrfs/xfs)
804
+
805
+ | Scenario | Attuale | Dopo Fase 5 | Miglioramento |
806
+ |----------|---------|-------------|---------------|
807
+ | 10 file, 2 symlink | ~5 ms | ~3 ms | –40% |
808
+ | 100 file, 10 symlink | ~40 ms | ~5 ms | –87% |
809
+ | 1000 file, 50 symlink | ~350 ms | ~15 ms | –96% |
810
+
811
+ ### 10.3 Directory listing su NFS/SMB (latenza stat ≈ 15 ms)
812
+
813
+ | Scenario | Attuale | Dopo Fase 5 | Miglioramento |
814
+ |----------|---------|-------------|---------------|
815
+ | 10 file, 2 symlink | ~180 ms | ~25 ms | –86% |
816
+ | 100 file, 10 symlink | ~1800 ms | ~25 ms | –99% |
817
+ | 1000 file, 50 symlink | ~18 s | ~25 ms | –99.9% |
818
+
819
+ ### 10.4 High-throughput file serving (>1000 req/s)
820
+
821
+ Le fasi 1, 2, 3, 4 riducono le allocazioni per-richiesta:
822
+
823
+ - Eliminata 1 chiamata `new URL()` incondizionata (fase 2)
824
+ - Eliminati 2 `split`/`join` di array per il prefisso URL (fase 1)
825
+ - Eliminata 1 funzione oggetto creata per richiesta (`escapeHtml`/`formatSize`, fase 3)
826
+ - Lookup MIME O(1) vs O(n) (fase 4)
827
+
828
+ Stima: **+8–15% throughput** su workload ad alto req/s con file statici.
829
+
830
+ ---
831
+
832
+ ## 11. Note implementative e ordine consigliato
833
+
834
+ ### Ordine consigliato
835
+
836
+ Le fasi sono state progettate per essere indipendenti, ma l'ordine seguente
837
+ minimizza il rischio e massimizza la verificabilità:
838
+
839
+ **Fase 1 → Fase 2 → Fase 3 → Fase 4 → Fase 5 → Fase 6 → Fase 7**
840
+
841
+ - Fasi 1–4 possono essere fatte in un singolo commit ciascuna (sono localizzate).
842
+ - Fase 5 richiede un refactor più ampio di `show_dir` — committare separatamente
843
+ i due sotto-punti (7.1 prima, poi 7.2).
844
+ - Fase 7 richiede una suite di test aggiuntiva per la classe `LFUCache`.
845
+
846
+ ### Test da eseguire dopo ogni fase
847
+
848
+ Dopo ogni fase: `npx jest --no-coverage` deve passare tutti i 451 test esistenti
849
+ senza modifiche. Le ottimizzazioni sono trasparenti al comportamento osservabile.
850
+
851
+ **Eccezioni:**
852
+
853
+ - **Fase 4 (#7):** se un test verifica `compressionConfig.mimeTypes` come Array
854
+ (`.length`, spread), aggiornarlo per usare l'API Set (`.size`, `for...of`).
855
+ - **Fase 7 (#6):** i test `server-cache.test.js` sulle eviction LFU rimangono
856
+ validi — la semantica non cambia, solo la velocità.
857
+
858
+ ### Compatibilità Node.js
859
+
860
+ Tutte le ottimizzazioni richiedono Node.js ≥ 18 (già dichiarato in `engines`).
861
+ `Promise.all` su array di Promise (fase 5) e `Set` (fase 4) sono disponibili
862
+ da Node.js 10+.
863
+ L'API dirent `isFile()` / `isDirectory()` / `isSymbolicLink()` è disponibile
864
+ da Node.js 10.10+.