diff --git a/src/static_middleware.ts b/src/static_middleware.ts index 1c8513a..7fa8221 100644 --- a/src/static_middleware.ts +++ b/src/static_middleware.ts @@ -34,6 +34,11 @@ export default class StaticMiddleware { */ #sendFile: RequestHandler + /** + * URL path prefixes the middleware is limited to, without trailing slashes + */ + #prefixes?: string[] + /** * Creates a new StaticMiddleware instance. * @@ -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, @@ -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. * @@ -103,6 +124,15 @@ export default class StaticMiddleware { * ``` */ async handle({ request, response }: HttpContext, next: NextFn): Promise { + /** + * 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 diff --git a/src/types.ts b/src/types.ts index 6179b6b..f02a369 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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) diff --git a/tests/static_middleware.spec.ts b/tests/static_middleware.spec.ts index 4ecea03..3035b8c 100644 --- a/tests/static_middleware.spec.ts +++ b/tests/static_middleware.spec.ts @@ -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' @@ -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' + ) + }) +})