Skip to content

feat(serve-static): respond 304 for If-Modified-Since requests - #396

Open
SynthLuvr wants to merge 3 commits into
honojs:mainfrom
SynthLuvr:feat/serve-static-if-modified-since
Open

feat(serve-static): respond 304 for If-Modified-Since requests#396
SynthLuvr wants to merge 3 commits into
honojs:mainfrom
SynthLuvr:feat/serve-static-if-modified-since

Conversation

@SynthLuvr

@SynthLuvr SynthLuvr commented Aug 24, 2026

Copy link
Copy Markdown

Fixes #189

serveStatic now evaluates If-Modified-Since per RFC 9110. Last-Modified is already set on every response, so this adds only the conditional check:

  • Applies to GET/HEAD requests only
  • Ignored when If-None-Match is present, as the latter takes precedence
  • Ignored for a field value that is not a valid HTTP-date, or has more than one member
  • Timestamps are compared at second granularity, since HTTP dates only have second precision; flooring both sides makes the Last-Modified -> If-Modified-Since round trip match exactly
  • Responds 304 Not Modified with an empty body when the file has not changed since the given date

The check runs after a file (or a precompressed variant, whose own mtime is used) has been found and before the response body is built, so a 304 skips Content-Length, Accept-Ranges, and Content-Range while still calling onFound. The comparison lives in a small isNotModifiedSince predicate so the handler reads as a single guard clause.

A 304 keeps Last-Modified and Vary but drops Content-Type and Content-Encoding, since a 304 cannot carry representation metadata (Section 15.4.5). Vary is retained so caches still know which precompressed representation was selected. This mirrors removeContentHeaderFields() in send, used by Express' serve-static.

11 tests cover a 304 on an exact and on a later date, 200 on an earlier date, an invalid header, If-None-Match precedence, a non-GET/HEAD method, a field value with more than one member, HEAD, range requests, precompressed variants, and onFound. All of format, lint, typecheck, test, and build + publint pass locally.

The author should do the following, if applicable

  • Add tests
  • Run tests
  • pnpm run format:fix && pnpm run lint:fix to format the code
  • Add TSDoc/JSDoc to document the code

serveStatic already sets the Last-Modified header, but never honored a
client's If-Modified-Since conditional request. Respond with 304 Not
Modified when the file has not been modified since the request date.

Per RFC 9110, If-Modified-Since is only evaluated for GET/HEAD, is
ignored when If-None-Match is present, and invalid dates are ignored.
Dates are compared at second granularity since HTTP dates carry no
sub-second precision. The 304 is emitted before any response body
branch runs, so no read stream is opened and no Content-Length,
Content-Range, or Accept-Ranges headers are set; the check also runs
after precompressed variant resolution so the served variant's mtime
is used. onFound still fires for 304 responses.

Fixes honojs#189
Extract the second-granularity date comparison into an
isNotModifiedSince predicate so the handler reads as a single
guard clause, and de-duplicate the statSync boilerplate in the
If-Modified-Since tests.
A 304 response cannot carry representation metadata, so Content-Type and
Content-Encoding are no longer sent alongside it, while Last-Modified and
Vary are kept to guide cache updates (RFC 9110 Section 15.4.5).

Also covers If-Modified-Since being ignored for methods other than
GET/HEAD and for field values with more than one member.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serveStatic should apply Last-Modified header

1 participant