Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(serveStatic): add precompressed option #3366

Merged
merged 6 commits into from
Sep 8, 2024
Merged
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
87 changes: 86 additions & 1 deletion src/middleware/serve-static/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('Serve Static Middleware', () => {
it('Should return 200 response - /static/hello.html', async () => {
const res = await app.request('/static/hello.html')
expect(res.status).toBe(200)
expect(res.headers.get('Content-Encoding')).toBeNull()
expect(res.headers.get('Content-Type')).toMatch(/^text\/html/)
expect(await res.text()).toBe('Hello in ./static/hello.html')
})
Expand Down Expand Up @@ -57,12 +58,15 @@ describe('Serve Static Middleware', () => {
it('Should decode URI strings - /static/%E7%82%8E.txt', async () => {
const res = await app.request('/static/%E7%82%8E.txt')
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/)
expect(await res.text()).toBe('Hello in ./static/炎.txt')
})

it('Should return 404 response - /static/not-found', async () => {
it('Should return 404 response - /static/not-found.txt', async () => {
const res = await app.request('/static/not-found.txt')
expect(res.status).toBe(404)
expect(res.headers.get('Content-Encoding')).toBeNull()
expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/)
expect(await res.text()).toBe('404 Not Found')
expect(getContent).toBeCalledTimes(1)
})
Expand All @@ -73,9 +77,90 @@ describe('Serve Static Middleware', () => {
url: 'http://localhost/static/%2e%2e/static/hello.html',
} as Request)
expect(res.status).toBe(404)
expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/)
expect(await res.text()).toBe('404 Not Found')
})

it('Should return a pre-compressed zstd response - /static/hello.html', async () => {
const app = new Hono().use(
'*',
baseServeStatic({
getContent,
precompressed: true,
})
)

const res = await app.request('/static/hello.html', {
headers: { 'Accept-Encoding': 'zstd' },
})

expect(res.status).toBe(200)
expect(res.headers.get('Content-Encoding')).toBe('zstd')
expect(res.headers.get('Vary')).toBe('Accept-Encoding')
expect(res.headers.get('Content-Type')).toMatch(/^text\/html/)
expect(await res.text()).toBe('Hello in static/hello.html.zst')
})

it('Should return a pre-compressed brotli response - /static/hello.html', async () => {
const app = new Hono().use(
'*',
baseServeStatic({
getContent,
precompressed: true,
})
)

const res = await app.request('/static/hello.html', {
headers: { 'Accept-Encoding': 'wompwomp, gzip, br, deflate, zstd' },
})

expect(res.status).toBe(200)
expect(res.headers.get('Content-Encoding')).toBe('br')
expect(res.headers.get('Vary')).toBe('Accept-Encoding')
expect(res.headers.get('Content-Type')).toMatch(/^text\/html/)
expect(await res.text()).toBe('Hello in static/hello.html.br')
})

it('Should not return a pre-compressed response - /static/not-found.txt', async () => {
const app = new Hono().use(
'*',
baseServeStatic({
getContent,
precompressed: true,
})
)

const res = await app.request('/static/not-found.txt', {
headers: { 'Accept-Encoding': 'gzip, zstd, br' },
})

expect(res.status).toBe(404)
expect(res.headers.get('Content-Encoding')).toBeNull()
expect(res.headers.get('Vary')).toBeNull()
expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/)
expect(await res.text()).toBe('404 Not Found')
})

it('Should not return a pre-compressed response - /static/hello.html', async () => {
const app = new Hono().use(
'*',
baseServeStatic({
getContent,
precompressed: true,
})
)

const res = await app.request('/static/hello.html', {
headers: { 'Accept-Encoding': 'wompwomp, unknown' },
})

expect(res.status).toBe(200)
expect(res.headers.get('Content-Encoding')).toBeNull()
expect(res.headers.get('Vary')).toBeNull()
expect(res.headers.get('Content-Type')).toMatch(/^text\/html/)
expect(await res.text()).toBe('Hello in static/hello.html')
})

it('Should return response object content as-is', async () => {
const body = new ReadableStream()
const response = new Response(body)
Expand Down
62 changes: 46 additions & 16 deletions src/middleware/serve-static/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ import { getMimeType } from '../../utils/mime'
export type ServeStaticOptions<E extends Env = Env> = {
root?: string
path?: string
precompressed?: boolean
mimes?: Record<string, string>
rewriteRequestPath?: (path: string) => string
onNotFound?: (path: string, c: Context<E>) => void | Promise<void>
}

const ENCODINGS = {
br: '.br',
zstd: '.zst',
gzip: '.gz',
} as const

const DEFAULT_DOCUMENT = 'index.html'
const defaultPathResolve = (path: string) => path

Expand Down Expand Up @@ -47,7 +54,7 @@ export const serveStatic = <E extends Env = Env>(
root,
})
if (path && (await options.isDir(path))) {
filename = filename + '/'
filename += '/'
}
}

Expand All @@ -63,24 +70,23 @@ export const serveStatic = <E extends Env = Env>(

const getContent = options.getContent
const pathResolve = options.pathResolve ?? defaultPathResolve

path = pathResolve(path)
let content = await getContent(path, c)

if (!content) {
let pathWithOutDefaultDocument = getFilePathWithoutDefaultDocument({
let pathWithoutDefaultDocument = getFilePathWithoutDefaultDocument({
filename,
root,
})
if (!pathWithOutDefaultDocument) {
if (!pathWithoutDefaultDocument) {
return await next()
}
pathWithOutDefaultDocument = pathResolve(pathWithOutDefaultDocument)
pathWithoutDefaultDocument = pathResolve(pathWithoutDefaultDocument)

if (pathWithOutDefaultDocument !== path) {
content = await getContent(pathWithOutDefaultDocument, c)
if (pathWithoutDefaultDocument !== path) {
content = await getContent(pathWithoutDefaultDocument, c)
if (content) {
path = pathWithOutDefaultDocument
path = pathWithoutDefaultDocument
}
}
}
Expand All @@ -89,16 +95,40 @@ export const serveStatic = <E extends Env = Env>(
return c.newResponse(content.body, content)
}

const mimeType = options.mimes
? getMimeType(path, options.mimes) ?? getMimeType(path)
: getMimeType(path)

if (mimeType) {
c.header('Content-Type', mimeType)
}

if (content) {
let mimeType: string | undefined
if (options.mimes) {
mimeType = getMimeType(path, options.mimes) ?? getMimeType(path)
} else {
mimeType = getMimeType(path)
}
if (mimeType) {
c.header('Content-Type', mimeType)
if (options.precompressed) {
const acceptEncodings =
c.req
.header('Accept-Encoding')
?.split(',')
.map((encoding) => encoding.trim())
.filter((encoding): encoding is keyof typeof ENCODINGS =>
Object.hasOwn(ENCODINGS, encoding)
)
.sort(
(a, b) => Object.keys(ENCODINGS).indexOf(a) - Object.keys(ENCODINGS).indexOf(b)
) ?? []

for (const encoding of acceptEncodings) {
const compressedContent = (await getContent(path + ENCODINGS[encoding], c)) as Data | null

if (compressedContent) {
content = compressedContent
c.header('Content-Encoding', encoding)
c.header('Vary', 'Accept-Encoding', { append: true })
break
}
}
}

return c.body(content)
}

Expand Down
Loading