/// The Spartan protocol client: plaintext TCP on port 300, a request line of /// ` ` CRLF optionally followed by that many /// bytes of body, and a response of ` ` CRLF plus a body on /// success. There is no TLS, so no identity and no trust to decide: anyone /// on the line can read or write the conversation. library; import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; /// The whole response of one request, redirects already followed. class SpartanResponse { const SpartanResponse({ required this.url, required this.status, required this.meta, this.body, }); /// The address the content finally came from, after redirects. final Uri url; /// The single-digit status code: 2 success, 3 redirect, 4 client error, /// 5 server error. final int status; /// The MIME type on success, the target address on a redirect, an error /// message otherwise. final String meta; /// The response body, present on a success. final Uint8List? body; bool get isSuccess => status == 2; bool get isRedirect => status == 3; } /// A connection, protocol or timeout failure. One failure to the reader. class SpartanException implements Exception { const SpartanException(this.message); final String message; @override String toString() => message; } /// The same address redirected more times than the client follows. class SpartanTooManyRedirects implements Exception { const SpartanTooManyRedirects(); } class SpartanClient { SpartanClient({this.timeout = const Duration(seconds: 20)}); static const defaultPort = 300; static const maxRedirects = 5; final Duration timeout; /// Fetches [url], following status 3 redirects up to [maxRedirects] times. /// [upload] becomes the request body, the protocol's only way of sending /// data to a server. Future fetch(Uri url, {Uint8List? upload}) async { var current = url; for (var hops = 0;; hops++) { final response = await _requestOnce(current, upload); if (!response.isRedirect) return response; if (hops >= maxRedirects) throw const SpartanTooManyRedirects(); final target = current.resolve(response.meta); // Only spartan addresses are followed; anything else the redirect // points to is left for the reader to open as its own navigation. if (!target.hasScheme || target.scheme != 'spartan' || target.host.isEmpty) { return response; } current = target; } } Future _requestOnce(Uri url, Uint8List? upload) async { final host = url.host; final port = url.hasPort ? url.port : defaultPort; final path = url.path.isEmpty ? '/' : url.path + (url.hasQuery ? '?${url.query}' : ''); final Socket socket; try { socket = await Socket.connect(host, port, timeout: timeout); } catch (_) { throw const SpartanException('could not connect'); } try { final length = upload?.length ?? 0; socket.write('$host $path $length\r\n'); if (upload != null) socket.add(upload); await socket.flush(); final raw = await _readToEnd(socket).timeout(timeout); final headerEnd = _indexOfCrlf(raw); if (headerEnd < 1) throw const SpartanException('response without a status line'); final header = String.fromCharCodes(raw.sublist(0, headerEnd)); if (header.length < 3 || header.codeUnitAt(1) != 0x20) { throw const SpartanException('malformed status line'); } final status = header.codeUnitAt(0) - 0x30; if (status < 0 || status > 9) throw const SpartanException('malformed status line'); final body = status == 2 ? Uint8List.fromList(raw.sublist(headerEnd + 2)) : null; return SpartanResponse(url: url, status: status, meta: header.substring(2), body: body); } finally { socket.destroy(); } } static Future _readToEnd(Socket socket) async { final builder = BytesBuilder(copy: false); await for (final chunk in socket) { builder.add(chunk); } return builder.toBytes(); } static int _indexOfCrlf(Uint8List bytes) { for (var i = 0; i + 1 < bytes.length; i++) { if (bytes[i] == 0x0d && bytes[i + 1] == 0x0a) return i; } return -1; } }