staticFiles function

Handler staticFiles(
  1. String directory, {
  2. bool html = false,
  3. String? defaultDocument = 'index.html',
  4. Set<String> revalidate = defaultRevalidatedFiles,
  5. Duration immutableFor = const Duration(days: 365),
  6. bool crossOriginIsolated = false,
  7. bool listDirectories = false,
  8. bool serveFilesOutsidePath = false,
})

Serves the files under directory.

Without html this is a thin pass to shelf_static, which already handles content types, ranges, conditional requests, and refusing paths that climb out of the directory. Mount it, so the handler sees paths relative to where it lives:

final app = Router()
  ..mount('/assets', staticFiles('web/assets'))
  ..route('/', get(homePage));

HTML mode

html turns a directory of files into a served application, the way Starlette's StaticFiles(html=True) does. Use it for build output — build/web, dist, build — from any toolchain that ships a document plus assets and routes in the browser: Flutter web, React, Vue, Svelte:

final app = Router()
  ..nest('/api', apiRoutes)
  ..fallback(staticFiles('build/web', html: true));

Three things it adds:

  1. Client-side routes reach the application. /orders/42 exists only in the browser's router, so serving files alone answers 404 and a shared link is broken. HTML mode answers defaultDocument instead, and the address bar keeps the path that was asked for.
  2. The shell is revalidated and the rest is not. Anything named in revalidate answers no-cache, which still allows a 304; everything else answers immutable for immutableFor, so a repeat visit costs nothing.
  3. Only GET and HEAD are answered. A POST to a path no API route matched is a wrong request, and handing it an HTML document would hide that behind a 200.

Set crossOriginIsolated for an application that needs SharedArrayBuffer, which a Flutter --wasm build does. It is off by default because the headers also block third-party images, fonts, and iframes that do not opt in with CORP.

Implementation

Handler staticFiles(
  String directory, {
  bool html = false,
  String? defaultDocument = 'index.html',
  Set<String> revalidate = defaultRevalidatedFiles,
  Duration immutableFor = const Duration(days: 365),
  bool crossOriginIsolated = false,
  bool listDirectories = false,
  bool serveFilesOutsidePath = false,
}) {
  final files = shelf_static.createStaticHandler(
    directory,
    defaultDocument: defaultDocument,
    listDirectories: listDirectories,
    serveFilesOutsidePath: serveFilesOutsidePath,
  );
  if (!html) return files;

  final document = defaultDocument ?? 'index.html';
  final immutable = 'public, max-age=${immutableFor.inSeconds}, immutable';

  Map<String, String> headersFor(String path) {
    // A directory request resolved through the default document, so `/` and
    // `/admin/` are the document however the URL was spelled. Reading the last
    // segment alone would leave the root cached for a year, which is the
    // failure HTML mode exists to prevent.
    final name =
        path.isEmpty || path.endsWith('/') ? document : path.split('/').last;
    return {
      'cache-control': revalidate.contains(name) ? 'no-cache' : immutable,
      if (crossOriginIsolated) ...const {
        'cross-origin-opener-policy': 'same-origin',
        'cross-origin-embedder-policy': 'require-corp',
      },
    };
  }

  return (Request request) async {
    if (request.method != 'GET' && request.method != 'HEAD') {
      return Response.notFound('no route for /${request.url.path}');
    }

    final direct = await files(request);
    if (direct.statusCode != 404) {
      return direct.change(headers: headersFor(request.url.path));
    }

    // Nothing on disk matched, so the path belongs to the browser's router.
    final shell = await files(_asDocumentRequest(request, document));
    if (shell.statusCode == 404) return shell;

    return shell.change(headers: headersFor(document));
  };
}