From 18e14211037558fa19315abef47343a5f1ddf0a5 Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Sun, 4 Oct 2026 08:27:16 +0000 Subject: [PATCH 1/4] perf(static): skip lookups for missing public files Static middleware probes public paths for application routes on every GET and HEAD request. Add an opt-in public directory index to skip known misses while preserving serve-static handling for possible hits. Index the whole directory instead of the Vite manifest so other public files remain accessible. Keep indexing disabled by default because new files require a process restart. --- README.md | 18 +++ src/define_config.ts | 1 + src/static_middleware.ts | 61 ++++++++- src/types.ts | 7 ++ tests/static_middleware.spec.ts | 211 ++++++++++++++++++++++++++++++++ 5 files changed, 297 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 53467ee..8af421f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,24 @@ Middleware to serve static files from a given directory of your AdonisJS apps The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/static-file-server) +## Indexing public files + +For deployments where public files do not change while the application is running, enable `indexFiles` in `config/static.ts`: + +```ts +import { defineConfig } from '@adonisjs/static' + +export default defineConfig({ + indexFiles: true, +}) +``` + +The middleware scans `public/` once when it is constructed. GET and HEAD requests for paths absent from the index go directly to the next middleware without a filesystem lookup. Existing files still use `serve-static`, including its metadata checks, caching, range responses and dotfile rules. Directories retain their redirects and index-file handling. Symlinks and their descendants retain filesystem lookups. + +Indexing is disabled by default. Restart the application after adding files or directories. Leave it disabled during development or when serving uploads. The index costs memory proportional to the public directory's size and a synchronous directory scan on initialization. It indexes the whole public directory, not just Vite assets. + +This reduces filesystem operations on missing paths. It does not eliminate file reads when serving an asset or guarantee fewer physical disk reads if the operating system already caches metadata. + ## Contributing One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework. diff --git a/src/define_config.ts b/src/define_config.ts index f6f8d90..7f7a56c 100644 --- a/src/define_config.ts +++ b/src/define_config.ts @@ -36,6 +36,7 @@ import { type AssetsConfig } from './types.ts' export function defineConfig(config: Partial): AssetsConfig { return { enabled: true, + indexFiles: false, dotFiles: 'ignore', etag: true, lastModified: true, diff --git a/src/static_middleware.ts b/src/static_middleware.ts index 1c8513a..7e75593 100644 --- a/src/static_middleware.ts +++ b/src/static_middleware.ts @@ -8,6 +8,8 @@ */ import { type ServerResponse } from 'node:http' +import { readdirSync } from 'node:fs' +import { join, resolve as resolvePath, sep } from 'node:path' import type { NextFn } from '@adonisjs/core/types/http' import staticServer, { type RequestHandler } from 'serve-static' import type { HttpContext, HttpResponse } from '@adonisjs/core/http' @@ -34,6 +36,10 @@ export default class StaticMiddleware { */ #sendFile: RequestHandler + #publicPath: string + #files?: Set + #symbolicLinks: string[] = [] + /** * Creates a new StaticMiddleware instance. * @@ -54,7 +60,29 @@ export default class StaticMiddleware { * ``` */ constructor(publicPath: string, config: AssetsConfig) { - const { dotFiles, ...rest } = config + const { dotFiles, indexFiles, ...rest } = config + this.#publicPath = resolvePath(publicPath) + + if (indexFiles) { + // Leave case and Unicode variants to serve-static and the underlying filesystem. + this.#files = new Set([this.#publicPath.normalize('NFC').toUpperCase()]) + try { + for (const entry of readdirSync(this.#publicPath, { + recursive: true, + withFileTypes: true, + })) { + const filePath = join(entry.parentPath, entry.name).normalize('NFC').toUpperCase() + this.#files.add(filePath) + if (entry.isSymbolicLink()) { + this.#symbolicLinks.push(filePath + sep) + } + } + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + throw error + } + } + } this.#sendFile = staticServer(publicPath, { ...rest, @@ -82,6 +110,28 @@ export default class StaticMiddleware { }) } + /** + * Skip definite misses while leaving path validation and symlink resolution to serve-static. + */ + #isIndexed(url: string): boolean { + if (!url.startsWith('/')) { + return true + } + + let filePath: string + try { + const pathname = decodeURIComponent(url.split(/[?#]/, 1)[0]) + filePath = resolvePath(this.#publicPath, `.${sep}${pathname}`).normalize('NFC').toUpperCase() + } catch { + return true + } + + return ( + this.#files!.has(filePath) || + this.#symbolicLinks.some((prefix) => filePath.startsWith(prefix)) + ) + } + /** * Handle the HTTP request to serve static files. * @@ -103,6 +153,15 @@ export default class StaticMiddleware { * ``` */ async handle({ request, response }: HttpContext, next: NextFn): Promise { + if ( + this.#files && + (request.request.method === 'GET' || request.request.method === 'HEAD') && + !this.#isIndexed(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..216e88c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,13 @@ export type AssetsConfig = { */ enabled: boolean + /** + * Index public files once when the middleware is constructed to skip filesystem lookups + * for missing paths. Defaults to false. Restart after adding files or directories. + * Symlinks and their descendants still use filesystem lookups. + */ + indexFiles?: boolean + /** * 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..2d68c05 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,212 @@ test.group('Serve Static', (group) => { assert.equal(text, 'body { background: #000 }') }) }) + +test.group('Indexed static files', (group) => { + group.each.teardown(async () => { + mock.restoreAll() + await fs.remove(BASE_PATH) + }) + + function createStaticServer(middleware: StaticMiddleware) { + 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 middleware.handle(ctx, () => { + ctx.response.status(404).send('404') + ctx.response.finish() + }) + }) + } + + test('skip filesystem lookups for missing GET and HEAD paths', async ({ assert }) => { + await fs.outputFile(join(BASE_PATH, 'public/style.css'), 'body {}') + const middleware = new StaticMiddleware( + join(BASE_PATH, 'public'), + defineConfig({ indexFiles: true }) + ) + const stat = mock.method(nodeFs, 'stat') + const server = createStaticServer(middleware) + + await supertest(server).get('/dashboard?tab=settings').expect(404) + await supertest(server).head('/assets/missing.js').expect(404) + await supertest(server).post('/style.css').expect(404) + + assert.equal(stat.mock.callCount(), 0) + }) + + test('preserve file contents, headers, HEAD, conditional and range responses', async ({ + assert, + }) => { + const content = 'body { background: #123456 }' + await fs.outputFile(join(BASE_PATH, 'public/assets/style.css'), content) + await fs.outputFile(join(BASE_PATH, 'public/robots.txt'), 'User-agent: *') + const middleware = new StaticMiddleware( + join(BASE_PATH, 'public'), + defineConfig({ indexFiles: true, headers: () => ({ 'X-Static': 'indexed' }) }) + ) + const stat = mock.method(nodeFs, 'stat') + const server = createStaticServer(middleware) + + const response = await supertest(server).get('/assets/style.css?v=37').expect(200) + assert.equal(response.text, content) + assert.equal(response.headers['x-static'], 'indexed') + assert.property(response.headers, 'etag') + assert.property(response.headers, 'last-modified') + await supertest(server).get('/robots.txt').expect(200, 'User-agent: *') + const head = await supertest(server).head('/assets/style.css').expect(200) + assert.equal(head.headers['content-length'], String(content.length)) + assert.isUndefined(head.text) + await supertest(server) + .get('/assets/style.css') + .set('If-None-Match', response.headers.etag) + .expect(304) + const range = await supertest(server) + .get('/assets/style.css') + .set('Range', 'bytes=2-11') + .expect(206) + assert.equal(range.text, content.slice(2, 12)) + assert.equal(stat.mock.callCount(), 5) + }) + + test('decode paths once and normalize encoded separators', async ({ assert }) => { + await fs.outputFile(join(BASE_PATH, 'public/assets/100%.css'), 'literal-percent') + await fs.outputFile(join(BASE_PATH, 'public/assets/percent%25.css'), 'encoded-percent') + const server = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) + ) + + await supertest(server).get('/assets%2F100%25.css?version=1').expect(200, 'literal-percent') + await supertest(server).get('/assets/percent%2525.css').expect(200, 'encoded-percent') + const normalized = await supertest(server).get('//assets/100%25.css').expect(200) + assert.equal(normalized.text, 'literal-percent') + }) + + test('leave case and Unicode variants to the filesystem', async ({ assert }) => { + const variants = [ + ['Case.css', 'case.css'], + ['Σ.css', 'ς.css'], + ['cafe\u0301.css', 'café.css'], + ] + const exists = await Promise.all( + variants.map(async ([filename, requested]) => { + await fs.outputFile(join(BASE_PATH, 'public', filename), filename) + return fs.pathExists(join(BASE_PATH, 'public', requested)) + }) + ) + const server = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) + ) + const stat = mock.method(nodeFs, 'stat') + + for (const [index, [filename, requested]] of variants.entries()) { + const response = await supertest(server) + .get(`/${encodeURIComponent(requested)}`) + .expect(exists[index] ? 200 : 404) + assert.equal(response.text, exists[index] ? filename : '404') + assert.equal(stat.mock.callCount(), index + 1) + } + }) + + test('preserve directory redirects and index files', async () => { + await fs.outputFile(join(BASE_PATH, 'public/index.html'), 'home') + await fs.outputFile(join(BASE_PATH, 'public/docs/index.html'), 'documentation') + const server = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) + ) + + await supertest(server).get('/').expect(200, 'home') + await supertest(server).get('/docs?lang=fr').expect(301).expect('Location', '/docs/?lang=fr') + await supertest(server).get('/docs/').expect(200, 'documentation') + }) + + test('keep dotfile rules and path traversal protection', async () => { + await fs.outputFile(join(BASE_PATH, 'public/.env'), 'hidden') + await fs.outputFile(join(BASE_PATH, 'public/.well-known/proof.txt'), 'proof') + await fs.outputFile(join(BASE_PATH, 'secret.txt'), 'outside-public') + const server = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) + ) + + await supertest(server).get('/.env').expect(404, '404') + await supertest(server).get('/%2e%2e/secret.txt').expect(404, '404') + await supertest(server).get('/%00').expect(404, '404') + const allowed = createStaticServer( + new StaticMiddleware( + join(BASE_PATH, 'public'), + defineConfig({ indexFiles: true, dotFiles: 'allow' }) + ) + ) + await supertest(allowed).get('/.well-known/proof.txt').expect(200, 'proof') + }) + + test('only opt-in indexing hides files added after construction', async () => { + await fs.ensureDir(join(BASE_PATH, 'public')) + const indexed = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) + ) + const live = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({})) + ) + + await fs.outputFile(join(BASE_PATH, 'public/added.txt'), 'new-file') + await supertest(indexed).get('/added.txt').expect(404) + await supertest(live).get('/added.txt').expect(200, 'new-file') + }) + + test('resolve symlinks without indexing their targets', async ({ assert }) => { + await fs.outputFile(join(BASE_PATH, 'outside/original.txt'), 'original') + await fs.ensureDir(join(BASE_PATH, 'public')) + await fs.symlink(join(BASE_PATH, 'outside'), join(BASE_PATH, 'public/linked'), 'dir') + await fs.symlink( + join(BASE_PATH, 'outside/original.txt'), + join(BASE_PATH, 'public/alias.txt'), + 'file' + ) + const server = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) + ) + await fs.outputFile(join(BASE_PATH, 'outside/added.txt'), 'added') + const stat = mock.method(nodeFs, 'stat') + + await supertest(server).get('/linked/original.txt').expect(200, 'original') + await supertest(server).get('/alias.txt').expect(200, 'original') + await supertest(server).get('/linked/added.txt').expect(200, 'added') + assert.equal(stat.mock.callCount(), 3) + await supertest(server).get('/linked-other/missing.txt').expect(404) + assert.equal(stat.mock.callCount(), 3) + }) + + test('fall through after removing an indexed file or with a missing public directory', async () => { + await fs.outputFile(join(BASE_PATH, 'public/style.css'), 'body {}') + const server = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) + ) + await fs.remove(join(BASE_PATH, 'public/style.css')) + await supertest(server).get('/style.css').expect(404) + + const missing = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'missing'), defineConfig({ indexFiles: true })) + ) + await supertest(missing).get('/assets/missing.css').expect(404) + }) + + test('propagate errors from next when skipping an unindexed path', async ({ assert }) => { + await fs.ensureDir(join(BASE_PATH, 'public')) + const middleware = new StaticMiddleware( + join(BASE_PATH, 'public'), + defineConfig({ indexFiles: true }) + ) + const ctx = new HttpContextFactory().merge({ url: '/dashboard' }).create() + + await assert.rejects( + () => + middleware.handle(ctx, () => { + throw new Error('next failed') + }), + 'next failed' + ) + }) +}) From e767b40c674474a9a0b050d479ffe394c9ec2a31 Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Sun, 4 Oct 2026 10:38:02 +0200 Subject: [PATCH 2/4] perf(static): skip index lookups for directories and scan at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip the filesystem for paths ending with a slash when the directory has no index.html, so application routes such as "/" no longer probe the public directory. Fold index keys through lowercase before uppercase. Uppercasing alone reports existing files as missing for "ẞ" and "ϴ" on case-insensitive filesystems. Construct the middleware while booting the web environment so the scan does not delay the first request, and store keys relative to the public directory to reduce memory. --- README.md | 2 +- providers/static_provider.ts | 11 ++++++++++ src/static_middleware.ts | 38 +++++++++++++++++++++++++++----- src/types.ts | 5 +++-- tests/static_middleware.spec.ts | 34 ++++++++++++++++++++++++---- tests/static_provider.spec.ts | 39 +++++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8af421f..c9e740e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ export default defineConfig({ }) ``` -The middleware scans `public/` once when it is constructed. GET and HEAD requests for paths absent from the index go directly to the next middleware without a filesystem lookup. Existing files still use `serve-static`, including its metadata checks, caching, range responses and dotfile rules. Directories retain their redirects and index-file handling. Symlinks and their descendants retain filesystem lookups. +The middleware scans `public/` once, while the HTTP server boots. GET and HEAD requests for paths absent from the index go directly to the next middleware without a filesystem lookup. Existing files still use `serve-static`, including its metadata checks, caching, range responses and dotfile rules. Directories retain their redirects and index-file handling, and a path ending with a slash skips the lookup when the directory has no `index.html`. Symlinks and their descendants retain filesystem lookups. Indexing is disabled by default. Restart the application after adding files or directories. Leave it disabled during development or when serving uploads. The index costs memory proportional to the public directory's size and a synchronous directory scan on initialization. It indexes the whole public directory, not just Vite assets. diff --git a/providers/static_provider.ts b/providers/static_provider.ts index 78d85df..42a1415 100644 --- a/providers/static_provider.ts +++ b/providers/static_provider.ts @@ -66,4 +66,15 @@ export default class StaticProvider { return new StaticMiddleware(publicPath, config) }) } + + /** + * Construct the middleware while booting the HTTP server when public files + * are indexed, so that the directory scan does not delay the first request. + */ + async boot() { + const config = this.app.config.get('static', {}) + if (this.app.getEnvironment() === 'web' && config.indexFiles) { + await this.app.container.make(StaticMiddleware) + } + } } diff --git a/src/static_middleware.ts b/src/static_middleware.ts index 7e75593..c2f3555 100644 --- a/src/static_middleware.ts +++ b/src/static_middleware.ts @@ -15,6 +15,19 @@ import staticServer, { type RequestHandler } from 'serve-static' import type { HttpContext, HttpResponse } from '@adonisjs/core/http' import type { AssetsConfig } from './types.ts' +/** + * Fold a path into the key used by the public files index. + * + * Two names the filesystem treats as the same file must produce the same key, + * otherwise an existing file is reported as missing. Uppercasing alone is not + * enough: "ẞ" (U+1E9E) and "ϴ" (U+03F4) are their own uppercase forms, yet + * case-insensitive filesystems such as APFS match them with "ß" and "Θ". + * Lowercasing first maps them onto those letters before uppercasing. + */ +function toIndexKey(path: string): string { + return path.normalize('NFC').toLowerCase().toUpperCase() +} + /** * Middleware to serve static assets from a pre-defined directory. * @@ -37,6 +50,7 @@ export default class StaticMiddleware { #sendFile: RequestHandler #publicPath: string + #publicPrefix: string #files?: Set #symbolicLinks: string[] = [] @@ -62,16 +76,19 @@ export default class StaticMiddleware { constructor(publicPath: string, config: AssetsConfig) { const { dotFiles, indexFiles, ...rest } = config this.#publicPath = resolvePath(publicPath) + this.#publicPrefix = this.#publicPath.endsWith(sep) ? this.#publicPath : this.#publicPath + sep if (indexFiles) { - // Leave case and Unicode variants to serve-static and the underlying filesystem. - this.#files = new Set([this.#publicPath.normalize('NFC').toUpperCase()]) + // Keys are relative to the public directory to keep the index small. + this.#files = new Set() try { for (const entry of readdirSync(this.#publicPath, { recursive: true, withFileTypes: true, })) { - const filePath = join(entry.parentPath, entry.name).normalize('NFC').toUpperCase() + const filePath = toIndexKey( + join(entry.parentPath, entry.name).slice(this.#publicPrefix.length) + ) this.#files.add(filePath) if (entry.isSymbolicLink()) { this.#symbolicLinks.push(filePath + sep) @@ -120,12 +137,23 @@ export default class StaticMiddleware { let filePath: string try { - const pathname = decodeURIComponent(url.split(/[?#]/, 1)[0]) - filePath = resolvePath(this.#publicPath, `.${sep}${pathname}`).normalize('NFC').toUpperCase() + const pathname = url.split(/[?#]/, 1)[0] + + // serve-static only looks for the index file when the path ends with a slash. + filePath = resolvePath( + this.#publicPath, + `.${sep}${decodeURIComponent(pathname)}`, + pathname.endsWith('/') ? 'index.html' : '' + ) } catch { return true } + if (!filePath.startsWith(this.#publicPrefix)) { + return false + } + + filePath = toIndexKey(filePath.slice(this.#publicPrefix.length)) return ( this.#files!.has(filePath) || this.#symbolicLinks.some((prefix) => filePath.startsWith(prefix)) diff --git a/src/types.ts b/src/types.ts index 216e88c..d486fa8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,8 +44,9 @@ export type AssetsConfig = { enabled: boolean /** - * Index public files once when the middleware is constructed to skip filesystem lookups - * for missing paths. Defaults to false. Restart after adding files or directories. + * Index public files once when the middleware is constructed (while booting the HTTP + * server) to skip filesystem lookups for missing paths. Defaults to false. Restart after + * adding files or directories. * Symlinks and their descendants still use filesystem lookups. */ indexFiles?: boolean diff --git a/tests/static_middleware.spec.ts b/tests/static_middleware.spec.ts index 2d68c05..36e1851 100644 --- a/tests/static_middleware.spec.ts +++ b/tests/static_middleware.spec.ts @@ -254,7 +254,7 @@ test.group('Indexed static files', (group) => { .set('Range', 'bytes=2-11') .expect(206) assert.equal(range.text, content.slice(2, 12)) - assert.equal(stat.mock.callCount(), 5) + assert.isAtLeast(stat.mock.callCount(), 5) }) test('decode paths once and normalize encoded separators', async ({ assert }) => { @@ -275,6 +275,8 @@ test.group('Indexed static files', (group) => { ['Case.css', 'case.css'], ['Σ.css', 'ς.css'], ['cafe\u0301.css', 'café.css'], + ['ß.css', 'ẞ.css'], + ['Θ.css', 'ϴ.css'], ] const exists = await Promise.all( variants.map(async ([filename, requested]) => { @@ -288,11 +290,12 @@ test.group('Indexed static files', (group) => { const stat = mock.method(nodeFs, 'stat') for (const [index, [filename, requested]] of variants.entries()) { + const lookups = stat.mock.callCount() const response = await supertest(server) .get(`/${encodeURIComponent(requested)}`) .expect(exists[index] ? 200 : 404) assert.equal(response.text, exists[index] ? filename : '404') - assert.equal(stat.mock.callCount(), index + 1) + assert.isAbove(stat.mock.callCount(), lookups) } }) @@ -308,6 +311,28 @@ test.group('Indexed static files', (group) => { await supertest(server).get('/docs/').expect(200, 'documentation') }) + test('skip filesystem lookups for directories without an index file', async ({ assert }) => { + await fs.outputFile(join(BASE_PATH, 'public/assets/app.js'), 'app') + await fs.outputFile(join(BASE_PATH, 'public/docs/Index.html'), 'documentation') + const docsIndexExists = await fs.pathExists(join(BASE_PATH, 'public/docs/index.html')) + const server = createStaticServer( + new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) + ) + const stat = mock.method(nodeFs, 'stat') + + await supertest(server).get('/').expect(404) + await supertest(server).get('/?page=2').expect(404) + await supertest(server).get('/assets/').expect(404) + await supertest(server).get('/assets/app.js/').expect(404) + assert.equal(stat.mock.callCount(), 0) + + await supertest(server).get('/assets').expect(301).expect('Location', '/assets/') + await supertest(server) + .get('/docs/') + .expect(docsIndexExists ? 200 : 404) + assert.isAbove(stat.mock.callCount(), 0) + }) + test('keep dotfile rules and path traversal protection', async () => { await fs.outputFile(join(BASE_PATH, 'public/.env'), 'hidden') await fs.outputFile(join(BASE_PATH, 'public/.well-known/proof.txt'), 'proof') @@ -360,9 +385,10 @@ test.group('Indexed static files', (group) => { await supertest(server).get('/linked/original.txt').expect(200, 'original') await supertest(server).get('/alias.txt').expect(200, 'original') await supertest(server).get('/linked/added.txt').expect(200, 'added') - assert.equal(stat.mock.callCount(), 3) + const lookups = stat.mock.callCount() + assert.isAtLeast(lookups, 3) await supertest(server).get('/linked-other/missing.txt').expect(404) - assert.equal(stat.mock.callCount(), 3) + assert.equal(stat.mock.callCount(), lookups) }) test('fall through after removing an indexed file or with a missing public directory', async () => { diff --git a/tests/static_provider.spec.ts b/tests/static_provider.spec.ts index b890cc3..a50e1ad 100644 --- a/tests/static_provider.spec.ts +++ b/tests/static_provider.spec.ts @@ -7,8 +7,12 @@ * file that was distributed with this source code. */ +import fs from 'fs-extra' import { test } from '@japa/runner' +import { fileURLToPath } from 'node:url' import { IgnitorFactory } from '@adonisjs/core/factories' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { defineConfig } from '../index.ts' import StaticMiddleware from '../src/static_middleware.ts' const BASE_URL = new URL('./tmp/', import.meta.url) @@ -37,4 +41,39 @@ test.group('Static provider', () => { await assert.doesNotReject(() => app.container.make(StaticMiddleware)) assert.instanceOf(await app.container.make(StaticMiddleware), StaticMiddleware) }) + + test('index public files while booting the web environment', async ({ assert, cleanup }) => { + cleanup(() => fs.remove(fileURLToPath(BASE_URL))) + + const ignitor = new IgnitorFactory() + .withCoreProviders() + .withCoreConfig() + .merge({ + config: { + static: defineConfig({ indexFiles: true }), + }, + rcFileContents: { + providers: [() => import('../providers/static_provider.js')], + }, + }) + .create(BASE_URL) + + const app = ignitor.createApp('web') + await app.init() + await fs.outputFile(app.publicPath('style.css'), 'body {}') + await app.boot() + + /** + * A file added after boot is only missing from the index when the + * directory has been scanned during boot. + */ + await fs.outputFile(app.publicPath('added.txt'), 'new-file') + const middleware = await app.container.make(StaticMiddleware) + + let nextCalled = false + await middleware.handle(new HttpContextFactory().merge({ url: '/added.txt' }).create(), () => { + nextCalled = true + }) + assert.isTrue(nextCalled) + }) }) From 9c6fce5b85418eb04300ae35dd2ae9374e1b7bc4 Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Mon, 12 Oct 2026 13:18:08 +0200 Subject: [PATCH 3/4] perf(static): replace public files index with URL prefixes The index skipped lookups for missing paths, but it had to reproduce the path resolution of serve-static: URL decoding, case folding, Unicode normalization and symlinks. A mismatch between the two turns an existing file into a 404. The index also needed a scan at boot and a restart after adding files. Remove `indexFiles` and add a `prefixes` option. The middleware compares the request URL path with the prefixes and calls the next middleware without a filesystem lookup when none matches. Requests under a prefix go to serve-static as before, so a missing file under a prefix still costs one lookup. Without the option, the behavior does not change. --- README.md | 12 +- providers/static_provider.ts | 11 -- src/define_config.ts | 1 - src/static_middleware.ts | 99 +++----------- src/types.ts | 14 +- tests/static_middleware.spec.ts | 221 ++++++-------------------------- tests/static_provider.spec.ts | 39 ------ 7 files changed, 72 insertions(+), 325 deletions(-) diff --git a/README.md b/README.md index c9e740e..2839484 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,23 @@ Middleware to serve static files from a given directory of your AdonisJS apps The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/static-file-server) -## Indexing public files +## Limiting lookups to URL prefixes -For deployments where public files do not change while the application is running, enable `indexFiles` in `config/static.ts`: +By default, the middleware looks for a file in `public/` on every request, including the requests your routes handle. Define `prefixes` in `config/static.ts` to limit the lookup to some URL paths: ```ts import { defineConfig } from '@adonisjs/static' export default defineConfig({ - indexFiles: true, + prefixes: ['/assets', '/favicon.ico', '/robots.txt'], }) ``` -The middleware scans `public/` once, while the HTTP server boots. GET and HEAD requests for paths absent from the index go directly to the next middleware without a filesystem lookup. Existing files still use `serve-static`, including its metadata checks, caching, range responses and dotfile rules. Directories retain their redirects and index-file handling, and a path ending with a slash skips the lookup when the directory has no `index.html`. Symlinks and their descendants retain filesystem lookups. +A request matches when its path equals a prefix or is nested under it. `/assets/app.js` matches `/assets`, and `/assets-old/app.js` does not. All other requests go to the next middleware without a filesystem lookup. -Indexing is disabled by default. Restart the application after adding files or directories. Leave it disabled during development or when serving uploads. The index costs memory proportional to the public directory's size and a synchronous directory scan on initialization. It indexes the whole public directory, not just Vite assets. +The prefixes do not change the file path. `/assets/app.js` still serves `public/assets/app.js`. -This reduces filesystem operations on missing paths. It does not eliminate file reads when serving an asset or guarantee fewer physical disk reads if the operating system already caches metadata. +A file in `public/` with no matching prefix is not served. The middleware compares the prefixes as-is with the request URL, so it does not decode the path or ignore the case. ## Contributing diff --git a/providers/static_provider.ts b/providers/static_provider.ts index 42a1415..78d85df 100644 --- a/providers/static_provider.ts +++ b/providers/static_provider.ts @@ -66,15 +66,4 @@ export default class StaticProvider { return new StaticMiddleware(publicPath, config) }) } - - /** - * Construct the middleware while booting the HTTP server when public files - * are indexed, so that the directory scan does not delay the first request. - */ - async boot() { - const config = this.app.config.get('static', {}) - if (this.app.getEnvironment() === 'web' && config.indexFiles) { - await this.app.container.make(StaticMiddleware) - } - } } diff --git a/src/define_config.ts b/src/define_config.ts index 7f7a56c..f6f8d90 100644 --- a/src/define_config.ts +++ b/src/define_config.ts @@ -36,7 +36,6 @@ import { type AssetsConfig } from './types.ts' export function defineConfig(config: Partial): AssetsConfig { return { enabled: true, - indexFiles: false, dotFiles: 'ignore', etag: true, lastModified: true, diff --git a/src/static_middleware.ts b/src/static_middleware.ts index c2f3555..7fa8221 100644 --- a/src/static_middleware.ts +++ b/src/static_middleware.ts @@ -8,26 +8,11 @@ */ import { type ServerResponse } from 'node:http' -import { readdirSync } from 'node:fs' -import { join, resolve as resolvePath, sep } from 'node:path' import type { NextFn } from '@adonisjs/core/types/http' import staticServer, { type RequestHandler } from 'serve-static' import type { HttpContext, HttpResponse } from '@adonisjs/core/http' import type { AssetsConfig } from './types.ts' -/** - * Fold a path into the key used by the public files index. - * - * Two names the filesystem treats as the same file must produce the same key, - * otherwise an existing file is reported as missing. Uppercasing alone is not - * enough: "ẞ" (U+1E9E) and "ϴ" (U+03F4) are their own uppercase forms, yet - * case-insensitive filesystems such as APFS match them with "ß" and "Θ". - * Lowercasing first maps them onto those letters before uppercasing. - */ -function toIndexKey(path: string): string { - return path.normalize('NFC').toLowerCase().toUpperCase() -} - /** * Middleware to serve static assets from a pre-defined directory. * @@ -49,10 +34,10 @@ export default class StaticMiddleware { */ #sendFile: RequestHandler - #publicPath: string - #publicPrefix: string - #files?: Set - #symbolicLinks: string[] = [] + /** + * URL path prefixes the middleware is limited to, without trailing slashes + */ + #prefixes?: string[] /** * Creates a new StaticMiddleware instance. @@ -74,32 +59,10 @@ export default class StaticMiddleware { * ``` */ constructor(publicPath: string, config: AssetsConfig) { - const { dotFiles, indexFiles, ...rest } = config - this.#publicPath = resolvePath(publicPath) - this.#publicPrefix = this.#publicPath.endsWith(sep) ? this.#publicPath : this.#publicPath + sep - - if (indexFiles) { - // Keys are relative to the public directory to keep the index small. - this.#files = new Set() - try { - for (const entry of readdirSync(this.#publicPath, { - recursive: true, - withFileTypes: true, - })) { - const filePath = toIndexKey( - join(entry.parentPath, entry.name).slice(this.#publicPrefix.length) - ) - this.#files.add(filePath) - if (entry.isSymbolicLink()) { - this.#symbolicLinks.push(filePath + sep) - } - } - } catch (error) { - if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { - throw error - } - } - } + const { dotFiles, prefixes, ...rest } = config + this.#prefixes = prefixes?.map((prefix) => { + return prefix.length > 1 ? prefix.replace(/\/+$/, '') : prefix + }) this.#sendFile = staticServer(publicPath, { ...rest, @@ -128,36 +91,16 @@ export default class StaticMiddleware { } /** - * Skip definite misses while leaving path validation and symlink resolution to serve-static. + * Check if the request URL path equals one of the configured prefixes + * or is nested under it. */ - #isIndexed(url: string): boolean { - if (!url.startsWith('/')) { - return true - } - - let filePath: string - try { - const pathname = url.split(/[?#]/, 1)[0] - - // serve-static only looks for the index file when the path ends with a slash. - filePath = resolvePath( - this.#publicPath, - `.${sep}${decodeURIComponent(pathname)}`, - pathname.endsWith('/') ? 'index.html' : '' - ) - } catch { - return true - } + #matchesPrefix(url: string): boolean { + const queryIndex = url.indexOf('?') + const pathname = queryIndex === -1 ? url : url.slice(0, queryIndex) - if (!filePath.startsWith(this.#publicPrefix)) { - return false - } - - filePath = toIndexKey(filePath.slice(this.#publicPrefix.length)) - return ( - this.#files!.has(filePath) || - this.#symbolicLinks.some((prefix) => filePath.startsWith(prefix)) - ) + return this.#prefixes!.some((prefix) => { + return pathname === prefix || pathname.startsWith(`${prefix}/`) + }) } /** @@ -181,11 +124,11 @@ export default class StaticMiddleware { * ``` */ async handle({ request, response }: HttpContext, next: NextFn): Promise { - if ( - this.#files && - (request.request.method === 'GET' || request.request.method === 'HEAD') && - !this.#isIndexed(request.request.url || '/') - ) { + /** + * Skip the filesystem lookup when the request is outside the + * configured prefixes. + */ + if (this.#prefixes && !this.#matchesPrefix(request.request.url || '/')) { await next() return } diff --git a/src/types.ts b/src/types.ts index d486fa8..f02a369 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,12 +44,16 @@ export type AssetsConfig = { enabled: boolean /** - * Index public files once when the middleware is constructed (while booting the HTTP - * server) to skip filesystem lookups for missing paths. Defaults to false. Restart after - * adding files or directories. - * Symlinks and their descendants still use filesystem lookups. + * 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. */ - indexFiles?: boolean + prefixes?: string[] /** * Enable or disable accepting ranged requests. diff --git a/tests/static_middleware.spec.ts b/tests/static_middleware.spec.ts index 36e1851..3035b8c 100644 --- a/tests/static_middleware.spec.ts +++ b/tests/static_middleware.spec.ts @@ -188,234 +188,85 @@ test.group('Serve Static', (group) => { }) }) -test.group('Indexed static files', (group) => { +test.group('Serve Static | prefixes', (group) => { group.each.teardown(async () => { mock.restoreAll() await fs.remove(BASE_PATH) }) - function createStaticServer(middleware: StaticMiddleware) { + 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 middleware.handle(ctx, () => { + await serveStatic.handle(ctx, () => { ctx.response.status(404).send('404') ctx.response.finish() }) }) } - test('skip filesystem lookups for missing GET and HEAD paths', async ({ assert }) => { - await fs.outputFile(join(BASE_PATH, 'public/style.css'), 'body {}') - const middleware = new StaticMiddleware( - join(BASE_PATH, 'public'), - defineConfig({ indexFiles: true }) - ) - const stat = mock.method(nodeFs, 'stat') - const server = createStaticServer(middleware) - - await supertest(server).get('/dashboard?tab=settings').expect(404) - await supertest(server).head('/assets/missing.js').expect(404) - await supertest(server).post('/style.css').expect(404) - - assert.equal(stat.mock.callCount(), 0) - }) - - test('preserve file contents, headers, HEAD, conditional and range responses', async ({ - assert, - }) => { - const content = 'body { background: #123456 }' - await fs.outputFile(join(BASE_PATH, 'public/assets/style.css'), content) + 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 middleware = new StaticMiddleware( - join(BASE_PATH, 'public'), - defineConfig({ indexFiles: true, headers: () => ({ 'X-Static': 'indexed' }) }) - ) - const stat = mock.method(nodeFs, 'stat') - const server = createStaticServer(middleware) + const server = createStaticServer(['/assets', '/robots.txt']) - const response = await supertest(server).get('/assets/style.css?v=37').expect(200) - assert.equal(response.text, content) - assert.equal(response.headers['x-static'], 'indexed') - assert.property(response.headers, 'etag') - assert.property(response.headers, 'last-modified') + await supertest(server).get('/assets/app.js?v=3').expect(200, 'app') await supertest(server).get('/robots.txt').expect(200, 'User-agent: *') - const head = await supertest(server).head('/assets/style.css').expect(200) - assert.equal(head.headers['content-length'], String(content.length)) - assert.isUndefined(head.text) - await supertest(server) - .get('/assets/style.css') - .set('If-None-Match', response.headers.etag) - .expect(304) - const range = await supertest(server) - .get('/assets/style.css') - .set('Range', 'bytes=2-11') - .expect(206) - assert.equal(range.text, content.slice(2, 12)) - assert.isAtLeast(stat.mock.callCount(), 5) - }) - - test('decode paths once and normalize encoded separators', async ({ assert }) => { - await fs.outputFile(join(BASE_PATH, 'public/assets/100%.css'), 'literal-percent') - await fs.outputFile(join(BASE_PATH, 'public/assets/percent%25.css'), 'encoded-percent') - const server = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) - ) - - await supertest(server).get('/assets%2F100%25.css?version=1').expect(200, 'literal-percent') - await supertest(server).get('/assets/percent%2525.css').expect(200, 'encoded-percent') - const normalized = await supertest(server).get('//assets/100%25.css').expect(200) - assert.equal(normalized.text, 'literal-percent') - }) - - test('leave case and Unicode variants to the filesystem', async ({ assert }) => { - const variants = [ - ['Case.css', 'case.css'], - ['Σ.css', 'ς.css'], - ['cafe\u0301.css', 'café.css'], - ['ß.css', 'ẞ.css'], - ['Θ.css', 'ϴ.css'], - ] - const exists = await Promise.all( - variants.map(async ([filename, requested]) => { - await fs.outputFile(join(BASE_PATH, 'public', filename), filename) - return fs.pathExists(join(BASE_PATH, 'public', requested)) - }) - ) - const server = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) - ) - const stat = mock.method(nodeFs, 'stat') - - for (const [index, [filename, requested]] of variants.entries()) { - const lookups = stat.mock.callCount() - const response = await supertest(server) - .get(`/${encodeURIComponent(requested)}`) - .expect(exists[index] ? 200 : 404) - assert.equal(response.text, exists[index] ? filename : '404') - assert.isAbove(stat.mock.callCount(), lookups) - } - }) - - test('preserve directory redirects and index files', async () => { - await fs.outputFile(join(BASE_PATH, 'public/index.html'), 'home') - await fs.outputFile(join(BASE_PATH, 'public/docs/index.html'), 'documentation') - const server = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) - ) - - await supertest(server).get('/').expect(200, 'home') - await supertest(server).get('/docs?lang=fr').expect(301).expect('Location', '/docs/?lang=fr') - await supertest(server).get('/docs/').expect(200, 'documentation') + await supertest(server).get('/assets').expect(301).expect('Location', '/assets/') }) - test('skip filesystem lookups for directories without an index file', async ({ assert }) => { - await fs.outputFile(join(BASE_PATH, 'public/assets/app.js'), 'app') - await fs.outputFile(join(BASE_PATH, 'public/docs/Index.html'), 'documentation') - const docsIndexExists = await fs.pathExists(join(BASE_PATH, 'public/docs/index.html')) - const server = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) - ) + 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('/').expect(404) - await supertest(server).get('/?page=2').expect(404) - await supertest(server).get('/assets/').expect(404) - await supertest(server).get('/assets/app.js/').expect(404) + 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').expect(301).expect('Location', '/assets/') - await supertest(server) - .get('/docs/') - .expect(docsIndexExists ? 200 : 404) - assert.isAbove(stat.mock.callCount(), 0) - }) - - test('keep dotfile rules and path traversal protection', async () => { - await fs.outputFile(join(BASE_PATH, 'public/.env'), 'hidden') - await fs.outputFile(join(BASE_PATH, 'public/.well-known/proof.txt'), 'proof') - await fs.outputFile(join(BASE_PATH, 'secret.txt'), 'outside-public') - const server = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) - ) - - await supertest(server).get('/.env').expect(404, '404') - await supertest(server).get('/%2e%2e/secret.txt').expect(404, '404') - await supertest(server).get('/%00').expect(404, '404') - const allowed = createStaticServer( - new StaticMiddleware( - join(BASE_PATH, 'public'), - defineConfig({ indexFiles: true, dotFiles: 'allow' }) - ) - ) - await supertest(allowed).get('/.well-known/proof.txt').expect(200, 'proof') + await supertest(server).get('/assets/missing.js').expect(404) + assert.equal(stat.mock.callCount(), 1) }) - test('only opt-in indexing hides files added after construction', async () => { - await fs.ensureDir(join(BASE_PATH, 'public')) - const indexed = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) - ) - const live = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({})) - ) + test('ignore trailing slashes in prefixes', async () => { + await fs.outputFile(join(BASE_PATH, 'public/assets/app.js'), 'app') + const server = createStaticServer(['/assets/']) - await fs.outputFile(join(BASE_PATH, 'public/added.txt'), 'new-file') - await supertest(indexed).get('/added.txt').expect(404) - await supertest(live).get('/added.txt').expect(200, 'new-file') + await supertest(server).get('/assets/app.js').expect(200, 'app') }) - test('resolve symlinks without indexing their targets', async ({ assert }) => { - await fs.outputFile(join(BASE_PATH, 'outside/original.txt'), 'original') - await fs.ensureDir(join(BASE_PATH, 'public')) - await fs.symlink(join(BASE_PATH, 'outside'), join(BASE_PATH, 'public/linked'), 'dir') - await fs.symlink( - join(BASE_PATH, 'outside/original.txt'), - join(BASE_PATH, 'public/alias.txt'), - 'file' - ) - const server = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) - ) - await fs.outputFile(join(BASE_PATH, 'outside/added.txt'), 'added') - const stat = mock.method(nodeFs, 'stat') + 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('/linked/original.txt').expect(200, 'original') - await supertest(server).get('/alias.txt').expect(200, 'original') - await supertest(server).get('/linked/added.txt').expect(200, 'added') - const lookups = stat.mock.callCount() - assert.isAtLeast(lookups, 3) - await supertest(server).get('/linked-other/missing.txt').expect(404) - assert.equal(stat.mock.callCount(), lookups) + await supertest(server).get('/').expect(200, 'home') + await supertest(server).get('/style.css').expect(404) }) - test('fall through after removing an indexed file or with a missing public directory', async () => { + test('serve no file when prefixes are empty', async () => { await fs.outputFile(join(BASE_PATH, 'public/style.css'), 'body {}') - const server = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'public'), defineConfig({ indexFiles: true })) - ) - await fs.remove(join(BASE_PATH, 'public/style.css')) - await supertest(server).get('/style.css').expect(404) + const server = createStaticServer([]) - const missing = createStaticServer( - new StaticMiddleware(join(BASE_PATH, 'missing'), defineConfig({ indexFiles: true })) - ) - await supertest(missing).get('/assets/missing.css').expect(404) + await supertest(server).get('/style.css').expect(404) }) - test('propagate errors from next when skipping an unindexed path', async ({ assert }) => { - await fs.ensureDir(join(BASE_PATH, 'public')) - const middleware = new StaticMiddleware( + test('propagate errors from next when skipping a request', async ({ assert }) => { + const serveStatic = new StaticMiddleware( join(BASE_PATH, 'public'), - defineConfig({ indexFiles: true }) + defineConfig({ prefixes: ['/assets'] }) ) const ctx = new HttpContextFactory().merge({ url: '/dashboard' }).create() await assert.rejects( () => - middleware.handle(ctx, () => { + serveStatic.handle(ctx, () => { throw new Error('next failed') }), 'next failed' diff --git a/tests/static_provider.spec.ts b/tests/static_provider.spec.ts index a50e1ad..b890cc3 100644 --- a/tests/static_provider.spec.ts +++ b/tests/static_provider.spec.ts @@ -7,12 +7,8 @@ * file that was distributed with this source code. */ -import fs from 'fs-extra' import { test } from '@japa/runner' -import { fileURLToPath } from 'node:url' import { IgnitorFactory } from '@adonisjs/core/factories' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { defineConfig } from '../index.ts' import StaticMiddleware from '../src/static_middleware.ts' const BASE_URL = new URL('./tmp/', import.meta.url) @@ -41,39 +37,4 @@ test.group('Static provider', () => { await assert.doesNotReject(() => app.container.make(StaticMiddleware)) assert.instanceOf(await app.container.make(StaticMiddleware), StaticMiddleware) }) - - test('index public files while booting the web environment', async ({ assert, cleanup }) => { - cleanup(() => fs.remove(fileURLToPath(BASE_URL))) - - const ignitor = new IgnitorFactory() - .withCoreProviders() - .withCoreConfig() - .merge({ - config: { - static: defineConfig({ indexFiles: true }), - }, - rcFileContents: { - providers: [() => import('../providers/static_provider.js')], - }, - }) - .create(BASE_URL) - - const app = ignitor.createApp('web') - await app.init() - await fs.outputFile(app.publicPath('style.css'), 'body {}') - await app.boot() - - /** - * A file added after boot is only missing from the index when the - * directory has been scanned during boot. - */ - await fs.outputFile(app.publicPath('added.txt'), 'new-file') - const middleware = await app.container.make(StaticMiddleware) - - let nextCalled = false - await middleware.handle(new HttpContextFactory().merge({ url: '/added.txt' }).create(), () => { - nextCalled = true - }) - assert.isTrue(nextCalled) - }) }) From 3f64b83bfa440214a5dd7e3aae9e68e41733f14a Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Mon, 12 Oct 2026 13:20:26 +0200 Subject: [PATCH 4/4] docs: remove prefixes section from the readme The option is documented on the AdonisJS website with the other static file server options. --- README.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/README.md b/README.md index 2839484..53467ee 100644 --- a/README.md +++ b/README.md @@ -12,24 +12,6 @@ Middleware to serve static files from a given directory of your AdonisJS apps The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/static-file-server) -## Limiting lookups to URL prefixes - -By default, the middleware looks for a file in `public/` on every request, including the requests your routes handle. Define `prefixes` in `config/static.ts` to limit the lookup to some URL paths: - -```ts -import { defineConfig } from '@adonisjs/static' - -export default defineConfig({ - prefixes: ['/assets', '/favicon.ico', '/robots.txt'], -}) -``` - -A request matches when its path equals a prefix or is nested under it. `/assets/app.js` matches `/assets`, and `/assets-old/app.js` does not. All other requests go to the next middleware without a filesystem lookup. - -The prefixes do not change the file path. `/assets/app.js` still serves `public/assets/app.js`. - -A file in `public/` with no matching prefix is not served. The middleware compares the prefixes as-is with the request URL, so it does not decode the path or ignore the case. - ## Contributing One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework.