koa-classic-server 4.1.0 → 4.2.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 (3) hide show
  1. package/README.md +25 -0
  2. package/index.cjs +178 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -150,6 +150,25 @@ app.use(koaClassicServer(path.join(__dirname, 'www'), {
150
150
  // .env, .git/config, *.key → 404
151
151
  ```
152
152
 
153
+ ### Custom error pages
154
+
155
+ Branded 404/500/504 pages from self-contained `.html` files (v4.2+). Keep them **outside**
156
+ `rootDir` so they are not themselves reachable via URL; edit them anytime — changes are
157
+ picked up without a restart, and an unreadable file falls back to the built-in page:
158
+
159
+ ```javascript
160
+ app.use(koaClassicServer(path.join(__dirname, 'www'), {
161
+ errorPages: {
162
+ 404: path.join(__dirname, 'errors', '404.html'),
163
+ 500: path.join(__dirname, 'errors', '500.html'),
164
+ },
165
+ }));
166
+ ```
167
+
168
+ One rule: each page must be a single self-contained file (inline CSS, no external
169
+ css/js/img references). Custom pages are served without the built-in pages'
170
+ `Content-Security-Policy`, so an external reference would really load.
171
+
153
172
  ### Mount under a prefix, pass through some routes
154
173
 
155
174
  ```javascript
@@ -299,6 +318,12 @@ koaClassicServer(rootDir, {
299
318
  symlinks: 'follow', // 'follow' | 'follow-within-root' | 'deny'
300
319
  staticSecurityHeaders: { nosniff: false }, // set nosniff on static responses
301
320
 
321
+ errorPages: { // custom error pages (v4.2+); omitted → built-ins
322
+ // 404: './errors/404.html', // supported statuses: 404, 500, 504
323
+ // 500: './errors/500.html', // self-contained .html (inline CSS, no external refs)
324
+ // 504: './errors/504.html', // editable without a restart; unreadable → built-in
325
+ },
326
+
302
327
  template: {
303
328
  ext: [], // e.g. ['ejs']
304
329
  renderTimeout: 30000, // ms; on timeout the request fails closed (0 = off)
package/index.cjs CHANGED
@@ -121,9 +121,12 @@ const LISTING_CSP = `default-src 'none'; style-src '${_listingCssHash}'; frame-a
121
121
  const NOT_FOUND_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
122
122
 
123
123
  // Sets security headers on all middleware-generated HTML pages (listing + error).
124
- // Must NOT be called for user files served from disk.
124
+ // Must NOT be called for user files served from disk. `csp` may be null for
125
+ // operator-authored custom error pages (options.errorPages): the built-in pages'
126
+ // `default-src 'none'` would block their inline styles, so they get the non-CSP
127
+ // headers only — the self-contained requirement is documented, not enforced.
125
128
  function setGeneratedPageHeaders(ctx, csp) {
126
- ctx.set('Content-Security-Policy', csp);
129
+ if (csp) ctx.set('Content-Security-Policy', csp);
127
130
  ctx.set('X-Content-Type-Options', 'nosniff');
128
131
  ctx.set('X-Frame-Options', 'DENY');
129
132
  ctx.set('Referrer-Policy', 'no-referrer');
@@ -153,10 +156,38 @@ const _GATEWAY_TIMEOUT_HTML = buildErrorHtml('Gateway Timeout', 'Gateway T
153
156
  const _TEMPLATE_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'Template rendering failed for the requested resource.');
154
157
  const _INTERNAL_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'The server encountered an unexpected condition.');
155
158
 
156
- function sendNotFound(ctx) {
157
- setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
158
- ctx.status = 404;
159
- ctx.body = _NOT_FOUND_HTML;
159
+ // Statuses an operator can override via options.errorPages, each with its
160
+ // built-in fallback page. 400 is deliberately absent: it answers malformed /
161
+ // hostile requests and stays minimal by design. _TEMPLATE_ERROR_HTML is a
162
+ // call-site-specific 500 body (template failures), not a separate status.
163
+ const _BUILTIN_ERROR_HTML = {
164
+ 404: _NOT_FOUND_HTML,
165
+ 500: _INTERNAL_ERROR_HTML,
166
+ 504: _GATEWAY_TIMEOUT_HTML,
167
+ };
168
+
169
+ // Representation / caching headers a partially-built response may have left
170
+ // behind by the time an error is detected: a stale Content-Encoding would
171
+ // corrupt the error page (its body is never compressed), a public
172
+ // Cache-Control could get the 404/500 cached by proxies under the resource's
173
+ // URL. Scrubbed on every error page write.
174
+ const ERROR_PAGE_SCRUB_HEADERS = [
175
+ 'Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary',
176
+ 'ETag', 'Last-Modified', 'Accept-Ranges',
177
+ 'Cache-Control', 'Pragma', 'Expires',
178
+ ];
179
+
180
+ // Single writer for every middleware-generated error response (404 / 500 / 504).
181
+ // `customBuffer` is the operator's page from options.errorPages (or null →
182
+ // `builtinHtml` is served). Assumes the response is still writable — callers
183
+ // keep their own headerSent / writableEnded guards.
184
+ function writeErrorPage(ctx, status, customBuffer, builtinHtml) {
185
+ for (const h of ERROR_PAGE_SCRUB_HEADERS) ctx.remove(h);
186
+ if (status >= 500) ctx.set('Cache-Control', 'no-store');
187
+ setGeneratedPageHeaders(ctx, customBuffer ? null : NOT_FOUND_CSP);
188
+ ctx.set('Content-Type', 'text/html; charset=utf-8');
189
+ ctx.status = status;
190
+ ctx.body = customBuffer || builtinHtml;
160
191
  }
161
192
 
162
193
  // Plain-text 400 for malformed requests (bad percent-encoding, invalid Host,
@@ -212,16 +243,16 @@ function warnConfigDeprecation(logger, message) {
212
243
 
213
244
  // Sends an error response for a failed template render. If headers were already
214
245
  // flushed by the render itself, destroys the underlying socket instead (the
215
- // status/body can no longer be changed at that point).
216
- function sendTemplateError(ctx, status, html, logMsg, err, logger) {
246
+ // status/body can no longer be changed at that point). Page selection (custom
247
+ // vs built-in) goes through the instance's sendErrorPage; `builtinHtml` keeps
248
+ // the call-site-specific fallback body (timeout vs render failure).
249
+ async function sendTemplateError(ctx, status, builtinHtml, logMsg, err, logger, sendErrorPage) {
217
250
  logger.error(logMsg, err);
218
251
  if (ctx.headerSent || ctx.res.writableEnded) {
219
252
  ctx.res.destroy();
220
253
  return;
221
254
  }
222
- setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
223
- ctx.status = status;
224
- ctx.body = html;
255
+ await sendErrorPage(ctx, status, builtinHtml);
225
256
  }
226
257
 
227
258
  // Rewrites an already-rendered response into an RFC 9110 §9.3.2 compliant HEAD
@@ -256,7 +287,7 @@ function stripBodyForHead(ctx) {
256
287
  // client disconnect. Cooperative renders that propagate the signal to fetch/db
257
288
  // release backend resources promptly; non-cooperative renders still get a 504
258
289
  // response, but their work continues in the background.
259
- async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger) {
290
+ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger, sendErrorPage) {
260
291
  if (templateOpts.ext.length === 0 || !templateOpts.render) return false;
261
292
 
262
293
  const fileExt = path.extname(filePath).slice(1);
@@ -304,11 +335,11 @@ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, l
304
335
  }
305
336
  } catch (error) {
306
337
  if (timedOut || error.code === 'ETEMPLATETIMEOUT') {
307
- sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
308
- 'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger);
338
+ await sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
339
+ 'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger, sendErrorPage);
309
340
  } else {
310
- sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
311
- 'Template rendering error:', error, logger);
341
+ await sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
342
+ 'Template rendering error:', error, logger, sendErrorPage);
312
343
  }
313
344
  } finally {
314
345
  if (timer) clearTimeout(timer);
@@ -691,6 +722,25 @@ module.exports = function koaClassicServer(
691
722
  ext: '.ejs', // Extension to hide (required, string, case-sensitive, must start with '.')
692
723
  redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
693
724
  },
725
+ errorPages: { // Custom error pages (V4.2+). Opt-in — omitted statuses keep the built-in pages.
726
+ // Keys: the statuses the middleware generates error pages for: 404, 500, 504.
727
+ // Any other key throws at factory time. (400 replies to malformed/hostile
728
+ // requests are deliberately NOT customizable — they stay minimal.)
729
+ // Values: filesystem path (absolute, or relative to process.cwd()) to a
730
+ // SELF-CONTAINED .html file: one single file, inline CSS only, no external
731
+ // css/js/img references. Documented requirement, not enforced — custom
732
+ // pages are served WITHOUT the built-in pages' Content-Security-Policy
733
+ // (which would block their inline styles); the other generated-page
734
+ // security headers (nosniff, X-Frame-Options, ...) are still sent.
735
+ // The file may live outside rootDir (recommended: keeps it unreachable via URL).
736
+ // Read and validated at factory time (missing/unreadable → throw), cached in
737
+ // RAM, and re-read automatically when its mtime/size changes — editable
738
+ // without a restart. If it becomes unreadable at request time, the built-in
739
+ // page is served instead (fallback; throttled warning via logger).
740
+ // 404: './errors/404.html',
741
+ // 500: './errors/500.html',
742
+ // 504: './errors/504.html',
743
+ },
694
744
  hidden: { // Block files/dirs from listing and serving (HTTP 404)
695
745
  dotFiles: { // Dot-files (names starting with '.'): visible by default — design philosophy
696
746
  default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
@@ -1414,6 +1464,98 @@ module.exports = function koaClassicServer(
1414
1464
  nosniff: !!(_ssh && _ssh.nosniff),
1415
1465
  };
1416
1466
 
1467
+ // ── errorPages (V4.2+) — operator-supplied custom error pages ──
1468
+ // Validated and read once at factory time (a typo must fail at startup, not
1469
+ // on the first 404), then kept in RAM and refreshed when mtime/size changes.
1470
+ const _errorPages = new Map(); // status → { path, buffer, mtimeMs, size }
1471
+ const userErrorPages = opts.errorPages;
1472
+ if (userErrorPages !== undefined) {
1473
+ if (typeof userErrorPages !== 'object' || userErrorPages === null || Array.isArray(userErrorPages)) {
1474
+ throw new Error(
1475
+ '[koa-classic-server] options.errorPages must be an object mapping a supported HTTP status ' +
1476
+ "to an .html file path. Example: errorPages: { 404: './errors/404.html' }"
1477
+ );
1478
+ }
1479
+ for (const key of Object.keys(userErrorPages)) {
1480
+ const status = Number(key);
1481
+ if (_BUILTIN_ERROR_HTML[status] === undefined) {
1482
+ throw new Error(
1483
+ `[koa-classic-server] errorPages: unsupported status "${key}". ` +
1484
+ 'Supported statuses: ' + Object.keys(_BUILTIN_ERROR_HTML).join(', ') + '. ' +
1485
+ '(400 is deliberately not customizable: replies to malformed requests stay minimal.)'
1486
+ );
1487
+ }
1488
+ const value = userErrorPages[key];
1489
+ if (typeof value !== 'string' || value === '') {
1490
+ throw new Error(
1491
+ `[koa-classic-server] errorPages[${key}] must be a non-empty string path to an .html file. ` +
1492
+ 'Got: ' + (value === '' ? 'an empty string' : value === null ? 'null' : typeof value)
1493
+ );
1494
+ }
1495
+ const resolved = path.resolve(value);
1496
+ let pageStat, pageBuffer;
1497
+ try {
1498
+ pageStat = fs.statSync(resolved);
1499
+ if (!pageStat.isFile()) throw new Error('not a regular file');
1500
+ pageBuffer = fs.readFileSync(resolved);
1501
+ } catch (err) {
1502
+ throw new Error(
1503
+ `[koa-classic-server] errorPages[${key}]: cannot read "${resolved}" (${err.message}). ` +
1504
+ 'The file must exist and be readable at startup.'
1505
+ );
1506
+ }
1507
+ _errorPages.set(status, { path: resolved, buffer: pageBuffer, mtimeMs: pageStat.mtimeMs, size: pageStat.size });
1508
+ }
1509
+ }
1510
+
1511
+ // Runtime read-failure warnings, throttled per status: a 404 flood against a
1512
+ // deleted custom page must not flood the log.
1513
+ const _errorPageWarnAt = new Map(); // status → last warn timestamp (ms)
1514
+ function warnErrorPageUnreadable(status, entry, err) {
1515
+ const now = Date.now();
1516
+ if (now - (_errorPageWarnAt.get(status) || 0) < 60000) return;
1517
+ _errorPageWarnAt.set(status, now);
1518
+ _logger.warn(...warnPayload(_logger,
1519
+ `[koa-classic-server] errorPages[${status}]: cannot read "${entry.path}" — ` +
1520
+ `serving the built-in page instead. (${err.message})`));
1521
+ }
1522
+
1523
+ // Returns the custom page buffer for `status`, re-read from disk when the
1524
+ // file changed (mtime+size — pages are editable without a restart). Returns
1525
+ // null when no custom page is configured for the status, or when the file is
1526
+ // no longer readable (the caller falls back to the built-in page).
1527
+ async function getCustomErrorPage(status) {
1528
+ const entry = _errorPages.get(status);
1529
+ if (!entry) return null;
1530
+ try {
1531
+ const st = await fs.promises.stat(entry.path);
1532
+ if (st.mtimeMs !== entry.mtimeMs || st.size !== entry.size) {
1533
+ entry.buffer = await fs.promises.readFile(entry.path);
1534
+ entry.mtimeMs = st.mtimeMs;
1535
+ entry.size = st.size;
1536
+ }
1537
+ return entry.buffer;
1538
+ } catch (err) {
1539
+ warnErrorPageUnreadable(status, entry, err);
1540
+ return null;
1541
+ }
1542
+ }
1543
+
1544
+ // Single sender for every middleware-generated error response. `builtinHtml`
1545
+ // lets a call site keep its specific fallback body (e.g. the template-failure
1546
+ // 500) while still honoring the operator's custom page when configured.
1547
+ async function sendErrorPage(ctx, status, builtinHtml = _BUILTIN_ERROR_HTML[status]) {
1548
+ writeErrorPage(ctx, status, await getCustomErrorPage(status), builtinHtml);
1549
+ }
1550
+
1551
+ // Sync variant for stream-error callbacks, where no await is possible: serves
1552
+ // the last-loaded custom buffer without the freshness stat — an acceptable
1553
+ // trade for a branch that only fires when the disk fails mid-response.
1554
+ function sendErrorPageSync(ctx, status) {
1555
+ const entry = _errorPages.get(status);
1556
+ writeErrorPage(ctx, status, entry ? entry.buffer : null, _BUILTIN_ERROR_HTML[status]);
1557
+ }
1558
+
1417
1559
  const compressionConfig = normalizeCompressionConfig(options.compression);
1418
1560
  const serverCacheConfig = normalizeServerCacheConfig(options.serverCache);
1419
1561
 
@@ -1471,7 +1613,7 @@ module.exports = function koaClassicServer(
1471
1613
  ctx.body = pipeline(src, compress, (err) => {
1472
1614
  if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
1473
1615
  _logger.error('Stream error:', err);
1474
- if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1616
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
1475
1617
  });
1476
1618
  }
1477
1619
 
@@ -1695,7 +1837,7 @@ module.exports = function koaClassicServer(
1695
1837
  // Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
1696
1838
  // matching the symlink-escape and hidden-entry outcomes.
1697
1839
  if (!_isWithinRoot(fullPath, normalizedRootDir)) {
1698
- sendNotFound(ctx);
1840
+ await sendErrorPage(ctx, 404);
1699
1841
  return;
1700
1842
  }
1701
1843
 
@@ -1708,7 +1850,7 @@ module.exports = function koaClassicServer(
1708
1850
  const segName = segments[i];
1709
1851
  const segRelPath = segments.slice(0, i + 1).join('/');
1710
1852
  if (isHiddenEntry(segName, segRelPath, true)) {
1711
- sendNotFound(ctx);
1853
+ await sendErrorPage(ctx, 404);
1712
1854
  return;
1713
1855
  }
1714
1856
  }
@@ -1823,7 +1965,7 @@ module.exports = function koaClassicServer(
1823
1965
  stat = await fs.promises.stat(toOpen);
1824
1966
  } catch {
1825
1967
  // File/directory doesn't exist or can't be accessed
1826
- sendNotFound(ctx);
1968
+ await sendErrorPage(ctx, 404);
1827
1969
  return;
1828
1970
  }
1829
1971
 
@@ -1832,7 +1974,7 @@ module.exports = function koaClassicServer(
1832
1974
  const entryName = path.basename(toOpen);
1833
1975
  const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
1834
1976
  if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
1835
- sendNotFound(ctx);
1977
+ await sendErrorPage(ctx, 404);
1836
1978
  return;
1837
1979
  }
1838
1980
  }
@@ -1842,7 +1984,7 @@ module.exports = function koaClassicServer(
1842
1984
  // resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
1843
1985
  // before any await so the default 'follow' mode stays truly zero-overhead.
1844
1986
  if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
1845
- sendNotFound(ctx);
1987
+ await sendErrorPage(ctx, 404);
1846
1988
  return;
1847
1989
  }
1848
1990
 
@@ -1891,7 +2033,7 @@ module.exports = function koaClassicServer(
1891
2033
  // symlink escaping rootDir — validate before serving it.
1892
2034
  // Guarded so the default 'follow' mode skips the await entirely.
1893
2035
  if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
1894
- sendNotFound(ctx);
2036
+ await sendErrorPage(ctx, 404);
1895
2037
  return;
1896
2038
  }
1897
2039
  await loadFile(indexPath, indexFile.stat);
@@ -1904,7 +2046,7 @@ module.exports = function koaClassicServer(
1904
2046
  ctx.body = await show_dir(toOpen, ctx);
1905
2047
  } else {
1906
2048
  // Directory listing disabled
1907
- sendNotFound(ctx);
2049
+ await sendErrorPage(ctx, 404);
1908
2050
  }
1909
2051
  return;
1910
2052
  } else {
@@ -1914,7 +2056,7 @@ module.exports = function koaClassicServer(
1914
2056
  // from not-found) rather than serving the file at a non-canonical
1915
2057
  // URL. Disabled by dirListing.trailingSlash: false (v3 behavior).
1916
2058
  if (options.dirListing.trailingSlash && _pathEndsWithSlash) {
1917
- sendNotFound(ctx);
2059
+ await sendErrorPage(ctx, 404);
1918
2060
  return;
1919
2061
  }
1920
2062
  await loadFile(toOpen, stat);
@@ -1926,17 +2068,10 @@ module.exports = function koaClassicServer(
1926
2068
  ctx.res.destroy(); // response already in flight — nothing sane left to send
1927
2069
  return;
1928
2070
  }
1929
- // Scrub representation/caching headers a partially-built response may
1930
- // have left behind: a stale Content-Encoding would corrupt the error
1931
- // page, a public Cache-Control could get the 500 cached by proxies.
1932
- for (const h of ['Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary', 'ETag', 'Last-Modified', 'Accept-Ranges']) {
1933
- ctx.remove(h);
1934
- }
1935
- ctx.set('Cache-Control', 'no-store');
1936
- ctx.set('Content-Type', 'text/html; charset=utf-8');
1937
- setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
1938
- ctx.status = 500;
1939
- ctx.body = _INTERNAL_ERROR_HTML;
2071
+ // writeErrorPage scrubs the representation/caching headers a
2072
+ // partially-built response may have left behind (stale
2073
+ // Content-Encoding, public Cache-Control, ...) and sets no-store.
2074
+ await sendErrorPage(ctx, 500);
1940
2075
  }
1941
2076
 
1942
2077
  // Internal functions
@@ -1949,7 +2084,7 @@ module.exports = function koaClassicServer(
1949
2084
  fileStat = await fs.promises.stat(toOpen);
1950
2085
  } catch (error) {
1951
2086
  _logger.error('File stat error:', error);
1952
- sendNotFound(ctx);
2087
+ await sendErrorPage(ctx, 404);
1953
2088
  return;
1954
2089
  }
1955
2090
  }
@@ -1993,7 +2128,7 @@ module.exports = function koaClassicServer(
1993
2128
  }
1994
2129
  }
1995
2130
 
1996
- if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger)) {
2131
+ if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger, sendErrorPage)) {
1997
2132
  return;
1998
2133
  }
1999
2134
 
@@ -2029,7 +2164,7 @@ module.exports = function koaClassicServer(
2029
2164
  await fs.promises.access(toOpen, fs.constants.R_OK);
2030
2165
  } catch (error) {
2031
2166
  _logger.error('File access error:', error);
2032
- sendNotFound(ctx);
2167
+ await sendErrorPage(ctx, 404);
2033
2168
  return;
2034
2169
  }
2035
2170
  }
@@ -2133,10 +2268,7 @@ module.exports = function koaClassicServer(
2133
2268
  const src = fs.createReadStream(toOpen, { start, end });
2134
2269
  src.on('error', (err) => {
2135
2270
  _logger.error('Stream error:', err);
2136
- if (!ctx.headerSent) {
2137
- ctx.status = 500;
2138
- ctx.body = 'Error reading file';
2139
- }
2271
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2140
2272
  });
2141
2273
  ctx.body = src;
2142
2274
  }
@@ -2264,7 +2396,7 @@ module.exports = function koaClassicServer(
2264
2396
  abandonAccumulation();
2265
2397
  if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
2266
2398
  _logger.error('Stream error:', err);
2267
- if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
2399
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2268
2400
  });
2269
2401
  } catch (err) {
2270
2402
  // Defensive: nothing between add() and pipeline() is expected to
@@ -2319,7 +2451,7 @@ module.exports = function koaClassicServer(
2319
2451
  const src = fs.createReadStream(toOpen);
2320
2452
  src.on('error', (streamErr) => {
2321
2453
  _logger.error('Stream error:', streamErr);
2322
- if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
2454
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2323
2455
  });
2324
2456
  ctx.body = src;
2325
2457
  } else {
@@ -2371,7 +2503,7 @@ module.exports = function koaClassicServer(
2371
2503
  const src = fs.createReadStream(toOpen);
2372
2504
  src.on('error', (err) => {
2373
2505
  _logger.error('Stream error:', err);
2374
- if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
2506
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2375
2507
  });
2376
2508
  ctx.body = src;
2377
2509
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-classic-server",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "High-performance Koa middleware for serving static files with classic directory listing (similar, but not identical, to Apache 2), HTTP caching, template engine support, and comprehensive security fixes",
5
5
  "main": "index.cjs",
6
6
  "exports": {