koa-classic-server 3.0.0 → 3.0.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.
@@ -0,0 +1,160 @@
1
+ const Koa = require('koa');
2
+ const koaClassicServer = require('../index.cjs');
3
+ const supertest = require('supertest');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const ejs = require('ejs');
7
+
8
+ // Regression tests for the HEAD-method HTTP-conformance bug.
9
+ //
10
+ // RFC 9110 §9.3.2: a HEAD response must be identical to the GET response for the
11
+ // same resource, minus the message body — same status code, same headers
12
+ // (notably Content-Type and Content-Length).
13
+ //
14
+ // Before the fix, a route served by the template engine returned 200 on GET but
15
+ // 404 on HEAD whenever the operator's render function did not itself handle HEAD
16
+ // (e.g. it returned early on non-GET requests). The static-file branch already
17
+ // handled HEAD correctly, and directory listings happened to work because Koa
18
+ // strips the string body for HEAD — only the template branch was broken.
19
+
20
+ const ROOT = path.join(__dirname, 'publicWwwTest');
21
+
22
+ // Minimal data for the templates exercised here. ejs-templates/index.ejs needs no
23
+ // variables; simple.ejs needs these four. Anything else renders with no locals.
24
+ function templateData(filePath) {
25
+ if (path.basename(filePath, '.ejs') === 'simple') {
26
+ return {
27
+ title: 'Simple EJS Test',
28
+ heading: 'Hello from EJS',
29
+ message: 'This is a simple template test',
30
+ timestamp: '2025-11-18',
31
+ };
32
+ }
33
+ return {};
34
+ }
35
+
36
+ // A method-AWARE render: it only produces a body on GET. This is a common
37
+ // real-world pattern (operators guard render work behind a GET check) and is
38
+ // exactly what exposed the bug — on HEAD it never set ctx.body, so the status
39
+ // stayed at Koa's default 404. This render is what makes the regression real:
40
+ // with a naive render the test would pass even without the fix, because Koa
41
+ // strips the body of a 200 GET response for HEAD on its own.
42
+ const methodAwareRender = async (ctx, next, filePath) => {
43
+ if (ctx.method !== 'GET') return;
44
+ const tpl = await fs.promises.readFile(filePath, 'utf-8');
45
+ ctx.type = 'text/html';
46
+ ctx.body = ejs.render(tpl, templateData(filePath));
47
+ };
48
+
49
+ // A method-AGNOSTIC render: sets the body regardless of method. Proves the fix
50
+ // does not regress renders that already worked.
51
+ const naiveRender = async (ctx, next, filePath) => {
52
+ const tpl = await fs.promises.readFile(filePath, 'utf-8');
53
+ ctx.type = 'text/html';
54
+ ctx.body = ejs.render(tpl, templateData(filePath));
55
+ };
56
+
57
+ function buildServer(render) {
58
+ const app = new Koa();
59
+ app.use(
60
+ koaClassicServer(ROOT, {
61
+ method: ['GET', 'HEAD'],
62
+ index: ['index.html', 'index.ejs'],
63
+ dirListing: { enabled: true },
64
+ template: { ext: ['ejs'], render },
65
+ })
66
+ );
67
+ return app.listen();
68
+ }
69
+
70
+ // Asserts the HEAD response for `urlPath` matches the corresponding GET in status,
71
+ // Content-Type and Content-Length, and that HEAD carries no body.
72
+ async function expectHeadMatchesGet(request, urlPath, expectedStatus) {
73
+ const get = await request.get(urlPath);
74
+ const head = await request.head(urlPath);
75
+
76
+ expect(get.status).toBe(expectedStatus);
77
+ expect(head.status).toBe(expectedStatus);
78
+
79
+ // Same headers as GET (RFC 9110 §9.3.2)
80
+ expect(head.headers['content-type']).toBe(get.headers['content-type']);
81
+ expect(head.headers['content-length']).toBe(get.headers['content-length']);
82
+ // Content-Length must actually be populated for a body-bearing response
83
+ expect(head.headers['content-length']).toBeDefined();
84
+
85
+ // HEAD must not send a body (supertest surfaces an empty object for HEAD)
86
+ expect(head.text).toBeFalsy();
87
+ expect(head.body).toEqual({});
88
+
89
+ return { get, head };
90
+ }
91
+
92
+ describe('HEAD method — template engine routes (method-aware render)', () => {
93
+ let server;
94
+ let request;
95
+ beforeAll(() => { server = buildServer(methodAwareRender); request = supertest(server); });
96
+ afterAll(() => server.close());
97
+
98
+ // The headline bug: a directory whose index is a template (index.ejs as the
99
+ // index of /ejs-templates/). GET 200, HEAD must also be 200 — not 404.
100
+ test('HEAD on a directory whose index is a template → 200, matches GET', async () => {
101
+ const { get } = await expectHeadMatchesGet(request, '/ejs-templates/', 200);
102
+ // sanity: GET really did render the template
103
+ expect(get.text).toContain('Test Templates EJS');
104
+ expect(get.headers['content-type']).toMatch(/text\/html/);
105
+ });
106
+
107
+ test('HEAD on a directly-requested template file → 200, matches GET', async () => {
108
+ const { get } = await expectHeadMatchesGet(request, '/ejs-templates/simple.ejs', 200);
109
+ expect(get.text).toContain('Hello from EJS');
110
+ });
111
+
112
+ test('HEAD on a static file → 200, matches GET', async () => {
113
+ const { head } = await expectHeadMatchesGet(request, '/test.txt', 200);
114
+ // static branch advertises range support
115
+ expect(head.headers['accept-ranges']).toBe('bytes');
116
+ });
117
+
118
+ test('HEAD on a listable directory (no index) → 200, matches GET', async () => {
119
+ const { get } = await expectHeadMatchesGet(request, '/cartella/', 200);
120
+ expect(get.headers['content-type']).toMatch(/text\/html/);
121
+ });
122
+
123
+ test('HEAD on a non-existent template path → 404, matches GET', async () => {
124
+ const get = await request.get('/ejs-templates/non-existent.ejs');
125
+ const head = await request.head('/ejs-templates/non-existent.ejs');
126
+ expect(get.status).toBe(404);
127
+ expect(head.status).toBe(404);
128
+ expect(head.headers['content-type']).toBe(get.headers['content-type']);
129
+ expect(head.text).toBeFalsy();
130
+ });
131
+
132
+ test('HEAD on a non-existent static path → 404, matches GET', async () => {
133
+ const get = await request.get('/does-not-exist.txt');
134
+ const head = await request.head('/does-not-exist.txt');
135
+ expect(get.status).toBe(404);
136
+ expect(head.status).toBe(404);
137
+ expect(head.text).toBeFalsy();
138
+ });
139
+ });
140
+
141
+ describe('HEAD method — template engine routes (method-agnostic render, no regression)', () => {
142
+ let server;
143
+ let request;
144
+ beforeAll(() => { server = buildServer(naiveRender); request = supertest(server); });
145
+ afterAll(() => server.close());
146
+
147
+ test('HEAD on template index still matches GET → 200', async () => {
148
+ await expectHeadMatchesGet(request, '/ejs-templates/', 200);
149
+ });
150
+
151
+ test('HEAD on direct template still matches GET → 200', async () => {
152
+ await expectHeadMatchesGet(request, '/ejs-templates/simple.ejs', 200);
153
+ });
154
+
155
+ test('HEAD on non-existent template → 404', async () => {
156
+ const head = await request.head('/ejs-templates/non-existent.ejs');
157
+ expect(head.status).toBe(404);
158
+ expect(head.text).toBeFalsy();
159
+ });
160
+ });
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ 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
+ ## [3.0.1] - 2026-06-10
9
+
10
+ ### 🐛 Bug Fix
11
+
12
+ #### Fixed `HEAD` on template-engine routes returning 404 (HTTP conformance, RFC 9110 §9.3.2)
13
+ - **Issue**: When `HEAD` was enabled (`method: ['GET', 'HEAD']`), a `GET` on a route served by the template engine (e.g. `index.ejs` as the index of `/`) returned **200**, but a `HEAD` on the same route returned **404**. The static-file branch already handled `HEAD` correctly, and directory listings worked incidentally, so only the template branch was affected.
14
+ - **Root cause**: `loadFile()` calls `tryRenderTemplate()` before the static-serving branch and returns as soon as it reports the request was handled. `tryRenderTemplate()` invoked the operator's `render` callback with the real `ctx.method` and did nothing `HEAD`-specific. A render that does not itself set a body on non-`GET` requests (a common pattern — operators guard render work behind a `GET` check) therefore left `ctx.status` at Koa's default **404** for `HEAD`, even though `GET` rendered normally.
15
+ - **Impact**: MEDIUM — breaks caches, reverse proxies, link-checkers, and uptime monitors that issue `HEAD`. `HEAD` must be identical to `GET` minus the body (same status code and headers).
16
+ - **Fix**: In `tryRenderTemplate()`, a `HEAD` request now runs the render exactly as a `GET` (the method is presented as `GET` for the duration of the render, then restored) so it resolves, validates, and sets `Content-Type` / status identically. The new `stripBodyForHead()` helper then replaces the rendered body with an empty buffer and restores `Content-Length` to the byte length the `GET` body would have had — sending the correct status and headers with no body. The `GET` path and all public options are unchanged; compatible with Koa 2 and Koa 3.
17
+ - **Code**:
18
+ - `tryRenderTemplate()` — present `ctx.method` as `GET` for the render, restore to `HEAD` and call `stripBodyForHead()` in `finally`
19
+ - `stripBodyForHead()` — new helper: empty body + restored `Content-Length`, preserving the status and headers the render produced
20
+
21
+ ### 🧪 Testing
22
+ - Added `__tests__/head-method.test.js` (9 tests) covering, with both a method-aware and a method-agnostic render:
23
+ - `HEAD` on a directory whose index is a template → **200**, status/`Content-Type`/`Content-Length` match `GET`, empty body
24
+ - `HEAD` on a directly-requested template file → **200**, matches `GET`
25
+ - `HEAD` on a static file → **200**, matches `GET` (and still advertises `Accept-Ranges`)
26
+ - `HEAD` on a listable directory (no index) → **200**, matches `GET`
27
+ - `HEAD` on a non-existent template/static path → **404**, matches `GET`
28
+ - All 556 tests pass across 21 test suites (zero regressions)
29
+
30
+ ### 📦 Package Changes
31
+ - **Version**: `3.0.0` → `3.0.1`
32
+ - **Semver**: Patch version bump (bug fix only, no API changes)
33
+
8
34
  ## [3.0.0] - 2026-05-13
