| Three tarballs where a devShell was 5ce66d5 nandi 22h ago | 1 | // A static file server in one file, for `tools/build-web.sh serve`. |
| 2 | // |
| 3 | // `dart:io` and no packages, so there is nothing to `pub get` and nothing to |
| 4 | // pin: the toolchain already has a Dart, because Flutter ships one, and that |
| 5 | // is the whole reason this is Dart rather than the `python3 -m http.server` |
| 6 | // it replaces. Serving a directory should not add a language to the list of |
| 7 | // things a machine must have. |
| 8 | // |
| 9 | // It is the development server and says so: no caching headers, no |
| 10 | // compression, no range requests. `.modal/flutter-web/serve.py` is the one |
| 11 | // that faces a browser over the internet, and it has all three. |
| 12 | // |
| 13 | // dart tools/serve-dir.dart <directory> [port] |
| 14 | import 'dart:io'; |
| 15 | |
| 16 | // Enough of them for a Flutter web bundle, which is HTML, JavaScript, JSON, a |
| 17 | // wasm blob, fonts and images. Anything else goes out as bytes. |
| 18 | const _types = <String, String>{ |
| 19 | '.html': 'text/html; charset=utf-8', |
| 20 | '.js': 'application/javascript; charset=utf-8', |
| 21 | '.mjs': 'application/javascript; charset=utf-8', |
| 22 | '.json': 'application/json; charset=utf-8', |
| 23 | '.css': 'text/css; charset=utf-8', |
| 24 | '.wasm': 'application/wasm', |
| 25 | '.png': 'image/png', |
| 26 | '.jpg': 'image/jpeg', |
| 27 | '.jpeg': 'image/jpeg', |
| 28 | '.gif': 'image/gif', |
| 29 | '.svg': 'image/svg+xml', |
| 30 | '.ico': 'image/x-icon', |
| 31 | '.ttf': 'font/ttf', |
| 32 | '.otf': 'font/otf', |
| 33 | '.woff': 'font/woff', |
| 34 | '.woff2': 'font/woff2', |
| 35 | '.map': 'application/json; charset=utf-8', |
| 36 | }; |
| 37 | |
| 38 | String _typeOf(String path) { |
| 39 | final dot = path.lastIndexOf('.'); |
| 40 | if (dot < 0) return 'application/octet-stream'; |
| 41 | return _types[path.substring(dot).toLowerCase()] ?? 'application/octet-stream'; |
| 42 | } |
| 43 | |
| 44 | Future<void> main(List<String> args) async { |
| 45 | if (args.isEmpty) { |
| 46 | stderr.writeln('usage: dart serve-dir.dart <directory> [port]'); |
| 47 | exit(2); |
| 48 | } |
| 49 | final root = Directory(args[0]).absolute; |
| 50 | final port = args.length > 1 ? int.parse(args[1]) : 8080; |
| 51 | |
| 52 | // 0.0.0.0 and not loopback: in the container this is behind a Modal tunnel, |
| 53 | // and a server bound to 127.0.0.1 is one the tunnel cannot reach. |
| 54 | final server = await HttpServer.bind(InternetAddress.anyIPv4, port); |
| 55 | stdout.writeln('serving ${root.path} on :$port'); |
| 56 | |
| 57 | await for (final request in server) { |
| 58 | var path = Uri.decodeComponent(request.uri.path); |
| 59 | if (path.endsWith('/')) path = '${path}index.html'; |
| 60 | while (path.startsWith('/')) path = path.substring(1); |
| 61 | // The one rule that is not "read the file": a path that escapes the root |
| 62 | // is refused rather than resolved. This binds to 0.0.0.0, which in a |
| 63 | // container means the internet is one tunnel away. |
| 64 | final file = File('${root.path}/$path').absolute; |
| 65 | final resolved = file.path; |
| 66 | if (!resolved.startsWith(root.path)) { |
| 67 | request.response.statusCode = HttpStatus.forbidden; |
| 68 | await request.response.close(); |
| 69 | continue; |
| 70 | } |
| 71 | if (!await file.exists()) { |
| 72 | request.response.statusCode = HttpStatus.notFound; |
| 73 | request.response.write('not found: $path'); |
| 74 | await request.response.close(); |
| 75 | continue; |
| 76 | } |
| 77 | request.response.headers.contentType = ContentType.parse(_typeOf(resolved)); |
| 78 | // A single-page app served from a build directory: nothing here is |
| 79 | // versioned by name, so every response is one the browser must re-ask for. |
| 80 | request.response.headers.set('cache-control', 'no-store'); |
| 81 | await request.response.addStream(file.openRead()); |
| 82 | await request.response.close(); |
| 83 | } |
| 84 | } |