cyberhybridhub/scripts/web_static_server.dart

147 lines
4.3 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.
// * No-cache headers on every response so a fresh `flutter build web`
// always reaches the browser without a hard refresh.
// * Rejects path traversal (`..` segments).
//
// Usage:
// dart run scripts/web_static_server.dart <root-dir> <port>
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 Directory rootDir = Directory(rootArg).absolute;
if (!rootDir.existsSync()) {
stderr.writeln('Static root not found: ${rootDir.path}');
stderr.writeln(
'Hint: run `flutter build web --debug` before starting this server.',
);
exit(1);
}
final HttpServer server = await HttpServer.bind('localhost', port);
stdout.writeln('Static server listening on http://localhost:$port');
stdout.writeln('Serving: ${rootDir.path}');
await for (final HttpRequest req in server) {
unawaited(_handle(req, rootDir));
}
}
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))
// Disable caching so dev rebuilds always reach the browser.
..headers.set(
'Cache-Control',
'no-store, no-cache, must-revalidate, max-age=0',
)
..headers.set('Pragma', 'no-cache')
..headers.set('Expires', '0');
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.
}
}
}
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';
}
}