[BUG] HEAD method not working
Describe the bug
Evershop's request router matches HTTP methods literally against the methods array in each route.json. Because route definitions only declare GET (or POST etc.), a HEAD request to a URL that exists for GET falls through to the catch-all 404 handler instead of being treated as a body-less GET. This violates RFC 9110 §9.3.2 ("a server SHOULD send the same header fields in response to a HEAD request as it would have sent if the request had been a GET") and breaks any tooling that probes resources via HEAD — CDN/cache revalidation, uptime monitors, link checkers, browser prefetch.
To Reproduce Steps to reproduce the behavior:
Run any Evershop instance with at least one product image uploaded:
curl -F "[email protected]" http://localhost:3001/api/images/catalog/product # → /assets/catalog/product/some.png is now servedIssue a
GETto confirm the resource exists:curl -i http://localhost:3001/assets/catalog/product/some.png # HTTP/1.1 200 OK # Content-Type: image/pngIssue a
HEADto the same URL:curl -I http://localhost:3001/assets/catalog/product/some.png # HTTP/1.1 404 Not Found # Content-Type: text/html; charset=utf-8
The same pattern reproduces against any storefront page (HEAD / → 404), any extension-defined GET API route, and any built-in catalog GET.
Expected behavior Either of:
- Preferred:
HEADis treated asGETwhen no explicitHEADroute exists. Response is200 OKwith the same headersGETwould produce, and an empty body. This matches RFC 9110 and Express's default behavior forapp.get(path, handler). - Acceptable fallback: if the project deliberately wants method-explicit routing,
HEADto aGET-only resource should return405 Method Not Allowed(with anAllow: GETheader), not404 Not Found.404lies about resource existence and breaksIf-None-Match/If-Modified-Sincerevalidation.
Screenshots If applicable, add screenshots to help explain your problem.
Not applicable
Additional context
Root cause: In bin/lib/addDefaultMiddlewareFuncs.js, the route matcher does:
const matchedRoutes = routes.filter((r) => {
const regexp = pathToRegexp(r.path, []);
const match = regexp.exec(requestPath);
if (match && r.method.includes(method)) {
return true;
}
return false;
});r.method is the literal array from route.json (e.g. ["GET"]). When the incoming method is "HEAD", includes("HEAD") returns false, no route matches, request.currentRoute stays unset, and the request flows through to the 404 handler in the same file (around line 214).
This contrasts with Express's stock behavior: app.get(path, handler) registers the handler for both GET and HEAD automatically, and Express strips the body before sending the HEAD response.
Why it matters in practice:
- Cache revalidation. We hit this while debugging cache behavior on
Cache-Control: max-age=31536000, immutablefor product images: tools that try aHEADfirst to checkLast-Modified/ETagget a404, masking the real state. - Uptime monitors and link checkers (most default to
HEADbecause it's cheap). - Browser prefetch /
<link rel="preload">validation. - Generic curl-based debugging — a developer running
curl -sI <url>to confirm a route exists gets a misleading404, even though the resource is reachable. This wasted several hours of debugging on our side.
Suggested fix: one-line change to the matcher to fall back to GET for HEAD:
const matchedRoutes = routes.filter((r) => {
const regexp = pathToRegexp(r.path, []);
if (!regexp.exec(requestPath)) return false;
if (r.method.includes(method)) return true;
if (method === 'HEAD' && r.method.includes('GET')) return true;
return false;
});Source: evershopcommerce/evershop