beta

Serve Static

Static file server with Range, conditional GET, charset, dotfile guard, and SPA fallback.

@miiajs/serve-static serves files from disk under a URL prefix. The package speaks full HTTP semantics: weak ETag and Last-Modified for cache revalidation, Range requests for resumable downloads and <video>/<audio> seeking, HEAD without body, and a SPA fallback gated by Accept and path extension. Default-deny dotfile policy keeps .env, .git/HEAD, and .well-known/... from leaking.

Installation

bun add @miiajs/serve-static

Setup

Mount a directory under a URL prefix:

import { Miia } from '@miiajs/core'
import { serveStatic } from '@miiajs/serve-static'

const app = new Miia().register(AppModule)

serveStatic(app, '/static', './public')

This serves all files from ./public under the /static URL prefix:

GET /static/style.css  ->  ./public/style.css
GET /static/app.js     ->  ./public/app.js
GET /static/logo.png   ->  ./public/logo.png

Configuration options

serveStatic(app, '/assets', './dist', {
  index: 'index.html',
  maxAge: 3600,
  etag: true,
  dotfiles: false,
  fallback: 'index.html',
})
OptionTypeDefaultDescription
indexstring | false'index.html'Index file for directory requests. Set to false to disable.
maxAgenumber0Cache-Control: max-age value in seconds. Applied to 200 and 304 responses.
etagbooleantrueSend ETag and Last-Modified and honor If-None-Match / If-Modified-Since. Disable to skip conditional GET.
dotfilesbooleanfalseWhen false, any URL with a path segment starting with . returns 404 (e.g. .env, .git/HEAD, .well-known/...).
fallbackstring | falsefalsePath inside root to serve when the requested file is missing and the request looks like a SPA navigation. Typically 'index.html'.
getMimeType(path: string) => stringbuilt-in mapCustom MIME type resolver. Result is automatically suffixed with ; charset=utf-8 for text-like types unless it already specifies a charset.

Usage

A typical SPA setup - serve built assets, fall back to index.html for client-side routes, cache hashed bundles aggressively:

import { Miia } from '@miiajs/core'
import { serveStatic } from '@miiajs/serve-static'

const app = new Miia().register(AppModule)

// Hashed assets - long-lived cache + SPA fallback for deep links.
serveStatic(app, '/', './dist', {
  maxAge: 60 * 60 * 24 * 365,
  fallback: 'index.html',
})

await app.listen(3000)

GET /about resolves to dist/index.html; GET /assets/app-abc123.js returns the bundle with a year-long cache; GET /favicon.ico returns the icon. Refresh on /about keeps client-side routing intact.

HTTP semantics

Conditional GET (304 Not Modified)

Each response carries a weak ETag of the form W/"<size>-<mtime>" and a Last-Modified header. Browsers and CDNs revalidate on subsequent requests:

> GET /assets/app.js
< 200 OK
< ETag: W/"4ad-194a8c4b800"
< Last-Modified: Mon, 12 May 2025 10:00:00 GMT

> GET /assets/app.js
> If-None-Match: W/"4ad-194a8c4b800"
< 304 Not Modified

If-None-Match takes precedence over If-Modified-Since per RFC 7232. Disable both with etag: false.

Range requests (206 Partial Content)

Range: bytes=0-99, bytes=900-, and suffix form bytes=-50 are supported. The server replies 206 with Content-Range: bytes start-end/total and streams only the requested slice. Out-of-range requests return 416 with Content-Range: bytes */total. Multi-range requests fall back to a full 200.

If-Range is honored against either the ETag or the Last-Modified value - if it doesn't match, the full file is served (200) so the client knows to discard its partial cache.

HEAD requests reuse the GET pipeline (status code, all headers, including Content-Length and Content-Range for ranged HEAD) but return no body.

Charset

