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
32 changes: 31 additions & 1 deletion src/static_middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export default class StaticMiddleware {
*/
#sendFile: RequestHandler<ServerResponse & { parent?: HttpResponse }>

/**
* URL path prefixes the middleware is limited to, without trailing slashes
*/
#prefixes?: string[]

/**
* Creates a new StaticMiddleware instance.
*
Expand All @@ -54,7 +59,10 @@ export default class StaticMiddleware {
* ```
*/
constructor(publicPath: string, config: AssetsConfig) {
const { dotFiles, ...rest } = config
const { dotFiles, prefixes, ...rest } = config
this.#prefixes = prefixes?.map((prefix) => {
return prefix.length > 1 ? prefix.replace(/\/+$/, '') : prefix
})

this.#sendFile = staticServer(publicPath, {
...rest,
Expand Down Expand Up @@ -82,6 +90,19 @@ export default class StaticMiddleware {
})
}

/**
* Check if the request URL path equals one of the configured prefixes
* or is nested under it.
*/
#matchesPrefix(url: string): boolean {
const queryIndex = url.indexOf('?')
const pathname = queryIndex === -1 ? url : url.slice(0, queryIndex)

return this.#prefixes!.some((prefix) => {
return pathname === prefix || pathname.startsWith(`${prefix}/`)
})
}

/**
* Handle the HTTP request to serve static files.
*
Expand All @@ -103,6 +124,15 @@ export default class StaticMiddleware {
* ```
*/
async handle({ request, response }: HttpContext, next: NextFn): Promise<void> {
/**
* Skip the filesystem lookup when the request is outside the
* configured prefixes.
*/
if (this.#prefixes && !this.#matchesPrefix(request.request.url || '/')) {
await next()
return
}

const serveStaticResponse: ServerResponse & { parent?: HttpResponse } = response.response
serveStaticResponse['parent'] = response

Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ export type AssetsConfig = {
*/
enabled: boolean

/**
* Limit static file serving to the given URL path prefixes. A request
* matches when its path equals a prefix or is nested under it, so
* "/assets" matches "/assets/app.js" but not "/assets-old/app.js".
* Other requests go to the next middleware without a filesystem lookup.
*
* The prefixes are compared as-is with the request URL and do not change
* the file path: "/assets/app.js" still serves "public/assets/app.js".
* When not defined, every request is looked up in the public directory.
*/
prefixes?: string[]

/**
* Enable or disable accepting ranged requests.
* When enabled, supports partial content requests (HTTP 206 responses)
Expand Down
88 changes: 88 additions & 0 deletions tests/static_middleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
*/

import fs from 'fs-extra'
import nodeFs from 'node:fs'
import { join } from 'node:path'
import { mock } from 'node:test'
import supertest from 'supertest'
import { test } from '@japa/runner'
import { createServer } from 'node:http'
Expand Down Expand Up @@ -185,3 +187,89 @@ test.group('Serve Static', (group) => {
assert.equal(text, 'body { background: #000 }')
})
})

test.group('Serve Static | prefixes', (group) => {
group.each.teardown(async () => {
mock.restoreAll()
await fs.remove(BASE_PATH)
})

function createStaticServer(prefixes: string[]) {
const serveStatic = new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ prefixes }))

return createServer(async (req, res) => {
const request = new RequestFactory().merge({ req, res }).create()
const response = new ResponseFactory().merge({ req, res }).create()
const ctx = new HttpContextFactory().merge({ request, response }).create()

await serveStatic.handle(ctx, () => {
ctx.response.status(404).send('404')
ctx.response.finish()
})
})
}

test('serve files under a prefix', async () => {
await fs.outputFile(join(BASE_PATH, 'public/assets/app.js'), 'app')
await fs.outputFile(join(BASE_PATH, 'public/robots.txt'), 'User-agent: *')
const server = createStaticServer(['/assets', '/robots.txt'])

await supertest(server).get('/assets/app.js?v=3').expect(200, 'app')
await supertest(server).get('/robots.txt').expect(200, 'User-agent: *')
await supertest(server).get('/assets').expect(301).expect('Location', '/assets/')
})

test('skip filesystem lookups outside the prefixes', async ({ assert }) => {
await fs.outputFile(join(BASE_PATH, 'public/logo.png'), 'logo')
await fs.outputFile(join(BASE_PATH, 'public/assets-old/app.js'), 'old')
const server = createStaticServer(['/assets'])
const stat = mock.method(nodeFs, 'stat')

await supertest(server).get('/dashboard?tab=assets').expect(404)
await supertest(server).get('/logo.png').expect(404)
await supertest(server).get('/assets-old/app.js').expect(404)
assert.equal(stat.mock.callCount(), 0)

await supertest(server).get('/assets/missing.js').expect(404)
assert.equal(stat.mock.callCount(), 1)
})

test('ignore trailing slashes in prefixes', async () => {
await fs.outputFile(join(BASE_PATH, 'public/assets/app.js'), 'app')
const server = createStaticServer(['/assets/'])

await supertest(server).get('/assets/app.js').expect(200, 'app')
})

test('match only the root path with the "/" prefix', async () => {
await fs.outputFile(join(BASE_PATH, 'public/index.html'), 'home')
await fs.outputFile(join(BASE_PATH, 'public/style.css'), 'body {}')
const server = createStaticServer(['/'])

await supertest(server).get('/').expect(200, 'home')
await supertest(server).get('/style.css').expect(404)
})

test('serve no file when prefixes are empty', async () => {
await fs.outputFile(join(BASE_PATH, 'public/style.css'), 'body {}')
const server = createStaticServer([])

await supertest(server).get('/style.css').expect(404)
})

test('propagate errors from next when skipping a request', async ({ assert }) => {
const serveStatic = new StaticMiddleware(
join(BASE_PATH, 'public'),
defineConfig({ prefixes: ['/assets'] })
)
const ctx = new HttpContextFactory().merge({ url: '/dashboard' }).create()

await assert.rejects(
() =>
serveStatic.handle(ctx, () => {
throw new Error('next failed')
}),
'next failed'
)
})
})
Loading