201 lines
6.0 KiB
Dart
201 lines
6.0 KiB
Dart
// Tiny pure-Dart static file server for serving `flutter build web` output.
|
|
//
|
|
// Designed for local development as a replacement for `flutter run -d
|
|
// web-server`, which pins itself to a single browser session via DWDS and
|
|
// breaks when the browser is closed and reopened.
|
|
//
|
|
// Behavior:
|
|
// * Serves files from a root directory passed on the command line.
|
|
// * SPA fallback: requests with no file extension that don't match a real
|
|
// file fall back to index.html so client-side routing works.
|
|
// * Dev (default): no-cache on every response.
|
|
// * Production (STATIC_SERVER_PRODUCTION=1): cache static assets; no-cache
|
|
// for HTML entrypoints.
|
|
// * Rejects path traversal (`..` segments).
|
|
//
|
|
// Usage:
|
|
// dart run scripts/web_static_server.dart <root-dir> <port> [bind-host]
|
|
//
|
|
// bind-host: 127.0.0.1 (default), localhost, 0.0.0.0, or any
|
|
// Override bind via WEB_BIND_HOST env.
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
Future<void> main(List<String> args) async {
|
|
final String rootArg = args.isNotEmpty ? args[0] : 'build/web';
|
|
final int port = args.length > 1 ? int.parse(args[1]) : 8080;
|
|
final String bindHost = args.length > 2
|
|
? args[2]
|
|
: (Platform.environment['WEB_BIND_HOST'] ?? '127.0.0.1');
|
|
|
|
final Directory rootDir = Directory(rootArg).absolute;
|
|
if (!rootDir.existsSync()) {
|
|
stderr.writeln('Static root not found: ${rootDir.path}');
|
|
stderr.writeln(
|
|
'Hint: run `flutter build web` before starting this server.',
|
|
);
|
|
exit(1);
|
|
}
|
|
|
|
final InternetAddress address = _parseBindHost(bindHost);
|
|
final HttpServer server = await HttpServer.bind(address, port);
|
|
final String displayHost = address.type == InternetAddressType.unix
|
|
? bindHost
|
|
: (address.isLoopback ? '127.0.0.1' : '0.0.0.0');
|
|
stdout.writeln('Static server listening on http://$displayHost:$port');
|
|
stdout.writeln('Serving: ${rootDir.path}');
|
|
if (Platform.environment['STATIC_SERVER_PRODUCTION'] == '1') {
|
|
stdout.writeln('Production mode: cache enabled for static assets');
|
|
}
|
|
|
|
await for (final HttpRequest req in server) {
|
|
unawaited(_handle(req, rootDir));
|
|
}
|
|
}
|
|
|
|
InternetAddress _parseBindHost(String host) {
|
|
final String normalized = host.trim().toLowerCase();
|
|
if (normalized == '0.0.0.0' ||
|
|
normalized == 'any' ||
|
|
normalized == '*' ||
|
|
normalized == 'all') {
|
|
return InternetAddress.anyIPv4;
|
|
}
|
|
if (normalized == 'localhost' ||
|
|
normalized == '127.0.0.1' ||
|
|
normalized == 'loopback') {
|
|
return InternetAddress.loopbackIPv4;
|
|
}
|
|
return InternetAddress(normalized);
|
|
}
|
|
|
|
Future<void> _handle(HttpRequest req, Directory root) async {
|
|
try {
|
|
String requestPath = req.uri.path;
|
|
if (requestPath.isEmpty || requestPath == '/') {
|
|
requestPath = '/index.html';
|
|
}
|
|
if (requestPath.contains('..')) {
|
|
req.response.statusCode = HttpStatus.forbidden;
|
|
await req.response.close();
|
|
return;
|
|
}
|
|
|
|
final String relPath = requestPath.startsWith('/')
|
|
? requestPath.substring(1)
|
|
: requestPath;
|
|
File target = File('${root.path}/$relPath');
|
|
final bool hasExtension = relPath.contains('.');
|
|
|
|
if (!target.existsSync()) {
|
|
if (hasExtension) {
|
|
req.response
|
|
..statusCode = HttpStatus.notFound
|
|
..write('Not Found');
|
|
await req.response.close();
|
|
return;
|
|
}
|
|
// SPA fallback: extensionless path → index.html.
|
|
target = File('${root.path}/index.html');
|
|
if (!target.existsSync()) {
|
|
req.response
|
|
..statusCode = HttpStatus.notFound
|
|
..write('index.html not found');
|
|
await req.response.close();
|
|
return;
|
|
}
|
|
}
|
|
|
|
req.response
|
|
..statusCode = HttpStatus.ok
|
|
..headers.contentType = ContentType.parse(_mimeFor(target.path));
|
|
_applyCacheHeaders(req.response, target.path);
|
|
|
|
await target.openRead().pipe(req.response);
|
|
} catch (e, st) {
|
|
stderr.writeln('static server error: $e\n$st');
|
|
try {
|
|
req.response.statusCode = HttpStatus.internalServerError;
|
|
await req.response.close();
|
|
} catch (_) {
|
|
// Ignore: response may already be closed.
|
|
}
|
|
}
|
|
}
|
|
|
|
void _applyCacheHeaders(HttpResponse response, String filePath) {
|
|
final bool production =
|
|
Platform.environment['STATIC_SERVER_PRODUCTION'] == '1';
|
|
if (!production) {
|
|
response.headers.set(
|
|
'Cache-Control',
|
|
'no-store, no-cache, must-revalidate, max-age=0',
|
|
);
|
|
response.headers.set('Pragma', 'no-cache');
|
|
response.headers.set('Expires', '0');
|
|
return;
|
|
}
|
|
|
|
final String lower = filePath.toLowerCase();
|
|
final String name = lower.split('/').last;
|
|
if (lower.endsWith('.html') ||
|
|
lower.endsWith('.htm') ||
|
|
name.startsWith('main.dart.js') ||
|
|
name.startsWith('flutter_bootstrap.js') ||
|
|
name == 'flutter.js') {
|
|
response.headers.set(
|
|
'Cache-Control',
|
|
'no-cache, must-revalidate',
|
|
);
|
|
return;
|
|
}
|
|
|
|
response.headers.set('Cache-Control', 'public, max-age=86400');
|
|
}
|
|
|
|
String _mimeFor(String filePath) {
|
|
final int dot = filePath.lastIndexOf('.');
|
|
final String ext = dot < 0 ? '' : filePath.substring(dot + 1).toLowerCase();
|
|
switch (ext) {
|
|
case 'html':
|
|
case 'htm':
|
|
return 'text/html; charset=utf-8';
|
|
case 'js':
|
|
case 'mjs':
|
|
return 'application/javascript; charset=utf-8';
|
|
case 'css':
|
|
return 'text/css; charset=utf-8';
|
|
case 'json':
|
|
case 'map':
|
|
return 'application/json; charset=utf-8';
|
|
case 'wasm':
|
|
return 'application/wasm';
|
|
case 'svg':
|
|
return 'image/svg+xml';
|
|
case 'png':
|
|
return 'image/png';
|
|
case 'jpg':
|
|
case 'jpeg':
|
|
return 'image/jpeg';
|
|
case 'gif':
|
|
return 'image/gif';
|
|
case 'webp':
|
|
return 'image/webp';
|
|
case 'ico':
|
|
return 'image/x-icon';
|
|
case 'woff':
|
|
return 'font/woff';
|
|
case 'woff2':
|
|
return 'font/woff2';
|
|
case 'ttf':
|
|
return 'font/ttf';
|
|
case 'otf':
|
|
return 'font/otf';
|
|
case 'txt':
|
|
return 'text/plain; charset=utf-8';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|