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.
- package/__tests__/head-method.test.js +160 -0
- package/docs/CHANGELOG.md +26 -0
- package/index.cjs +36 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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": {
|