9
35
 
10
36
  ### 🆕 New Features
package/index.cjs CHANGED
@@ -198,6 +198,28 @@ function sendTemplateError(ctx, status, html, logMsg, err, logger) {
198
198
  ctx.body = html;
199
199
  }
200
200
 
201
+ // Rewrites an already-rendered response into an RFC 9110 §9.3.2 compliant HEAD
202
+ // response: the status and headers produced by the render are preserved, the
203
+ // body is replaced with an empty buffer (so no content is sent), and
204
+ // Content-Length is restored to the byte length the GET body would have had.
205
+ // Reassigning ctx.body to a non-stream value also makes Koa auto-destroy a
206
+ // previous stream body, so no file descriptor leaks. Stream / non-buffer bodies
207
+ // (uncommon for template renders) carry no Content-Length, matching the static
208
+ // streaming-HEAD branch.
209
+ function stripBodyForHead(ctx) {
210
+ if (ctx.headerSent) return; // render already flushed — status/headers are locked
211
+ const body = ctx.body;
212
+ if (body == null) return; // render produced no body (redirect, pass-through, ...) — leave status as-is
213
+ const hasKnownLength = typeof body === 'string' || Buffer.isBuffer(body);
214
+ const length = hasKnownLength ? Buffer.byteLength(body) : null;
215
+ ctx.body = Buffer.alloc(0);
216
+ if (length !== null) {
217
+ ctx.set('Content-Length', String(length)); // body setter zeroed it — restore the real length
218
+ } else {
219
+ ctx.remove('Content-Length'); // unknown length — omit, like static streaming HEAD
220
+ }
221
+ }
222
+
201
223
  // Attempts to render the requested file through the user's template engine.
