Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
24 changes: 24 additions & 0 deletions src/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -190,6 +196,24 @@ export const serveStatic = <E extends Env = any>(
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)
Expand Down
138 changes: 138 additions & 0 deletions test/serve-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down