diff --git a/README.md b/README.md index 88a4146..e725f43 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 6cf59ee..bcc7abc 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,6 +196,24 @@ export const serveStatic = ( const range = c.req.header('range') || '' c.header('Last-Modified', stats.mtime.toUTCString()) + // 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') && + 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) + } + 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 ef6cc34..56008c2 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -615,6 +615,144 @@ 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 res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { 'if-modified-since': lastModified(plainTxtPath) }, + }) + expect(res.status).toBe(304) + expect(res.headers.get('last-modified')).toBe(lastModified(plainTxtPath)) + 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('') + }) + + it('Should return 304 when the date is newer than the file mtime', async () => { + const mtimeMs = statSync(plainTxtPath).mtimeMs + const res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + 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 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(mtimeMs - 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 res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { + 'if-none-match': '"v1"', + 'if-modified-since': lastModified(plainTxtPath), + }, + }) + 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 res = await requestServer(server, { + method: 'HEAD', + path: '/static/plain.txt', + 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 res = await requestServer(server, { + method: 'GET', + path: '/static/plain.txt', + headers: { range: '0-9', 'if-modified-since': lastModified(plainTxtPath) }, + }) + 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 res = await requestServer(server, { + method: 'GET', + path: '/static-with-precompressed/hello.txt', + headers: { + 'accept-encoding': 'zstd', + 'if-modified-since': lastModified(zstPath), + }, + }) + expect(res.status).toBe(304) + 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', + path: '/static/plain.txt', + headers: { 'if-modified-since': lastModified(plainTxtPath) }, + }) + expect(res.status).toBe(304) + expect(res.headers.get('x-custom')).toContain('plain.txt') + }) + }) }) describe('Serve Static Middleware with wrong path', () => {