202
224
  // Returns true if the request was handled (success, timeout, or error response
203
225
  // already written), false if no template applies (caller should continue with
@@ -214,6 +236,16 @@ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, l
214
236
  const fileExt = path.extname(filePath).slice(1);
215
237
  if (!fileExt || !templateOpts.ext.includes(fileExt)) return false;
216
238
 
239
+ // RFC 9110 §9.3.2: HEAD must mirror GET (same status + headers, no body). The
240
+ // user's render is run exactly as for GET — by presenting ctx.method as GET for
241
+ // the duration of the render — so it resolves, validates, and sets Content-Type
242
+ // / status identically; stripBodyForHead() then discards the body and restores
243
+ // Content-Length. Without this, a render that early-returns on non-GET never
244
+ // sets ctx.body, leaving ctx.status at Koa's default 404 for HEAD even though
245
+ // GET returns 200.
246
+ const isHeadRequest = ctx.method === 'HEAD';
247
+ if (isHeadRequest) ctx.method = 'GET';
248
+
217
249
  const controller = new AbortController();
218
250
  const onClientClose = () => controller.abort();
219
251
  ctx.req.on('close', onClientClose);
@@ -255,6 +287,10 @@ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, l
255
287
  } finally {
256
288
  if (timer) clearTimeout(timer);
257
289
  ctx.req.removeListener('close', onClientClose);
290
+ if (isHeadRequest) {
291
+ ctx.method = 'HEAD';
292
+ stripBodyForHead(ctx);
293
+ }
258
294
  }
259
295
 
260
296
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-classic-server",
3
- "version": "3.0.0",
3
+ "version": "3.0.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": {