Static File Serving
Asena provides built-in support for serving static files through the @StaticServe decorator and StaticServeService base class.
A static serve middleware in a few lines.
02Every @StaticServe option explained.
Three optional hooks on StaticServeService.
From a basic file server to protected assets.
05Serving build output, uploads and SPA fallbacks.
06When files are not being served as expected.
Quick Start
1. Create a Static Serve Middleware
Create a middleware class that extends StaticServeService and configure the root directory:
import { StaticServe } from '@asenajs/asena/decorators';
import { StaticServeService, type Context } from '@asenajs/ergenecore';
import path from 'path';
@StaticServe({ root: path.join(process.cwd(), 'public') })
export class StaticServeMiddleware extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
// Remove /static prefix from path
return reqPath.replace(/^\/static\/|^\/static/, '');
}
public onFound(filePath: string, _c: Context): void {
console.log(`File served: ${filePath}`);
}
public onNotFound(reqPath: string, c: Context): void {
console.log(`File not found: ${reqPath}`);
}
}import { StaticServe } from '@asenajs/asena/decorators';
import { StaticServeService, type Context } from '@asenajs/hono-adapter';
import path from 'path';
@StaticServe({ root: path.join(process.cwd(), 'public') })
export class StaticServeMiddleware extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
// Remove /static prefix from path
return reqPath.replace(/^\/static\/|^\/static/, '');
}
public onFound(filePath: string, _c: Context): void {
console.log(`File served: ${filePath}`);
}
public onNotFound(reqPath: string, c: Context): void {
console.log(`File not found: ${reqPath}`);
}
}2. Create a Controller for Static Routes
Create a controller that uses the middleware:
import { Controller } from '@asenajs/asena/decorators';
import { Get } from '@asenajs/asena/decorators/http';
import { StaticServeMiddleware } from './middlewares/StaticServeMiddleware';
@Controller({ path: '/static' })
export class StaticController {
@Get({ path: '/*', staticServe: StaticServeMiddleware })
public static() {}
}import { Controller } from '@asenajs/asena/decorators';
import { Get } from '@asenajs/asena/decorators/http';
import { StaticServeMiddleware } from './middlewares/StaticServeMiddleware';
@Controller({ path: '/static' })
export class StaticController {
@Get({ path: '/*', staticServe: StaticServeMiddleware })
public static() {}
}3. Create Your Public Directory
Create a public directory in your project root and add your static files:
your-project/
├── src/
│ ├── controllers/
│ │ └── StaticController.ts
│ └── middlewares/
│ └── StaticServeMiddleware.ts
└── public/
├── index.html
├── style.css
├── script.js
└── images/
└── logo.png4. Access Your Static Files
Start your server and access files via the configured route:
# Access HTML files
curl http://localhost:3000/static/index.html
# Access CSS files
curl http://localhost:3000/static/style.css
# Access images
curl http://localhost:3000/static/images/logo.pngConfiguration
@StaticServe Options
Configure the static serve middleware with the following options:
@StaticServe({
root: string; // Root directory for static files (required)
})| Option | Type | Description | Example |
|---|---|---|---|
root | string | Absolute path to the directory containing static files | path.join(process.cwd(), 'public') |
Example:
import path from 'path';
// Serve from 'public' directory
@StaticServe({ root: path.join(process.cwd(), 'public') })
// Serve from 'assets' directory
@StaticServe({ root: path.join(process.cwd(), 'assets') })
// Serve from custom path
@StaticServe({ root: '/var/www/static' })Lifecycle Hooks
The StaticServeService base class provides three lifecycle hooks. All three are optional - override only the ones you need.
rewriteRequestPath(reqPath: string): string
Transform the incoming request path before looking up the file. Useful for removing route prefixes or implementing custom path logic.
Parameters:
reqPath: string- The original request path
Returns: string - The transformed path to use for file lookup
Example:
public rewriteRequestPath(reqPath: string): string {
console.log(`Original path: ${reqPath}`);
// Remove /static prefix
const rewritten = reqPath.replace(/^\/static\/|^\/static/, '');
console.log(`Rewritten path: ${rewritten}`);
return rewritten;
}Use Cases:
- Remove route prefixes (
/static/file.js→file.js) - Add file extensions (
/page→/page.html) - Normalize paths (
/foo//bar→/foo/bar)
onFound(filePath: string, c: Context): void | Promise<void>
Called when a file is successfully found and served. Useful for logging, analytics, or custom headers.
Parameters:
filePath: string- The looked-up path. Not absolute, and not the same across adapters: Ergenecore passes the rewritten request path (no root prefix), Hono passes the root-joined path (e.g../public/index.html).c: Context- The request context
Example:
public onFound(filePath: string, c: Context): void {
console.log(`✅ File served: ${filePath}`);
// Track analytics
this.analytics.track('file_served', { path: filePath });
}Setting response headers here does not work on Ergenecore
c.setResponseHeader() writes into the context's response object, which Ergenecore only consults for send()/html(). Static responses are built directly from the file, so the header is dropped. (On Hono it does work, because the wrapper writes into the live response.)
Use the service's extra property instead - it is honoured by both adapters:
@StaticServe({ root: path.join(process.cwd(), 'public') })
export class PublicStaticServe extends StaticServeService {
public extra = {
cacheControl: 'public, max-age=31536000, immutable',
headers: { 'X-Served-By': 'Asena Static Serve' },
precompressed: true, // prefer .br / .gz variants when present
mimes: { '.ts': 'text/typescript' }, // override MIME detection
};
}onNotFound(reqPath: string, c: Context): void | Promise<void>
Called when a requested file is not found. Useful for logging 404s or implementing fallback logic.
Parameters:
reqPath: string- The requested path that wasn't foundc: Context- The request context
Example:
public onNotFound(reqPath: string, c: Context): void {
console.log(`❌ File not found: ${reqPath}`);
// Log 404s
this.logger.warn('Static file not found', { path: reqPath });
}onNotFound is observational
Its return value is discarded and the adapter still answers 404. To take the request over, mark the hook with @Override() - Ergenecore then skips its own 404 and lets the request fall through to your route handlers:
import { Override } from '@asenajs/asena/decorators';
@Override()
public onNotFound(reqPath: string, c: Context): void {
this.logger.warn('Static file not found', { path: reqPath });
}Examples
Basic Static File Server
Serve files from a public directory:
import { StaticServe } from '@asenajs/asena/decorators';
import { StaticServeService, type Context } from '@asenajs/ergenecore';
import path from 'path';
@StaticServe({ root: path.join(process.cwd(), 'public') })
export class StaticServeMiddleware extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
return reqPath.replace(/^\/static\/|^\/static/, '');
}
public onFound(filePath: string, _c: Context): void {
console.log(`Served: ${filePath}`);
}
public onNotFound(reqPath: string, _c: Context): void {
console.log(`Not found: ${reqPath}`);
}
}import { StaticServe } from '@asenajs/asena/decorators';
import { StaticServeService, type Context } from '@asenajs/hono-adapter';
import path from 'path';
@StaticServe({ root: path.join(process.cwd(), 'public') })
export class StaticServeMiddleware extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
return reqPath.replace(/^\/static\/|^\/static/, '');
}
public onFound(filePath: string, _c: Context): void {
console.log(`Served: ${filePath}`);
}
public onNotFound(reqPath: string, _c: Context): void {
console.log(`Not found: ${reqPath}`);
}
}// Controller
@Controller({ path: '/static' })
export class StaticController {
@Get({ path: '/*', staticServe: StaticServeMiddleware })
public static() {}
}Usage:
# http://localhost:3000/static/index.html
# Serves: public/index.htmlSPA with Fallback to index.html
Serve a Single Page Application with fallback routing:
import { StaticServe } from '@asenajs/asena/decorators';
import { StaticServeService, type Context } from '@asenajs/ergenecore';
import path from 'path';
import { existsSync } from 'fs';
@StaticServe({ root: path.join(process.cwd(), 'dist') })
export class SPAStaticServe extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
const cleanPath = reqPath.replace(/^\/|^/, '');
const fullPath = path.join(process.cwd(), 'dist', cleanPath);
// If file doesn't exist and no extension, fallback to index.html
if (!existsSync(fullPath) && !path.extname(cleanPath)) {
return 'index.html';
}
return cleanPath;
}
public onFound(filePath: string, c: Context): void {
// Cache static assets
if (filePath.match(/\.(js|css|png|jpg|svg)$/)) {
c.setResponseHeader('Cache-Control', 'public, max-age=31536000');
}
}
public onNotFound(reqPath: string, c: Context): void {
console.warn(`SPA: File not found - ${reqPath}`);
}
}import { StaticServe } from '@asenajs/asena/decorators';
import { StaticServeService, type Context } from '@asenajs/hono-adapter';
import path from 'path';
import { existsSync } from 'fs';
@StaticServe({ root: path.join(process.cwd(), 'dist') })
export class SPAStaticServe extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
const cleanPath = reqPath.replace(/^\/|^/, '');
const fullPath = path.join(process.cwd(), 'dist', cleanPath);
// If file doesn't exist and no extension, fallback to index.html
if (!existsSync(fullPath) && !path.extname(cleanPath)) {
return 'index.html';
}
return cleanPath;
}
public onFound(filePath: string, c: Context): void {
// Cache static assets
if (filePath.match(/\.(js|css|png|jpg|svg)$/)) {
c.setResponseHeader('Cache-Control', 'public, max-age=31536000');
}
}
public onNotFound(reqPath: string, c: Context): void {
console.warn(`SPA: File not found - ${reqPath}`);
}
}// Controller
@Controller({ path: '/' })
export class AppController {
@Get({ path: '/*', staticServe: SPAStaticServe })
public spa() {}
}Usage:
# All these routes serve dist/index.html
http://localhost:3000/
http://localhost:3000/about
http://localhost:3000/users/123
# Static assets served directly
http://localhost:3000/app.js # Serves: dist/app.js
http://localhost:3000/style.css # Serves: dist/style.cssMultiple Static Directories
Serve different directories from different routes:
import { StaticServe } from '@asenajs/asena/decorators';
import { StaticServeService, type Context } from '@asenajs/ergenecore';
import path from 'path';
// Public assets
@StaticServe({ root: path.join(process.cwd(), 'public') })
export class PublicStaticServe extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
return reqPath.replace(/^\/assets\/|^\/assets/, '');
}
}
// Downloads
@StaticServe({ root: path.join(process.cwd(), 'downloads') })
export class DownloadStaticServe extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
return reqPath.replace(/^\/downloads\/|^\/downloads/, '');
}
public onFound(filePath: string, c: Context): void {
// Force download
const fileName = path.basename(filePath);
c.setResponseHeader('Content-Disposition', `attachment; filename="${fileName}"`);
}
}
// Images with custom headers
@StaticServe({ root: path.join(process.cwd(), 'images') })
export class ImageStaticServe extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
return reqPath.replace(/^\/images\/|^\/images/, '');
}
public onFound(filePath: string, c: Context): void {
// Long cache for images
c.setResponseHeader('Cache-Control', 'public, max-age=2592000'); // 30 days
}
}import { StaticServe } from '@asenajs/asena/decorators';
import { StaticServeService, type Context } from '@asenajs/hono-adapter';
import path from 'path';
// Public assets
@StaticServe({ root: path.join(process.cwd(), 'public') })
export class PublicStaticServe extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
return reqPath.replace(/^\/assets\/|^\/assets/, '');
}
}
// Downloads
@StaticServe({ root: path.join(process.cwd(), 'downloads') })
export class DownloadStaticServe extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
return reqPath.replace(/^\/downloads\/|^\/downloads/, '');
}
public onFound(filePath: string, c: Context): void {
// Force download
const fileName = path.basename(filePath);
c.setResponseHeader('Content-Disposition', `attachment; filename="${fileName}"`);
}
}
// Images with custom headers
@StaticServe({ root: path.join(process.cwd(), 'images') })
export class ImageStaticServe extends StaticServeService {
public rewriteRequestPath(reqPath: string): string {
return reqPath.replace(/^\/images\/|^\/images/, '');
}
public onFound(filePath: string, c: Context): void {
// Long cache for images
c.setResponseHeader('Cache-Control', 'public, max-age=2592000'); // 30 days
}
}// Controllers
@Controller({ path: '/assets' })
export class AssetsController {
@Get({ path: '/*', staticServe: PublicStaticServe })
public assets() {}
}
@Controller({ path: '/downloads' })
export class DownloadsController {
@Get({ path: '/*', staticServe: DownloadStaticServe })
public downloads() {}
}
@Controller({ path: '/images' })
export class ImagesController {
@Get({ path: '/*', staticServe: ImageStaticServe })
public images() {}
}Usage:
# Public assets
http://localhost:3000/assets/logo.png
# Downloads (forces download)
http://localhost:3000/downloads/report.pdf
# Images (with long cache)
http://localhost:3000/images/banner.jpgBest Practices
1. Use Absolute Paths for Root Directory
Always use path.join(process.cwd(), 'directory') for the root directory:
// ✅ Good
@StaticServe({ root: path.join(process.cwd(), 'public') })
// ❌ Bad - relative path may not work
@StaticServe({ root: './public' })2. Implement Security Checks
Prevent directory traversal attacks in rewriteRequestPath:
public rewriteRequestPath(reqPath: string): string {
let cleanPath = reqPath.replace(/^\/static\/|^\/static/, '');
// Remove directory traversal attempts
cleanPath = cleanPath.replace(/\.\./g, '');
// Normalize path
cleanPath = path.normalize(cleanPath);
return cleanPath;
}3. Set Appropriate Cache Headers
Use onFound to set cache headers based on file type:
public onFound(filePath: string, c: Context): void {
const ext = path.extname(filePath);
// Long cache for immutable assets
if (ext.match(/\.(js|css|woff2?|ttf|svg|png|jpg|gif)$/)) {
c.setResponseHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
// Short cache for HTML
else if (ext === '.html') {
c.setResponseHeader('Cache-Control', 'public, max-age=300');
}
// No cache for others
else {
c.setResponseHeader('Cache-Control', 'no-cache');
}
}4. Log 404s for Monitoring
Use onNotFound to track missing files:
public onNotFound(reqPath: string, c: Context): void {
console.warn(`404: ${reqPath} - Referrer: ${c.req.header('referer')}`);
// Track in monitoring service
this.monitoring.track404(reqPath);
}5. Keep Middleware Simple
Avoid heavy logic in lifecycle hooks. Keep them fast and focused:
// ✅ Good - Simple and fast
public rewriteRequestPath(reqPath: string): string {
return reqPath.replace(/^\/static\//, '');
}
// ❌ Bad - Too much logic
public rewriteRequestPath(reqPath: string): string {
// Multiple regex checks
// Database lookups
// Complex transformations
// This slows down every request!
}Common Use Cases
Serve Build Output
Serve production build from frontend frameworks:
// React/Vue/Svelte build output
@StaticServe({ root: path.join(process.cwd(), 'dist') })
// Next.js static export
@StaticServe({ root: path.join(process.cwd(), 'out') })Serve Documentation
Serve generated documentation:
// VitePress docs
@StaticServe({ root: path.join(process.cwd(), 'docs/.vitepress/dist') })
// Storybook
@StaticServe({ root: path.join(process.cwd(), 'storybook-static') })Serve User Uploads
Serve user-uploaded files:
@StaticServe({ root: path.join(process.cwd(), 'uploads') })
export class UploadsStaticServe extends StaticServeService {
public onFound(filePath: string, c: Context): void {
// Add security headers
c.setResponseHeader('X-Content-Type-Options', 'nosniff');
c.setResponseHeader('Content-Disposition', 'inline');
}
}Troubleshooting
Files Not Being Served
Check:
- Is the
rootpath correct and absolute? - Is
rewriteRequestPathremoving the route prefix correctly? - Do the files exist in the specified directory?
- Check console logs from
onFoundandonNotFoundhooks
MIME Type Issues
Ensure proper file extensions and let the adapter handle MIME types automatically.
404 Errors for Existing Files
Check that rewriteRequestPath is not over-transforming the path:
public rewriteRequestPath(reqPath: string): string {
console.log('Original:', reqPath);
const result = reqPath.replace(/^\/static\//, '');
console.log('Rewritten:', result);
return result;
}Related
- Controllers - Setting up controllers
- Middleware - Understanding middleware
- Context - Working with request context