From 93e7fbb1ef07743433b36387a81b6628a7ea7318 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:07:23 +0800 Subject: [PATCH 1/2] feat(fetch): make Body subclassable --- CHANGELOG.md | 2 + lib/src/fetch/body.dart | 86 +++++++++++++++++++------------ test/public_api_surface_test.dart | 23 +++++++++ 3 files changed, 79 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf5537..9d300dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ instead of reading the previous `body.stream` getter. - Added `Body.size` for exposing known body byte lengths without consuming the body. +- Added a public generative `Body` constructor so downstream wrappers can extend + `Body` without reimplementing `BodyInit` normalization. - Fixed `Blob` byte snapshot semantics so byte-backed parts and read buffers are copied consistently across native, `dart:io`, and js wrappers. diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index af93544..be80d9e 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -40,30 +40,18 @@ const _defaultBlobChunkSize = 16 * 1024; /// /// This is the shared body baseline that web/io implementations align to. class Body extends Blob with Stream implements Stream { - Body._({ - Iterable blobParts = const [], - Stream? streamHost, - int? streamSize, - String type = '', - this.contentType, - }) : assert(streamSize == null || streamSize >= 0), - _streamHost = streamHost, - _streamSize = streamSize, - super(blobParts, type); - - factory Body([BodyInit? init]) { - return switch (init) { - final Body body => body.clone(), - final FormData formData => Body._fromFormData(formData), - final Stream> stream => Body._fromStream(stream), - final String text => Body._fromBlobInit(text, _textPlainUtf8), - final URLSearchParams params => Body._fromBlobInit( - params.toString(), - _urlEncodedUtf8, - ), - _ => Body._fromBlobInit(init, _blobInitType(init)), - }; - } + /// Creates a body from [init]. + /// + /// This constructor is generative so downstream wrappers can extend [Body] + /// and call `super(init)` without reimplementing [BodyInit] normalization. + Body([BodyInit? init]) : this._fromState(_BodyState.from(init)); + + Body._fromState(_BodyState state) + : assert(state.streamSize == null || state.streamSize! >= 0), + _streamHost = state.streamHost, + _streamSize = state.streamSize, + contentType = state.contentType, + super(state.blobParts, state.type); Stream? _streamHost; int? _streamSize; @@ -146,13 +134,17 @@ class Body extends Blob with Stream implements Stream { } Body clone() { + return Body._fromState(_cloneState()); + } + + _BodyState _cloneState() { if (_used) { throw StateError('Body has already been consumed.'); } final streamHost = _streamHost; if (streamHost == null) { - return Body._( + return _BodyState( blobParts: [super.slice(0, null, type)], type: type, contentType: contentType, @@ -161,7 +153,7 @@ class Body extends Blob with Stream implements Stream { final (left, right) = streamTee(streamHost); _streamHost = left; - return Body._( + return _BodyState( streamHost: right, streamSize: _streamSize, type: type, @@ -204,17 +196,17 @@ class Body extends Blob with Stream implements Stream { _used = true; } - static Body _fromBlobInit(BodyInit init, String type) { - return Body._( + static _BodyState _fromBlobInit(BodyInit init, String type) { + return _BodyState( blobParts: init == null ? const [] : [init], type: type, contentType: _contentType(type), ); } - static Body _fromFormData(FormData formData) { + static _BodyState _fromFormData(FormData formData) { final encoded = formData.encodeMultipart(); - return Body._( + return _BodyState( streamHost: encoded.stream, streamSize: encoded.contentLength, type: encoded.contentType, @@ -222,8 +214,8 @@ class Body extends Blob with Stream implements Stream { ); } - static Body _fromStream(Stream> stream) { - return Body._(streamHost: stream.map(Uint8List.fromList)); + static _BodyState _fromStream(Stream> stream) { + return _BodyState(streamHost: stream.map(Uint8List.fromList)); } static Future _readStream(Stream stream) async { @@ -247,3 +239,33 @@ class Body extends Blob with Stream implements Stream { static String? _contentType(String type) => type.isEmpty ? null : type; } + +final class _BodyState { + const _BodyState({ + this.blobParts = const [], + this.streamHost, + this.streamSize, + this.type = '', + this.contentType, + }) : assert(streamSize == null || streamSize >= 0); + + final Iterable blobParts; + final Stream? streamHost; + final int? streamSize; + final String type; + final String? contentType; + + static _BodyState from(BodyInit? init) { + return switch (init) { + final Body body => body._cloneState(), + final FormData formData => Body._fromFormData(formData), + final Stream> stream => Body._fromStream(stream), + final String text => Body._fromBlobInit(text, _textPlainUtf8), + final URLSearchParams params => Body._fromBlobInit( + params.toString(), + _urlEncodedUtf8, + ), + _ => Body._fromBlobInit(init, Body._blobInitType(init)), + }; + } +} diff --git a/test/public_api_surface_test.dart b/test/public_api_surface_test.dart index a34d1f1..bd100c4 100644 --- a/test/public_api_surface_test.dart +++ b/test/public_api_surface_test.dart @@ -5,6 +5,17 @@ import 'package:block/block.dart' as block; import 'package:ht/ht.dart'; import 'package:test/test.dart'; +final class _RequestBody extends Body { + _RequestBody(super.init, {required this.replayable}); + + final bool replayable; + + @override + _RequestBody clone() { + return _RequestBody(this, replayable: replayable); + } +} + void main() { test('public API symbols are importable and usable', () async { const method = 'POST'; @@ -25,6 +36,13 @@ void main() { final form = FormData()..append('file', Multipart.blob(file)); final multipart = form.encodeMultipart(boundary: 'api'); final body = Body('public'); + final requestBody = _RequestBody( + Stream>.fromIterable(>[ + Uint8List.fromList([119, 114, 97, 112]), + ]), + replayable: true, + ); + final requestBodyClone = requestBody.clone(); final blockBody = block.Block(['block-body'], type: 'text/plain'); final request = Request( @@ -48,6 +66,11 @@ void main() { expect(body, isA()); expect(body, isA>()); expect(body.size, 6); + expect(requestBody, isA()); + expect(requestBodyClone, isA<_RequestBody>()); + expect(requestBodyClone.replayable, isTrue); + expect(await requestBody.text(), 'wrap'); + expect(await requestBodyClone.text(), 'wrap'); expect(request.headers.has('content-type'), isTrue); expect(await multipart.bytes(), isNotEmpty); expect(await response.text(), 'block-body'); From 2a37e915e83b5a80fde53295d1e1bcec0bf7b263 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:22:20 +0800 Subject: [PATCH 2/2] fix(fetch): preserve Body subclass clone hooks --- lib/src/fetch/body.dart | 3 +++ lib/src/fetch/request.native.dart | 5 ++++- lib/src/fetch/response.native.dart | 1 + test/public_api_surface_test.dart | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index be80d9e..21aeff0 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -257,6 +257,9 @@ final class _BodyState { static _BodyState from(BodyInit? init) { return switch (init) { + // Constructor-copy path for subclasses calling super(init). Dispatching + // through clone() here would recurse for clone() methods that rebuild + // the subclass from the current instance. final Body body => body._cloneState(), final FormData formData => Body._fromFormData(formData), final Stream> stream => Body._fromStream(stream), diff --git a/lib/src/fetch/request.native.dart b/lib/src/fetch/request.native.dart index 51cebc9..4478ae6 100644 --- a/lib/src/fetch/request.native.dart +++ b/lib/src/fetch/request.native.dart @@ -275,7 +275,10 @@ class Request { ) { if (init != null) { _validateRequestBodyMethod(method); - return Body(init); + return switch (init) { + final Body body => body.clone(), + _ => Body(init), + }; } final body = switch (input) { diff --git a/lib/src/fetch/response.native.dart b/lib/src/fetch/response.native.dart index f839168..c9fb924 100644 --- a/lib/src/fetch/response.native.dart +++ b/lib/src/fetch/response.native.dart @@ -189,6 +189,7 @@ class Response { 'body', 'Response status $status cannot have a body.', ), + final Body body => body.clone(), _ => Body(init), }; } diff --git a/test/public_api_surface_test.dart b/test/public_api_surface_test.dart index bd100c4..91458a4 100644 --- a/test/public_api_surface_test.dart +++ b/test/public_api_surface_test.dart @@ -43,6 +43,11 @@ void main() { replayable: true, ); final requestBodyClone = requestBody.clone(); + final requestBodyForRequest = _RequestBody( + 'request-body', + replayable: false, + ); + final responseBody = _RequestBody('response-body', replayable: true); final blockBody = block.Block(['block-body'], type: 'text/plain'); final request = Request( @@ -51,6 +56,11 @@ void main() { ); final response = Response(blockBody, responseInit); + final requestWithSubclassBody = Request( + Uri.parse('https://example.com/subclass'), + RequestInit(method: method, body: requestBodyForRequest), + ); + final responseWithSubclassBody = Response(responseBody); final Object init = 'x'; @@ -71,9 +81,15 @@ void main() { expect(requestBodyClone.replayable, isTrue); expect(await requestBody.text(), 'wrap'); expect(await requestBodyClone.text(), 'wrap'); + expect(requestWithSubclassBody.body, isA<_RequestBody>()); + expect((requestWithSubclassBody.body! as _RequestBody).replayable, isFalse); + expect(await requestWithSubclassBody.text(), 'request-body'); expect(request.headers.has('content-type'), isTrue); expect(await multipart.bytes(), isNotEmpty); expect(await response.text(), 'block-body'); + expect(responseWithSubclassBody.body, isA<_RequestBody>()); + expect((responseWithSubclassBody.body! as _RequestBody).replayable, isTrue); + expect(await responseWithSubclassBody.text(), 'response-body'); expect(response.ok, isTrue); expect(init, 'x'); });