Static files and SPA
Serve a folder of files, or a single page app (React, Vue, Flutter web), from the Dox app, with the right cache headers.
What you need
- A Dox app.
- A
publicfolder with the built front-end (index.html,assets/...).
Serve a folder: one line
Route.files serves every file of a storage disk. Make a disk for the folder, and add the route.
StorageConfig storage = StorageConfig(
defaultDisk: 'local',
disks: <String, Map<String, dynamic>>{
'local': <String, dynamic>{'driver': 'local', 'root': 'storage'},
/// the front-end files
'public': <String, dynamic>{'driver': 'local', 'root': 'public'},
},
);
GET /static/app.js sends public/app.js, streamed, with the right Content-Type. A missing file answers 404.
The disk rejects .. in the path, so a client can not read files outside public.
This is enough for a few files. It sends no Cache-Control header. For an SPA, go on.
Serve an SPA
An SPA needs two things:
index.htmlfor every URL the SPA router handles, with no cache.- The built assets with a long cache. Build tools put a hash in the file name (
app.3f9c2b.js), so a new build has new names.
1. Write the controller
import 'dart:io';
import 'package:dox_core/dox_core.dart';
class StaticController {
/// GET /assets/{path*}
/// Built files have a hash in the name, so the browser may keep them
/// for one year. The `public` disk rejects `..` in the path.
Future<DoxResponse> asset(String path) async {
StreamFile file = await Storage().disk('public').stream(path);
return response(file).cache(const Duration(days: 365));
}
/// GET / and GET /{path*}
/// Every other URL gets index.html, so the router of the SPA can
/// handle it. Never cache it, so a new deploy shows at once.
/// An unknown API URL stays a 404, it does not get the page.
DoxResponse index(DoxRequest req) {
if (req.uri.path.startsWith('/api/')) {
throw NotFoundHttpException();
}
return response(File('public/index.html')).cache(Duration.zero);
}
}
StaticController staticController = StaticController();
cache(duration) sets Cache-Control: max-age=<seconds>. Duration.zero gives max-age=0, so the browser asks again each time.
2. Add the routes
The catch-all route must come last. Put the API router before the web router in AppConfig.
import 'package:dox_core/dox_core.dart';
import 'package:my_app/app/http/controllers/static.controller.dart';
class WebRouter extends Router {
@override
List<dynamic> get middleware => <dynamic>[];
@override
void register() {
// the SPA: assets with a long cache, every other URL gets index.html
Route.get('/assets/{path*}', staticController.asset);
Route.get('/', staticController.index);
Route.get('/{path*}', staticController.index);
}
}
AppConfig appConfig = AppConfig(
/// the SPA catch-all route is in WebRouter, so it comes last
routers: <Router>[ApiRouter(), WebRouter()],
// ...
);
Keep the route for / as well, so the start page is always served.
3. Put the build in public
# React (Vite)
npm run build && cp -r dist/. public/
# Flutter web
flutter build web && cp -r build/web/. public/
Vite puts the files in assets/. Flutter web puts them next to index.html. For Flutter, change the asset route to match, or serve everything with Route.files and give index.html the catch-all.
Check it
curl -i http://localhost:3000/settings/profile # same answer: index.html
curl -i http://localhost:3000/assets/app.js
GET /assets/nope.js answers 404. GET /assets/..%2Fpubspec.yaml answers 404 too.
GET /api/nothing answers 404, not the page.
In production
Put the proxy in front of Dox, and let it do gzip. See Deployment.
For a big site, serve public from the proxy or a CDN, and keep Dox for the API only.