// 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 [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 main(List 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 _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'; } }