Text-like MIME types (text/*, application/javascript, application/json, application/xml, image/svg+xml) are returned with ; charset=utf-8 appended automatically. Binary types (images, fonts, archives) are not modified. If a custom getMimeType already specifies a charset, it's left alone.

SPA fallback

Single-page applications need any unmatched URL to resolve to index.html so client-side routing can take over after a page reload:

serveStatic(app, '/', './dist', {
  fallback: 'index.html',
})

The fallback only triggers when:

  • the request is GET or HEAD
  • Accept includes text/html or */* (so fetch('/api/users') with Accept: application/json still 404s correctly)
  • the path has no extension or ends with .html/.htm (so a missing /assets/missing.png 404s instead of returning HTML pretending to be an image)

The fallback path is served fresh each time with its own ETag, Last-Modified, and Content-Type.

Dotfiles policy

By default, any URL segment starting with . returns 404, so a stray .env next to your public/ directory or a .git/HEAD won't leak:

GET /static/.env             ->  404
GET /static/.well-known/x    ->  404
GET /static/foo/.git/HEAD    ->  404

Set dotfiles: true to allow them (useful for .well-known/acme-challenge/... for ACME/HTTPS):

serveStatic(app, '/.well-known', './public/.well-known', { dotfiles: true })
The dotfile guard is a logical URL check. A symlink at public/legit.txt pointing to public/.env is not blocked - if you allow symlinks under public/, audit their targets.

Custom MIME types

Override or extend MIME type detection with a custom getMimeType function:

import { serveStatic, getMimeType } from '@miiajs/serve-static'

serveStatic(app, '/assets', './public', {
  getMimeType: (path) => {
    if (path.endsWith('.glb')) return 'model/gltf-binary'
    if (path.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl'
    return getMimeType(path)
  }
})

The built-in getMimeType is exported for use as a fallback.

Advanced: raw handler

For custom routing, use createStaticHandler directly:

import { createStaticHandler } from '@miiajs/serve-static'

app.addRoute('GET', '/custom/*', createStaticHandler('./public', { maxAge: 3600 }))

Security

  • Lexical traversal protection - paths normalized with .. segments outside root are rejected before any filesystem call.
  • Symlink escape protection - realpath() resolves the target and re-checks it stays under root. A symlink inside public/ pointing outside is treated as 404.
  • Dotfile guard - on by default, see above.
  • Fallback path validation - if you set fallback: '.something' while dotfiles is disabled, createStaticHandler throws at startup rather than serving a guard-bypassing fallback.

Supported MIME types

CategoryExtensions
Text.html, .css, .txt, .csv, .xml, .yaml, .yml
JavaScript.js, .mjs, .cjs, .wasm
Data.json, .map, .pdf
Images.svg, .png, .jpg, .jpeg, .gif, .webp, .avif, .ico
Fonts.woff, .woff2, .ttf, .otf, .eot
Audio/Video.mp3, .ogg, .wav, .mp4, .webm
Archives.zip, .gz, .tar

Unknown extensions default to application/octet-stream.

API Reference

serveStatic(app, prefix, root, options?)

Registers a wildcard GET ${prefix}/* route on app. Convenience wrapper for the common case.

serveStatic(app: Miia, prefix: string, root: string, options?: ServeStaticOptions): void

createStaticHandler(root, options?)

Returns a RequestContext-handler. Use when you need to mount the handler under a custom route, behind guards, or composed with middleware.

createStaticHandler(root: string, options?: ServeStaticOptions): (ctx: RequestContext) => Promise<Response>

getMimeType(path)

Built-in MIME resolver. Exported for use as a fallback inside a custom getMimeType.

getMimeType(path: string): string

Testing

Use a Miia instance directly - mount serveStatic against a temp directory, then drive it with app.fetch():

import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Miia } from '@miiajs/core'
import { serveStatic } from '@miiajs/serve-static'

const root = mkdtempSync(join(tmpdir(), 'static-'))
writeFileSync(join(root, 'hello.txt'), 'hello world')

const app = new Miia({ logger: false })
serveStatic(app, '/static', root)
await app.init()

const res = await app.fetch(new Request('http://localhost/static/hello.txt'))
expect(res.status).toBe(200)
expect(await res.text()).toBe('hello world')

await app.destroy()

The same pattern works for asserting ETag round-trips, Range slicing, dotfile blocks, and SPA fallback - just toggle the relevant option and inspect headers on the response.

Exports

import {
  serveStatic,
  createStaticHandler,
  getMimeType,
  type ServeStaticOptions,
} from '@miiajs/serve-static'

See also