staticFiles function
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:
- Client-side routes reach the application.
/orders/42exists only in the browser's router, so serving files alone answers 404 and a shared link is broken. HTML mode answersdefaultDocumentinstead, and the address bar keeps the path that was asked for. - The shell is revalidated and the rest is not. Anything named in
revalidateanswersno-cache, which still allows a 304; everything else answersimmutableforimmutableFor, so a repeat visit costs nothing. - Only
GETandHEADare answered. APOSTto 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));
};
}