Skip to content

rabbxdev/sirv

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@rabbx/sirv

Ultra fast static file middleware for Bun, Deno, Node.js, and Cloudflare Workers
Zero deps • Streaming • Plugin system • Edge-native

npm version npm downloads github stars size


Features

  • Universal - Bun, Deno, Node.js, CF Workers
  • Streaming - Zero-copy on Bun, range requests, video seeking
  • Plugin system - Composable setHeaders + transform hooks
  • Smart routing - Extensions, index files, SPA fallback
  • Production ready - ETags, compression, cache control, 108 MIME types
  • TypeScript - Full types included

Sponsors

@rabbx/sirv is supported by:

add your logo here

Buy me a coffee GitHub Sponsors

If you use sirv in production, consider sponsoring to keep development active.


Install

bun add @rabbx/sirv

Quick Start

Bun

import { 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;

Deno

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 })));

API

`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
}

Routing

Extension fallback

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

Plugin System

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';
}

Plugin: Markdown → HTML

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');
  }
});

Full Bun Example

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 }));
  }
});

Advanced

Compression

Pre-compress files for zero CPU:
gzip -k public/app.js # → public/app.js.gz
brotli -k public/app.js # → public/app.js.br

Sirv serves .br or .gz automatically with Content-Encoding.

Range Requests

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.

Conditional Requests

// Client sends If-None-Match: W/"abc123"

// Server responds HTTP/1.1 304 Not Modified Saves bandwidth on unchanged files.

Custom MIME Types

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');
  }
});

Performance

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.

License

MIT © rabbxdev

Credits

Inspired by https://github.com/lukeed/sirv by @lukeed. Rewritten for edge runtimes with plugin system.

About

Ultra fast static file middleware for Bun, Deno, Node.js, and Cloudflare Workers Zero deps • Streaming • Plugin system • Edge-native

Topics

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Sponsor this project

Packages

 
 
 

Contributors