Skip to content

Commit

Permalink
feat(serve-static): add precompressed option (#199)
Browse files Browse the repository at this point in the history
* feat(serve-static): add `precompressed` option

* update readme

* perf(serve-static): performance optimization for precompressed feature (#200)

* perf(serve-static): use "Set" for checking precompressed

* perf(serve-static): search precompressed file only if content type is compressible

---------

Co-authored-by: Taku Amano <taku@taaas.jp>
  • Loading branch information
yusukebe and usualoma authored Sep 16, 2024
1 parent d0bbc92 commit 98d711a
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 1 deletion.
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ app.use(
You can specify handling when the requested file is found with `onFound`.

```ts
app.get(
app.use(
'/static/*',
serveStatic({
// ...
Expand All @@ -226,6 +226,19 @@ app.use(
)
```

#### `precompressed`

The `precompressed` option checks if files with extensions like `.br` or `.gz` are available and serves them based on the `Accept-Encoding` header. It prioritizes Brotli, then Zstd, and Gzip. If none are available, it serves the original file.

```ts
app.use(
'/static/*',
serveStatic({
precompressed: true,
})
)
```

## ConnInfo Helper

You can use the [ConnInfo Helper](https://hono.dev/docs/helpers/conninfo) by importing `getConnInfo` from `@hono/node-server/conninfo`.
Expand Down
33 changes: 33 additions & 0 deletions src/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,21 @@ export type ServeStaticOptions<E extends Env = Env> = {
root?: string
path?: string
index?: string // default is 'index.html'
precompressed?: boolean
rewriteRequestPath?: (path: string) => string
onFound?: (path: string, c: Context<E>) => void | Promise<void>
onNotFound?: (path: string, c: Context<E>) => void | Promise<void>
}

const COMPRESSIBLE_CONTENT_TYPE_REGEX =
/^\s*(?:text\/[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i
const ENCODINGS = {
br: '.br',
zstd: '.zst',
gzip: '.gz',
} as const
const ENCODINGS_ORDERED_KEYS = Object.keys(ENCODINGS) as (keyof typeof ENCODINGS)[]

const createStreamBody = (stream: ReadStream) => {
const body = new ReadableStream({
start(controller) {
Expand Down Expand Up @@ -95,6 +105,29 @@ export const serveStatic = (options: ServeStaticOptions = { root: '' }): Middlew
c.header('Content-Type', mimeType)
}

if (options.precompressed && (!mimeType || COMPRESSIBLE_CONTENT_TYPE_REGEX.test(mimeType))) {
const acceptEncodingSet = new Set(
c.req
.header('Accept-Encoding')
?.split(',')
.map((encoding) => encoding.trim())
)

for (const encoding of ENCODINGS_ORDERED_KEYS) {
if (!acceptEncodingSet.has(encoding)) {
continue
}
const precompressedStats = getStats(path + ENCODINGS[encoding])
if (precompressedStats) {
c.header('Content-Encoding', encoding)
c.header('Vary', 'Accept-Encoding', { append: true })
stats = precompressedStats
path = path + ENCODINGS[encoding]
break
}
}
}

const size = stats.size

if (c.req.method == 'HEAD' || c.req.method == 'OPTIONS') {
Expand Down
1 change: 1 addition & 0 deletions test/assets/static-with-precompressed/hello.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello Not Compressed
1 change: 1 addition & 0 deletions test/assets/static-with-precompressed/hello.txt.br
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello br Compressed
1 change: 1 addition & 0 deletions test/assets/static-with-precompressed/hello.txt.zst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello zstd Compressed
46 changes: 46 additions & 0 deletions test/serve-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ describe('Serve Static Middleware', () => {
})
)

app.use(
'/static-with-precompressed/*',
serveStatic({
root: './test/assets',
precompressed: true,
})
)

const server = createAdaptorServer(app)

it('Should return index.html', async () => {
Expand Down Expand Up @@ -149,4 +157,42 @@ describe('Serve Static Middleware', () => {
expect(res.status).toBe(200)
expect(res.text).toBe('Extensionless')
})

it('Should return a pre-compressed zstd response - /static-with-precompressed/hello.txt', async () => {
// Check if it returns a normal response
let res = await request(server).get('/static-with-precompressed/hello.txt')
expect(res.status).toBe(200)
expect(res.headers['content-length']).toBe('20')
expect(res.text).toBe('Hello Not Compressed')

res = await request(server)
.get('/static-with-precompressed/hello.txt')
.set('Accept-Encoding', 'zstd')
expect(res.status).toBe(200)
expect(res.headers['content-length']).toBe('21')
expect(res.headers['content-encoding']).toBe('zstd')
expect(res.headers['vary']).toBe('Accept-Encoding')
expect(res.text).toBe('Hello zstd Compressed')
})

it('Should return a pre-compressed brotli response - /static-with-precompressed/hello.txt', async () => {
const res = await request(server)
.get('/static-with-precompressed/hello.txt')
.set('Accept-Encoding', 'wompwomp, gzip, br, deflate, zstd')
expect(res.status).toBe(200)
expect(res.headers['content-length']).toBe('19')
expect(res.headers['content-encoding']).toBe('br')
expect(res.headers['vary']).toBe('Accept-Encoding')
expect(res.text).toBe('Hello br Compressed')
})

it('Should not return a pre-compressed response - /static-with-precompressed/hello.txt', async () => {
const res = await request(server)
.get('/static-with-precompressed/hello.txt')
.set('Accept-Encoding', 'wompwomp, unknown')
expect(res.status).toBe(200)
expect(res.headers['content-encoding']).toBeUndefined()
expect(res.headers['vary']).toBeUndefined()
expect(res.text).toBe('Hello Not Compressed')
})
})

0 comments on commit 98d711a

Please sign in to comment.