From b1339d8f57c3d97621562842ed8c652934a6966b Mon Sep 17 00:00:00 2001 From: TonyTonyCoder11 Date: Sat, 24 Oct 2026 04:26:36 +0200 Subject: [PATCH 1/6] Read the messages of a WebSocket and of an event stream A WebSocket upgrade used to end the call at once. The capture now wraps the detached socket and reads the frames in both directions, joining fragments, decompressing permessage-deflate messages and listing ping, pong and close frames as messages of their own. The call stays open until both sides have said goodbye, the other side stops sending, or the socket is destroyed or fails, and it keeps going when the app closes the client that opened it. A text/event-stream response is parsed as it arrives, one event per blank line. Either way a call keeps the latest thousand messages and a megabyte of payload, applies the redaction to text, and counts all of them. The address of a WebSocket is recorded as the app wrote it, with ws or wss. --- packages/flutwire/lib/flutwire.dart | 1 + .../lib/src/capture/messages/message_log.dart | 48 +++ .../lib/src/capture/messages/sse_parser.dart | 178 +++++++++ .../capture/messages/websocket_reader.dart | 273 +++++++++++++ .../flutwire/lib/src/capture/recorder.dart | 121 ++++++ .../lib/src/capture/wire/wire_io.dart | 50 ++- .../lib/src/capture/wire/wire_socket.dart | 194 ++++++++++ packages/flutwire/lib/src/model/call.dart | 12 + packages/flutwire/lib/src/model/message.dart | 247 ++++++++++++ .../test/capture/message_log_test.dart | 94 +++++ .../flutwire/test/capture/messages_test.dart | 366 ++++++++++++++++++ .../test/capture/sse_parser_test.dart | 147 +++++++ .../test/capture/websocket_capture_test.dart | 298 ++++++++++++++ .../test/capture/websocket_reader_test.dart | 337 ++++++++++++++++ .../test/capture/wire_socket_test.dart | 192 +++++++++ .../flutwire/test/model/message_test.dart | 219 +++++++++++ .../test/support/websocket_frames.dart | 37 ++ 17 files changed, 2805 insertions(+), 9 deletions(-) create mode 100644 packages/flutwire/lib/src/capture/messages/message_log.dart create mode 100644 packages/flutwire/lib/src/capture/messages/sse_parser.dart create mode 100644 packages/flutwire/lib/src/capture/messages/websocket_reader.dart create mode 100644 packages/flutwire/lib/src/capture/wire/wire_socket.dart create mode 100644 packages/flutwire/lib/src/model/message.dart create mode 100644 packages/flutwire/test/capture/message_log_test.dart create mode 100644 packages/flutwire/test/capture/messages_test.dart create mode 100644 packages/flutwire/test/capture/sse_parser_test.dart create mode 100644 packages/flutwire/test/capture/websocket_capture_test.dart create mode 100644 packages/flutwire/test/capture/websocket_reader_test.dart create mode 100644 packages/flutwire/test/capture/wire_socket_test.dart create mode 100644 packages/flutwire/test/model/message_test.dart create mode 100644 packages/flutwire/test/support/websocket_frames.dart diff --git a/packages/flutwire/lib/flutwire.dart b/packages/flutwire/lib/flutwire.dart index b01f59a..1b83303 100644 --- a/packages/flutwire/lib/flutwire.dart +++ b/packages/flutwire/lib/flutwire.dart @@ -14,6 +14,7 @@ export 'src/flutwire.dart' show Flutwire; export 'src/model/body.dart'; export 'src/model/call.dart'; export 'src/model/headers.dart'; +export 'src/model/message.dart'; export 'src/model/multipart.dart'; export 'src/store/preferences.dart'; export 'src/store/store.dart'; diff --git a/packages/flutwire/lib/src/capture/messages/message_log.dart b/packages/flutwire/lib/src/capture/messages/message_log.dart new file mode 100644 index 0000000..556ae8d --- /dev/null +++ b/packages/flutwire/lib/src/capture/messages/message_log.dart @@ -0,0 +1,48 @@ +import 'dart:collection'; + +import '../../model/message.dart'; + +/// The messages of one call, kept within bounds. +/// +/// A conversation can run for hours, so the log keeps the latest messages and +/// lets go of the oldest, and it counts all of them. +final class MessageLog { + /// Creates a log that keeps at most [maxMessages] messages, and at most + /// [maxBytes] of payload between them. + MessageLog({this.maxMessages = 1000, this.maxBytes = 1 << 20}); + + /// The most messages the log keeps. + final int maxMessages; + + /// The most payload bytes the messages of the log keep together. A single + /// message that is bigger than that is kept all the same, on its own. + final int maxBytes; + + final Queue _items = Queue(); + int _total = 0; + int _bytes = 0; + + /// How many messages were added, kept or not. It is also the index the next + /// one gets. + int get total => _total; + + /// Adds [message] and lets go of the oldest ones while the log is over a + /// limit. + void add(FlutwireMessage message) { + _items.add(message); + _total++; + _bytes += message.bytes.length; + while (_items.length > maxMessages || + (_bytes > maxBytes && _items.length > 1)) { + _bytes -= _items.removeFirst().bytes.length; + } + } + + /// What the log holds now, as the messages of a call of the given [kind]. + FlutwireMessages snapshot(FlutwireConversationKind kind) => FlutwireMessages( + kind: kind, + items: List.unmodifiable(_items), + total: _total, + keptBytes: _bytes, + ); +} diff --git a/packages/flutwire/lib/src/capture/messages/sse_parser.dart b/packages/flutwire/lib/src/capture/messages/sse_parser.dart new file mode 100644 index 0000000..76a6d45 --- /dev/null +++ b/packages/flutwire/lib/src/capture/messages/sse_parser.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; + +/// A server-sent event, as the stream wrote it. +final class SseEvent { + /// Creates an event. + const SseEvent({ + required this.data, + required this.totalBytes, + this.event, + this.id, + }); + + /// The `data:` lines joined by a line feed, cut at the limit the parser was + /// given. + final String data; + + /// The size of the whole of [data] in UTF-8 bytes. + final int totalBytes; + + /// The `event:` field, when the event had one. + final String? event; + + /// The last `id:` the stream sent, which stays in force for the events after + /// it. + final String? id; +} + +/// Reads a `text/event-stream` as it arrives and reports each event once it is +/// complete. +/// +/// It follows the rules of the HTML standard: lines end with a line feed, a +/// carriage return or both, a line that starts with a colon is a comment, an +/// event ends at an empty line, and a leading byte order mark is ignored. +final class SseParser { + /// Creates a parser that calls [onEvent] for each event. A `data` field + /// longer than [maxChars] is cut there, and its real size is reported. + SseParser(this._onEvent, {this.maxChars = 250000}) { + _decoder = const Utf8Decoder( + allowMalformed: true, + ).startChunkedConversion(_TextSink(_text)); + } + + final void Function(SseEvent event) _onEvent; + + /// How many characters of an event's data are kept. + final int maxChars; + + late final Sink> _decoder; + final StringBuffer _line = StringBuffer(); + final StringBuffer _data = StringBuffer(); + bool _first = true; + bool _afterCarriageReturn = false; + bool _hasData = false; + int _dataBytes = 0; + int _kept = 0; + String? _eventType; + String? _lastId; + + /// Feeds the next [chunk] of the stream. + void add(List chunk) => _decoder.add(chunk); + + /// Says the stream ended. An event that was not completed by an empty line + /// is dropped, as the standard asks. + void close() => _decoder.close(); + + void _text(String chunk) { + var text = chunk; + if (_first) { + _first = false; + if (text.startsWith('')) text = text.substring(1); + } + for (var i = 0; i < text.length; i++) { + final unit = text.codeUnitAt(i); + if (_afterCarriageReturn) { + _afterCarriageReturn = false; + if (unit == 0x0A) continue; + } + if (unit == 0x0D) { + _endOfLine(); + _afterCarriageReturn = true; + } else if (unit == 0x0A) { + _endOfLine(); + } else { + _line.writeCharCode(unit); + } + } + } + + void _endOfLine() { + final line = _line.toString(); + _line.clear(); + if (line.isEmpty) { + _dispatch(); + return; + } + if (line.startsWith(':')) return; + final colon = line.indexOf(':'); + final name = colon < 0 ? line : line.substring(0, colon); + var value = colon < 0 ? '' : line.substring(colon + 1); + if (value.startsWith(' ')) value = value.substring(1); + switch (name) { + case 'event': + _eventType = value; + case 'data': + _addData(value); + case 'id': + if (!value.contains('\u0000')) _lastId = value; + } + } + + void _addData(String value) { + if (_hasData) { + _dataBytes += 1; + _write('\n'); + } + _hasData = true; + _dataBytes += _utf8Length(value); + _write(value); + } + + void _write(String text) { + final room = maxChars - _kept; + if (room <= 0) return; + final piece = text.length <= room ? text : text.substring(0, room); + _data.write(piece); + _kept += piece.length; + } + + void _dispatch() { + final had = _hasData; + final type = _eventType; + final data = _data.toString(); + final bytes = _dataBytes; + _data.clear(); + _hasData = false; + _dataBytes = 0; + _kept = 0; + _eventType = null; + if (!had) return; + _onEvent( + SseEvent( + data: data, + totalBytes: bytes, + event: type == null || type.isEmpty ? null : type, + id: _lastId == null || _lastId!.isEmpty ? null : _lastId, + ), + ); + } + + static int _utf8Length(String text) { + var bytes = 0; + for (final unit in text.codeUnits) { + if (unit < 0x80) { + bytes += 1; + } else if (unit < 0x800) { + bytes += 2; + } else if (unit >= 0xD800 && unit <= 0xDFFF) { + // Half of a surrogate pair, which is four bytes in all. + bytes += 2; + } else { + bytes += 3; + } + } + return bytes; + } +} + +class _TextSink implements Sink { + _TextSink(this._onText); + + final void Function(String text) _onText; + + @override + void add(String data) => _onText(data); + + @override + void close() {} +} diff --git a/packages/flutwire/lib/src/capture/messages/websocket_reader.dart b/packages/flutwire/lib/src/capture/messages/websocket_reader.dart new file mode 100644 index 0000000..e4fef4a --- /dev/null +++ b/packages/flutwire/lib/src/capture/messages/websocket_reader.dart @@ -0,0 +1,273 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import '../../model/message.dart'; + +/// What the WebSocket handshake agreed about compression, which decides how the +/// messages of each direction are read. +final class WebSocketDeflate { + /// Creates the agreement. + const WebSocketDeflate({ + this.serverNoContextTakeover = false, + this.clientNoContextTakeover = false, + }); + + /// Reads the `Sec-WebSocket-Extensions` header of the handshake response. + /// Returns null when `permessage-deflate` was not agreed. + static WebSocketDeflate? parse(String? header) { + if (header == null) return null; + for (final extension in header.split(',')) { + final parts = extension.split(';').map((p) => p.trim().toLowerCase()); + if (parts.isEmpty || parts.first != 'permessage-deflate') continue; + final parameters = parts.skip(1).toSet(); + return WebSocketDeflate( + serverNoContextTakeover: parameters.contains( + 'server_no_context_takeover', + ), + clientNoContextTakeover: parameters.contains( + 'client_no_context_takeover', + ), + ); + } + return null; + } + + /// Whether the server starts a fresh compression window for every message. + final bool serverNoContextTakeover; + + /// Whether the client starts a fresh compression window for every message. + final bool clientNoContextTakeover; +} + +/// Reads the frames one side of a WebSocket connection wrote, from the bytes +/// as they cross the socket, and reports each message once it is complete. +/// +/// A message that arrives in several frames is joined, a compressed one is +/// decompressed, and the ping, pong and close frames in between are reported +/// as messages of their own. Only the first [maxBytes] of a payload are kept, +/// and the size of the whole is reported. +/// +/// Anything it cannot make sense of stops it for good: it never throws, and a +/// connection it cannot read is simply not listed. +final class WebSocketReader { + /// Creates a reader that calls [onMessage] for each message. + /// + /// [deflate] says whether `permessage-deflate` was agreed, and + /// [noContextTakeover] whether this side starts a fresh window for every + /// message. + WebSocketReader({ + required this.onMessage, + this.maxBytes = 1 << 20, + this.deflate = false, + this.noContextTakeover = false, + }); + + /// Called with each complete message. + final void Function( + FlutwireMessageKind kind, + Uint8List payload, + int totalBytes, { + required bool undecoded, + }) + onMessage; + + /// How many bytes of a payload are kept. + final int maxBytes; + + /// Whether the messages may be compressed. + final bool deflate; + + /// Whether every compressed message stands alone. + final bool noContextTakeover; + + /// Whether the bytes stopped making sense, after which nothing is reported. + bool get broken => _broken; + + static const List _tail = [0x00, 0x00, 0xff, 0xff]; + + final BytesBuilder _head = BytesBuilder(copy: false); + final Uint8List _mask = Uint8List(4); + final BytesBuilder _kept = BytesBuilder(copy: false); + final BytesBuilder _control = BytesBuilder(copy: false); + bool _broken = false; + int _headNeeded = 2; + bool _inPayload = false; + bool _fin = false; + bool _masked = false; + int _opcode = 0; + int _remaining = 0; + int _maskOffset = 0; + FlutwireMessageKind? _kind; + bool _compressed = false; + bool _undecoded = false; + int _total = 0; + RawZLibFilter? _inflater; + bool _inflateBroken = false; + + /// Feeds the next [chunk] of bytes of this side of the connection. + void add(List chunk) { + if (_broken) return; + try { + _read(chunk); + } on Object { + _broken = true; + } + } + + void _read(List chunk) { + var at = 0; + while (at < chunk.length && !_broken) { + if (!_inPayload) { + _head.addByte(chunk[at++]); + if (_head.length == 2) _headNeeded = _lengthOfHead(_head.toBytes()); + if (_head.length >= _headNeeded) _startFrame(_head.takeBytes()); + continue; + } + final take = chunk.length - at < _remaining + ? chunk.length - at + : _remaining; + final bytes = Uint8List(take); + for (var i = 0; i < take; i++) { + final byte = chunk[at + i]; + bytes[i] = _masked ? byte ^ _mask[(_maskOffset + i) & 3] : byte; + } + _maskOffset += take; + at += take; + _remaining -= take; + _payload(bytes); + if (_remaining == 0) _endFrame(); + } + } + + /// How many bytes the header of a frame takes, from its first two. + static int _lengthOfHead(Uint8List first) { + final length = first[1] & 0x7f; + final extended = length == 126 ? 2 : (length == 127 ? 8 : 0); + final mask = (first[1] & 0x80) != 0 ? 4 : 0; + return 2 + extended + mask; + } + + void _startFrame(Uint8List head) { + _headNeeded = 2; + _fin = (head[0] & 0x80) != 0; + final rsv1 = (head[0] & 0x40) != 0; + final reserved = (head[0] & 0x30) != 0; + _opcode = head[0] & 0x0f; + _masked = (head[1] & 0x80) != 0; + var length = head[1] & 0x7f; + var at = 2; + if (length == 126) { + length = (head[2] << 8) | head[3]; + at = 4; + } else if (length == 127) { + length = 0; + for (var i = 0; i < 8; i++) { + length = (length << 8) | head[2 + i]; + } + at = 10; + } + if (_masked) { + for (var i = 0; i < 4; i++) { + _mask[i] = head[at + i]; + } + } + _maskOffset = 0; + final control = _opcode >= 8; + final invalid = + length < 0 || + reserved || + (rsv1 && (!deflate || control || _opcode == 0)) || + (control && (!_fin || length > 125)) || + (_opcode > 2 && !control) || + (_opcode > 10) || + (_opcode == 0 && _kind == null) || + ((_opcode == 1 || _opcode == 2) && _kind != null); + if (invalid) { + _broken = true; + return; + } + if (_opcode == 1 || _opcode == 2) { + _kind = _opcode == 1 + ? FlutwireMessageKind.text + : FlutwireMessageKind.binary; + _compressed = rsv1; + _undecoded = false; + _total = 0; + _kept.clear(); + } + _remaining = length; + _inPayload = true; + if (_remaining == 0) _endFrame(); + } + + void _payload(Uint8List bytes) { + if (_opcode >= 8) { + _control.add(bytes); + } else if (_compressed) { + _inflate(bytes); + } else { + _total += bytes.length; + _keep(bytes); + } + } + + void _endFrame() { + _inPayload = false; + if (_opcode >= 8) { + final payload = _control.takeBytes(); + final kind = switch (_opcode) { + 8 => FlutwireMessageKind.close, + 9 => FlutwireMessageKind.ping, + _ => FlutwireMessageKind.pong, + }; + onMessage(kind, payload, payload.length, undecoded: false); + return; + } + if (!_fin) return; + if (_compressed) { + _inflate(_tail, tail: true); + if (noContextTakeover) _inflater = null; + } + final kind = _kind!; + final payload = _kept.takeBytes(); + final total = _total; + final undecoded = _undecoded; + _kind = null; + _compressed = false; + _total = 0; + onMessage(kind, payload, total, undecoded: undecoded); + } + + void _keep(List bytes) { + final room = maxBytes - _kept.length; + if (room <= 0) return; + _kept.add(bytes.length <= room ? bytes : bytes.sublist(0, room)); + } + + void _inflate(List data, {bool tail = false}) { + if (_inflateBroken) { + // The window is lost, so what follows cannot be read. The bytes are kept + // as they came, and the message says they are not decoded. + _undecoded = true; + if (!tail) { + _total += data.length; + _keep(data); + } + return; + } + try { + final filter = _inflater ??= RawZLibFilter.inflateFilter(raw: true); + filter.process(data, 0, data.length); + while (true) { + final out = filter.processed(); + if (out == null || out.isEmpty) break; + _total += out.length; + _keep(out); + } + } on Object { + _inflateBroken = true; + _inflater = null; + _undecoded = true; + } + } +} diff --git a/packages/flutwire/lib/src/capture/recorder.dart b/packages/flutwire/lib/src/capture/recorder.dart index f016987..d39cf04 100644 --- a/packages/flutwire/lib/src/capture/recorder.dart +++ b/packages/flutwire/lib/src/capture/recorder.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/foundation.dart' show internal; @@ -7,7 +8,10 @@ import '../config.dart'; import '../model/body.dart'; import '../model/call.dart'; import '../model/headers.dart'; +import '../model/message.dart'; import 'graphql.dart'; +import 'messages/message_log.dart'; +import 'messages/sse_parser.dart'; /// What a recorder needs from the engine that created it. abstract interface class FlutwireRecorderHost { @@ -57,6 +61,11 @@ final class FlutwireCallRecorder { String? _contentType; DateTime? _lastProgress; int _publishedResponseBytes = -1; + final MessageLog _messages = MessageLog(); + FlutwireConversationKind? _conversation; + DateTime? _lastMessagePublish; + Timer? _messageTimer; + SseParser? _sse; /// The latest published snapshot. FlutwireCall get snapshot => _call; @@ -105,6 +114,10 @@ final class FlutwireCallRecorder { }) => _guard(() { if (_finished) return; _finishRequest(); + _sse = + (headers.contentType ?? '').toLowerCase().contains('text/event-stream') + ? SseParser(_onEvent, maxChars: _host.config.maxBodyBytes) + : null; final redacted = _host.config.redaction.applyToHeaders(headers); _call = _call.copyWith( firstByteAt: _host.now(), @@ -131,6 +144,7 @@ final class FlutwireCallRecorder { void responseBody(List chunk) => _guard(() { if (_finished) return; _res.add(chunk); + _sse?.add(chunk); final now = _host.now(); final last = _lastProgress; if (last != null && now.difference(last) < _progressInterval) return; @@ -138,6 +152,60 @@ final class FlutwireCallRecorder { _publishProgress(); }); + /// Says that the call carries a conversation from now on, which a WebSocket + /// does after its handshake. The call then has a list of messages, empty + /// until the first one arrives. Reporting a message does the same. + void startConversation(FlutwireConversationKind kind) => _guard(() { + if (_finished || _conversation != null) return; + _conversation = kind; + if (kind == FlutwireConversationKind.webSocket) { + _call = _call.copyWith(uri: _webSocketAddress(_call.uri)); + } + _publishMessages(force: true); + }); + + /// Adds a message to the conversation: a WebSocket message or frame, or a + /// server-sent event. [sent] is true for what the app sent. + /// + /// Only the first `maxBodyBytes` of [payload] are kept, and [totalBytes] is + /// the size of the whole, which defaults to the length of [payload]. Only the + /// latest messages are kept, to bound what a conversation that runs for hours + /// can hold; the count of all of them is kept. + void message({ + required bool sent, + required FlutwireMessageKind kind, + required List payload, + int? totalBytes, + String? event, + String? eventId, + bool undecoded = false, + }) => _guard(() { + if (_finished) return; + _conversation ??= kind == FlutwireMessageKind.event + ? FlutwireConversationKind.eventStream + : FlutwireConversationKind.webSocket; + final cap = _host.config.maxBodyBytes; + var bytes = payload is Uint8List ? payload : Uint8List.fromList(payload); + if (bytes.length > cap) bytes = Uint8List.fromList(bytes.sublist(0, cap)); + if (kind == FlutwireMessageKind.text || kind == FlutwireMessageKind.event) { + bytes = _host.config.redaction.applyToBody(bytes, contentType: null); + } + _messages.add( + FlutwireMessage( + index: _messages.total, + sent: sent, + at: _host.now(), + kind: kind, + bytes: bytes, + totalBytes: totalBytes ?? payload.length, + event: event, + eventId: eventId, + undecoded: undecoded, + ), + ); + _publishMessages(force: false); + }); + /// Passes [source], the bytes of the request body, through unchanged while /// recording them. Use it for a body that is only available as a stream. Stream> observeRequest(Stream> source) => @@ -201,16 +269,69 @@ final class FlutwireCallRecorder { static const Duration _progressInterval = Duration(milliseconds: 200); + /// The address of a WebSocket as the app wrote it. The handshake is an HTTP + /// request, so the capture is handed an `http` or `https` address, with a + /// port of 0 when the app gave none and an empty fragment. + static Uri _webSocketAddress(Uri handshake) => Uri( + scheme: switch (handshake.scheme) { + 'https' => 'wss', + 'http' => 'ws', + final other => other, + }, + userInfo: handshake.userInfo.isEmpty ? null : handshake.userInfo, + host: handshake.host, + port: handshake.hasPort && handshake.port != 0 ? handshake.port : null, + path: handshake.path.isEmpty ? '/' : handshake.path, + query: handshake.query.isEmpty ? null : handshake.query, + fragment: handshake.fragment.isEmpty ? null : handshake.fragment, + ); + + void _onEvent(SseEvent event) => message( + sent: false, + kind: FlutwireMessageKind.event, + payload: Uint8List.fromList(utf8.encode(event.data)), + totalBytes: event.totalBytes, + event: event.event, + eventId: event.id, + ); + + /// Puts the messages in a new snapshot. Messages come in bursts, so this + /// publishes at most every [_progressInterval] and leaves the rest to a timer + /// that fires once the burst is over. + void _publishMessages({required bool force}) { + final kind = _conversation; + if (kind == null) return; + final now = _host.now(); + final last = _lastMessagePublish; + if (!force && last != null && now.difference(last) < _progressInterval) { + _messageTimer ??= Timer(_progressInterval, () { + _messageTimer = null; + _guard(() => _publishMessages(force: true)); + }); + return; + } + _messageTimer?.cancel(); + _messageTimer = null; + _lastMessagePublish = now; + _call = _call.copyWith(messages: _messages.snapshot(kind)); + _publish(finished: false); + } + void _end(FlutwireCallState state, {FlutwireError? error}) => _guard(() { if (_finished) return; _finishRequest(); _finished = true; + _messageTimer?.cancel(); + _messageTimer = null; + _sse?.close(); final res = _call.response; + final conversation = _conversation; _call = _call.copyWith( state: state, endedAt: _host.now(), error: error, response: res?.copyWith(body: _responseBody(res.headers.contentType)), + messages: conversation == null ? null : _messages.snapshot(conversation), ); _publish(finished: true); _done.complete(); diff --git a/packages/flutwire/lib/src/capture/wire/wire_io.dart b/packages/flutwire/lib/src/capture/wire/wire_io.dart index 10a3603..e909a5e 100644 --- a/packages/flutwire/lib/src/capture/wire/wire_io.dart +++ b/packages/flutwire/lib/src/capture/wire/wire_io.dart @@ -5,7 +5,9 @@ import 'dart:io'; import '../../engine.dart'; import '../../model/call.dart'; import '../../model/headers.dart'; +import '../messages/websocket_reader.dart'; import '../recorder.dart'; +import 'wire_socket.dart'; /// Records every call made through `dart:io`, with no setup in the client. /// @@ -200,7 +202,9 @@ final class FlutwireHttpClient implements HttpClient { if (recorder == null) return request; _open.add(recorder); unawaited(recorder.done.then((_) => _open.remove(recorder))); - return _WireRequest(request, recorder); + // A WebSocket outlives the client that opened it, which an app closes once + // the handshake is over. + return _WireRequest(request, recorder, () => _open.remove(recorder)); } @override @@ -270,10 +274,11 @@ final class FlutwireHttpClient implements HttpClient { } class _WireRequest implements HttpClientRequest { - _WireRequest(this._inner, this._recorder); + _WireRequest(this._inner, this._recorder, this._onDetached); final HttpClientRequest _inner; final FlutwireCallRecorder _recorder; + final void Function() _onDetached; Future? _closing; HttpClientResponse? _wrapped; @@ -316,7 +321,7 @@ class _WireRequest implements HttpClientRequest { Future get done => _inner.done.then(_wrap); HttpClientResponse _wrap(HttpClientResponse response) => - _wrapped ??= _WireResponse(response, _recorder); + _wrapped ??= _WireResponse(response, _recorder, _onDetached); @override void abort([Object? exception, StackTrace? stackTrace]) { @@ -386,7 +391,7 @@ class _WireRequest implements HttpClientRequest { } class _WireResponse extends Stream> implements HttpClientResponse { - _WireResponse(this._inner, this._recorder) { + _WireResponse(this._inner, this._recorder, this._onDetached) { _recorder.responseHead( statusCode: _inner.statusCode, reasonPhrase: _inner.reasonPhrase, @@ -412,6 +417,7 @@ class _WireResponse extends Stream> implements HttpClientResponse { final HttpClientResponse _inner; final FlutwireCallRecorder _recorder; + final void Function() _onDetached; /// The body as the app reads it: the same bytes, recorded on the way past. late final Stream> _observed = _recorder.observeResponse(_inner); @@ -440,14 +446,40 @@ class _WireResponse extends Stream> implements HttpClientResponse { @override int get statusCode => _inner.statusCode; - /// A protocol switch, such as a WebSocket upgrade, hands the socket away. The - /// call is over as far as HTTP is concerned; the frames are not recorded. + /// A WebSocket upgrade hands the socket away. The call stays open and lists + /// the messages that cross the socket until it is closed. Any other protocol + /// switch is over as far as HTTP is concerned, and its bytes are not read. @override - Future detachSocket() { - _recorder.complete(); - return _inner.detachSocket(); + Future detachSocket() async { + if (!_isWebSocket) { + _recorder.complete(); + return _inner.detachSocket(); + } + // The app owns the socket from here and closes it. + // ignore: close_sinks + final Socket socket; + try { + socket = await _inner.detachSocket(); + } catch (error, stack) { + _recorder.fail(error, stack); + rethrow; + } + _onDetached(); + return WireSocket( + socket, + _recorder, + deflate: WebSocketDeflate.parse( + _inner.headers['sec-websocket-extensions']?.join(','), + ), + ); } + bool get _isWebSocket => + _inner.statusCode == HttpStatus.switchingProtocols && + (_inner.headers[HttpHeaders.upgradeHeader] ?? const []).any( + (value) => value.toLowerCase() == 'websocket', + ); + @override Future redirect([ String? method, diff --git a/packages/flutwire/lib/src/capture/wire/wire_socket.dart b/packages/flutwire/lib/src/capture/wire/wire_socket.dart new file mode 100644 index 0000000..37c2553 --- /dev/null +++ b/packages/flutwire/lib/src/capture/wire/wire_socket.dart @@ -0,0 +1,194 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../../model/message.dart'; +import '../messages/websocket_reader.dart'; +import '../recorder.dart'; + +/// A socket that was an HTTP connection and became a WebSocket. It hands the +/// bytes through unchanged, in both directions, while a reader on each side +/// finds the frames and reports them to the recorder of the call. +/// +/// The call stays open for as long as the connection does. It ends when both +/// sides have sent a close frame, when the other side stops sending, when the +/// socket is destroyed, or when it fails. +final class WireSocket extends Stream implements Socket { + /// Wraps [_inner], reporting the messages that cross it to [_recorder]. + /// [deflate] is what the handshake agreed about compression. + WireSocket(this._inner, this._recorder, {WebSocketDeflate? deflate}) { + _recorder.startConversation(FlutwireConversationKind.webSocket); + _incoming = _reader( + sent: false, + deflate: deflate != null, + noContextTakeover: deflate?.serverNoContextTakeover ?? false, + ); + _outgoing = _reader( + sent: true, + deflate: deflate != null, + noContextTakeover: deflate?.clientNoContextTakeover ?? false, + ); + // `done` is the end of the writing side, which closing the sink reaches + // while the other side may still be saying goodbye, so only a failure ends + // the conversation here. + unawaited( + _inner.done.then( + (_) {}, + onError: (Object error, StackTrace stack) => + _recorder.fail(error, stack), + ), + ); + } + + final Socket _inner; + final FlutwireCallRecorder _recorder; + late final WebSocketReader _incoming; + late final WebSocketReader _outgoing; + bool _closeSent = false; + bool _closeReceived = false; + + WebSocketReader _reader({ + required bool sent, + required bool deflate, + required bool noContextTakeover, + }) => WebSocketReader( + onMessage: (kind, payload, total, {required undecoded}) { + _recorder.message( + sent: sent, + kind: kind, + payload: payload, + totalBytes: total, + undecoded: undecoded, + ); + if (kind != FlutwireMessageKind.close) return; + // The closing handshake is over once each side has said goodbye, which + // is before the connection itself goes away, if it ever does. + if (sent) { + _closeSent = true; + } else { + _closeReceived = true; + } + if (_closeSent && _closeReceived) _recorder.complete(); + }, + deflate: deflate, + noContextTakeover: noContextTakeover, + ); + + /// The bytes as the app reads them: the same, seen on the way past. + late final Stream _tapped = _inner.transform( + StreamTransformer.fromHandlers( + handleData: (chunk, sink) { + _incoming.add(chunk); + sink.add(chunk); + }, + handleError: (error, stack, sink) { + _recorder.fail(error, stack); + sink.addError(error, stack); + }, + handleDone: (sink) { + _recorder.complete(); + sink.close(); + }, + ), + ); + + @override + StreamSubscription listen( + void Function(Uint8List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => _tapped.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + Encoding get encoding => _inner.encoding; + @override + set encoding(Encoding value) => _inner.encoding = value; + + @override + void add(List data) { + _outgoing.add(data); + _inner.add(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) => + _inner.addError(error, stackTrace); + + @override + Future addStream(Stream> stream) => _inner.addStream( + stream.map((chunk) { + _outgoing.add(chunk); + return chunk; + }), + ); + + @override + Future close() => _inner.close(); + + @override + Future get done => _inner.done; + + @override + Future flush() => _inner.flush(); + + @override + void write(Object? object) { + final text = '$object'; + if (text.isEmpty) return; + add(encoding.encode(text)); + } + + @override + void writeAll(Iterable objects, [String separator = '']) { + var first = true; + for (final object in objects) { + if (!first && separator.isNotEmpty) write(separator); + first = false; + write(object); + } + } + + @override + void writeCharCode(int charCode) => write(String.fromCharCode(charCode)); + + @override + void writeln([Object? object = '']) { + write(object); + write('\n'); + } + + @override + InternetAddress get address => _inner.address; + + @override + InternetAddress get remoteAddress => _inner.remoteAddress; + + @override + int get port => _inner.port; + + @override + int get remotePort => _inner.remotePort; + + @override + void destroy() { + _inner.destroy(); + _recorder.complete(); + } + + @override + bool setOption(SocketOption option, bool enabled) => + _inner.setOption(option, enabled); + + @override + Uint8List getRawOption(RawSocketOption option) => _inner.getRawOption(option); + + @override + void setRawOption(RawSocketOption option) => _inner.setRawOption(option); +} diff --git a/packages/flutwire/lib/src/model/call.dart b/packages/flutwire/lib/src/model/call.dart index b456d3b..596b956 100644 --- a/packages/flutwire/lib/src/model/call.dart +++ b/packages/flutwire/lib/src/model/call.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'body.dart'; import 'headers.dart'; +import 'message.dart'; /// Where in the networking stack a call was recorded. enum FlutwireSource { @@ -320,6 +321,7 @@ final class FlutwireCall { this.graphql, this.pinned = false, this.resentFrom, + this.messages, }); /// Restores a summary written by [toJson]. The bodies come back unloaded. @@ -377,6 +379,9 @@ final class FlutwireCall { : null, pinned: json['pinned'] as bool? ?? false, resentFrom: (json['resentFrom'] as num?)?.toInt(), + messages: json['messages'] is Map + ? FlutwireMessages.fromJson(json['messages']! as Map) + : null, ); } @@ -423,6 +428,10 @@ final class FlutwireCall { /// inspector. The original may be gone from the history by now. final int? resentFrom; + /// The messages of a WebSocket or of a server-sent event stream, which keep + /// arriving after the response head. Null for an ordinary call. + final FlutwireMessages? messages; + /// The response status code, or null before one arrived. int? get statusCode => response?.statusCode; @@ -489,6 +498,7 @@ final class FlutwireCall { FlutwireError? error, FlutwireGraphql? graphql, bool? pinned, + FlutwireMessages? messages, }) => FlutwireCall( id: id, method: method, @@ -504,6 +514,7 @@ final class FlutwireCall { graphql: graphql ?? this.graphql, pinned: pinned ?? this.pinned, resentFrom: resentFrom, + messages: messages ?? this.messages, ); /// A JSON-encodable summary. Body bytes are not included: the store keeps @@ -538,6 +549,7 @@ final class FlutwireCall { 'graphql': graphql?.toJson(), 'pinned': pinned, if (resentFrom != null) 'resentFrom': resentFrom, + if (messages != null) 'messages': messages!.toJson(), }; @override diff --git a/packages/flutwire/lib/src/model/message.dart b/packages/flutwire/lib/src/model/message.dart new file mode 100644 index 0000000..e7e96e4 --- /dev/null +++ b/packages/flutwire/lib/src/model/message.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +/// What a message of a conversation is. +enum FlutwireMessageKind { + /// A WebSocket text message. + text, + + /// A WebSocket binary message. + binary, + + /// A WebSocket ping, which asks the other side to answer with a pong. + ping, + + /// A WebSocket pong. + pong, + + /// A WebSocket close frame, which carries a status code and a reason. + close, + + /// A server-sent event. + event, +} + +/// What kind of conversation a call carries after its response head. +enum FlutwireConversationKind { + /// A WebSocket: messages go both ways until one side closes. + webSocket, + + /// A server-sent event stream: events arrive until the server ends it. + eventStream, +} + +/// One message of a WebSocket conversation, or one server-sent event. +/// +/// Like a body, a message keeps only the first bytes of its payload. [totalBytes] +/// is the real size, so a reader can tell a message that was cut from one that +/// is whole. +@immutable +final class FlutwireMessage { + /// Creates a message. [totalBytes] is the size of the whole payload, which + /// is more than the length of [bytes] when some of it was not kept. + const FlutwireMessage({ + required this.index, + required this.sent, + required this.at, + required this.kind, + required this.bytes, + required this.totalBytes, + this.event, + this.eventId, + this.undecoded = false, + }); + + /// Restores a message written by [toJson]. + factory FlutwireMessage.fromJson(Map json) { + final data = json['data'] as String?; + final bytes = data == null ? Uint8List(0) : base64Decode(data); + return FlutwireMessage( + index: (json['index'] as num?)?.toInt() ?? 0, + sent: json['sent'] as bool? ?? false, + at: DateTime.tryParse(json['at'] as String? ?? '') ?? DateTime.now(), + kind: FlutwireMessageKind.values.firstWhere( + (k) => k.name == json['kind'], + orElse: () => FlutwireMessageKind.binary, + ), + bytes: bytes, + totalBytes: (json['total'] as num?)?.toInt() ?? bytes.length, + event: json['event'] as String?, + eventId: json['id'] as String?, + undecoded: json['undecoded'] as bool? ?? false, + ); + } + + /// The position in the conversation, counting from 0. Messages that were not + /// kept leave a gap. + final int index; + + /// Whether the app sent it. False for what the server sent. + final bool sent; + + /// When the message was complete. + final DateTime at; + + /// What the message is. + final FlutwireMessageKind kind; + + /// The first bytes of the payload, as far as they were kept. A compressed + /// message is kept after decompression. + final Uint8List bytes; + + /// The size of the whole payload in bytes. + final int totalBytes; + + /// The `event:` field of a server-sent event, when it had one. + final String? event; + + /// The `id:` field of a server-sent event, when it had one. + final String? eventId; + + /// Whether the message was compressed and could not be decompressed, so + /// [bytes] are the compressed ones. + final bool undecoded; + + /// Whether some of the payload was not kept. + bool get truncated => totalBytes > bytes.length; + + /// The payload as text, for the kinds that carry text, or null. + String? get text => switch (kind) { + FlutwireMessageKind.text || + FlutwireMessageKind.event => utf8.decode(bytes, allowMalformed: true), + FlutwireMessageKind.close => closeReason, + _ => null, + }; + + /// The status code of a close frame, or null when there is none. + int? get closeCode => kind == FlutwireMessageKind.close && bytes.length >= 2 + ? (bytes[0] << 8) | bytes[1] + : null; + + /// The reason of a close frame, which is empty when there is none. + String? get closeReason => kind == FlutwireMessageKind.close + ? utf8.decode( + bytes.length > 2 ? bytes.sublist(2) : Uint8List(0), + allowMalformed: true, + ) + : null; + + /// What the message holds in a line or two, for a list or a text export: the + /// text of a text message or an event, the code and the reason of a close, + /// and the first bytes, in hex, of anything else. + String get preview { + switch (kind) { + case FlutwireMessageKind.text: + case FlutwireMessageKind.event: + final flat = (text ?? '').replaceAll(RegExp(r'\s+'), ' ').trim(); + return flat.length <= 240 ? flat : '${flat.substring(0, 240)}…'; + case FlutwireMessageKind.close: + final code = closeCode; + final reason = closeReason ?? ''; + return [ + if (code != null) '$code', + if (reason.isNotEmpty) reason, + ].join(' '); + case FlutwireMessageKind.binary: + case FlutwireMessageKind.ping: + case FlutwireMessageKind.pong: + if (bytes.isEmpty) return ''; + final shown = bytes.length <= 16 ? bytes : bytes.sublist(0, 16); + final hex = [ + for (final byte in shown) byte.toRadixString(16).padLeft(2, '0'), + ].join(' '); + return totalBytes > shown.length ? '$hex …' : hex; + } + } + + /// A JSON-encodable form, with the payload in base64. + Map toJson() => { + 'index': index, + 'sent': sent, + 'at': at.toIso8601String(), + 'kind': kind.name, + if (bytes.isNotEmpty) 'data': base64Encode(bytes), + 'total': totalBytes, + if (event != null) 'event': event, + if (eventId != null) 'id': eventId, + if (undecoded) 'undecoded': true, + }; +} + +/// The messages of a call, as far as they were kept. +/// +/// A conversation can run for hours, so only the latest messages are kept, and +/// [total] says how many there were. +@immutable +final class FlutwireMessages { + /// Creates the messages of a conversation. [keptBytes] is the size of the + /// payloads in [items]. + const FlutwireMessages({ + required this.kind, + required this.items, + required this.total, + required this.keptBytes, + }) : loaded = true; + + /// Messages whose payloads live on disk and have not been read yet. It keeps + /// the count and the size, so a list row can still describe them. + const FlutwireMessages.unloaded({ + required this.kind, + required this.total, + required this.keptBytes, + }) : items = const [], + loaded = false; + + /// Restores the description written by [toJson], without the messages. + factory FlutwireMessages.fromJson(Map json) => + FlutwireMessages.unloaded( + kind: FlutwireConversationKind.values.firstWhere( + (k) => k.name == json['kind'], + orElse: () => FlutwireConversationKind.webSocket, + ), + total: (json['total'] as num?)?.toInt() ?? 0, + keptBytes: (json['bytes'] as num?)?.toInt() ?? 0, + ); + + /// Whether this is a WebSocket or an event stream. + final FlutwireConversationKind kind; + + /// The messages kept, oldest first. + final List items; + + /// How many messages there were, kept or not. + final int total; + + /// How many payload bytes were kept, whether or not they are loaded now. + final int keptBytes; + + /// Whether [items] holds the messages. A summary read from disk does not, + /// until the full call is loaded. + final bool loaded; + + /// How many payload bytes are held in memory now. + int get memoryBytes => loaded ? keptBytes : 0; + + /// How many messages were not kept. + int get dropped => loaded ? total - items.length : 0; + + /// A copy that carries [items] in place of the placeholder. + FlutwireMessages withItems(List items) => FlutwireMessages( + kind: kind, + items: items, + total: total, + keptBytes: items.fold(0, (sum, m) => sum + m.bytes.length), + ); + + /// A copy that keeps the count and lets go of the messages. + FlutwireMessages unload() => + FlutwireMessages.unloaded(kind: kind, total: total, keptBytes: keptBytes); + + /// A description without the payloads, for the summary kept on disk. + Map toJson() => { + 'kind': kind.name, + 'total': total, + 'bytes': keptBytes, + }; +} diff --git a/packages/flutwire/test/capture/message_log_test.dart b/packages/flutwire/test/capture/message_log_test.dart new file mode 100644 index 0000000..0d0eeb4 --- /dev/null +++ b/packages/flutwire/test/capture/message_log_test.dart @@ -0,0 +1,94 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/capture/messages/message_log.dart'; + +FlutwireMessage message(int index, {int bytes = 1}) => FlutwireMessage( + index: index, + sent: false, + at: DateTime.utc(2026, 10, 24), + kind: FlutwireMessageKind.binary, + bytes: Uint8List(bytes), + totalBytes: bytes, +); + +void main() { + test('keeps every message while it is within its limits', () { + final log = MessageLog(); + for (var i = 0; i < 5; i++) { + log.add(message(i)); + } + + final snapshot = log.snapshot(FlutwireConversationKind.webSocket); + expect(snapshot.items.map((m) => m.index), [0, 1, 2, 3, 4]); + expect(snapshot.total, 5); + expect(snapshot.keptBytes, 5); + expect(snapshot.kind, FlutwireConversationKind.webSocket); + }); + + test('counts a message by the index the next one gets', () { + final log = MessageLog(); + + expect(log.total, 0); + + log + ..add(message(0)) + ..add(message(1)); + + expect(log.total, 2); + }); + + test('lets go of the oldest messages past the count limit', () { + final log = MessageLog(maxMessages: 3); + for (var i = 0; i < 10; i++) { + log.add(message(i)); + } + + final snapshot = log.snapshot(FlutwireConversationKind.webSocket); + expect(snapshot.items.map((m) => m.index), [7, 8, 9]); + expect(snapshot.total, 10); + expect(snapshot.dropped, 7); + expect(snapshot.keptBytes, 3); + }); + + test('lets go of the oldest messages past the byte limit', () { + final log = MessageLog(maxBytes: 100); + for (var i = 0; i < 5; i++) { + log.add(message(i, bytes: 40)); + } + + final snapshot = log.snapshot(FlutwireConversationKind.webSocket); + expect(snapshot.items.map((m) => m.index), [3, 4]); + expect(snapshot.keptBytes, 80); + }); + + test('keeps a single message that is bigger than the byte limit', () { + final log = MessageLog(maxBytes: 10)..add(message(0, bytes: 500)); + + expect( + log.snapshot(FlutwireConversationKind.webSocket).items, + hasLength(1), + ); + }); + + test('replaces a big message with the next one, which is bigger still', () { + final log = MessageLog(maxBytes: 10) + ..add(message(0, bytes: 500)) + ..add(message(1, bytes: 600)); + + final snapshot = log.snapshot(FlutwireConversationKind.webSocket); + expect(snapshot.items.map((m) => m.index), [1]); + expect(snapshot.keptBytes, 600); + }); + + test('hands out a snapshot that later messages do not change', () { + final log = MessageLog()..add(message(0)); + final snapshot = log.snapshot(FlutwireConversationKind.webSocket); + + log.add(message(1)); + + expect(snapshot.items, hasLength(1)); + expect(() => snapshot.items.add(message(2)), throwsUnsupportedError); + }); +} diff --git a/packages/flutwire/test/capture/messages_test.dart b/packages/flutwire/test/capture/messages_test.dart new file mode 100644 index 0000000..b24766c --- /dev/null +++ b/packages/flutwire/test/capture/messages_test.dart @@ -0,0 +1,366 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/engine.dart'; + +({FlutwireEngine engine, MemoryFlutwireStore store}) make([ + FlutwireConfig config = const FlutwireConfig(enabled: true), +]) { + final store = MemoryFlutwireStore(notifyInterval: Duration.zero); + return (engine: FlutwireEngine(config: config, store: store), store: store); +} + +FlutwireCallRecorder begin(FlutwireEngine engine) => engine.begin( + method: 'GET', + uri: Uri.parse('https://api.test/live'), + source: FlutwireSource.io, +)!; + +List utf(String text) => utf8.encode(text); + +FlutwireHeaders headers(String contentType) => + FlutwireHeaders.fromMap({'content-type': contentType}); + +void main() { + group('the messages of a call', () { + test('keep their direction, kind and size', () { + final t = make(); + final rec = begin(t.engine); + + rec + ..message( + sent: true, + kind: FlutwireMessageKind.text, + payload: utf('hi'), + ) + ..message( + sent: false, + kind: FlutwireMessageKind.binary, + payload: [1, 2, 3], + ) + ..complete(); + + final messages = t.store.find(rec.id)!.messages!; + expect(messages.kind, FlutwireConversationKind.webSocket); + expect(messages.total, 2); + final [first, second] = messages.items; + expect(first.sent, isTrue); + expect(first.kind, FlutwireMessageKind.text); + expect(first.text, 'hi'); + expect(first.index, 0); + expect(second.sent, isFalse); + expect(second.bytes, [1, 2, 3]); + expect(second.index, 1); + }); + + test('are an empty list once the call says it holds a conversation', () { + final t = make(); + final rec = begin(t.engine); + + rec.startConversation(FlutwireConversationKind.webSocket); + + final messages = t.store.find(rec.id)!.messages!; + expect(messages.items, isEmpty); + expect(messages.total, 0); + }); + + test('give a WebSocket the address the app wrote', () { + final t = make(); + final rec = t.engine.begin( + method: 'GET', + uri: Uri.parse('https://echo.test:0#'), + source: FlutwireSource.io, + )!; + + rec.startConversation(FlutwireConversationKind.webSocket); + + expect(t.store.find(rec.id)!.uri.toString(), 'wss://echo.test/'); + }); + + test('keep the port, the path and the query of a WebSocket address', () { + final t = make(); + final rec = t.engine.begin( + method: 'GET', + uri: Uri.parse('http://user:pw@echo.test:8080/live?room=7'), + source: FlutwireSource.io, + )!; + + rec.startConversation(FlutwireConversationKind.webSocket); + + expect( + t.store.find(rec.id)!.uri.toString(), + 'ws://user:pw@echo.test:8080/live?room=7', + ); + }); + + test('leave the address of an event stream as it was', () { + final t = make(); + final rec = t.engine.begin( + method: 'GET', + uri: Uri.parse('https://api.test/stream?x=1'), + source: FlutwireSource.io, + )!; + + rec.startConversation(FlutwireConversationKind.eventStream); + + expect( + t.store.find(rec.id)!.uri.toString(), + 'https://api.test/stream?x=1', + ); + }); + + test('are absent from an ordinary call', () { + final t = make(); + final rec = begin(t.engine)..complete(); + + expect(t.store.find(rec.id)!.messages, isNull); + }); + + test('are cut at the body limit, and the real size is kept', () { + final t = make(const FlutwireConfig(enabled: true, maxBodyBytes: 10)); + final rec = begin(t.engine); + + rec + ..message( + sent: false, + kind: FlutwireMessageKind.text, + payload: utf('x' * 100), + ) + ..complete(); + + final message = t.store.find(rec.id)!.messages!.items.single; + expect(message.bytes, hasLength(10)); + expect(message.totalBytes, 100); + expect(message.truncated, isTrue); + }); + + test('are the size a frame reader reported, when it says so', () { + final t = make(); + final rec = begin(t.engine); + + rec + ..message( + sent: false, + kind: FlutwireMessageKind.text, + payload: utf('start'), + totalBytes: 9000, + ) + ..complete(); + + expect(t.store.find(rec.id)!.messages!.items.single.totalBytes, 9000); + }); + + test('stop at the latest thousand, and still count all of them', () { + final t = make(); + final rec = begin(t.engine); + + for (var i = 0; i < 1200; i++) { + rec.message( + sent: i.isEven, + kind: FlutwireMessageKind.text, + payload: utf('$i'), + ); + } + rec.complete(); + + final messages = t.store.find(rec.id)!.messages!; + expect(messages.items, hasLength(1000)); + expect(messages.total, 1200); + expect(messages.dropped, 200); + expect(messages.items.first.index, 200); + expect(messages.items.last.index, 1199); + }); + + test('stop at a megabyte of payload, keeping the latest', () { + final t = make(const FlutwireConfig(enabled: true, maxBodyBytes: 500000)); + final rec = begin(t.engine); + + for (var i = 0; i < 5; i++) { + rec.message( + sent: false, + kind: FlutwireMessageKind.binary, + payload: Uint8List(400000), + ); + } + rec.complete(); + + final messages = t.store.find(rec.id)!.messages!; + expect(messages.items.map((m) => m.index), [3, 4]); + expect(messages.total, 5); + }); + + test('keep a single message that is bigger than the budget', () { + final t = make( + const FlutwireConfig(enabled: true, maxBodyBytes: 2000000), + ); + final rec = begin(t.engine); + + rec + ..message( + sent: false, + kind: FlutwireMessageKind.binary, + payload: Uint8List(1500000), + ) + ..complete(); + + expect(t.store.find(rec.id)!.messages!.items, hasLength(1)); + }); + + test('hide the fields the redaction names, in text and not in bytes', () { + final t = make( + const FlutwireConfig( + enabled: true, + redaction: FlutwireRedaction(bodyFields: {'password'}), + ), + ); + final rec = begin(t.engine); + + rec + ..message( + sent: true, + kind: FlutwireMessageKind.text, + payload: utf('{"user":"vega","password":"hunter2"}'), + ) + ..message( + sent: true, + kind: FlutwireMessageKind.binary, + payload: utf('{"password":"hunter2"}'), + ) + ..complete(); + + final [text, binary] = t.store.find(rec.id)!.messages!.items; + expect(text.text, '{"user":"vega","password":"[redacted]"}'); + expect(utf8.decode(binary.bytes), '{"password":"hunter2"}'); + }); + + test('are ignored once the call is over', () { + final t = make(); + final rec = begin(t.engine); + + rec + ..message(sent: true, kind: FlutwireMessageKind.text, payload: utf('a')) + ..complete() + ..message( + sent: true, + kind: FlutwireMessageKind.text, + payload: utf('b'), + ); + + expect(t.store.find(rec.id)!.messages!.items, hasLength(1)); + }); + + test('reach the store while the call runs, a burst at a time', () async { + final t = make(); + final rec = begin(t.engine); + + for (var i = 0; i < 5; i++) { + rec.message( + sent: false, + kind: FlutwireMessageKind.text, + payload: utf('$i'), + ); + } + // The first one went out at once; the rest wait for the burst to end. + expect(t.store.find(rec.id)!.messages!.items, hasLength(1)); + + await Future.delayed(const Duration(milliseconds: 350)); + + expect(t.store.find(rec.id)!.messages!.items, hasLength(5)); + expect(t.store.find(rec.id)!.isPending, isTrue); + }); + }); + + group('a server-sent event stream', () { + FlutwireCallRecorder stream(FlutwireEngine engine, {String? type}) { + final rec = begin(engine); + rec.responseHead( + statusCode: 200, + headers: headers(type ?? 'text/event-stream; charset=utf-8'), + ); + return rec; + } + + test('lists its events as they complete', () { + final t = make(); + final rec = stream(t.engine); + + rec + ..responseBody(utf('event: tick\ndata: {"n":1}\n\n')) + ..responseBody(utf('id: 9\ndata: second')) + ..responseBody(utf(' half\n\n')) + ..complete(); + + final messages = t.store.find(rec.id)!.messages!; + expect(messages.kind, FlutwireConversationKind.eventStream); + final [first, second] = messages.items; + expect(first.kind, FlutwireMessageKind.event); + expect(first.sent, isFalse); + expect(first.event, 'tick'); + expect(first.text, '{"n":1}'); + expect(second.eventId, '9'); + expect(second.text, 'second half'); + }); + + test('still keeps the raw stream as the body of the response', () { + final t = make(); + final rec = stream(t.engine); + + rec + ..responseBody(utf('data: a\n\n')) + ..complete(); + + final call = t.store.find(rec.id)!; + expect(call.response!.body.text, 'data: a\n\n'); + }); + + test('is a pending call until it ends, with the events so far', () { + final t = make(); + final rec = stream(t.engine); + + rec.responseBody(utf('data: a\n\n')); + + final call = t.store.find(rec.id)!; + expect(call.isPending, isTrue); + expect(call.messages!.items.single.text, 'a'); + }); + + test('does not turn another response into events', () { + final t = make(); + final rec = stream(t.engine, type: 'text/plain'); + + rec + ..responseBody(utf('data: a\n\n')) + ..complete(); + + expect(t.store.find(rec.id)!.messages, isNull); + }); + + test('drops an event the stream ended in the middle of', () { + final t = make(); + final rec = stream(t.engine); + + rec + ..responseBody(utf('data: whole\n\ndata: cut')) + ..complete(); + + expect(t.store.find(rec.id)!.messages!.items.map((m) => m.text), [ + 'whole', + ]); + }); + + test('keeps the size of an event that was cut', () { + final t = make(const FlutwireConfig(enabled: true, maxBodyBytes: 8)); + final rec = stream(t.engine); + + rec + ..responseBody(utf('data: ${'z' * 50}\n\n')) + ..complete(); + + final message = t.store.find(rec.id)!.messages!.items.single; + expect(message.bytes, hasLength(8)); + expect(message.totalBytes, 50); + }); + }); +} diff --git a/packages/flutwire/test/capture/sse_parser_test.dart b/packages/flutwire/test/capture/sse_parser_test.dart new file mode 100644 index 0000000..aca3f67 --- /dev/null +++ b/packages/flutwire/test/capture/sse_parser_test.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/src/capture/messages/sse_parser.dart'; + +/// Feeds [chunks] to a parser and returns what it reported. +List parse(List chunks, {int maxChars = 250000}) { + final events = []; + final parser = SseParser(events.add, maxChars: maxChars); + for (final chunk in chunks) { + parser.add(utf8.encode(chunk)); + } + parser.close(); + return events; +} + +void main() { + test('reports an event when the empty line after it arrives', () { + final events = parse(['data: hello\n\n']); + + expect(events.single.data, 'hello'); + expect(events.single.event, isNull); + expect(events.single.id, isNull); + }); + + test('waits for the empty line', () { + final events = []; + final parser = SseParser(events.add)..add(utf8.encode('data: a\n')); + + expect(events, isEmpty); + + parser.add(utf8.encode('\n')); + + expect(events.single.data, 'a'); + }); + + test('joins several data lines with a line feed', () { + final events = parse(['data: one\ndata: two\ndata: three\n\n']); + + expect(events.single.data, 'one\ntwo\nthree'); + expect(events.single.totalBytes, 'one\ntwo\nthree'.length); + }); + + test('reads the event name and the id', () { + final events = parse(['event: tick\nid: 7\ndata: {"n":1}\n\n']); + + expect(events.single.event, 'tick'); + expect(events.single.id, '7'); + expect(events.single.data, '{"n":1}'); + }); + + test('keeps the id for the events after it, and the name for one only', () { + final events = parse(['id: 1\nevent: a\ndata: x\n\n', 'data: y\n\n']); + + expect(events.map((e) => e.id), ['1', '1']); + expect(events.map((e) => e.event), ['a', null]); + }); + + test('ignores comments, retry and fields it does not know', () { + final events = parse([': keep alive\nretry: 3000\nnote: x\ndata: ok\n\n']); + + expect(events.single.data, 'ok'); + }); + + test('drops one leading space from a value and no more', () { + final events = parse(['data: two spaces\n\n']); + + expect(events.single.data, ' two spaces'); + }); + + test('takes a line with no colon as a field with an empty value', () { + final events = parse(['data\n\n']); + + expect(events.single.data, ''); + }); + + test('says nothing for an empty line with no data before it', () { + expect(parse(['\n\n: only a comment\n\n']), isEmpty); + }); + + test('ends a line with a line feed, a carriage return or both', () { + final events = parse(['data: a\r\n\r\ndata: b\r\rdata: c\n\n']); + + expect(events.map((e) => e.data), ['a', 'b', 'c']); + }); + + test( + 'reads a carriage return and its line feed in two chunks as one end', + () { + final events = parse(['data: a\r', '\n\r', '\n']); + + expect(events.map((e) => e.data), ['a']); + }, + ); + + test('puts an event together from pieces of any size', () { + final text = 'event: e\ndata: split here\n\n'; + final events = []; + final parser = SseParser(events.add); + for (final unit in utf8.encode(text)) { + parser.add([unit]); + } + + expect(events.single.event, 'e'); + expect(events.single.data, 'split here'); + }); + + test('reads a character that is cut between two chunks', () { + final bytes = utf8.encode('data: caffè ☕\n\n'); + final events = []; + SseParser(events.add) + ..add(bytes.sublist(0, 10)) + ..add(bytes.sublist(10)); + + expect(events.single.data, 'caffè ☕'); + expect(events.single.totalBytes, utf8.encode('caffè ☕').length); + }); + + test('ignores a byte order mark at the start of the stream', () { + final events = parse(['data: a\n\n']); + + expect(events.single.data, 'a'); + }); + + test('drops an event the stream ended in the middle of', () { + expect(parse(['data: never finished\n']), isEmpty); + }); + + test('keeps a cut data and the size of the whole', () { + final events = parse(['data: ${'x' * 100}\n\n'], maxChars: 10); + + expect(events.single.data, 'x' * 10); + expect(events.single.totalBytes, 100); + }); + + test('ignores an id with a null character in it', () { + final events = parse(['id: a\u0000b\ndata: x\n\n']); + + expect(events.single.id, isNull); + }); + + test('reports an event with an empty data field', () { + final events = parse(['data:\n\n']); + + expect(events.single.data, ''); + }); +} diff --git a/packages/flutwire/test/capture/websocket_capture_test.dart b/packages/flutwire/test/capture/websocket_capture_test.dart new file mode 100644 index 0000000..1bf39ab --- /dev/null +++ b/packages/flutwire/test/capture/websocket_capture_test.dart @@ -0,0 +1,298 @@ +// These tests open real WebSockets and event streams against a server on the +// loopback interface, through the clients the SDK ships. They avoid the widget +// test binding, which replaces every HttpClient with one that refuses to +// connect. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/capture/wire/wire_io.dart'; +import 'package:flutwire/src/engine.dart'; + +late HttpServer server; +late String host; + +Future handle(HttpRequest req) async { + try { + switch (req.uri.path) { + case '/ws': + final ws = await WebSocketTransformer.upgrade(req); + ws.listen((message) { + if (message is String) { + ws.add('echo:$message'); + } else { + ws.add(message as List); + } + }, onDone: ws.close); + case '/plain': + final ws = await WebSocketTransformer.upgrade( + req, + compression: CompressionOptions.compressionOff, + ); + ws.listen((message) => ws.add('echo:$message'), onDone: ws.close); + case '/sse': + req.response.headers.contentType = ContentType( + 'text', + 'event-stream', + charset: 'utf-8', + ); + req.response.write('data: first\n\n'); + await req.response.flush(); + await Future.delayed(const Duration(milliseconds: 60)); + req.response.write('event: tick\nid: 2\ndata: {"n":2}\n\n'); + await req.response.flush(); + await Future.delayed(const Duration(milliseconds: 60)); + await req.response.close(); + default: + req.response.statusCode = 404; + await req.response.close(); + } + } on Object { + // The client walked away, which some of the tests do on purpose. + } +} + +/// A WebSocket with the one listener an app has, which keeps it reading: dart:io +/// holds a socket nobody listens to, and it reads nothing, not even a pong. +class Connection { + Connection(this.socket) { + socket.listen( + inbox.add, + onDone: () => closed.complete(), + onError: (Object _) {}, + ); + } + + final WebSocket socket; + final List inbox = []; + final Completer closed = Completer(); + + /// Waits until [count] messages have come from the server. + Future received(int count) async { + final end = DateTime.now().add(const Duration(seconds: 5)); + while (inbox.length < count) { + if (DateTime.now().isAfter(end)) fail('Only ${inbox.length} messages.'); + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + /// Closes the way an app does, and waits for the other side to answer. + Future close([int? code, String? reason]) async { + await socket.close(code, reason); + await closed.future.timeout(const Duration(seconds: 5)); + } +} + +void main() { + setUpAll(() async { + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen(handle); + host = '127.0.0.1:${server.port}'; + }); + + tearDownAll(() => server.close(force: true)); + + late MemoryFlutwireStore store; + late FlutwireEngine engine; + late void Function() uninstall; + + void start([FlutwireConfig config = const FlutwireConfig(enabled: true)]) { + store = MemoryFlutwireStore(notifyInterval: Duration.zero); + engine = FlutwireEngine(config: config, store: store); + uninstall = installWireCapture(engine); + } + + tearDown(() { + uninstall(); + HttpOverrides.global = null; + }); + + FlutwireCall only() => store.calls.value.single; + + /// Opens a WebSocket through a client made after the capture was installed, + /// and closes that client once the handshake is over, as apps do. The shared + /// client of `WebSocket.connect` may be older than the capture: the test + /// runner opens a WebSocket of its own before any test starts. + Future connect( + String path, { + CompressionOptions compression = CompressionOptions.compressionDefault, + }) async { + final client = HttpClient(); + // The connection returned closes it. + // ignore: close_sinks + final socket = await WebSocket.connect( + 'ws://$host$path', + compression: compression, + customClient: client, + ); + client.close(); + return Connection(socket); + } + + /// Waits until the one call is over, however it ended. + Future finished() async { + final end = DateTime.now().add(const Duration(seconds: 10)); + while (DateTime.now().isBefore(end)) { + store.flush(); + final calls = store.calls.value; + if (calls.length == 1 && !calls.single.isPending) return calls.single; + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('The call did not finish.'); + } + + String describe(FlutwireMessage m) => + '${m.sent ? 'sent' : 'got'} ${m.kind.name} ${m.text ?? m.bytes}'; + + test('a WebSocket lists what crossed it, in both directions', () async { + start(); + final connection = await connect('/ws'); + connection.socket + ..add('hello') + ..add(Uint8List.fromList([1, 2, 3])); + await connection.received(2); + await connection.close(1000, 'done'); + final call = await finished(); + + expect(call.statusCode, 101); + expect(call.uri.scheme, 'ws'); + expect(call.uri.path, '/ws'); + expect(call.state, FlutwireCallState.completed); + expect(call.messages!.kind, FlutwireConversationKind.webSocket); + final items = call.messages!.items; + expect( + items.map(describe), + containsAllInOrder(['sent text hello', 'got text echo:hello']), + ); + expect( + items.map(describe), + containsAll(['sent binary [1, 2, 3]', 'got binary [1, 2, 3]']), + ); + final closes = items.where((m) => m.kind == FlutwireMessageKind.close); + expect(closes.map((m) => m.sent), containsAll([true, false])); + final sentClose = closes.firstWhere((m) => m.sent); + expect(sentClose.closeCode, 1000); + expect(sentClose.closeReason, 'done'); + expect( + items.map((m) => m.index), + orderedEquals(List.generate(items.length, (i) => i)), + ); + expect(call.messages!.total, items.length); + }); + + test('the call stays open for as long as the connection does', () async { + start(); + final connection = await connect('/ws'); + connection.socket.add('ping'); + await connection.received(1); + // Messages reach the store in bursts, a moment after they cross. + await Future.delayed(const Duration(milliseconds: 350)); + store.flush(); + + expect(only().isPending, isTrue); + expect(only().messages!.items.map(describe), [ + 'sent text ping', + 'got text echo:ping', + ]); + + await connection.close(); + expect((await finished()).state, FlutwireCallState.completed); + }); + + test('compressed messages are read, in both directions', () async { + start(); + final text = 'a long line that repeats, ' * 200; + final connection = await connect('/ws'); + connection.socket.add(text); + await connection.received(1); + await connection.close(); + final call = await finished(); + + expect( + call.response!.headers.first('sec-websocket-extensions'), + contains('permessage-deflate'), + ); + final texts = call.messages!.items.where( + (m) => m.kind == FlutwireMessageKind.text, + ); + expect(texts.map((m) => m.text), [text, 'echo:$text']); + expect(texts.every((m) => !m.undecoded), isTrue); + expect(texts.first.totalBytes, utf8.encode(text).length); + }); + + test('messages are read when the connection is not compressed', () async { + start(); + final connection = await connect( + '/plain', + compression: CompressionOptions.compressionOff, + ); + connection.socket.add('plain'); + await connection.received(1); + await connection.close(); + final call = await finished(); + + expect( + call.response!.headers.contains('sec-websocket-extensions'), + isFalse, + ); + expect( + call.messages!.items + .where((m) => m.kind == FlutwireMessageKind.text) + .map((m) => m.text), + ['plain', 'echo:plain'], + ); + }); + + test('pings and pongs are listed', () async { + start(); + final connection = await connect('/ws'); + connection.socket.pingInterval = const Duration(milliseconds: 40); + await Future.delayed(const Duration(milliseconds: 250)); + await connection.close(); + final call = await finished(); + + final kinds = call.messages!.items.map((m) => (m.kind, m.sent)); + expect(kinds, contains((FlutwireMessageKind.ping, true))); + expect(kinds, contains((FlutwireMessageKind.pong, false))); + }); + + test('a WebSocket that is not recorded works all the same', () async { + start(); + engine.paused.value = true; + final connection = await connect('/ws'); + connection.socket.add('quiet'); + await connection.received(1); + expect(connection.inbox.single, 'echo:quiet'); + await connection.close(); + + expect(store.calls.value, isEmpty); + }); + + test( + 'an event stream lists its events and stays a normal response', + () async { + start(); + final client = HttpClient(); + final request = await client.getUrl(Uri.parse('http://$host/sse')); + final response = await request.close(); + final seen = []; + await response.transform(utf8.decoder).forEach(seen.add); + client.close(); + final call = await finished(); + + expect(call.statusCode, 200); + expect(call.messages!.kind, FlutwireConversationKind.eventStream); + final [first, second] = call.messages!.items; + expect(first.text, 'first'); + expect(first.event, isNull); + expect(second.event, 'tick'); + expect(second.eventId, '2'); + expect(second.text, '{"n":2}'); + expect(second.at.isAfter(first.at), isTrue); + expect(call.response!.body.text, seen.join()); + }, + ); +} diff --git a/packages/flutwire/test/capture/websocket_reader_test.dart b/packages/flutwire/test/capture/websocket_reader_test.dart new file mode 100644 index 0000000..91be8a4 --- /dev/null +++ b/packages/flutwire/test/capture/websocket_reader_test.dart @@ -0,0 +1,337 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/capture/messages/websocket_reader.dart'; + +import '../support/websocket_frames.dart'; + +typedef Got = ({ + FlutwireMessageKind kind, + Uint8List payload, + int total, + bool undecoded, +}); + +/// What a reader reports for [chunks]. +List read( + List> chunks, { + bool deflate = false, + bool noContextTakeover = false, + int maxBytes = 1 << 20, + void Function(WebSocketReader reader)? inspect, +}) { + final got = []; + final reader = WebSocketReader( + onMessage: (kind, payload, total, {required undecoded}) => got.add(( + kind: kind, + payload: payload, + total: total, + undecoded: undecoded, + )), + deflate: deflate, + noContextTakeover: noContextTakeover, + maxBytes: maxBytes, + ); + chunks.forEach(reader.add); + inspect?.call(reader); + return got; +} + +List text(String value) => utf8.encode(value); + +/// A message as a sender that compresses writes it: raw deflate, flushed, with +/// the four bytes at the end cut off. +List deflated(RawZLibFilter filter, String message) { + final data = utf8.encode(message); + filter.process(data, 0, data.length); + final out = BytesBuilder(); + while (true) { + final piece = filter.processed(); + if (piece == null || piece.isEmpty) break; + out.add(piece); + } + final bytes = out.toBytes(); + return bytes.sublist(0, bytes.length - 4); +} + +void main() { + group('a message in one frame', () { + test('is a text message with its payload', () { + final got = read([frame(1, text('hello'))]); + + expect(got.single.kind, FlutwireMessageKind.text); + expect(utf8.decode(got.single.payload), 'hello'); + expect(got.single.total, 5); + expect(got.single.undecoded, isFalse); + }); + + test('is a binary message when the opcode says so', () { + final got = read([ + frame(2, [1, 2, 3]), + ]); + + expect(got.single.kind, FlutwireMessageKind.binary); + expect(got.single.payload, [1, 2, 3]); + }); + + test('is read through the mask a client puts on it', () { + final got = read([frame(1, text('masked'), masked: true)]); + + expect(utf8.decode(got.single.payload), 'masked'); + }); + + test('may be empty', () { + final got = read([frame(1, [])]); + + expect(got.single.payload, isEmpty); + expect(got.single.total, 0); + }); + + test('has a length written in two bytes when it is long', () { + final payload = List.filled(300, 65); + final got = read([frame(1, payload)]); + + expect(got.single.payload, hasLength(300)); + }); + + test('has a length written in eight bytes when it is longer', () { + final payload = List.filled(70000, 66); + final got = read([frame(2, payload, masked: true)]); + + expect(got.single.total, 70000); + expect(got.single.payload.every((b) => b == 66), isTrue); + }); + + test('arrives however the bytes are cut', () { + final bytes = frame(1, text('one byte at a time'), masked: true); + final got = read([ + for (final byte in bytes) [byte], + ]); + + expect(utf8.decode(got.single.payload), 'one byte at a time'); + }); + + test('is followed by the next in the same chunk', () { + final both = [...frame(1, text('a')), ...frame(1, text('b'))]; + final got = read([both]); + + expect(got.map((g) => utf8.decode(g.payload)), ['a', 'b']); + }); + }); + + test('a message in several frames is joined into one', () { + final got = read([ + frame(1, text('hel'), fin: false), + frame(0, text('lo '), fin: false), + frame(0, text('world')), + ]); + + expect(got, hasLength(1)); + expect(utf8.decode(got.single.payload), 'hello world'); + expect(got.single.total, 11); + }); + + test('control frames between the pieces of a message are messages too', () { + final got = read([ + frame(1, text('hel'), fin: false), + frame(9, text('?')), + frame(0, text('lo')), + ]); + + expect(got.map((g) => g.kind), [ + FlutwireMessageKind.ping, + FlutwireMessageKind.text, + ]); + expect(utf8.decode(got.last.payload), 'hello'); + }); + + group('control frames', () { + test('are a ping, a pong and a close', () { + final got = read([ + frame(9, text('a')), + frame(10, text('b')), + frame(8, [0x03, 0xe8, ...text('bye')]), + ]); + + expect(got.map((g) => g.kind), [ + FlutwireMessageKind.ping, + FlutwireMessageKind.pong, + FlutwireMessageKind.close, + ]); + final close = FlutwireMessage( + index: 0, + sent: false, + at: DateTime.utc(2026), + kind: FlutwireMessageKind.close, + bytes: got.last.payload, + totalBytes: got.last.total, + ); + expect(close.closeCode, 1000); + expect(close.closeReason, 'bye'); + }); + + test('may have no payload at all', () { + final got = read([frame(8, [])]); + + expect(got.single.kind, FlutwireMessageKind.close); + expect(got.single.payload, isEmpty); + }); + }); + + test('keeps the first bytes of a long payload and counts all of it', () { + final got = read([frame(1, List.filled(5000, 120))], maxBytes: 100); + + expect(got.single.payload, hasLength(100)); + expect(got.single.total, 5000); + }); + + group('compression', () { + test('a compressed message is decompressed', () { + final filter = RawZLibFilter.deflateFilter(raw: true); + final got = read([ + frame(1, deflated(filter, '{"hello":"world"}'), rsv1: true), + ], deflate: true); + + expect(utf8.decode(got.single.payload), '{"hello":"world"}'); + expect(got.single.total, 17); + expect(got.single.undecoded, isFalse); + }); + + test('messages that share a window are read in order', () { + final filter = RawZLibFilter.deflateFilter(raw: true); + final first = deflated(filter, 'the same words, the same words'); + final second = deflated(filter, 'the same words, the same words'); + // The second one points back into the first, so it only reads right if + // the window was kept. + expect(second.length, lessThan(first.length)); + + final got = read([ + frame(1, first, rsv1: true), + frame(1, second, rsv1: true), + ], deflate: true); + + expect(got.map((g) => utf8.decode(g.payload)), [ + 'the same words, the same words', + 'the same words, the same words', + ]); + }); + + test('messages that stand alone are each read from a fresh window', () { + final got = read( + [ + frame( + 1, + deflated(RawZLibFilter.deflateFilter(raw: true), 'one'), + rsv1: true, + ), + frame( + 1, + deflated(RawZLibFilter.deflateFilter(raw: true), 'two'), + rsv1: true, + ), + ], + deflate: true, + noContextTakeover: true, + ); + + expect(got.map((g) => utf8.decode(g.payload)), ['one', 'two']); + }); + + test('a compressed message in two frames is read as one', () { + final bytes = deflated( + RawZLibFilter.deflateFilter(raw: true), + 'in two parts', + ); + final half = bytes.length ~/ 2; + final got = read([ + frame(1, bytes.sublist(0, half), fin: false, rsv1: true), + frame(0, bytes.sublist(half)), + ], deflate: true); + + expect(utf8.decode(got.single.payload), 'in two parts'); + }); + + test('a plain message is untouched while compression is on', () { + final got = read([frame(1, text('plain'))], deflate: true); + + expect(utf8.decode(got.single.payload), 'plain'); + }); + + test('bytes that are not compressed are reported as not decoded', () { + final got = read([ + frame(1, [1, 2, 3, 4, 5, 6, 7, 8], rsv1: true), + frame(1, [9, 9, 9], rsv1: true), + ], deflate: true); + + expect(got.map((g) => g.undecoded), [isTrue, isTrue]); + expect(got.last.payload, [9, 9, 9]); + }); + + test('is not expected unless the handshake agreed to it', () { + WebSocketReader? seen; + final got = read([ + frame(1, text('hi'), rsv1: true), + ], inspect: (reader) => seen = reader); + + expect(got, isEmpty); + expect(seen!.broken, isTrue); + }); + }); + + group('what cannot be read', () { + bool brokenBy(List bytes, {bool deflate = false}) { + late WebSocketReader reader; + read([bytes], deflate: deflate, inspect: (r) => reader = r); + return reader.broken; + } + + test('a reserved bit stops the reader', () { + expect(brokenBy([0xA1, 0x00]), isTrue); + }); + + test('an opcode that does not exist stops the reader', () { + expect(brokenBy(frame(3, [1])), isTrue); + expect(brokenBy(frame(11, [])), isTrue); + }); + + test('a continuation with nothing to continue stops the reader', () { + expect(brokenBy(frame(0, [1])), isTrue); + }); + + test('a new message in the middle of one stops the reader', () { + final broken = [ + ...frame(1, text('a'), fin: false), + ...frame(1, text('b')), + ]; + + expect(brokenBy(broken), isTrue); + }); + + test('a control frame in pieces stops the reader', () { + expect(brokenBy(frame(9, text('x'), fin: false)), isTrue); + }); + + test('a control frame that is too long stops the reader', () { + expect(brokenBy(frame(9, List.filled(126, 0))), isTrue); + }); + + test('a length that cannot be real stops the reader', () { + expect( + brokenBy([0x82, 127, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), + isTrue, + ); + }); + + test('says nothing more once it has stopped', () { + final got = read([ + frame(3, [1]), + frame(1, text('after')), + ]); + + expect(got, isEmpty); + }); + }); +} diff --git a/packages/flutwire/test/capture/wire_socket_test.dart b/packages/flutwire/test/capture/wire_socket_test.dart new file mode 100644 index 0000000..67b4bc8 --- /dev/null +++ b/packages/flutwire/test/capture/wire_socket_test.dart @@ -0,0 +1,192 @@ +// The sockets here stand in for ones an app closes, so none of them is. +// ignore_for_file: close_sinks + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/capture/wire/wire_socket.dart'; +import 'package:flutwire/src/engine.dart'; + +import '../support/websocket_frames.dart'; + +/// A socket that does what a test tells it to, and answers nothing else. +class FakeSocket extends Stream implements Socket { + final StreamController incoming = StreamController(); + final Completer writing = Completer(); + final List> written = []; + bool destroyed = false; + + @override + StreamSubscription listen( + void Function(Uint8List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => incoming.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + void add(List data) => written.add(data); + + @override + Future addStream(Stream> stream) => + stream.forEach(written.add); + + @override + Future get done => writing.future; + + @override + void destroy() => destroyed = true; + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName}'); +} + +Uint8List bytes(List data) => Uint8List.fromList(data); + +void main() { + late FlutwireEngine engine; + late MemoryFlutwireStore store; + late FlutwireCallRecorder recorder; + late FakeSocket fake; + late WireSocket socket; + late List seen; + late List errors; + + setUp(() { + store = MemoryFlutwireStore(notifyInterval: Duration.zero); + engine = FlutwireEngine( + config: const FlutwireConfig(enabled: true), + store: store, + ); + recorder = engine.begin( + method: 'GET', + uri: Uri.parse('wss://api.test/live'), + source: FlutwireSource.io, + )!; + fake = FakeSocket(); + socket = WireSocket(fake, recorder); + seen = []; + errors = []; + socket.listen(seen.add, onError: errors.add); + }); + + tearDown(() => fake.incoming.close()); + + FlutwireCall call() => store.find(recorder.id)!; + + Future pump() => Future.delayed(Duration.zero); + + test('hands the bytes on untouched, in both directions', () async { + final server = frame(1, utf8.encode('from the server')); + final client = frame(1, utf8.encode('from the app'), masked: true); + + fake.incoming.add(server); + socket.add(client); + await pump(); + + expect(seen.single, server); + expect(fake.written.single, client); + }); + + test('lists the messages that crossed, each with its direction', () async { + fake.incoming.add(frame(1, utf8.encode('down'))); + await pump(); + socket.add(frame(1, utf8.encode('up'), masked: true)); + recorder.complete(); + + final items = call().messages!.items; + expect(items.map((m) => (m.sent, m.text)), [(false, 'down'), (true, 'up')]); + }); + + test('reads the frames of an addStream the way it reads add', () async { + final done = socket.addStream( + Stream.fromIterable([ + frame(2, [7, 7, 7], masked: true), + ]), + ); + await done; + recorder.complete(); + + expect(call().messages!.items.single.bytes, [7, 7, 7]); + }); + + test('says the call holds a WebSocket from the start', () { + expect(call().messages!.kind, FlutwireConversationKind.webSocket); + expect(call().isPending, isTrue); + }); + + test('keeps passing bytes it cannot read, and lists nothing', () async { + fake.incoming.add(bytes([0xA1, 0x00, 1, 2, 3])); + fake.incoming.add(frame(1, utf8.encode('after'))); + await pump(); + + expect(seen, hasLength(2)); + expect(call().messages!.items, isEmpty); + expect(call().isPending, isTrue); + }); + + group('the call ends', () { + test('when the other side stops sending', () async { + await fake.incoming.close(); + await pump(); + + expect(call().state, FlutwireCallState.completed); + }); + + test('as failed when the connection breaks', () async { + fake.incoming.addError(const SocketException('reset by peer')); + await pump(); + + expect(call().state, FlutwireCallState.failed); + expect(call().error!.type, 'SocketException'); + expect(errors.single, isA()); + }); + + test('as failed when writing fails', () async { + fake.writing.completeError(const SocketException('broken pipe')); + await pump(); + + expect(call().state, FlutwireCallState.failed); + }); + + test('when the app destroys the socket', () { + socket.destroy(); + + expect(fake.destroyed, isTrue); + expect(call().state, FlutwireCallState.completed); + }); + + test('once each side has said goodbye', () async { + socket.add(frame(8, [0x03, 0xe8], masked: true)); + await pump(); + + expect(call().isPending, isTrue); + + fake.incoming.add(frame(8, [0x03, 0xe8])); + await pump(); + + expect(call().state, FlutwireCallState.completed); + expect(call().messages!.items.map((m) => m.kind), [ + FlutwireMessageKind.close, + FlutwireMessageKind.close, + ]); + }); + + test('not when only the writing side is closed', () async { + fake.writing.complete(); + await pump(); + + expect(call().isPending, isTrue); + }); + }); +} diff --git a/packages/flutwire/test/model/message_test.dart b/packages/flutwire/test/model/message_test.dart new file mode 100644 index 0000000..eae910b --- /dev/null +++ b/packages/flutwire/test/model/message_test.dart @@ -0,0 +1,219 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; + +FlutwireMessage message( + FlutwireMessageKind kind, + List bytes, { + int? total, + String? event, + String? eventId, + bool undecoded = false, +}) => FlutwireMessage( + index: 3, + sent: true, + at: DateTime.utc(2026, 10, 24, 12, 0, 1), + kind: kind, + bytes: Uint8List.fromList(bytes), + totalBytes: total ?? bytes.length, + event: event, + eventId: eventId, + undecoded: undecoded, +); + +void main() { + group('a message', () { + test('reads text as text and nothing else', () { + final text = message(FlutwireMessageKind.text, utf8.encode('héllo')); + final event = message(FlutwireMessageKind.event, utf8.encode('{"a":1}')); + final binary = message(FlutwireMessageKind.binary, [1, 2]); + final ping = message(FlutwireMessageKind.ping, [1]); + + expect(text.text, 'héllo'); + expect(event.text, '{"a":1}'); + expect(binary.text, isNull); + expect(ping.text, isNull); + }); + + test('reads the code and the reason out of a close', () { + final close = message(FlutwireMessageKind.close, [ + 0x03, + 0xe8, + ...utf8.encode('bye'), + ]); + + expect(close.closeCode, 1000); + expect(close.closeReason, 'bye'); + expect(close.text, 'bye'); + }); + + test('has no code in a close with no payload', () { + final close = message(FlutwireMessageKind.close, const []); + + expect(close.closeCode, isNull); + expect(close.closeReason, ''); + }); + + test('has no code unless it is a close', () { + expect(message(FlutwireMessageKind.text, [3, 232]).closeCode, isNull); + }); + + test('is cut when its payload is shorter than the whole', () { + expect( + message(FlutwireMessageKind.text, [65], total: 9).truncated, + isTrue, + ); + expect(message(FlutwireMessageKind.text, [65]).truncated, isFalse); + }); + + test('survives a trip through JSON with everything it had', () { + final original = message( + FlutwireMessageKind.event, + utf8.encode('payload'), + total: 700, + event: 'tick', + eventId: '12', + undecoded: true, + ); + + final back = FlutwireMessage.fromJson( + jsonDecode(jsonEncode(original.toJson())) as Map, + ); + + expect(back.index, 3); + expect(back.sent, isTrue); + expect(back.at, original.at); + expect(back.kind, FlutwireMessageKind.event); + expect(back.bytes, original.bytes); + expect(back.totalBytes, 700); + expect(back.event, 'tick'); + expect(back.eventId, '12'); + expect(back.undecoded, isTrue); + }); + + test('leaves out of its JSON what it does not have', () { + final json = message(FlutwireMessageKind.ping, const []).toJson(); + + expect(json.containsKey('data'), isFalse); + expect(json.containsKey('event'), isFalse); + expect(json.containsKey('id'), isFalse); + expect(json.containsKey('undecoded'), isFalse); + }); + + test('is read back from a line that lost its optional parts', () { + final back = FlutwireMessage.fromJson({ + 'index': 1, + 'sent': false, + 'at': '2026-10-24T12:00:00.000Z', + 'kind': 'unheard-of', + }); + + expect(back.kind, FlutwireMessageKind.binary); + expect(back.bytes, isEmpty); + expect(back.totalBytes, 0); + }); + }); + + group('the preview of a message', () { + test('is the text on one line', () { + final text = message( + FlutwireMessageKind.text, + utf8.encode('line one\n\n line two'), + ); + + expect(text.preview, 'line one line two'); + }); + + test('is cut where a long text gets unwieldy', () { + final text = message(FlutwireMessageKind.text, utf8.encode('x' * 500)); + + expect(text.preview, hasLength(241)); + expect(text.preview, endsWith('…')); + }); + + test('is the code and the reason of a close', () { + final close = message(FlutwireMessageKind.close, [ + 0x03, + 0xe9, + ...utf8.encode('going away'), + ]); + + expect(close.preview, '1001 going away'); + }); + + test('is the first bytes of anything else, in hex', () { + expect( + message(FlutwireMessageKind.binary, [0, 255, 16]).preview, + '00 ff 10', + ); + expect(message(FlutwireMessageKind.pong, const []).preview, ''); + }); + + test('says there is more when the bytes go on', () { + final binary = message( + FlutwireMessageKind.binary, + List.generate(40, (i) => i), + total: 40, + ); + + expect(binary.preview, endsWith('0f …')); + expect( + binary.preview.split(' ').where((p) => p.length == 2), + hasLength(16), + ); + }); + }); + + group('the messages of a call', () { + FlutwireMessages loaded() => FlutwireMessages( + kind: FlutwireConversationKind.webSocket, + items: [ + message(FlutwireMessageKind.text, [65, 66]), + message(FlutwireMessageKind.text, [67]), + ], + total: 5, + keptBytes: 3, + ); + + test('count what was kept and what was not', () { + final messages = loaded(); + + expect(messages.dropped, 3); + expect(messages.memoryBytes, 3); + }); + + test('let go of the messages and keep the count and the size', () { + final unloaded = loaded().unload(); + + expect(unloaded.loaded, isFalse); + expect(unloaded.items, isEmpty); + expect(unloaded.total, 5); + expect(unloaded.keptBytes, 3); + expect(unloaded.memoryBytes, 0); + expect(unloaded.dropped, 0); + }); + + test('take the messages back and count their size again', () { + final back = loaded().unload().withItems([ + message(FlutwireMessageKind.text, [1, 2, 3, 4]), + ]); + + expect(back.loaded, isTrue); + expect(back.keptBytes, 4); + expect(back.total, 5); + }); + + test('write only a description in the summary of a call', () { + final json = loaded().toJson(); + + expect(json, {'kind': 'webSocket', 'total': 5, 'bytes': 3}); + final back = FlutwireMessages.fromJson(json); + expect(back.loaded, isFalse); + expect(back.kind, FlutwireConversationKind.webSocket); + expect(back.total, 5); + expect(back.keptBytes, 3); + }); + }); +} diff --git a/packages/flutwire/test/support/websocket_frames.dart b/packages/flutwire/test/support/websocket_frames.dart new file mode 100644 index 0000000..8204f10 --- /dev/null +++ b/packages/flutwire/test/support/websocket_frames.dart @@ -0,0 +1,37 @@ +import 'dart:typed_data'; + +const _mask = [0x12, 0x34, 0x56, 0x78]; + +/// One frame as it goes over the wire. +Uint8List frame( + int opcode, + List payload, { + bool fin = true, + bool masked = false, + bool rsv1 = false, +}) { + final out = BytesBuilder(); + out.addByte((fin ? 0x80 : 0) | (rsv1 ? 0x40 : 0) | opcode); + final maskBit = masked ? 0x80 : 0; + if (payload.length < 126) { + out.addByte(maskBit | payload.length); + } else if (payload.length < 65536) { + out.addByte(maskBit | 126); + out.add([payload.length >> 8, payload.length & 0xff]); + } else { + out.addByte(maskBit | 127); + out.add([ + for (var shift = 56; shift >= 0; shift -= 8) + (payload.length >> shift) & 0xff, + ]); + } + if (masked) { + out.add(_mask); + out.add([ + for (var i = 0; i < payload.length; i++) payload[i] ^ _mask[i & 3], + ]); + } else { + out.add(payload); + } + return out.toBytes(); +} From 23ed59a56de4c7d2d10e6210becf89fb1e261c54 Mon Sep 17 00:00:00 2001 From: TonyTonyCoder11 Date: Sat, 24 Oct 2026 04:26:36 +0200 Subject: [PATCH 2/6] Keep the messages of a call with the call The memory budget counts them, a finished call writes them next to its bodies in a file of its own, a restart finds them again, and they are released and read back with the bodies. A line a crash left damaged is skipped. --- .../flutwire/lib/src/store/disk_store.dart | 68 +++++- packages/flutwire/lib/src/store/store.dart | 4 +- .../test/store/disk_store_messages_test.dart | 219 ++++++++++++++++++ 3 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 packages/flutwire/test/store/disk_store_messages_test.dart diff --git a/packages/flutwire/lib/src/store/disk_store.dart b/packages/flutwire/lib/src/store/disk_store.dart index af159da..457ee4f 100644 --- a/packages/flutwire/lib/src/store/disk_store.dart +++ b/packages/flutwire/lib/src/store/disk_store.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import '../config.dart'; import '../diagnostics.dart'; import '../model/call.dart'; +import '../model/message.dart'; import 'preferences_io.dart'; import 'store.dart'; @@ -19,14 +20,15 @@ import 'store.dart'; /// calls/.json the summary of a call: headers, status, timings /// bodies/.req the request body bytes that were kept /// bodies/.res the response body bytes that were kept +/// messages/.jsonl the messages of a WebSocket or an event stream, a line each /// preferences.json what the user picked in the settings /// ``` /// -/// A call is written when it ends, bodies first and summary last, through a -/// temporary file, so a summary on disk always has its bodies and a crash -/// leaves at worst an orphan body, which the next start sweeps away. Summaries -/// stay in memory; bodies are read back when a call is opened, and released -/// again when memory runs short. +/// A call is written when it ends, bodies and messages first and summary last, +/// through a temporary file, so a summary on disk always has its bodies and a +/// crash leaves at worst an orphan body, which the next start sweeps away. +/// Summaries stay in memory; bodies and messages are read back when a call is +/// opened, and released again when memory runs short. final class DiskFlutwireStore extends MemoryFlutwireStore { DiskFlutwireStore._( Directory root, @@ -36,6 +38,7 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { super.notifyInterval, }) : _calls = Directory('${root.path}/calls'), _bodies = Directory('${root.path}/bodies'), + _messages = Directory('${root.path}/messages'), super( maxCalls: _config.maxCalls, maxBytes: _config.maxMemoryBytes, @@ -66,6 +69,7 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { final FileFlutwirePreferences _preferences; final Directory _calls; final Directory _bodies; + final Directory _messages; /// Ids whose files are complete on disk, which is what makes it safe to let /// go of their bodies in memory. @@ -98,6 +102,7 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { return call.copyWith( request: call.request.copyWith(body: call.request.body.unload()), response: response?.copyWith(body: response.body.unload()), + messages: call.messages?.unload(), ); } @@ -106,7 +111,10 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { final call = find(id); if (call == null) return null; final response = call.response; - if (call.request.body.loaded && (response?.body.loaded ?? true)) { + final messages = call.messages; + if (call.request.body.loaded && + (response?.body.loaded ?? true) && + (messages?.loaded ?? true)) { return call; } await _queue; @@ -120,6 +128,9 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { return call.copyWith( request: call.request.copyWith(body: request), response: response?.copyWith(body: responseBody), + messages: messages == null || messages.loaded + ? messages + : messages.withItems(await _readMessages(id)), ); } on FileSystemException { return call; @@ -141,6 +152,26 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { } } + Future> _readMessages(int id) async { + final List lines; + try { + lines = await File('${_messages.path}/$id.jsonl').readAsLines(); + } on PathNotFoundException { + return const []; + } + return [for (final line in lines) ?_message(line)]; + } + + /// The message a line of a messages file holds, or null for a line that is + /// damaged, which a crash in the middle of a write can leave. + static FlutwireMessage? _message(String line) { + try { + return FlutwireMessage.fromJson(jsonDecode(line) as Map); + } on Object { + return null; + } + } + void _enqueue(Future Function() task) { _queue = _queue.then((_) => task()).catchError(( Object error, @@ -156,6 +187,7 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { Future _write(FlutwireCall call) async { await _bodies.create(recursive: true); + await _messages.create(recursive: true); await _calls.create(recursive: true); final id = call.id; final request = call.request.body; @@ -166,6 +198,12 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { if (response != null && response.loaded && response.bytes.isNotEmpty) { await File('${_bodies.path}/$id.res').writeAsBytes(response.bytes); } + final messages = call.messages; + if (messages != null && messages.loaded && messages.items.isNotEmpty) { + await File('${_messages.path}/$id.jsonl').writeAsString( + messages.items.map((m) => jsonEncode(m.toJson())).join('\n'), + ); + } final temporary = File('${_calls.path}/$id.json.tmp'); await temporary.writeAsString(jsonEncode(call.toJson()), flush: true); await temporary.rename('${_calls.path}/$id.json'); @@ -181,6 +219,7 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { '${_calls.path}/$id.json.tmp', '${_bodies.path}/$id.req', '${_bodies.path}/$id.res', + '${_messages.path}/$id.jsonl', ]) { try { await File(path).delete(); @@ -194,6 +233,7 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { Future _load() async { await _calls.create(recursive: true); await _bodies.create(recursive: true); + await _messages.create(recursive: true); final loaded = []; final known = {}; await for (final entity in _calls.list()) { @@ -218,11 +258,13 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { await entity.delete(); } } - // Bodies whose summary never made it to disk. - await for (final entity in _bodies.list()) { - if (entity is! File) continue; - final id = int.tryParse(entity.uri.pathSegments.last.split('.').first); - if (id == null || !known.contains(id)) await entity.delete(); + // Bodies and messages whose summary never made it to disk. + for (final directory in [_bodies, _messages]) { + await for (final entity in directory.list()) { + if (entity is! File) continue; + final id = int.tryParse(entity.uri.pathSegments.last.split('.').first); + if (id == null || !known.contains(id)) await entity.delete(); + } } _persisted.addAll(known); seed(loaded); @@ -264,5 +306,7 @@ final class DiskFlutwireStore extends MemoryFlutwireStore { } static int _diskBytes(FlutwireCall call) => - call.request.body.keptBytes + (call.response?.body.keptBytes ?? 0); + call.request.body.keptBytes + + (call.response?.body.keptBytes ?? 0) + + (call.messages?.keptBytes ?? 0); } diff --git a/packages/flutwire/lib/src/store/store.dart b/packages/flutwire/lib/src/store/store.dart index 8f98a51..c507383 100644 --- a/packages/flutwire/lib/src/store/store.dart +++ b/packages/flutwire/lib/src/store/store.dart @@ -251,5 +251,7 @@ class MemoryFlutwireStore implements FlutwireStore { } static int _sizeOf(FlutwireCall call) => - call.request.body.bytes.length + (call.response?.body.bytes.length ?? 0); + call.request.body.bytes.length + + (call.response?.body.bytes.length ?? 0) + + (call.messages?.memoryBytes ?? 0); } diff --git a/packages/flutwire/test/store/disk_store_messages_test.dart b/packages/flutwire/test/store/disk_store_messages_test.dart new file mode 100644 index 0000000..ec71c94 --- /dev/null +++ b/packages/flutwire/test/store/disk_store_messages_test.dart @@ -0,0 +1,219 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/store/disk_store.dart'; + +final epoch = DateTime.utc(2026, 10, 24, 12); + +FlutwireMessage message( + int index, { + bool sent = false, + FlutwireMessageKind kind = FlutwireMessageKind.text, + List? bytes, + String? event, +}) => FlutwireMessage( + index: index, + sent: sent, + at: epoch.add(Duration(milliseconds: index * 10)), + kind: kind, + bytes: Uint8List.fromList(bytes ?? utf8.encode('message $index')), + totalBytes: (bytes ?? utf8.encode('message $index')).length, + event: event, +); + +/// A finished WebSocket call that holds [items]. +FlutwireCall conversation( + int id, + List items, { + FlutwireCallState state = FlutwireCallState.completed, +}) => FlutwireCall( + id: id, + method: 'GET', + uri: Uri.parse('wss://api.test/live/$id'), + source: FlutwireSource.io, + state: state, + startedAt: epoch, + endedAt: state == FlutwireCallState.pending ? null : epoch, + request: FlutwireRequest( + headers: FlutwireHeaders.empty, + body: FlutwireBody.empty(), + ), + messages: FlutwireMessages( + kind: FlutwireConversationKind.webSocket, + items: items, + total: items.length, + keptBytes: items.fold(0, (sum, m) => sum + m.bytes.length), + ), +); + +void main() { + late Directory root; + + Future open([ + FlutwireConfig config = const FlutwireConfig(enabled: true), + ]) => DiskFlutwireStore.open( + root, + config, + clock: () => epoch, + notifyInterval: Duration.zero, + ); + + setUp(() => root = Directory.systemTemp.createTempSync('flutwire_messages')); + + tearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + + File file(int id) => File('${root.path}/messages/$id.jsonl'); + + test('the messages of a finished call survive a restart', () async { + var store = await open(); + store.put( + conversation(1, [ + message(0, sent: true), + message(1, kind: FlutwireMessageKind.binary, bytes: [0, 255, 7]), + message(2, event: 'tick'), + ]), + ); + await store.settled; + await store.dispose(); + + store = await open(); + store.flush(); + final summary = store.find(1)!.messages!; + expect(summary.loaded, isFalse); + expect(summary.total, 3); + expect(summary.kind, FlutwireConversationKind.webSocket); + expect(summary.keptBytes, 9 + 3 + 9); + + final full = (await store.load(1))!.messages!; + expect(full.loaded, isTrue); + expect(full.items.map((m) => m.index), [0, 1, 2]); + expect(full.items[0].sent, isTrue); + expect(full.items[0].text, 'message 0'); + expect(full.items[1].kind, FlutwireMessageKind.binary); + expect(full.items[1].bytes, [0, 255, 7]); + expect(full.items[2].event, 'tick'); + expect(full.items[0].at, epoch); + await store.dispose(); + }); + + test('a conversation that is still open is not written', () async { + final store = await open(); + store.put(conversation(1, [message(0)], state: FlutwireCallState.pending)); + await store.settled; + + expect(file(1).existsSync(), isFalse); + await store.dispose(); + }); + + test('a call with no messages writes no file for them', () async { + final store = await open(); + store.put(conversation(1, const [])); + await store.settled; + + expect(file(1).existsSync(), isFalse); + expect(store.find(1)!.messages!.items, isEmpty); + await store.dispose(); + }); + + test('removing a call removes its messages', () async { + final store = await open(); + store.put(conversation(1, [message(0)])); + await store.settled; + expect(file(1).existsSync(), isTrue); + + await store.remove([1]); + await store.settled; + + expect(file(1).existsSync(), isFalse); + await store.dispose(); + }); + + test('sweeps messages whose call never made it to disk', () async { + var store = await open(); + store.put(conversation(1, [message(0)])); + await store.settled; + await store.dispose(); + file(9).writeAsStringSync(jsonEncode(message(0).toJson())); + + store = await open(); + + expect(file(9).existsSync(), isFalse); + expect(file(1).existsSync(), isTrue); + await store.dispose(); + }); + + test('skips a line the crash left damaged', () async { + var store = await open(); + store.put(conversation(1, [message(0), message(1)])); + await store.settled; + await store.dispose(); + final lines = file(1).readAsLinesSync(); + file(1).writeAsStringSync('${lines.first}\n{ torn\n${lines.last}\n'); + + store = await open(); + final loaded = (await store.load(1))!.messages!; + + expect(loaded.items.map((m) => m.index), [0, 1]); + await store.dispose(); + }); + + test('a call whose file is gone still opens, without its messages', () async { + var store = await open(); + store.put(conversation(1, [message(0)])); + await store.settled; + await store.dispose(); + file(1).deleteSync(); + + store = await open(); + final loaded = (await store.load(1))!.messages!; + + expect(loaded.loaded, isTrue); + expect(loaded.items, isEmpty); + expect(loaded.total, 1); + await store.dispose(); + }); + + test( + 'memory lets go of the messages of an old call when it runs short', + () async { + final store = await open( + const FlutwireConfig(enabled: true, maxMemoryBytes: 100), + ); + store.put(conversation(1, [for (var i = 0; i < 20; i++) message(i)])); + await store.settled; + store.put(conversation(2, [for (var i = 0; i < 20; i++) message(i)])); + await store.settled; + + final old = store.find(1)!.messages!; + expect(old.loaded, isFalse); + expect(old.total, 20); + // They are on disk, so opening the call brings them back. + expect((await store.load(1))!.messages!.items, hasLength(20)); + await store.dispose(); + }, + ); + + test('the disk budget counts the messages', () async { + final store = await open( + const FlutwireConfig(enabled: true, maxDiskBytes: 300), + ); + store.put(conversation(1, [for (var i = 0; i < 20; i++) message(i)])); + await store.settled; + store.put(conversation(2, [for (var i = 0; i < 20; i++) message(i)])); + await store.settled; + // A write sweeps at most every ten minutes, so ask for one by opening again. + await store.dispose(); + final reopened = await open( + const FlutwireConfig(enabled: true, maxDiskBytes: 300), + ); + reopened.flush(); + + expect(reopened.calls.value.map((c) => c.id), [2]); + await reopened.dispose(); + }); +} From 11c63b309f8363e494fdb12aeaf4c797cc682dd9 Mon Sep 17 00:00:00 2001 From: TonyTonyCoder11 Date: Sat, 24 Oct 2026 04:26:36 +0200 Subject: [PATCH 3/6] List the messages of a conversation in the inspector A Messages or Events tab lists each message with its direction, kind, size and the moment it crossed, filters by direction, follows the newest while the call runs, and opens a message in the viewer that fits its payload. The list badges the call, the overview counts it, and a WebSocket is not offered for sending again. English and Italian. --- packages/flutwire/lib/src/l10n/strings.dart | 39 ++ .../flutwire/lib/src/l10n/strings_en.dart | 49 ++ .../flutwire/lib/src/l10n/strings_it.dart | 49 ++ .../lib/src/ui/detail/call_detail_page.dart | 21 +- .../lib/src/ui/detail/messages_tab.dart | 335 +++++++++++++ .../lib/src/ui/detail/overview_tab.dart | 10 + .../lib/src/ui/widgets/call_tile.dart | 15 + .../flutwire/test/ui/messages_tab_test.dart | 459 ++++++++++++++++++ .../flutwire/test/ui/resend_entry_test.dart | 21 + 9 files changed, 995 insertions(+), 3 deletions(-) create mode 100644 packages/flutwire/lib/src/ui/detail/messages_tab.dart create mode 100644 packages/flutwire/test/ui/messages_tab_test.dart diff --git a/packages/flutwire/lib/src/l10n/strings.dart b/packages/flutwire/lib/src/l10n/strings.dart index aeaa611..b53bd7b 100644 --- a/packages/flutwire/lib/src/l10n/strings.dart +++ b/packages/flutwire/lib/src/l10n/strings.dart @@ -1,4 +1,5 @@ import '../coverage.dart' show FlutwireGap; +import '../model/message.dart' show FlutwireMessageKind; /// Every piece of text Flutwire shows, in one language. /// @@ -552,4 +553,42 @@ abstract class FlutwireStrings { /// What a screen reader says about a call that was sent again. String get resentBadge; + + // Messages and events + + /// The tab with the messages of a WebSocket. + String get tabMessages; + + /// The tab with the events of a server-sent event stream. + String get tabEvents; + + /// How many messages a conversation holds. + String messageCount(int count); + + /// How many events a stream holds. + String eventCount(int count); + + /// Shows the messages that went either way. + String get messagesAll; + + /// Shows the messages the app sent. + String get messagesSent; + + /// Shows the messages the server sent. + String get messagesReceived; + + /// How many messages at the start were not kept. + String messagesDropped(int count); + + /// A conversation that has had no message yet. + String get messagesEmpty; + + /// The name of a kind of message. + String messageKind(FlutwireMessageKind kind); + + /// A message that carries nothing. + String get messageNoPayload; + + /// A message that was compressed and could not be decompressed. + String get messageUndecoded; } diff --git a/packages/flutwire/lib/src/l10n/strings_en.dart b/packages/flutwire/lib/src/l10n/strings_en.dart index 03213a0..11e9478 100644 --- a/packages/flutwire/lib/src/l10n/strings_en.dart +++ b/packages/flutwire/lib/src/l10n/strings_en.dart @@ -1,4 +1,5 @@ import '../coverage.dart' show FlutwireGap; +import '../model/message.dart' show FlutwireMessageKind; import 'strings.dart'; /// English. @@ -581,4 +582,52 @@ class FlutwireStringsEn extends FlutwireStrings { @override String get resentBadge => 'Sent again'; + + @override + String get tabMessages => 'Messages'; + + @override + String get tabEvents => 'Events'; + + @override + String messageCount(int count) => + count == 1 ? '1 message' : '$count messages'; + + @override + String eventCount(int count) => count == 1 ? '1 event' : '$count events'; + + @override + String get messagesAll => 'All'; + + @override + String get messagesSent => 'Sent'; + + @override + String get messagesReceived => 'Received'; + + @override + String messagesDropped(int count) => count == 1 + ? 'One earlier message was not kept.' + : '$count earlier messages were not kept.'; + + @override + String get messagesEmpty => 'Nothing has crossed yet'; + + @override + String messageKind(FlutwireMessageKind kind) => switch (kind) { + FlutwireMessageKind.text => 'Text', + FlutwireMessageKind.binary => 'Binary', + FlutwireMessageKind.ping => 'Ping', + FlutwireMessageKind.pong => 'Pong', + FlutwireMessageKind.close => 'Close', + FlutwireMessageKind.event => 'Event', + }; + + @override + String get messageNoPayload => 'No payload'; + + @override + String get messageUndecoded => + 'This message was compressed and could not be decompressed, so its ' + 'bytes are shown as they came.'; } diff --git a/packages/flutwire/lib/src/l10n/strings_it.dart b/packages/flutwire/lib/src/l10n/strings_it.dart index 38f35aa..b7632e4 100644 --- a/packages/flutwire/lib/src/l10n/strings_it.dart +++ b/packages/flutwire/lib/src/l10n/strings_it.dart @@ -1,4 +1,5 @@ import '../coverage.dart' show FlutwireGap; +import '../model/message.dart' show FlutwireMessageKind; import 'strings.dart'; /// Italian. @@ -590,4 +591,52 @@ class FlutwireStringsIt extends FlutwireStrings { @override String get resentBadge => 'Reinviata'; + + @override + String get tabMessages => 'Messaggi'; + + @override + String get tabEvents => 'Eventi'; + + @override + String messageCount(int count) => + count == 1 ? '1 messaggio' : '$count messaggi'; + + @override + String eventCount(int count) => count == 1 ? '1 evento' : '$count eventi'; + + @override + String get messagesAll => 'Tutti'; + + @override + String get messagesSent => 'Inviati'; + + @override + String get messagesReceived => 'Ricevuti'; + + @override + String messagesDropped(int count) => count == 1 + ? 'Un messaggio precedente non è stato conservato.' + : '$count messaggi precedenti non sono stati conservati.'; + + @override + String get messagesEmpty => 'Non è ancora passato niente'; + + @override + String messageKind(FlutwireMessageKind kind) => switch (kind) { + FlutwireMessageKind.text => 'Testo', + FlutwireMessageKind.binary => 'Binario', + FlutwireMessageKind.ping => 'Ping', + FlutwireMessageKind.pong => 'Pong', + FlutwireMessageKind.close => 'Chiusura', + FlutwireMessageKind.event => 'Evento', + }; + + @override + String get messageNoPayload => 'Nessun contenuto'; + + @override + String get messageUndecoded => + 'Questo messaggio era compresso e non è stato possibile decomprimerlo, ' + 'quindi i byte sono mostrati così come sono arrivati.'; } diff --git a/packages/flutwire/lib/src/ui/detail/call_detail_page.dart b/packages/flutwire/lib/src/ui/detail/call_detail_page.dart index 78f66c8..1f18be3 100644 --- a/packages/flutwire/lib/src/ui/detail/call_detail_page.dart +++ b/packages/flutwire/lib/src/ui/detail/call_detail_page.dart @@ -6,6 +6,7 @@ import '../../export/export.dart'; import '../../model/call.dart'; import '../../model/cookies.dart'; import '../../model/headers.dart'; +import '../../model/message.dart'; import '../../store/store.dart'; import '../../util/format.dart'; import '../actions.dart'; @@ -19,9 +20,10 @@ import '../widgets/wire_rule.dart'; import 'body_pane.dart'; import 'cookies_view.dart'; import 'headers_view.dart'; +import 'messages_tab.dart'; import 'overview_tab.dart'; -enum _Tab { overview, request, response } +enum _Tab { overview, request, response, messages } enum _Part { body, headers, cookies } @@ -149,6 +151,12 @@ class _CallDetailPageState extends State { _Tab.overview: s.tabOverview, _Tab.request: s.tabRequest, _Tab.response: s.tabResponse, + if (call.messages case final messages?) + _Tab.messages: + messages.kind == + FlutwireConversationKind.eventStream + ? s.tabEvents + : s.tabMessages, }, value: _tab, onChanged: (v) => setState(() => _tab = v), @@ -165,8 +173,11 @@ class _CallDetailPageState extends State { final session = context.session; return [ // A callable function travels through the native SDK, which adds - // credentials Flutwire never sees, so it cannot be sent again from here. - if (session.canResend && call.source != FlutwireSource.functions) + // credentials Flutwire never sees, and a WebSocket handshake is not a + // request that can be sent on its own, so neither is sent again from here. + if (session.canResend && + call.source != FlutwireSource.functions && + call.messages?.kind != FlutwireConversationKind.webSocket) IconButton( tooltip: s.resend, icon: const Icon(Icons.replay_rounded), @@ -223,6 +234,10 @@ class _CallDetailPageState extends State { switch (_tab) { case _Tab.overview: return OverviewTab(call: call, onOpenCall: _openCall); + case _Tab.messages: + return call.messages == null + ? OverviewTab(call: call, onOpenCall: _openCall) + : MessagesTab(call: call); case _Tab.request: final cookies = [ for (final line in call.request.headers['cookie']) diff --git a/packages/flutwire/lib/src/ui/detail/messages_tab.dart b/packages/flutwire/lib/src/ui/detail/messages_tab.dart new file mode 100644 index 0000000..8b5ab25 --- /dev/null +++ b/packages/flutwire/lib/src/ui/detail/messages_tab.dart @@ -0,0 +1,335 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../model/body.dart'; +import '../../model/call.dart'; +import '../../model/message.dart'; +import '../../util/format.dart'; +import '../scope.dart'; +import '../shell.dart'; +import '../theme/tokens.dart'; +import '../widgets/segmented_control.dart'; +import 'body_pane.dart'; + +enum _Direction { all, sent, received } + +/// The messages of a WebSocket, or the events of a server-sent event stream, in +/// the order they crossed, with the moment each one did. +/// +/// While the call runs the list follows the newest message, until the reader +/// scrolls up to look at an older one. +class MessagesTab extends StatefulWidget { + /// Creates the tab for [call], which must hold messages. + const MessagesTab({super.key, required this.call}); + + /// The call whose messages are listed. + final FlutwireCall call; + + @override + State createState() => _MessagesTabState(); +} + +class _MessagesTabState extends State { + final ScrollController _scroll = ScrollController(); + _Direction _direction = _Direction.all; + bool _following = true; + + FlutwireMessages get _messages => widget.call.messages!; + + @override + void initState() { + super.initState(); + // A conversation that is still going is read from its end, a finished one + // from its start. + if (widget.call.isPending) { + WidgetsBinding.instance.addPostFrameCallback((_) => _toNewest()); + } + } + + @override + void didUpdateWidget(MessagesTab old) { + super.didUpdateWidget(old); + if (_following && widget.call.messages!.total != old.call.messages!.total) { + WidgetsBinding.instance.addPostFrameCallback((_) => _toNewest()); + } + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + void _toNewest() { + if (!mounted || !_scroll.hasClients) return; + _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + + bool _scrolled(ScrollNotification notification) { + if (notification.metrics.axis != Axis.vertical) return false; + // Reading an older message turns following off, and the end turns it on. + _following = notification.metrics.extentAfter < 48; + return false; + } + + List get _shown => switch (_direction) { + _Direction.all => _messages.items, + _Direction.sent => [ + for (final m in _messages.items) + if (m.sent) m, + ], + _Direction.received => [ + for (final m in _messages.items) + if (!m.sent) m, + ], + }; + + @override + Widget build(BuildContext context) { + final s = context.strings; + final t = context.fw; + final events = _messages.kind == FlutwireConversationKind.eventStream; + final shown = _shown; + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + events + ? s.eventCount(_messages.total) + : s.messageCount(_messages.total), + style: t.body(size: 13, color: t.muted), + ), + ), + ), + // An event stream only goes one way, so there is nothing to tell apart. + if (!events) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: SegmentedControl<_Direction>( + options: { + _Direction.all: s.messagesAll, + _Direction.sent: s.messagesSent, + _Direction.received: s.messagesReceived, + }, + value: _direction, + onChanged: (value) => setState(() => _direction = value), + ), + ), + if (_messages.dropped > 0) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + s.messagesDropped(_messages.dropped), + style: t.body(size: 12, color: t.faint), + ), + ), + ), + Expanded( + child: shown.isEmpty + ? Center( + child: Text(s.messagesEmpty, style: t.body(color: t.faint)), + ) + : NotificationListener( + onNotification: _scrolled, + child: ListView.builder( + controller: _scroll, + padding: const EdgeInsets.only(bottom: 24), + itemCount: shown.length, + itemBuilder: (context, index) => + _MessageTile(call: widget.call, message: shown[index]), + ), + ), + ), + ], + ); + } +} + +class _MessageTile extends StatelessWidget { + const _MessageTile({required this.call, required this.message}); + + final FlutwireCall call; + final FlutwireMessage message; + + @override + Widget build(BuildContext context) { + final t = context.fw; + final s = context.strings; + final preview = message.preview; + final color = message.sent ? t.copperTone : t.ok; + return InkWell( + onTap: () => unawaited(_openMessage(context, call, message)), + child: Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: t.line)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + message.sent + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 18, + color: color, + semanticLabel: message.sent ? s.messagesSent : s.messagesReceived, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + [ + message.event ?? s.messageKind(message.kind), + formatBytes(message.totalBytes), + ].join(' · '), + style: t.body( + size: 12, + weight: FontWeight.w600, + color: t.muted, + ), + ), + if (preview.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + preview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: t.mono(size: 12.5, color: t.ink), + ), + ], + ], + ), + ), + const SizedBox(width: 10), + Text( + '+${formatDuration(message.at.difference(call.startedAt))}', + style: t.mono(size: 11.5, color: t.faint), + ), + ], + ), + ), + ); + } +} + +/// Opens [message] in a sheet over the call: its payload in the viewer that fits +/// it, so a JSON message is a tree. +Future _openMessage( + BuildContext context, + FlutwireCall call, + FlutwireMessage message, +) { + final session = context.session; + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + elevation: 0, + showDragHandle: false, + builder: (_) => FlutwireShell( + session: session, + child: _MessageSheet(call: call, message: message), + ), + ); +} + +class _MessageSheet extends StatelessWidget { + const _MessageSheet({required this.call, required this.message}); + + final FlutwireCall call; + final FlutwireMessage message; + + @override + Widget build(BuildContext context) { + final t = context.fw; + final s = context.strings; + final height = MediaQuery.sizeOf(context).height; + return Material( + color: t.surface, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(FlutwireTokens.radiusXl), + ), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: t.lineStrong, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Text( + [ + message.sent ? s.messagesSent : s.messagesReceived, + message.event ?? s.messageKind(message.kind), + formatBytes(message.totalBytes), + ].join(' · '), + style: t.body(size: 15, weight: FontWeight.w600, color: t.ink), + ), + ), + if (message.undecoded) + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Text( + s.messageUndecoded, + style: t.body(size: 12.5, color: t.warning), + ), + ), + SizedBox(height: height * 0.55, child: _payload(context)), + ], + ), + ), + ); + } + + Widget _payload(BuildContext context) { + final t = context.fw; + final s = context.strings; + if (message.kind == FlutwireMessageKind.close) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: SelectableText( + message.preview.isEmpty ? s.messageNoPayload : message.preview, + style: t.mono(size: 13, color: t.ink), + ), + ); + } + if (message.bytes.isEmpty) { + return Center( + child: Text(s.messageNoPayload, style: t.body(color: t.faint)), + ); + } + return BodyPane( + call: call, + body: FlutwireBody( + bytes: message.bytes, + totalBytes: message.totalBytes, + contentType: message.kind == FlutwireMessageKind.binary + ? 'application/octet-stream' + : null, + ), + isRequest: message.sent, + ); + } +} diff --git a/packages/flutwire/lib/src/ui/detail/overview_tab.dart b/packages/flutwire/lib/src/ui/detail/overview_tab.dart index d25a2cd..d20ed5d 100644 --- a/packages/flutwire/lib/src/ui/detail/overview_tab.dart +++ b/packages/flutwire/lib/src/ui/detail/overview_tab.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../model/call.dart'; +import '../../model/message.dart'; import '../../util/format.dart'; import '../actions.dart'; import '../scope.dart'; @@ -76,6 +77,15 @@ class OverviewTab extends StatelessWidget { FlutwireCallState.cancelled => s.cancelled, FlutwireCallState.completed => s.noResponseYet, }, color: t.statusColor(call)), + if (call.messages case final messages?) + _Fact( + messages.kind == FlutwireConversationKind.eventStream + ? s.tabEvents + : s.tabMessages, + messages.kind == FlutwireConversationKind.eventStream + ? s.eventCount(messages.total) + : s.messageCount(messages.total), + ), _Fact( s.duration, call.isPending diff --git a/packages/flutwire/lib/src/ui/widgets/call_tile.dart b/packages/flutwire/lib/src/ui/widgets/call_tile.dart index 468e911..dac62f6 100644 --- a/packages/flutwire/lib/src/ui/widgets/call_tile.dart +++ b/packages/flutwire/lib/src/ui/widgets/call_tile.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../model/call.dart'; +import '../../model/message.dart'; import '../../util/format.dart'; import '../scope.dart'; import '../theme/tokens.dart'; @@ -45,8 +46,12 @@ class CallTile extends StatelessWidget { final t = context.fw; final title = call.graphql?.operationName ?? _path(call); final slow = call.duration.inMilliseconds; + // A conversation lasts as long as its two sides have something to say, so + // its length says nothing about how slow it was. final durationColor = call.isPending ? t.copperTone + : call.messages != null + ? t.muted : slow >= 3000 ? t.error : slow >= 1000 @@ -102,6 +107,16 @@ class CallTile extends StatelessWidget { const SizedBox(width: 6), _Badge('GQL', t.copperTone), ], + if (call.messages case final messages?) ...[ + const SizedBox(width: 6), + _Badge( + messages.kind == + FlutwireConversationKind.eventStream + ? 'SSE' + : 'WS', + t.info, + ), + ], if (call.resentFrom != null) ...[ const SizedBox(width: 6), Icon( diff --git a/packages/flutwire/test/ui/messages_tab_test.dart b/packages/flutwire/test/ui/messages_tab_test.dart new file mode 100644 index 0000000..168b174 --- /dev/null +++ b/packages/flutwire/test/ui/messages_tab_test.dart @@ -0,0 +1,459 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/l10n/strings_it.dart'; +import 'package:flutwire/src/ui/detail/call_detail_page.dart'; +import 'package:flutwire/src/ui/detail/messages_tab.dart'; +import 'package:flutwire/src/ui/widgets/call_tile.dart'; + +import 'calls.dart'; +import 'fake_session.dart'; +import 'harness.dart'; + +final start = DateTime.utc(2026, 10, 23, 12); + +FlutwireMessage message( + int index, { + bool sent = false, + FlutwireMessageKind kind = FlutwireMessageKind.text, + String? text, + List? bytes, + int? total, + String? event, + int millis = 0, +}) { + final payload = bytes ?? utf8.encode(text ?? 'message $index'); + return FlutwireMessage( + index: index, + sent: sent, + at: start.add(Duration(milliseconds: millis)), + kind: kind, + bytes: Uint8List.fromList(payload), + totalBytes: total ?? payload.length, + event: event, + ); +} + +FlutwireCall conversation( + List items, { + FlutwireConversationKind kind = FlutwireConversationKind.webSocket, + FlutwireCallState state = FlutwireCallState.completed, + int? total, + int id = 1, +}) => makeCall(id, status: 101, state: state).copyWith( + messages: FlutwireMessages( + kind: kind, + items: items, + total: total ?? items.length, + keptBytes: items.fold(0, (sum, m) => sum + m.bytes.length), + ), +); + +void main() { + group('the tab', () { + Future open(WidgetTester tester, FlutwireCall call) async { + setScreen(tester, const Size(420, 900)); + final session = FakeSession(calls: [call]); + await tester.pumpWidget( + inspectorApp(session, CallDetailPage(callId: call.id)), + ); + await settle(tester); + } + + testWidgets('is there for a WebSocket and is called Messages', ( + tester, + ) async { + await open(tester, conversation([message(0)])); + + // Once as the tab, and once as the line of the overview. + expect(find.text('Messages'), findsNWidgets(2)); + }); + + testWidgets('is called Events for an event stream', (tester) async { + await open( + tester, + conversation([ + message(0, kind: FlutwireMessageKind.event), + ], kind: FlutwireConversationKind.eventStream), + ); + + expect(find.text('Events'), findsNWidgets(2)); + expect(find.text('Messages'), findsNothing); + }); + + testWidgets('is not there for an ordinary call', (tester) async { + await open(tester, makeCall(1)); + + expect(find.text('Messages'), findsNothing); + expect(find.text('Events'), findsNothing); + }); + + testWidgets('opens on the list of messages', (tester) async { + await open(tester, conversation([message(0, text: 'hello')])); + + await tester.tap(find.text('Messages').first); + await settle(tester); + + expect(find.text('hello'), findsOneWidget); + }); + + testWidgets('is counted in the overview too', (tester) async { + await open(tester, conversation([message(0), message(1)])); + + expect(find.text('2 messages'), findsWidgets); + }); + }); + + group('the messages', () { + Future show( + WidgetTester tester, + FlutwireCall call, { + FakeSession? session, + }) async { + await pumpFlutwire( + tester, + MessagesTab(call: call), + session: session, + size: const Size(420, 900), + ); + await settle(tester); + } + + testWidgets('say which way they went, what they are and how big', ( + tester, + ) async { + await show( + tester, + conversation([ + message(0, sent: true, text: 'hello'), + message(1, text: 'world!', millis: 1500), + ]), + ); + + expect(find.text('Text · 5 B'), findsOneWidget); + expect(find.text('Text · 6 B'), findsOneWidget); + expect(find.text('hello'), findsOneWidget); + expect(find.text('world!'), findsOneWidget); + expect(find.text('+1.50 s'), findsOneWidget); + expect(find.byIcon(Icons.arrow_upward_rounded), findsOneWidget); + expect(find.byIcon(Icons.arrow_downward_rounded), findsOneWidget); + }); + + testWidgets('keep a long text to a line or two', (tester) async { + await show(tester, conversation([message(0, text: 'word ' * 200)])); + + final preview = tester.widget(find.textContaining('word word')); + expect(preview.maxLines, 2); + expect(preview.data!.length, lessThan(300)); + }); + + testWidgets('show the first bytes of a binary one', (tester) async { + await show( + tester, + conversation([ + message(0, kind: FlutwireMessageKind.binary, bytes: [0, 255, 16, 1]), + ]), + ); + + expect(find.text('Binary · 4 B'), findsOneWidget); + expect(find.text('00 ff 10 01'), findsOneWidget); + }); + + testWidgets('show how a binary one goes on past the first bytes', ( + tester, + ) async { + await show( + tester, + conversation([ + message( + 0, + kind: FlutwireMessageKind.binary, + bytes: List.generate(16, (i) => i), + total: 5000, + ), + ]), + ); + + expect(find.textContaining('0f …'), findsOneWidget); + }); + + testWidgets('show the code and the reason of a close', (tester) async { + await show( + tester, + conversation([ + message( + 0, + kind: FlutwireMessageKind.close, + bytes: [0x03, 0xe8, ...utf8.encode('done')], + ), + ]), + ); + + expect(find.text('Close · 6 B'), findsOneWidget); + expect(find.text('1000 done'), findsOneWidget); + }); + + testWidgets('show nothing under a ping that carries nothing', ( + tester, + ) async { + await show( + tester, + conversation([ + message(0, sent: true, kind: FlutwireMessageKind.ping, bytes: []), + ]), + ); + + expect(find.text('Ping · 0 B'), findsOneWidget); + }); + + testWidgets('are told apart by direction', (tester) async { + await show( + tester, + conversation([ + message(0, sent: true, text: 'from the app'), + message(1, text: 'from the server'), + ]), + ); + + await tester.tap(find.text('Sent')); + await settle(tester); + + expect(find.text('from the app'), findsOneWidget); + expect(find.text('from the server'), findsNothing); + + await tester.tap(find.text('Received')); + await settle(tester); + + expect(find.text('from the app'), findsNothing); + expect(find.text('from the server'), findsOneWidget); + + await tester.tap(find.text('All')); + await settle(tester); + + expect(find.text('from the app'), findsOneWidget); + expect(find.text('from the server'), findsOneWidget); + }); + + testWidgets('say when there is nothing yet', (tester) async { + await show(tester, conversation(const [])); + + expect(find.text('Nothing has crossed yet'), findsOneWidget); + expect(find.text('0 messages'), findsOneWidget); + }); + + testWidgets('say how many at the start were not kept', (tester) async { + await show( + tester, + conversation([message(200), message(201)], total: 202), + ); + + expect(find.text('202 messages'), findsOneWidget); + expect(find.text('200 earlier messages were not kept.'), findsOneWidget); + }); + + testWidgets('of an event stream have a name, and no direction to pick', ( + tester, + ) async { + await show( + tester, + conversation([ + message(0, kind: FlutwireMessageKind.event, event: 'tick'), + message(1, kind: FlutwireMessageKind.event, text: '{"n":2}'), + ], kind: FlutwireConversationKind.eventStream), + ); + + expect(find.text('tick · 9 B'), findsOneWidget); + expect(find.text('Event · 7 B'), findsOneWidget); + expect(find.text('2 events'), findsOneWidget); + expect(find.text('Sent'), findsNothing); + }); + + testWidgets('open in a sheet with the payload in the viewer', ( + tester, + ) async { + await show(tester, conversation([message(0, text: '{"price":42}')])); + + await tester.tap(find.text('{"price":42}')); + await settle(tester); + + expect(find.text('Received · Text · 12 B'), findsOneWidget); + expect(find.textContaining('price'), findsWidgets); + }); + + testWidgets('one that could not be decompressed says so', (tester) async { + await show( + tester, + conversation([ + FlutwireMessage( + index: 0, + sent: false, + at: start, + kind: FlutwireMessageKind.binary, + bytes: Uint8List.fromList([1, 2, 3, 4]), + totalBytes: 4, + undecoded: true, + ), + ]), + ); + + await tester.tap(find.text('01 02 03 04')); + await settle(tester); + + expect(find.textContaining('could not be decompressed'), findsOneWidget); + }); + + testWidgets('a close opens as its code and reason', (tester) async { + await show( + tester, + conversation([ + message( + 0, + kind: FlutwireMessageKind.close, + bytes: [0x03, 0xe9, ...utf8.encode('going away')], + ), + ]), + ); + + await tester.tap(find.text('1001 going away')); + await settle(tester); + + expect(find.text('Received · Close · 12 B'), findsOneWidget); + expect(find.text('1001 going away'), findsWidgets); + }); + + testWidgets('speak Italian when the app does', (tester) async { + await pumpFlutwire( + tester, + MessagesTab(call: conversation([message(0, sent: true), message(1)])), + strings: const FlutwireStringsIt(), + size: const Size(420, 900), + ); + await settle(tester); + + expect(find.text('2 messaggi'), findsOneWidget); + expect(find.text('Tutti'), findsOneWidget); + expect(find.text('Inviati'), findsOneWidget); + expect(find.text('Ricevuti'), findsOneWidget); + expect(find.text('Testo · 9 B'), findsNWidgets(2)); + }); + }); + + testWidgets('a conversation is badged in the list', (tester) async { + final now = DateTime.utc(2026, 10, 23, 12, 1); + await pumpFlutwire( + tester, + Column( + children: [ + CallTile(call: conversation([message(0)]), now: now), + CallTile( + call: conversation( + [message(0, kind: FlutwireMessageKind.event)], + kind: FlutwireConversationKind.eventStream, + id: 2, + ), + now: now, + ), + CallTile(call: makeCall(3), now: now), + ], + ), + ); + + expect(find.text('WS'), findsOneWidget); + expect(find.text('SSE'), findsOneWidget); + }); + + group('while the call runs', () { + Future> follow( + WidgetTester tester, + int count, + ) async { + final notifier = ValueNotifier( + conversation([ + for (var i = 0; i < count; i++) message(i), + ], state: FlutwireCallState.pending), + ); + await pumpFlutwire( + tester, + ValueListenableBuilder( + valueListenable: notifier, + builder: (_, call, _) => MessagesTab(call: call), + ), + size: const Size(420, 900), + ); + await settle(tester); + return notifier; + } + + double offset(WidgetTester tester) => + tester.state(find.byType(Scrollable)).position.pixels; + + double end(WidgetTester tester) => tester + .state(find.byType(Scrollable)) + .position + .maxScrollExtent; + + FlutwireCall grown(int count) => conversation([ + for (var i = 0; i < count; i++) message(i), + ], state: FlutwireCallState.pending); + + testWidgets('the list opens on the newest message', (tester) async { + await follow(tester, 60); + + expect(end(tester), greaterThan(0)); + expect(offset(tester), end(tester)); + }); + + testWidgets('and keeps following as more arrive', (tester) async { + final notifier = await follow(tester, 60); + + notifier.value = grown(70); + await settle(tester); + + expect(offset(tester), end(tester)); + }); + + testWidgets('until the reader scrolls up to an older one', (tester) async { + final notifier = await follow(tester, 60); + + await tester.drag(find.byType(Scrollable), const Offset(0, 400)); + await settle(tester); + final where = offset(tester); + notifier.value = grown(70); + await settle(tester); + + expect(offset(tester), where); + expect(offset(tester), lessThan(end(tester))); + }); + + testWidgets('and follows again once the reader is back at the end', ( + tester, + ) async { + final notifier = await follow(tester, 60); + + await tester.drag(find.byType(Scrollable), const Offset(0, 400)); + await settle(tester); + await tester.drag(find.byType(Scrollable), const Offset(0, -2000)); + await settle(tester); + notifier.value = grown(70); + await settle(tester); + + expect(offset(tester), end(tester)); + }); + + testWidgets('a finished call opens at the first message', (tester) async { + await pumpFlutwire( + tester, + MessagesTab( + call: conversation([for (var i = 0; i < 60; i++) message(i)]), + ), + size: const Size(420, 900), + ); + await settle(tester); + + expect(offset(tester), 0); + }); + }); +} diff --git a/packages/flutwire/test/ui/resend_entry_test.dart b/packages/flutwire/test/ui/resend_entry_test.dart index 87297ef..22bd2ac 100644 --- a/packages/flutwire/test/ui/resend_entry_test.dart +++ b/packages/flutwire/test/ui/resend_entry_test.dart @@ -43,6 +43,27 @@ void main() { expect(find.byTooltip('Edit and resend'), findsNothing); }); + FlutwireCall holding(FlutwireConversationKind kind) => makeCall(1).copyWith( + messages: FlutwireMessages( + kind: kind, + items: const [], + total: 0, + keptBytes: 0, + ), + ); + + testWidgets('is not offered for a WebSocket', (tester) async { + await openDetail(tester, holding(FlutwireConversationKind.webSocket)); + + expect(find.byTooltip('Edit and resend'), findsNothing); + }); + + testWidgets('is offered for an event stream', (tester) async { + await openDetail(tester, holding(FlutwireConversationKind.eventStream)); + + expect(find.byTooltip('Edit and resend'), findsOneWidget); + }); + testWidgets('is not offered for a callable function', (tester) async { await openDetail( tester, From 1cd6097733387b29340a7a15ee9cdc3bfcef0a2f Mon Sep 17 00:00:00 2001 From: TonyTonyCoder11 Date: Sat, 24 Oct 2026 04:26:36 +0200 Subject: [PATCH 4/6] Export the messages of a conversation HAR carries a WebSocket as Chrome does, with the opcode of each frame, and the events of a stream under their own key. The text export lists them with their direction and offset. --- packages/flutwire/lib/src/export/har.dart | 44 ++++ packages/flutwire/lib/src/export/text.dart | 32 +++ .../test/export/messages_export_test.dart | 242 ++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 packages/flutwire/test/export/messages_export_test.dart diff --git a/packages/flutwire/lib/src/export/har.dart b/packages/flutwire/lib/src/export/har.dart index 146323e..919adee 100644 --- a/packages/flutwire/lib/src/export/har.dart +++ b/packages/flutwire/lib/src/export/har.dart @@ -4,6 +4,7 @@ import '../model/body.dart'; import '../model/call.dart'; import '../model/cookies.dart'; import '../model/headers.dart'; +import '../model/message.dart'; import '../util/format.dart'; /// The calls as a HAR 1.2 document, the format browsers and proxies import. @@ -59,6 +60,49 @@ Map _entry(FlutwireCall call) { 'state': call.state.name, if (call.graphql != null) 'graphql': call.graphql!.toJson(), }, + ..._conversation(call), + }; +} + +/// The messages of a WebSocket or an event stream, written the way Chrome +/// writes them: a WebSocket under `_webSocketMessages` with the opcode of each +/// frame, and the events of a stream under `_eventSourceMessages`. +Map _conversation(FlutwireCall call) { + final messages = call.messages; + if (messages == null || !messages.loaded) return const {}; + double seconds(DateTime at) => at.microsecondsSinceEpoch / 1000000; + if (messages.kind == FlutwireConversationKind.eventStream) { + return { + '_eventSourceMessages': [ + for (final m in messages.items) + { + 'time': seconds(m.at), + 'eventName': m.event ?? 'message', + 'eventId': ?m.eventId, + 'data': m.text ?? '', + }, + ], + }; + } + return { + '_resourceType': 'websocket', + '_webSocketMessages': [ + for (final m in messages.items) + { + 'type': m.sent ? 'send' : 'receive', + 'time': seconds(m.at), + 'opcode': switch (m.kind) { + FlutwireMessageKind.text || FlutwireMessageKind.event => 1, + FlutwireMessageKind.binary => 2, + FlutwireMessageKind.close => 8, + FlutwireMessageKind.ping => 9, + FlutwireMessageKind.pong => 10, + }, + 'data': m.kind == FlutwireMessageKind.text + ? m.text + : base64Encode(m.bytes), + }, + ], }; } diff --git a/packages/flutwire/lib/src/export/text.dart b/packages/flutwire/lib/src/export/text.dart index ed1bffb..12d5ab0 100644 --- a/packages/flutwire/lib/src/export/text.dart +++ b/packages/flutwire/lib/src/export/text.dart @@ -1,6 +1,7 @@ import '../model/body.dart'; import '../model/call.dart'; import '../model/headers.dart'; +import '../model/message.dart'; import '../util/format.dart'; /// One call as plain text a person can paste into a ticket or a chat. @@ -33,6 +34,19 @@ String callToText(FlutwireCall call) { buffer ..writeln(_row('Request size', formatBytes(call.requestSize))) ..writeln(_row('Response size', formatBytes(call.responseSize))); + if (call.messages case final messages?) { + final kept = messages.items.length; + buffer.writeln( + _row( + messages.kind == FlutwireConversationKind.eventStream + ? 'Events' + : 'Messages', + messages.loaded && messages.dropped > 0 + ? '${messages.total} ($kept kept)' + : '${messages.total}', + ), + ); + } if (response != null && response.redirects.isNotEmpty) { buffer.writeln( _row( @@ -64,6 +78,24 @@ String callToText(FlutwireCall call) { ..writeln('--- Response body ---') ..writeln(_body(response.body)); } + if (call.messages case final messages? when messages.items.isNotEmpty) { + buffer + ..writeln() + ..writeln( + messages.kind == FlutwireConversationKind.eventStream + ? '--- Events ---' + : '--- Messages ---', + ); + for (final m in messages.items) { + final offset = '+${formatDuration(m.at.difference(call.startedAt))}'; + buffer.writeln( + '${m.sent ? '->' : '<-'} ${offset.padRight(10)} ' + '${(m.event ?? m.kind.name).padRight(8)} ' + '${formatBytes(m.totalBytes).padRight(8)} ${m.preview}' + .trimRight(), + ); + } + } return buffer.toString().trimRight(); } diff --git a/packages/flutwire/test/export/messages_export_test.dart b/packages/flutwire/test/export/messages_export_test.dart new file mode 100644 index 0000000..87129ec --- /dev/null +++ b/packages/flutwire/test/export/messages_export_test.dart @@ -0,0 +1,242 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/export/har.dart'; +import 'package:flutwire/src/export/text.dart'; + +final started = DateTime.utc(2026, 10, 24, 12); + +FlutwireMessage message( + int index, + FlutwireMessageKind kind, + List bytes, { + bool sent = false, + int millis = 0, + String? event, + String? eventId, +}) => FlutwireMessage( + index: index, + sent: sent, + at: started.add(Duration(milliseconds: millis)), + kind: kind, + bytes: Uint8List.fromList(bytes), + totalBytes: bytes.length, + event: event, + eventId: eventId, +); + +FlutwireCall conversation( + List items, { + FlutwireConversationKind kind = FlutwireConversationKind.webSocket, + int? total, + bool loaded = true, +}) { + final messages = FlutwireMessages( + kind: kind, + items: items, + total: total ?? items.length, + keptBytes: items.fold(0, (sum, m) => sum + m.bytes.length), + ); + return FlutwireCall( + id: 1, + method: 'GET', + uri: Uri.parse('wss://api.test/live'), + source: FlutwireSource.io, + state: FlutwireCallState.completed, + startedAt: started, + endedAt: started.add(const Duration(seconds: 2)), + request: FlutwireRequest( + headers: FlutwireHeaders.empty, + body: FlutwireBody.empty(), + ), + response: FlutwireResponse( + statusCode: 101, + headers: FlutwireHeaders.fromMap({'upgrade': 'websocket'}), + body: FlutwireBody.empty(), + ), + messages: loaded ? messages : messages.unload(), + ); +} + +List har(FlutwireCall call, String key) { + final entries = + (toHar([call])['log']! as Map)['entries']! + as List; + return (entries.single! as Map)[key] as List; +} + +void main() { + group('HAR', () { + test('lists the messages of a WebSocket the way Chrome does', () { + final call = conversation([ + message(0, FlutwireMessageKind.text, utf8.encode('hello'), sent: true), + message(1, FlutwireMessageKind.binary, [1, 2, 3], millis: 500), + message(2, FlutwireMessageKind.ping, const [], millis: 900), + message(3, FlutwireMessageKind.pong, const [], sent: true, millis: 950), + message(4, FlutwireMessageKind.close, [0x03, 0xe8], millis: 1500), + ]); + + final frames = har( + call, + '_webSocketMessages', + ).cast>(); + + expect(frames.map((f) => f['type']), [ + 'send', + 'receive', + 'receive', + 'send', + 'receive', + ]); + expect(frames.map((f) => f['opcode']), [1, 2, 9, 10, 8]); + expect(frames.first['data'], 'hello'); + expect(frames[1]['data'], base64Encode([1, 2, 3])); + expect(frames.first['time'], started.microsecondsSinceEpoch / 1000000); + expect( + (frames[1]['time']! as double) - (frames.first['time']! as double), + closeTo(0.5, 1e-9), + ); + }); + + test('marks the entry as a WebSocket', () { + final call = conversation([ + message(0, FlutwireMessageKind.text, [65]), + ]); + + final entry = + ((toHar([call])['log']! as Map)['entries']! + as List) + .single! + as Map; + + expect(entry['_resourceType'], 'websocket'); + }); + + test('lists the events of a stream with their names and ids', () { + final call = conversation([ + message( + 0, + FlutwireMessageKind.event, + utf8.encode('{"n":1}'), + event: 'tick', + eventId: '7', + ), + message(1, FlutwireMessageKind.event, utf8.encode('plain'), millis: 20), + ], kind: FlutwireConversationKind.eventStream); + + final events = har( + call, + '_eventSourceMessages', + ).cast>(); + + expect(events.map((e) => e['eventName']), ['tick', 'message']); + expect(events.first['eventId'], '7'); + expect(events.last.containsKey('eventId'), isFalse); + expect(events.map((e) => e['data']), ['{"n":1}', 'plain']); + }); + + test('leaves messages out when they were not read back from disk', () { + final call = conversation([ + message(0, FlutwireMessageKind.text, [65]), + ], loaded: false); + + final entry = + ((toHar([call])['log']! as Map)['entries']! + as List) + .single! + as Map; + + expect(entry.containsKey('_webSocketMessages'), isFalse); + }); + + test('writes an ordinary call as before', () { + final call = conversation([]).copyWith(); + final plain = FlutwireCall( + id: 2, + method: 'GET', + uri: Uri.parse('https://api.test/'), + source: FlutwireSource.io, + startedAt: started, + request: call.request, + ); + + final entry = + ((toHar([plain])['log']! as Map)['entries']! + as List) + .single! + as Map; + + expect(entry.containsKey('_webSocketMessages'), isFalse); + expect(entry.containsKey('_resourceType'), isFalse); + }); + }); + + group('text', () { + test('counts the messages and lists them with their direction', () { + final text = callToText( + conversation([ + message( + 0, + FlutwireMessageKind.text, + utf8.encode('hello'), + sent: true, + ), + message( + 1, + FlutwireMessageKind.text, + utf8.encode('world'), + millis: 1250, + ), + message(2, FlutwireMessageKind.close, [ + 0x03, + 0xe8, + ...utf8.encode('done'), + ], millis: 1900), + ]), + ); + + expect(text, contains('Messages: 3')); + expect(text, contains('--- Messages ---')); + expect(text, contains('-> +0 ms')); + expect(text, contains('hello')); + expect(text, contains('<- +1.25 s')); + expect(text, contains('1000 done')); + }); + + test('says how many were kept when the first ones were not', () { + final text = callToText( + conversation([ + message(200, FlutwireMessageKind.text, [65]), + ], total: 201), + ); + + expect(text, contains('Messages: 201 (1 kept)')); + }); + + test('calls the events of a stream events', () { + final text = callToText( + conversation([ + message( + 0, + FlutwireMessageKind.event, + utf8.encode('a'), + event: 'tick', + ), + ], kind: FlutwireConversationKind.eventStream), + ); + + expect(text, contains('Events: 1')); + expect(text, contains('--- Events ---')); + expect(text, contains('tick')); + }); + + test('has no section for a call with no messages', () { + final text = callToText(conversation([])); + + expect(text, isNot(contains('--- Messages ---'))); + expect(text, contains('Messages: 0')); + }); + }); +} From dd17e1b682c482284b3262a7f59e0e3a142a401e Mon Sep 17 00:00:00 2001 From: TonyTonyCoder11 Date: Sat, 24 Oct 2026 04:26:36 +0200 Subject: [PATCH 5/6] Respect the connection timeout the app set, and read the recording rule plainly --- packages/flutwire/lib/src/capture/wire/send_again_io.dart | 5 +++-- packages/flutwire/lib/src/engine.dart | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/flutwire/lib/src/capture/wire/send_again_io.dart b/packages/flutwire/lib/src/capture/wire/send_again_io.dart index 9b2c7b9..1a82d78 100644 --- a/packages/flutwire/lib/src/capture/wire/send_again_io.dart +++ b/packages/flutwire/lib/src/capture/wire/send_again_io.dart @@ -6,7 +6,8 @@ import '../../resend.dart'; import '../recorder.dart'; import 'wire_io.dart'; -/// How long a request may wait for the connection. +/// How long a request may wait for the connection, unless the app's own +/// client already says. const Duration _connectTimeout = Duration(seconds: 30); /// How long a request may run, from the moment its connection is open. @@ -34,7 +35,7 @@ Future sendAgain( }) async { var inner = HttpClient(); if (inner is FlutwireHttpClient) inner = inner.unwrapped; - inner.connectionTimeout = _connectTimeout; + inner.connectionTimeout ??= _connectTimeout; final client = FlutwireHttpClient(inner, engine, resentFrom: request.from); final HttpClientRequest open; try { diff --git a/packages/flutwire/lib/src/engine.dart b/packages/flutwire/lib/src/engine.dart index 160a1bb..36a881e 100644 --- a/packages/flutwire/lib/src/engine.dart +++ b/packages/flutwire/lib/src/engine.dart @@ -81,9 +81,10 @@ final class FlutwireEngine implements FlutwireRecorderHost { }) { try { final upper = method.toUpperCase(); - if (resentFrom == null ? !_shouldRecord(upper, uri) : !config.isActive) { - return null; - } + final record = resentFrom == null + ? _shouldRecord(upper, uri) + : config.isActive; + if (!record) return null; final redaction = config.redaction; final call = FlutwireCall( id: _nextId(), From 3443b5712aa8614c74cc4082bba0a5ccbe17e37a Mon Sep 17 00:00:00 2001 From: TonyTonyCoder11 Date: Sat, 24 Oct 2026 04:26:36 +0200 Subject: [PATCH 6/6] Show a WebSocket and an event stream in the example, and describe them --- CHANGELOG.md | 1 + README.md | 2 +- packages/flutwire/README.md | 8 +++- packages/flutwire/example/lib/main.dart | 54 +++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 062c1f7..77dd35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ board can be cross-checked against what actually shipped. ### Added +- WebSockets and server-sent event streams. A WebSocket is one call that stays open, and it lists its messages with the ping, pong and close frames, which way each went, how big it was and when it crossed, compressed messages included. An event stream lists its events as they complete, with their names and ids, and keeps the raw stream as the body of the response. Both get a Messages or Events tab, a badge in the list, a line in the overview, entries in the HAR and text exports, and their messages are kept on disk with the call. The latest 1000 messages are kept, with a megabyte of payload at most, and the tab says how many came before (M16) - Any call can be opened in an editor, changed and sent again. The editor starts from the method, address, header fields and body that were recorded. A value the redaction masked was never kept, so it is flagged and not sent unless it is typed again. What goes out is recorded as a new call that names the one it repeats, marked in the list and linked from its overview. It needs `dart:io`, so the web does not offer it (M15) - A line above the list that says when Flutwire may be missing calls, with the reason and what to change: a client or adapter that does not use `dart:io`, an adapter that ran in another isolate, or something that replaced the capture's `HttpOverrides`. It stays out of sight until Flutwire has seen one of them happen, and it names the client or the isolate. `Flutwire.coverage` lists the notes, and an adapter that stands down for the capture reports through `Flutwire.watchStandDown`. The example app has a probe for each case (M24) - An example for each adapter, so the package page on pub.dev has one to show (M34) diff --git a/README.md b/README.md index eebe1df..3c44adf 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ When an app misbehaves on a phone, the call that explains it is usually out of reach. The console shows only what a logger was told to print. A proxy needs a certificate on the device and a changed Wi-Fi setting. Every HTTP client in the app has to be wired by hand before it says anything, and when the app is killed the evidence goes with it. -Flutwire records the calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A call can be edited and sent again from the same screen. A debug build needs one line in `main`; a release build records nothing. +Flutwire records the calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A call can be edited and sent again from the same screen, and a WebSocket or an event stream lists its messages as they arrive. A debug build needs one line in `main`; a release build records nothing.

