Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,15 @@ jobs:
- run: bun run lint
- run: bun run build
- run: bun run test

ci-windows:
runs-on: windows-latest

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run test
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ import { serveStatic } from '@hono/node-server/serve-static'
app.use('/static/*', serveStatic({ root: './' }))
```

Note that `root` must be _relative_ to the current working directory from which the app was started. Absolute paths are not supported.
If using a relative path, `root` will be relative to the current working directory from which the app was started.

This can cause confusion when running your application locally.

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
}
},
"scripts": {
"test": "node --expose-gc ./node_modules/.bin/jest",
"test": "node --expose-gc node_modules/jest/bin/jest.js",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To support Windows on CI.

"build": "tsup --external hono",
"watch": "tsup --watch",
"postbuild": "publint",
Expand Down Expand Up @@ -99,4 +99,4 @@
"peerDependencies": {
"hono": "^4"
}
}
}
49 changes: 44 additions & 5 deletions src/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const addCurrentDirPrefix = (path: string) => {
return `./${path}`
}

const addRootPrefix = (path: string) => {
return `/${path}`
}

const getStats = (path: string) => {
let stats: Stats | undefined
try {
Expand All @@ -56,10 +60,45 @@ const getStats = (path: string) => {
return stats
}

const isAbsolutePath = (path: string) => {
const isUnixAbsolutePath = path.startsWith('/')
const hasDriveLetter = /^[a-zA-Z]:\\/.test(path)
const isUncPath = /^\\\\[^\\]+\\[^\\]+/.test(path)
return isUnixAbsolutePath || hasDriveLetter || isUncPath
}

const windowsPathToUnixPath = (path: string) => {
return path.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/')
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const serveStatic = <E extends Env = any>(
options: ServeStaticOptions<E> = { root: '' }
): MiddlewareHandler<E> => {
let absolutePath = false
let optionRoot: string
let optionPath: string

if (options.root) {
if (isAbsolutePath(options.root)) {
absolutePath = true
optionRoot = windowsPathToUnixPath(options.root)
optionRoot = new URL(`file://${optionRoot}`).pathname
} else {
optionRoot = options.root
}
}

if (options.path) {
if (isAbsolutePath(options.path)) {
absolutePath = true
optionPath = windowsPathToUnixPath(options.path)
optionPath = new URL(`file://${optionPath}`).pathname
} else {
optionPath = options.path
}
}

return async (c, next) => {
// Do nothing if Response is already set
if (c.finalized) {
Expand All @@ -69,19 +108,19 @@ export const serveStatic = <E extends Env = any>(
let filename: string

try {
filename = options.path ?? decodeURIComponent(c.req.path)
filename = optionPath ?? decodeURIComponent(c.req.path)
} catch {
await options.onNotFound?.(c.req.path, c)
return next()
}

let path = getFilePathWithoutDefaultDocument({
filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename,
root: options.root,
root: optionRoot,
})

if (path) {
path = addCurrentDirPrefix(path)
path = absolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path)
} else {
return next()
}
Expand All @@ -91,12 +130,12 @@ export const serveStatic = <E extends Env = any>(
if (stats && stats.isDirectory()) {
path = getFilePath({
filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename,
root: options.root,
root: optionRoot,
defaultDocument: options.index ?? 'index.html',
})

if (path) {
path = addCurrentDirPrefix(path)
path = absolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path)
} else {
return next()
}
Expand Down
36 changes: 35 additions & 1 deletion test/serve-static.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Hono } from 'hono'

import request from 'supertest'
import path from 'node:path'
import { serveStatic } from './../src/serve-static'
import { createAdaptorServer } from './../src/server'

Expand Down Expand Up @@ -226,4 +226,38 @@ describe('Serve Static Middleware', () => {
expect(res.headers['vary']).toBeUndefined()
expect(res.text).toBe('Hello Not Compressed')
})

describe('Absolute path', () => {
const rootPaths = [
path.join(__dirname, 'assets'),
__dirname + path.sep + '..' + path.sep + 'test' + path.sep + 'assets',
]
rootPaths.forEach((root) => {
describe(root, () => {
const app = new Hono()
const server = createAdaptorServer(app)
app.use('/static/*', serveStatic({ root }))
app.use('/favicon.ico', serveStatic({ path: root + path.sep + 'favicon.ico' }))

it('Should return index.html', async () => {
const res = await request(server).get('/static')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toBe('text/html; charset=utf-8')
expect(res.text).toBe('<h1>Hello Hono</h1>')
})

it('Should return correct headers and data for text', async () => {
const res = await request(server).get('/static/plain.txt')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8')
expect(res.text).toBe('This is plain.txt')
})
it('Should return correct headers for icons', async () => {
const res = await request(server).get('/favicon.ico')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toBe('image/x-icon')
})
})
})
})
})
Loading