Skip to content

Commit b41bed8

Browse files
committed
Add cached response behavior tests
Add coverage for cached static file responses to ensure ETag, Cache-Control, 304 revalidation, custom headers, content-type, and index.html behavior remain consistent after an ElysiaFile is served from the LRU cache. Update the cached response path so cached files run through the same header and conditional request logic as uncached files.
1 parent 4dc837e commit b41bed8

3 files changed

Lines changed: 201 additions & 32 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -90,73 +90,76 @@ export async function staticPlugin<const Prefix extends string = '/prefix'>({
9090
for (const [headerName, headerVal] of Object.entries(
9191
initialHeaders ?? {}
9292
)) {
93-
set['headers'][headerName] = headerVal
93+
set.headers[headerName] = headerVal
9494
}
9595
}
9696

97+
const finalizeFileResponse = async (
98+
file: ElysiaFile,
99+
filePath: string
100+
) => {
101+
setInitialHeaders()
102+
103+
if (!useETag) return file
104+
105+
const etag = await generateETag(file)
106+
107+
if (etag && (await isCached(requestHeaders, etag, filePath)))
108+
return new Response(null, {
109+
status: 304
110+
})
111+
112+
set.headers['etag'] = etag
113+
set.headers['cache-control'] = maxAge
114+
? `${directive}, max-age=${maxAge}`
115+
: directive
116+
117+
return file
118+
}
119+
97120
if (shouldIgnore(relativeFilePath)) throw new NotFoundError()
98121

99122
const cachedFile = fileCache.get(relativeFilePath)
100-
if (cachedFile) {
101-
setInitialHeaders()
102-
return cachedFile
103-
}
123+
if (cachedFile) return finalizeFileResponse(cachedFile, relativeFilePath)
104124

105125
try {
106126
const fileStat = await fs.stat(relativeFilePath).catch(() => null)
107127
if (!fileStat) throw new NotFoundError()
108128

109129
if (!indexHTML && fileStat.isDirectory()) throw new NotFoundError()
110130

111-
// @ts-ignore
112131
let file:
113132
| NonNullable<Awaited<ReturnType<typeof getFile>>>
114133
| undefined
115134

135+
let cacheKey = relativeFilePath
136+
116137
if (fileStat.isDirectory() && indexHTML) {
117138
const htmlPath = path.join(relativeFilePath, 'index.html')
118139
const cachedFile = fileCache.get(htmlPath)
119-
if (cachedFile) {
120-
setInitialHeaders()
121-
return cachedFile
122-
}
140+
141+
if (cachedFile) return finalizeFileResponse(cachedFile, htmlPath)
123142

124143
if (await fileExists(htmlPath)) {
144+
cacheKey = htmlPath
145+
125146
if (bunFullstack) {
126-
file = (await import(htmlPath)).default // TODO: full path needed?
147+
file = (await import(htmlPath)).default
127148
} else {
128149
file = getFile(htmlPath)
129150
}
130151
}
131152
}
153+
132154
if (!fileStat.isDirectory()) {
133155
file = getFile(relativeFilePath)
134156
}
135157

136158
if (!file) throw new NotFoundError()
137-
fileCache.set(relativeFilePath, file)
138159

139-
if (!useETag) {
140-
setInitialHeaders()
141-
return file
142-
}
160+
fileCache.set(cacheKey, file)
143161

144-
const etag = await generateETag(file)
145-
if (
146-
etag &&
147-
(await isCached(requestHeaders, etag, relativeFilePath))
148-
)
149-
return new Response(null, {
150-
status: 304
151-
})
152-
153-
setInitialHeaders()
154-
155-
set.headers['etag'] = etag
156-
set.headers['cache-control'] = maxAge
157-
? `${directive}, max-age=${maxAge}`
158-
: directive
159-
return file
162+
return finalizeFileResponse(file, cacheKey)
160163
} catch (error) {
161164
if (error instanceof NotFoundError) throw error
162165
if (!silent) console.error(`[@elysiajs/static]`, error)

test/index.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,4 +473,169 @@ describe('Static Plugin', () => {
473473
const jsFile = await app.handle(req('/public/js/index.js'))
474474
expect(jsFile.headers.get('content-type')).toContain('/javascript')
475475
})
476+
477+
it('preserves content-type on cached file responses', async () => {
478+
const app = new Elysia().use(staticPlugin())
479+
480+
await app.modules
481+
482+
const first = await app.handle(req('/public/js/index.js'))
483+
expect(first.status).toBe(200)
484+
expect(first.headers.get('content-type')).toContain('/javascript')
485+
486+
const second = await app.handle(req('/public/js/index.js'))
487+
expect(second.status).toBe(200)
488+
expect(second.headers.get('content-type')).toContain('/javascript')
489+
})
490+
491+
it('preserves custom headers on cached file responses', async () => {
492+
const app = new Elysia().use(
493+
staticPlugin({
494+
headers: {
495+
'x-static-test': 'cached'
496+
}
497+
})
498+
)
499+
500+
await app.modules
501+
502+
const first = await app.handle(req('/public/takodachi.png'))
503+
expect(first.status).toBe(200)
504+
expect(first.headers.get('x-static-test')).toBe('cached')
505+
506+
const second = await app.handle(req('/public/takodachi.png'))
507+
expect(second.status).toBe(200)
508+
expect(second.headers.get('x-static-test')).toBe('cached')
509+
})
510+
511+
it('preserves etag and cache-control headers on cached file responses', async () => {
512+
const app = new Elysia().use(
513+
staticPlugin({
514+
maxAge: 3600,
515+
directive: 'private'
516+
})
517+
)
518+
519+
await app.modules
520+
521+
const first = await app.handle(req('/public/takodachi.png'))
522+
expect(first.status).toBe(200)
523+
524+
const etag = first.headers.get('etag')
525+
expect(etag).toBeTruthy()
526+
expect(first.headers.get('cache-control')).toBe('private, max-age=3600')
527+
528+
const second = await app.handle(req('/public/takodachi.png'))
529+
expect(second.status).toBe(200)
530+
expect(second.headers.get('etag')).toBe(etag)
531+
expect(second.headers.get('cache-control')).toBe(
532+
'private, max-age=3600'
533+
)
534+
})
535+
536+
it('returns 304 for if-none-match after the file has been cached', async () => {
537+
const app = new Elysia().use(staticPlugin())
538+
539+
await app.modules
540+
541+
const first = await app.handle(req('/public/takodachi.png'))
542+
expect(first.status).toBe(200)
543+
544+
const etag = first.headers.get('etag')
545+
expect(etag).toBeTruthy()
546+
547+
const request = req('/public/takodachi.png')
548+
request.headers.set('if-none-match', etag!)
549+
550+
const second = await app.handle(request)
551+
552+
expect(second.status).toBe(304)
553+
expect(second.body).toBe(null)
554+
})
555+
556+
it('does not return 304 when cache-control no-cache is sent after file is cached', async () => {
557+
const app = new Elysia().use(staticPlugin())
558+
559+
await app.modules
560+
561+
const first = await app.handle(req('/public/takodachi.png'))
562+
expect(first.status).toBe(200)
563+
564+
const etag = first.headers.get('etag')
565+
expect(etag).toBeTruthy()
566+
567+
const request = req('/public/takodachi.png')
568+
request.headers.set('if-none-match', etag!)
569+
request.headers.set('cache-control', 'no-cache')
570+
571+
const second = await app.handle(request)
572+
573+
expect(second.status).toBe(200)
574+
expect(await second.blob().then((b) => b.text())).toBe(takodachi.toString())
575+
})
576+
577+
it('returns 304 for if-none-match after cached alwaysStatic route response', async () => {
578+
const app = new Elysia().use(
579+
staticPlugin({
580+
alwaysStatic: true,
581+
extension: false
582+
})
583+
)
584+
585+
await app.modules
586+
587+
const first = await app.handle(req('/public/takodachi'))
588+
expect(first.status).toBe(200)
589+
590+
const etag = first.headers.get('etag')
591+
expect(etag).toBeTruthy()
592+
593+
const request = req('/public/takodachi')
594+
request.headers.set('if-none-match', etag!)
595+
596+
const second = await app.handle(request)
597+
598+
expect(second.status).toBe(304)
599+
expect(second.body).toBe(null)
600+
})
601+
602+
it('serves index.html from cache with content-type and cache headers', async () => {
603+
const app = new Elysia().use(staticPlugin())
604+
605+
await app.modules
606+
607+
const first = await app.handle(req('/public/html'))
608+
expect(first.status).toBe(200)
609+
expect(first.headers.get('content-type')).toContain('text/html')
610+
611+
const etag = first.headers.get('etag')
612+
expect(etag).toBeTruthy()
613+
expect(first.headers.get('cache-control')).toBe('public, max-age=86400')
614+
615+
const second = await app.handle(req('/public/html'))
616+
expect(second.status).toBe(200)
617+
expect(second.headers.get('content-type')).toContain('text/html')
618+
expect(second.headers.get('etag')).toBe(etag)
619+
expect(second.headers.get('cache-control')).toBe('public, max-age=86400')
620+
})
621+
622+
it('returns 304 for cached index.html default route', async () => {
623+
const app = new Elysia().use(staticPlugin())
624+
625+
await app.modules
626+
627+
const first = await app.handle(req('/public/html'))
628+
expect(first.status).toBe(200)
629+
630+
const etag = first.headers.get('etag')
631+
expect(etag).toBeTruthy()
632+
633+
const request = req('/public/html')
634+
request.headers.set('if-none-match', etag!)
635+
636+
const second = await app.handle(request)
637+
638+
expect(second.status).toBe(304)
639+
expect(second.body).toBe(null)
640+
})
476641
})

0 commit comments

Comments
 (0)