The notification in the shade lists the latest calls, and tapping it opens the inspector, where a call opens as a JSON tree
diff --git a/packages/flutwire/README.md b/packages/flutwire/README.md index 34185e7..f381a2d 100644 --- a/packages/flutwire/README.md +++ b/packages/flutwire/README.md @@ -6,7 +6,7 @@ **A network inspector for Flutter that opens from the notification shade.** -Flutwire records the HTTP calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A call can be edited and sent again from the same screen. +Flutwire records the HTTP calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A call can be edited and sent again from the same screen, and a WebSocket or an event stream lists its messages as they arrive.

The notification in the shade lists the latest calls, and tapping it opens the inspector, where a call opens as a JSON tree
@@ -49,6 +49,12 @@ MaterialApp( ); ``` +## WebSockets and event streams + +A WebSocket opened through `dart:io` is one call that stays open. Its Messages tab lists every message and control frame in the order it crossed, which way it went, how big it was and when, and a compressed message is shown after it is decompressed. An event stream (`text/event-stream`) lists its events as they complete, with their names and ids, and the raw stream stays the body of the response. Only the latest 1000 messages are kept, and the tab says how many came before them. + +`dart:io` makes one client for all WebSockets the first time one is opened, so a WebSocket opened before `Flutwire.init` is not seen: call `init` first thing in `main`. On the web the browser opens WebSockets itself, and none is recorded there. + ## Sending a call again Open a call and tap the replay button in the top bar. The method, the address, the header fields and the body can all be changed, and Send makes the request. What goes out is recorded as a new call that names the one it repeats. diff --git a/packages/flutwire/example/lib/main.dart b/packages/flutwire/example/lib/main.dart index 3d7b130..ed9053f 100644 --- a/packages/flutwire/example/lib/main.dart +++ b/packages/flutwire/example/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; +import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show kIsWeb; @@ -177,6 +178,10 @@ final List<(String, List)> groups = [ ), ], ), + ( + 'Streams', + [Probe('WebSocket', _webSocket), Probe('Event stream', _eventStream)], + ), ( 'Trouble', [ @@ -210,6 +215,55 @@ final List<(String, List)> groups = [ ), ]; +/// Opens a WebSocket to an echo server, sends a text and a binary message, and +/// closes it, so the inspector has a conversation to list. `dart:io` makes one +/// client for every WebSocket the first time it opens one, so this works +/// because `Flutwire.init` ran before that. +Future _webSocket() async { + if (kIsWeb) return; + try { + final socket = await WebSocket.connect('wss://echo.websocket.org'); + final closed = Completer(); + socket.listen( + (_) {}, + onDone: closed.complete, + onError: (Object _) => closed.complete(), + ); + socket + ..add('hello') + ..add(jsonEncode({'subscribe': 'prices'})) + ..add(Uint8List.fromList([1, 2, 3])); + await Future.delayed(const Duration(seconds: 2)); + await socket.close(1000, 'done'); + await closed.future.timeout(const Duration(seconds: 5)); + } on Object { + // The inspector shows what went wrong. + } +} + +/// Reads a few chunks of a public event stream and walks away, so the +/// inspector lists the events that arrived and the call ends as cancelled. +Future _eventStream() async { + if (kIsWeb) return; + final io = HttpClient(); + try { + final request = await io.getUrl( + Uri.parse('https://stream.wikimedia.org/v2/stream/recentchange'), + ); + request.headers.set('accept', 'text/event-stream'); + final response = await request.close(); + await response + .transform(utf8.decoder) + .take(6) + .drain() + .timeout(const Duration(seconds: 10)); + } on Object { + // The inspector shows what went wrong. + } finally { + io.close(force: true); + } +} + /// What Flutwire cannot see, so that the inspector has something to say about /// it. These are left out of "Run everything": replacing the capture stops the /// recording of everything after it.