From 9fa01d93a9b2ccb005ce9f5ade9c68e896d596e6 Mon Sep 17 00:00:00 2001 From: SynthLuvr <131367121+SynthLuvr@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:01:32 +0200 Subject: [PATCH 1/3] feat(serve-static): respond 304 for If-Modified-Since requests 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 #189 --- README.md | 4 ++ src/serve-static.ts | 20 +++++++ test/serve-static.test.ts | 119 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/README.md b/README.md index 88a41467..e725f432 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,10 @@ app.use( ) ``` +### Conditional Requests + +`serveStatic` sets the `Last-Modified` header on responses. For `GET` and `HEAD` requests carrying an `If-Modified-Since` header, it responds with `304 Not Modified` when the file has not been modified since that date, per [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-if-modified-since). An `If-Modified-Since` header is ignored when `If-None-Match` is present, as the latter takes precedence. + ## ConnInfo Helper You can use the [ConnInfo Helper](https://hono.dev/docs/helpers/conninfo) by importing `getConnInfo` from `@hono/node-server/conninfo`. diff --git a/src/serve-static.ts b/src/serve-static.ts index 6cf59ee9..92cea40f 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -190,6 +190,26 @@ export const serveStatic = ( const range = c.req.header('range') || '' c.header('Last-Modified', stats.mtime.toUTCString()) + // Respond with 304 if the file has not been modified since the + // If-Modified-Since date. Per RFC 9110, If-Modified-Since is only used + // for GET/HEAD requests and is ignored when If-None-Match is present. + const ifModifiedSince = c.req.header('if-modified-since') + if ( + ifModifiedSince && + !c.req.header('if-none-match') && + (c.req.method === 'GET' || c.req.method === 'HEAD') + ) { + const sinceMs = Date.parse(ifModifiedSince) + // HTTP dates only have second precision, so compare at second granularity + if ( + !Number.isNaN(sinceMs) && + Math.floor(stats.mtimeMs / 1000) <= Math.floor(sinceMs / 1000) + ) { + await options.onFound?.(path, c) + return c.body(null, 304) + } + } + if (c.req.method == 'HEAD' || c.req.method == 'OPTIONS') { c.header('Content-Length', size.toString()) c.status(200) diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index ef6cc340..f5b04400 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -615,6 +615,125 @@ describe('Serve Static Middleware', () => { } ) }) + describe('If-Modified-Since', () => { + it('Should return 304 when the file has not been modified since the date', async () => { + const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + const res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { 'if-modified-since': stats.mtime.toUTCString() }, + }) + expect(res.status).toBe(304) + expect(res.headers.get('last-modified')).toBe(stats.mtime.toUTCString()) + expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(res.headers.get('content-length')).toBeNull() + expect(res.headers.get('content-range')).toBeNull() + expect(await res.text()).toBe('') + }) + + it('Should return 304 when the date is newer than the file mtime', async () => { + const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + const res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { + 'if-modified-since': new Date(stats.mtimeMs + 60_000).toUTCString(), + }, + }) + expect(res.status).toBe(304) + expect(await res.text()).toBe('') + }) + + it('Should return 200 when the file mtime is newer than the date', async () => { + const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + const lastModifiedMs = Math.floor(stats.mtimeMs / 1000) * 1000 + const res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { 'if-modified-since': new Date(lastModifiedMs - 1_000).toUTCString() }, + }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should ignore an invalid If-Modified-Since header', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { 'if-modified-since': 'not-a-date' }, + }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should ignore If-Modified-Since when If-None-Match is present', async () => { + const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + const res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { + 'if-none-match': '"v1"', + 'if-modified-since': stats.mtime.toUTCString(), + }, + }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should return 304 for a HEAD request when not modified', async () => { + const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + const res = await requestServer(server, { + method: 'HEAD', + path: '/static/plain.txt', + headers: { 'if-modified-since': stats.mtime.toUTCString() }, + }) + expect(res.status).toBe(304) + expect(res.body).toBeNull() + }) + + it('Should return 304 for a range request when not modified', async () => { + const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + const res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { range: '0-9', 'if-modified-since': stats.mtime.toUTCString() }, + }) + expect(res.status).toBe(304) + expect(res.headers.get('content-range')).toBeNull() + expect(res.headers.get('accept-ranges')).toBeNull() + expect(await res.text()).toBe('') + }) + + it('Should return 304 for a precompressed response when not modified', async () => { + const stats = statSync( + path.join(__dirname, 'assets', 'static-with-precompressed', 'hello.txt.zst') + ) + const res = await requestServer(server, { + method: 'GET', + path: '/static-with-precompressed/hello.txt', + headers: { + 'accept-encoding': 'zstd', + 'if-modified-since': stats.mtime.toUTCString(), + }, + }) + expect(res.status).toBe(304) + expect(res.headers.get('content-encoding')).toBe('zstd') + expect(res.headers.get('last-modified')).toBe(stats.mtime.toUTCString()) + expect(res.headers.get('vary')).toBe('Accept-Encoding') + expect(await res.text()).toBe('') + }) + + it('Should call onFound for a 304 response', async () => { + const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + const res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { 'if-modified-since': stats.mtime.toUTCString() }, + }) + expect(res.status).toBe(304) + expect(res.headers.get('x-custom')).toContain('plain.txt') + }) + }) }) describe('Serve Static Middleware with wrong path', () => { From 664bc774385c58a204e4be08d9e2e22be7c8e7fa Mon Sep 17 00:00:00 2001 From: SynthLuvr <131367121+SynthLuvr@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:13:45 +0200 Subject: [PATCH 2/3] refactor(serve-static): flatten If-Modified-Since check 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. --- src/serve-static.ts | 25 ++++++++++++------------- test/serve-static.test.ts | 39 ++++++++++++++++----------------------- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index 92cea40f..5e63b3a9 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -84,6 +84,12 @@ const resolveByteRange = (spec: ByteRangeSpec, size: number): ByteRange | undefi return { start: spec.start, end } } +// HTTP dates only have second precision, so compare at second granularity. +const isNotModifiedSince = (ifModifiedSince: string, mtimeMs: number): boolean => { + const sinceMs = Date.parse(ifModifiedSince) + return !Number.isNaN(sinceMs) && Math.floor(mtimeMs / 1000) <= Math.floor(sinceMs / 1000) +} + type Decoder = (str: string) => string const tryDecode = (str: string, decoder: Decoder): string => { @@ -190,24 +196,17 @@ export const serveStatic = ( const range = c.req.header('range') || '' c.header('Last-Modified', stats.mtime.toUTCString()) - // Respond with 304 if the file has not been modified since the - // If-Modified-Since date. Per RFC 9110, If-Modified-Since is only used - // for GET/HEAD requests and is ignored when If-None-Match is present. + // RFC 9110: If-Modified-Since applies only to GET/HEAD requests and is + // ignored when If-None-Match is present. const ifModifiedSince = c.req.header('if-modified-since') if ( ifModifiedSince && !c.req.header('if-none-match') && - (c.req.method === 'GET' || c.req.method === 'HEAD') + (c.req.method === 'GET' || c.req.method === 'HEAD') && + isNotModifiedSince(ifModifiedSince, stats.mtimeMs) ) { - const sinceMs = Date.parse(ifModifiedSince) - // HTTP dates only have second precision, so compare at second granularity - if ( - !Number.isNaN(sinceMs) && - Math.floor(stats.mtimeMs / 1000) <= Math.floor(sinceMs / 1000) - ) { - await options.onFound?.(path, c) - return c.body(null, 304) - } + await options.onFound?.(path, c) + return c.body(null, 304) } if (c.req.method == 'HEAD' || c.req.method == 'OPTIONS') { diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index f5b04400..62db85df 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -616,15 +616,18 @@ describe('Serve Static Middleware', () => { ) }) describe('If-Modified-Since', () => { + const plainTxtPath = path.join(__dirname, 'assets', 'static', 'plain.txt') + const zstPath = path.join(__dirname, 'assets', 'static-with-precompressed', 'hello.txt.zst') + const lastModified = (file: string) => statSync(file).mtime.toUTCString() + it('Should return 304 when the file has not been modified since the date', async () => { - const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) const res = await requestServer(server, { method: 'GET', path: '/static/plain.txt', - headers: { 'if-modified-since': stats.mtime.toUTCString() }, + headers: { 'if-modified-since': lastModified(plainTxtPath) }, }) expect(res.status).toBe(304) - expect(res.headers.get('last-modified')).toBe(stats.mtime.toUTCString()) + expect(res.headers.get('last-modified')).toBe(lastModified(plainTxtPath)) expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') expect(res.headers.get('content-length')).toBeNull() expect(res.headers.get('content-range')).toBeNull() @@ -632,25 +635,22 @@ describe('Serve Static Middleware', () => { }) it('Should return 304 when the date is newer than the file mtime', async () => { - const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + const mtimeMs = statSync(plainTxtPath).mtimeMs const res = await requestServer(server, { method: 'GET', path: '/static/plain.txt', - headers: { - 'if-modified-since': new Date(stats.mtimeMs + 60_000).toUTCString(), - }, + headers: { 'if-modified-since': new Date(mtimeMs + 60_000).toUTCString() }, }) expect(res.status).toBe(304) expect(await res.text()).toBe('') }) it('Should return 200 when the file mtime is newer than the date', async () => { - const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) - const lastModifiedMs = Math.floor(stats.mtimeMs / 1000) * 1000 + const mtimeMs = Math.floor(statSync(plainTxtPath).mtimeMs / 1000) * 1000 const res = await requestServer(server, { method: 'GET', path: '/static/plain.txt', - headers: { 'if-modified-since': new Date(lastModifiedMs - 1_000).toUTCString() }, + headers: { 'if-modified-since': new Date(mtimeMs - 1_000).toUTCString() }, }) expect(res.status).toBe(200) expect(await res.text()).toBe('This is plain.txt') @@ -667,13 +667,12 @@ describe('Serve Static Middleware', () => { }) it('Should ignore If-Modified-Since when If-None-Match is present', async () => { - const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) const res = await requestServer(server, { method: 'GET', path: '/static/plain.txt', headers: { 'if-none-match': '"v1"', - 'if-modified-since': stats.mtime.toUTCString(), + 'if-modified-since': lastModified(plainTxtPath), }, }) expect(res.status).toBe(200) @@ -681,22 +680,20 @@ describe('Serve Static Middleware', () => { }) it('Should return 304 for a HEAD request when not modified', async () => { - const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) const res = await requestServer(server, { method: 'HEAD', path: '/static/plain.txt', - headers: { 'if-modified-since': stats.mtime.toUTCString() }, + headers: { 'if-modified-since': lastModified(plainTxtPath) }, }) expect(res.status).toBe(304) expect(res.body).toBeNull() }) it('Should return 304 for a range request when not modified', async () => { - const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) const res = await requestServer(server, { method: 'GET', path: '/static/plain.txt', - headers: { range: '0-9', 'if-modified-since': stats.mtime.toUTCString() }, + headers: { range: '0-9', 'if-modified-since': lastModified(plainTxtPath) }, }) expect(res.status).toBe(304) expect(res.headers.get('content-range')).toBeNull() @@ -705,30 +702,26 @@ describe('Serve Static Middleware', () => { }) it('Should return 304 for a precompressed response when not modified', async () => { - const stats = statSync( - path.join(__dirname, 'assets', 'static-with-precompressed', 'hello.txt.zst') - ) const res = await requestServer(server, { method: 'GET', path: '/static-with-precompressed/hello.txt', headers: { 'accept-encoding': 'zstd', - 'if-modified-since': stats.mtime.toUTCString(), + 'if-modified-since': lastModified(zstPath), }, }) expect(res.status).toBe(304) expect(res.headers.get('content-encoding')).toBe('zstd') - expect(res.headers.get('last-modified')).toBe(stats.mtime.toUTCString()) + expect(res.headers.get('last-modified')).toBe(lastModified(zstPath)) expect(res.headers.get('vary')).toBe('Accept-Encoding') expect(await res.text()).toBe('') }) it('Should call onFound for a 304 response', async () => { - const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) const res = await requestServer(server, { method: 'GET', path: '/static/plain.txt', - headers: { 'if-modified-since': stats.mtime.toUTCString() }, + headers: { 'if-modified-since': lastModified(plainTxtPath) }, }) expect(res.status).toBe(304) expect(res.headers.get('x-custom')).toContain('plain.txt') From 3bd0f0e669ce1e62de92c78f2c0578313f3c5604 Mon Sep 17 00:00:00 2001 From: SynthLuvr <131367121+SynthLuvr@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:37:31 +0200 Subject: [PATCH 3/3] fix(serve-static): drop representation metadata from 304 responses 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. --- src/serve-static.ts | 5 +++++ test/serve-static.test.ts | 30 ++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index 5e63b3a9..bcc7abcd 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -205,6 +205,11 @@ export const serveStatic = ( (c.req.method === 'GET' || c.req.method === 'HEAD') && isNotModifiedSince(ifModifiedSince, stats.mtimeMs) ) { + // A 304 response cannot carry representation metadata, so the content + // headers set above are removed. `Last-Modified` and `Vary` are kept, + // as they exist to guide cache updates. See RFC 9110 Section 15.4.5. + c.header('Content-Type', undefined) + c.header('Content-Encoding', undefined) await options.onFound?.(path, c) return c.body(null, 304) } diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index 62db85df..56008c29 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -615,6 +615,7 @@ describe('Serve Static Middleware', () => { } ) }) + describe('If-Modified-Since', () => { const plainTxtPath = path.join(__dirname, 'assets', 'static', 'plain.txt') const zstPath = path.join(__dirname, 'assets', 'static-with-precompressed', 'hello.txt.zst') @@ -628,7 +629,8 @@ describe('Serve Static Middleware', () => { }) expect(res.status).toBe(304) expect(res.headers.get('last-modified')).toBe(lastModified(plainTxtPath)) - expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(res.headers.get('content-type')).toBeNull() + expect(res.headers.get('content-encoding')).toBeNull() expect(res.headers.get('content-length')).toBeNull() expect(res.headers.get('content-range')).toBeNull() expect(await res.text()).toBe('') @@ -711,12 +713,36 @@ describe('Serve Static Middleware', () => { }, }) expect(res.status).toBe(304) - expect(res.headers.get('content-encoding')).toBe('zstd') + expect(res.headers.get('content-encoding')).toBeNull() expect(res.headers.get('last-modified')).toBe(lastModified(zstPath)) expect(res.headers.get('vary')).toBe('Accept-Encoding') expect(await res.text()).toBe('') }) + it('Should ignore If-Modified-Since for a method other than GET/HEAD', async () => { + const res = await requestServer(server, { + method: 'POST', + path: '/static/plain.txt', + headers: { 'if-modified-since': lastModified(plainTxtPath) }, + }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('This is plain.txt') + }) + + // RFC 9110 Section 13.1.3: a field value with more than one member is + // ignored. Node joins repeated headers into one comma-separated value. + it('Should ignore If-Modified-Since when the field value has more than one member', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { + 'if-modified-since': `${lastModified(plainTxtPath)}, ${lastModified(plainTxtPath)}`, + }, + }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('This is plain.txt') + }) + it('Should call onFound for a 304 response', async () => { const res = await requestServer(server, { method: 'GET',