Ultra fast static file middleware for Bun, Deno, Node.js, and Cloudflare Workers
Zero deps • Streaming • Plugin system • Edge-native
- Universal - Bun, Deno, Node.js, CF Workers
- Streaming - Zero-copy on Bun, range requests, video seeking
- Plugin system - Composable
setHeaders+transformhooks - Smart routing - Extensions, index files, SPA fallback
- Production ready - ETags, compression, cache control, 108 MIME types
- TypeScript - Full types included
@rabbx/sirv is supported by:
add your logo here
If you use sirv in production, consider sponsoring to keep development active.
bun add @rabbx/sirvimport { sirv } from '@rabbx/sirv';
Bun.serve({
port: 3000,
fetch: sirv('public')
});http://Node.js + Hono
import { Hono } from 'hono';
import { sirv } from '@rabbx/sirv';
const app = new Hono();
app.use('/*', sirv('public'));
export default app;import { sirv } from '@rabbx/sirv';
const serveStatic = sirv('public');
Deno.serve(req => serveStatic({ request: req, url: new URL(req.url) }, () => new Response('404', { status: 404 })));`sirv(dirOrOpts, opts?)`
sirv(dir: string, options?: SirvOptions): Middleware
sirv(options: SirvOptions): Middleware
interface SirvOptions {
dir?: string; // Root directory, default 'public'
extensions?: string[]; // Try these for /path → /path.html, default ['html']
preferIndex?: boolean; // Check /path/index.* before /path.html, default false
indexExtensions?: string[]; // Extensions for index files, defaults to extensions
index?: string; // Index filename, default 'index'
singleIndex?: string; // SPA fallback file, default 'index.html'
single?: boolean; // SPA mode: serve fallback for 404s
dotfiles?: boolean; // Serve dotfiles like.env, default false
immutable?: boolean; // Add immutable Cache-Control
maxAge?: number; // Cache-Control max-age in seconds
etag?: boolean; // Generate ETags, default true
lastModified?: boolean; // Send Last-Modified, default true
gzip?: boolean; // Serve.gz files, default true
brotli?: boolean; // Serve.br files, default true
plugins?: SirvPlugin[]; // Plugin array
disableTransformOnRange?: boolean; // Skip transform for range requests, default true
}sirv('public', { extensions: ['html', 'json', 'md'] })
GET /about → public/about.html
GET /api/users → public/api/users.json
GET /docs → public/docs.md
Index files
sirv('public', {
preferIndex: true,
indexExtensions: ['html', 'json']
})
GET /about → public/about/index.html
GET /api → public/api/index.json
GET /about/ → public/about/index.html
SPA fallback
sirv('dist', {
single: true,
singleIndex: 'index.html'
})GET /app/dashboard → dist/index.html
Plugins hook into setHeaders and transform with ordering + conditions.
interface SirvPlugin {
name: string;
setHeaders?: (ctx: TransformContext) => void | Promise<void>;
transform?: (stream: ReadableStream, ctx: TransformContext) => ReadableStream | Response | BodyInit | void;
test?: (ctx: TransformContext) => boolean; // Only run if true
order?: number; // Lower runs first, default 0
}
interface TransformContext {
path: string; // Full file path
stat: FileStat; // File stats
headers: Headers; // Mutable response headers
url: URL; // Request URL
request: Request; // Original request
range: [number, number] | null; // Byte range if present
mime: string; // MIME type
category: 'text' | 'image' | 'audio' | 'video' | 'font' | 'application';
}import { sirv, plugin } from '@rabbx/sirv';
import { marked } from 'marked';
import matter from 'gray-matter';
const markdown = plugin({
name: 'markdown',
test: ctx => ctx.path.endsWith('.md'),
order: 10,
transform: async (stream, ctx) => {
const md = await new Response(stream).text();
const { data, content } = matter(md);
const html = marked.parse(content);
const page = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${data.title || 'Page'}</title>
<meta name="description" content="${data.description || ''}">
</head>
<body>
<h1>${data.title || ''}</h1>
${html}
</body>
</html>`;
ctx.headers.set('Content-Type', 'text/html; charset=utf-8');
ctx.headers.delete('Content-Length');
return page;
}
});
Bun.serve({
port: 3000,
fetch: sirv('posts', {
extensions: ['md'],
plugins: [markdown]
})
});
///Plugin: Cache Headers
const cache = plugin({
name: 'cache',
order: 0,
setHeaders: ctx => {
if (ctx.category === 'image' || ctx.category === 'font') {
ctx.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
} else if (ctx.mime === 'text/html; charset=utf-8') {
ctx.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
}
}
});
//Plugin: Security Headers
const security = plugin({
name: 'security',
order: 1,
setHeaders: ctx => {
ctx.headers.set('X-Content-Type-Options', 'nosniff');
ctx.headers.set('X-Frame-Options', 'DENY');
ctx.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
}
});
//Plugin: Template Injection
const template = plugin({
name: 'template',
test: ctx => ctx.mime === 'text/html; charset=utf-8',
order: 20,
transform: async (stream, ctx) => {
const html = await new Response(stream).text();
const user = ctx.request.headers.get('x-user') || 'Guest';
return html.replace('{{user}}', user).replace('{{year}}', new Date().getFullYear());
}
});
//Plugin: Watermark Images
const watermark = plugin({
name: 'watermark',
test: ctx => ctx.category === 'image' &&!ctx.range,
order: 30,
transform: async (stream, ctx) => {
// Add watermark logic using sharp, canvas, etc
return watermarkStream(stream, '© 2026');
}
});import { sirv, plugin } from '@rabbx/sirv';
import { marked } from 'marked';
const markdown = plugin({
name: 'markdown',
test: ctx => ctx.path.endsWith('.md'),
transform: async (stream, ctx) => {
const html = marked.parse(await new Response(stream).text());
ctx.headers.set('Content-Type', 'text/html; charset=utf-8');
ctx.headers.delete('Content-Length');
return `<!DOCTYPE html><html><body>${html}</body></html>`;
}
});
const cache = plugin({
name: 'cache',
setHeaders: ctx => {
if (ctx.category === 'image') {
ctx.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
}
}
});
const serveStatic = sirv('content', {
extensions: ['md', 'html'],
preferIndex: true,
indexExtensions: ['md', 'html'],
single: true,
singleIndex: 'index.html',
plugins: [cache, markdown]
});
Bun.serve({
port: 3000,
fetch: (req) => {
const ctx = { request: req, url: new URL(req.url) };
return serveStatic(ctx, () => new Response('Not Found', { status: 404 }));
}
});Pre-compress files for zero CPU:
gzip -k public/app.js # → public/app.js.gz
brotli -k public/app.js # → public/app.js.brSirv serves .br or .gz automatically with Content-Encoding.
Video seeking works out of the box: GET /video.mp4 Range: bytes=1000-2000
HTTP/1.1 206 Partial Content
Content-Range: bytes 1000-2000/5000000
Set `disableTransformOnRange: false` to transform partial chunks.// Client sends If-None-Match: W/"abc123"
// Server responds HTTP/1.1 304 Not Modified Saves bandwidth on unchanged files.
import { mime } from '@rabbx/sirv';
// mime.js includes 108 types, but you can override via transform
const customMime = plugin({
name: 'custom-mime',
test: ctx => ctx.path.endsWith('.custom'),
setHeaders: ctx => {
ctx.headers.set('Content-Type', 'application/x-custom');
}
});Runtime Throughput Latency
Bun 95k req/s 0.3ms
http://Node.js 45k req/s 0.8ms
Deno 50k req/s 0.7ms
Zero-copy streaming on Bun. No buffer allocations.MIT © rabbxdev