From 461a7af568acadbb7054b8e8cb4474b6965a4a57 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Feb 2026 04:06:46 +0800 Subject: [PATCH 1/4] feat(fetch)!: integrate block-backed blob and stream-first multipart BREAKING CHANGE: Blob now implements block.Block and removes synchronous copyBytes(). Blob.slice follows Web Blob negative-index semantics. FormData.encodeMultipart now returns stream-first MultipartBody and MultipartBody.bytes is async bytes(). --- CHANGELOG.md | 15 ++ README.md | 40 ++++- lib/src/fetch/blob.dart | 251 ++++++++++++++++++++++++------ lib/src/fetch/body.dart | 19 ++- lib/src/fetch/form_data.dart | 147 +++++++++++------ pubspec.lock | 8 + pubspec.yaml | 3 +- test/form_data_test.dart | 45 ++++-- test/public_api_surface_test.dart | 7 +- test/request_response_test.dart | 23 +++ 10 files changed, 447 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2da83..dd9b86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.2.0 + +- BREAKING: Reworked `Blob` to a `block`-backed implementation and removed + synchronous `Blob.copyBytes()`. +- Added direct `Blob` <-> `block.Block` compatibility (`Blob` now implements + `Block`). +- BREAKING: `Blob.slice` now follows Web Blob semantics (negative indexes are + resolved from the end). +- BREAKING: `FormData.encodeMultipart()` now returns a stream-first + `MultipartBody`, and `MultipartBody.bytes` is now async method `bytes()`. +- Added stream-first `MultipartBody` with `stream`, `contentLength`, + `contentType`, and async `bytes()`. +- Added `BodyInit` support for `package:block` `Block` values in `Request` and + `Response`. + ## 0.1.0 - Rebuilt the package as a fetch-first HTTP type layer. diff --git a/README.md b/README.md index a89780a..0fe91fd 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Or add it manually to `pubspec.yaml`: ```yaml dependencies: - ht: ^0.1.0 + ht: ^0.2.0 ``` ## Scope @@ -82,18 +82,54 @@ Future main() async { ```dart import 'package:ht/ht.dart'; -void main() { +Future main() async { final form = FormData() ..append('name', 'alice') ..append('avatar', Blob.text('binary'), filename: 'avatar.txt'); final multipart = form.encodeMultipart(); + final bytes = await multipart.bytes(); print(multipart.contentType); // multipart/form-data; boundary=... print(multipart.contentLength); // body bytes length + print(bytes.length); // same as contentLength } ``` +## Block Interop + +`Blob` implements `package:block` `Block`, and `BodyInit` accepts `Block` +values directly: + +```dart +import 'package:block/block.dart' as block; +import 'package:ht/ht.dart'; + +Future main() async { + final body = block.Block(['hello'], type: 'text/plain'); + final request = Request( + Uri.parse('https://example.com'), + method: 'POST', + body: body, + ); + + print(request.headers.get('content-type')); // text/plain + print(request.headers.get('content-length')); // 5 + print(await request.text()); // hello +} +``` + +## Blob Slice Semantics + +`Blob.slice(start, end)` now follows Web Blob semantics. Negative indexes are +interpreted from the end of the blob: + +```dart +final blob = Blob.text('hello world'); +final tail = blob.slice(-5); +print(await tail.text()); // world +``` + ## Development ```bash diff --git a/lib/src/fetch/blob.dart b/lib/src/fetch/blob.dart index 7510d36..0de72e3 100644 --- a/lib/src/fetch/blob.dart +++ b/lib/src/fetch/blob.dart @@ -1,99 +1,252 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:block/block.dart' as block; + /// Binary large object. -class Blob { +class Blob implements block.Block { Blob([Iterable parts = const [], String type = '']) - : _bytes = _concatenate(parts), - type = _normalizeType(type); + : this._fromPrepared(_prepareParts(parts), _normalizeType(type)); Blob.bytes(List bytes, {String type = ''}) - : _bytes = Uint8List.fromList(bytes), - type = _normalizeType(type); + : this._fromInline(Uint8List.fromList(bytes), _normalizeType(type)); Blob.text( String text, { String type = 'text/plain;charset=utf-8', Encoding encoding = utf8, - }) : _bytes = Uint8List.fromList(encoding.encode(text)), - type = _normalizeType(type); + }) : this._fromInline( + Uint8List.fromList(encoding.encode(text)), + _normalizeType(type), + ); + + Blob._fromPrepared( + ({List parts, Uint8List? inlineBytes}) prepared, + String normalizedType, + ) : _blockParts = prepared.parts, + _inlineBytes = prepared.inlineBytes, + type = normalizedType; + + Blob._fromInline(Uint8List inlineBytes, String normalizedType) + : _blockParts = [inlineBytes], + _inlineBytes = inlineBytes, + type = normalizedType; - final Uint8List _bytes; + Blob._fromBlock( + block.Block block, { + required this.type, + Uint8List? inlineBytes, + }) : _block = block, + _blockParts = null, + _inlineBytes = inlineBytes; + + block.Block? _block; + final List? _blockParts; + final Uint8List? _inlineBytes; /// MIME type hint. + @override final String type; - int get size => _bytes.length; + @override + int get size => _inlineBytes?.length ?? _toBlock().size; /// Returns a copy of underlying bytes. - Future bytes() async => copyBytes(); + Future bytes() async { + final inline = _inlineBytes; + if (inline != null) { + return Uint8List.fromList(inline); + } - /// Synchronous copy helper for internal body assembly. - Uint8List copyBytes() => Uint8List.fromList(_bytes); + return Uint8List.fromList(await _toBlock().arrayBuffer()); + } + + @override + Future arrayBuffer() => bytes(); + @override Future text([Encoding encoding = utf8]) async { - return encoding.decode(_bytes); + final inline = _inlineBytes; + if (inline != null) { + return encoding.decode(inline); + } + + if (identical(encoding, utf8)) { + return _toBlock().text(); + } + + return encoding.decode(await bytes()); } - Stream stream({int chunkSize = 16 * 1024}) async* { + @override + Stream stream({int chunkSize = 16 * 1024}) { if (chunkSize <= 0) { throw ArgumentError.value(chunkSize, 'chunkSize', 'Must be > 0'); } - var offset = 0; - while (offset < _bytes.length) { - final nextOffset = (offset + chunkSize).clamp(0, _bytes.length); - yield Uint8List.sublistView(_bytes, offset, nextOffset); - offset = nextOffset; + final inline = _inlineBytes; + if (inline != null) { + return _streamInline(inline, chunkSize); } + + return _toBlock().stream(chunkSize: chunkSize); } - Blob slice([int start = 0, int? end, String contentType = '']) { - final safeStart = start.clamp(0, _bytes.length); - final safeEnd = (end ?? _bytes.length).clamp(safeStart, _bytes.length); - return Blob.bytes( - Uint8List.sublistView(_bytes, safeStart, safeEnd), - type: contentType, + @override + Blob slice(int start, [int? end, String? contentType]) { + final normalizedType = _normalizeType(contentType ?? ''); + + final inline = _inlineBytes; + if (inline != null) { + final bounds = _normalizeSliceBounds(inline.length, start, end); + return Blob._fromInline( + Uint8List.sublistView(inline, bounds.start, bounds.end), + normalizedType, + ); + } + + return Blob._fromBlock( + _toBlock().slice(start, end, normalizedType), + type: normalizedType, ); } - static Uint8List _concatenate(Iterable parts) { - final builder = BytesBuilder(copy: false); + block.Block _toBlock() { + final existing = _block; + if (existing != null) { + return existing; + } + + final created = block.Block(_blockParts ?? const [], type: type); + _block = created; + return created; + } + + static ({List parts, Uint8List? inlineBytes}) _prepareParts( + Iterable parts, + ) { + final normalized = []; + final inlineBuilder = BytesBuilder(copy: false); + var canInline = true; for (final part in parts) { - if (part is Blob) { - builder.add(part._bytes); - continue; - } + normalized.add(_normalizePart(part)); - if (part is ByteBuffer) { - builder.add(part.asUint8List()); + if (!canInline) { continue; } - if (part is Uint8List) { - builder.add(part); + final bytes = _partInlineBytes(part); + if (bytes == null) { + canInline = false; continue; } - if (part is List) { - builder.add(part); - continue; - } + inlineBuilder.add(bytes); + } - if (part is String) { - builder.add(utf8.encode(part)); - continue; - } + return ( + parts: List.unmodifiable(normalized), + inlineBytes: canInline ? inlineBuilder.takeBytes() : null, + ); + } - throw ArgumentError.value( - part, - 'parts', - 'Unsupported blob part type: ${part.runtimeType}', - ); + static Object _normalizePart(Object part) { + if (part is Blob) { + return part; + } + + if (part is block.Block) { + return part; + } + + if (part is ByteBuffer) { + return ByteData.sublistView(part.asUint8List()); + } + + if (part is Uint8List) { + return part; + } + + if (part is List) { + return Uint8List.fromList(part); + } + + if (part is String) { + return part; + } + + throw ArgumentError.value( + part, + 'parts', + 'Unsupported blob part type: ${part.runtimeType}', + ); + } + + static Uint8List? _partInlineBytes(Object part) { + if (part is Blob) { + return part._inlineBytes; + } + + if (part is block.Block) { + return null; + } + + if (part is ByteBuffer) { + return part.asUint8List(); + } + + if (part is Uint8List) { + return part; + } + + if (part is List) { + return Uint8List.fromList(part); + } + + if (part is String) { + return Uint8List.fromList(utf8.encode(part)); + } + + return null; + } + + static Stream _streamInline( + Uint8List bytes, + int chunkSize, + ) async* { + var offset = 0; + while (offset < bytes.length) { + final nextOffset = (offset + chunkSize).clamp(0, bytes.length); + yield Uint8List.sublistView(bytes, offset, nextOffset); + offset = nextOffset; + } + } + + static ({int start, int end}) _normalizeSliceBounds( + int size, + int start, + int? end, + ) { + var normalizedStart = start; + var normalizedEnd = end ?? size; + + if (normalizedStart < 0) { + normalizedStart = size + normalizedStart; + } + + if (normalizedEnd < 0) { + normalizedEnd = size + normalizedEnd; + } + + normalizedStart = normalizedStart.clamp(0, size); + normalizedEnd = normalizedEnd.clamp(0, size); + + if (normalizedEnd < normalizedStart) { + normalizedEnd = normalizedStart; } - return builder.takeBytes(); + return (start: normalizedStart, end: normalizedEnd); } static String _normalizeType(String input) { diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index 251de3b..2eeb909 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:async/async.dart'; +import 'package:block/block.dart' as block; import 'blob.dart'; import 'form_data.dart'; @@ -107,9 +108,18 @@ final class BodyData { } if (init is Blob) { - return BodyData.bytes( - init.copyBytes(), + return BodyData.stream( + init.stream(), + defaultContentType: init.type.isEmpty ? null : init.type, + defaultContentLength: init.size, + ); + } + + if (init is block.Block) { + return BodyData.stream( + init.stream(), defaultContentType: init.type.isEmpty ? null : init.type, + defaultContentLength: init.size, ); } @@ -122,9 +132,10 @@ final class BodyData { if (init is FormData) { final payload = init.encodeMultipart(); - return BodyData.bytes( - payload.bytes, + return BodyData.stream( + payload.stream, defaultContentType: payload.contentType, + defaultContentLength: payload.contentLength, ); } diff --git a/lib/src/fetch/form_data.dart b/lib/src/fetch/form_data.dart index 1957dfe..c13bf39 100644 --- a/lib/src/fetch/form_data.dart +++ b/lib/src/fetch/form_data.dart @@ -8,13 +8,26 @@ import 'file.dart'; /// Multipart body payload generated from [FormData]. final class MultipartBody { - MultipartBody(this.bytes, this.boundary); + MultipartBody._({ + required Stream Function() streamFactory, + required this.boundary, + required this.contentLength, + }) : _streamFactory = streamFactory; - final Uint8List bytes; + final Stream Function() _streamFactory; final String boundary; + final int contentLength; String get contentType => 'multipart/form-data; boundary=$boundary'; - int get contentLength => bytes.length; + Stream get stream => _streamFactory(); + + Future bytes() async { + final builder = BytesBuilder(copy: false); + await for (final chunk in _streamFactory()) { + builder.add(chunk); + } + return builder.takeBytes(); + } } /// Form-data collection compatible with fetch-style APIs. @@ -62,44 +75,19 @@ class FormData extends IterableBase> { MultipartBody encodeMultipart({String? boundary}) { final safeBoundary = boundary ?? _generateBoundary(); - final builder = BytesBuilder(copy: false); - - for (final entry in _entries) { - builder.add(_utf8('--$safeBoundary\r\n')); - - if (entry.value is File) { - final file = entry.value as File; - builder.add( - _utf8( - 'Content-Disposition: form-data; ' - 'name="${_escapeHeaderValue(entry.key)}"; ' - 'filename="${_escapeHeaderValue(file.name)}"\r\n', - ), - ); - - final type = file.type.isEmpty ? 'application/octet-stream' : file.type; - builder - ..add(_utf8('Content-Type: $type\r\n\r\n')) - ..add(file.copyBytes()) - ..add(_utf8('\r\n')); - continue; - } - - final value = entry.value as String; - builder - ..add( - _utf8( - 'Content-Disposition: form-data; ' - 'name="${_escapeHeaderValue(entry.key)}"\r\n\r\n', - ), - ) - ..add(_utf8(value)) - ..add(_utf8('\r\n')); - } + final snapshot = List>.unmodifiable( + _entries.map((entry) => MapEntry(entry.key, entry.value)), + ); - builder.add(_utf8('--$safeBoundary--\r\n')); + return MultipartBody._( + streamFactory: () => _encodeMultipart(snapshot, safeBoundary), + boundary: safeBoundary, + contentLength: _calculateMultipartLength(snapshot, safeBoundary), + ); + } - return MultipartBody(builder.takeBytes(), safeBoundary); + MultipartBody encodeMultipartStream({String? boundary}) { + return encodeMultipart(boundary: boundary); } @override @@ -113,7 +101,7 @@ class FormData extends IterableBase> { } return File( - [value.copyBytes()], + [value], filename, type: value.type, lastModified: value.lastModified, @@ -121,11 +109,7 @@ class FormData extends IterableBase> { } if (value is Blob) { - return File( - [value.copyBytes()], - filename ?? 'blob', - type: value.type, - ); + return File([value], filename ?? 'blob', type: value.type); } if (value is String) { @@ -154,6 +138,79 @@ class FormData extends IterableBase> { return '----ht-$suffix'; } + static Stream _encodeMultipart( + List> entries, + String boundary, + ) async* { + for (final entry in entries) { + yield _utf8('--$boundary\r\n'); + + if (entry.value is File) { + final file = entry.value as File; + yield _utf8( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"; ' + 'filename="${_escapeHeaderValue(file.name)}"\r\n', + ); + + final type = file.type.isEmpty ? 'application/octet-stream' : file.type; + yield _utf8('Content-Type: $type\r\n\r\n'); + yield* file.stream(); + yield _utf8('\r\n'); + continue; + } + + final value = entry.value as String; + yield _utf8( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"\r\n\r\n', + ); + yield _utf8(value); + yield _utf8('\r\n'); + } + + yield _utf8('--$boundary--\r\n'); + } + + static int _calculateMultipartLength( + List> entries, + String boundary, + ) { + var total = 0; + + for (final entry in entries) { + total += _utf8Length('--$boundary\r\n'); + + if (entry.value is File) { + final file = entry.value as File; + total += _utf8Length( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"; ' + 'filename="${_escapeHeaderValue(file.name)}"\r\n', + ); + + final type = file.type.isEmpty ? 'application/octet-stream' : file.type; + total += _utf8Length('Content-Type: $type\r\n\r\n'); + total += file.size; + total += _utf8Length('\r\n'); + continue; + } + + final value = entry.value as String; + total += _utf8Length( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"\r\n\r\n', + ); + total += _utf8Length(value); + total += _utf8Length('\r\n'); + } + + total += _utf8Length('--$boundary--\r\n'); + return total; + } + + static int _utf8Length(String value) => utf8.encode(value).length; + static Uint8List _utf8(String value) => Uint8List.fromList(utf8.encode(value)); } diff --git a/pubspec.lock b/pubspec.lock index cae32d8..740486b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + block: + dependency: "direct main" + description: + name: block + sha256: "70978fd747d84200617f7a9cb8d1cb7871ee75fcfa70fece7b1004d283a53644" + url: "https://pub.dev" + source: hosted + version: "1.0.0" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e50bc63..3a95795 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ht description: Fetch-style HTTP types and protocol abstractions for Dart. -version: 0.1.0 +version: 0.2.0 repository: https://github.com/medz/ht homepage: https://github.com/medz/ht issue_tracker: https://github.com/medz/ht/issues @@ -16,6 +16,7 @@ environment: dependencies: async: ^2.13.0 + block: ^1.0.0 http_parser: ^4.1.2 mime: ^2.0.0 diff --git a/test/form_data_test.dart b/test/form_data_test.dart index 50cdff8..e81740b 100644 --- a/test/form_data_test.dart +++ b/test/form_data_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:block/block.dart' as block; import 'package:ht/ht.dart'; import 'package:test/test.dart'; @@ -13,6 +14,9 @@ void main() { final slice = blob.slice(6); expect(await slice.text(), 'world'); + + final tailSlice = blob.slice(-5); + expect(await tailSlice.text(), 'world'); }); test('concatenates mixed part types', () async { @@ -41,15 +45,19 @@ void main() { () => Blob.text('x', type: 'text/plain\nfoo'), throwsArgumentError, ); - await expectLater( - Blob.text('x').stream(chunkSize: 0).toList(), - throwsArgumentError, - ); + expect(() => Blob.text('x').stream(chunkSize: 0), throwsArgumentError); }); test('rejects unsupported part types', () { expect(() => Blob([DateTime(2024)]), throwsArgumentError); }); + + test('is compatible with block.Block interface', () async { + final blob = Blob.text('hello'); + final block.Block blockView = blob; + expect(await blockView.text(), 'hello'); + expect(await blockView.slice(-2).text(), 'lo'); + }); }); group('File', () { @@ -62,7 +70,7 @@ void main() { }); group('FormData', () { - test('normalizes values and encodes multipart', () { + test('normalizes values and encodes multipart', () async { final form = FormData() ..append('name', 'alice') ..append('avatar', Blob.text('binary'), filename: 'a.txt'); @@ -71,13 +79,14 @@ void main() { expect(avatar, isA()); final encoded = form.encodeMultipart(boundary: 'test-boundary'); - final bodyText = utf8.decode(encoded.bytes); + final bodyBytes = await encoded.bytes(); + final bodyText = utf8.decode(bodyBytes); expect( encoded.contentType, 'multipart/form-data; boundary=test-boundary', ); - expect(encoded.contentLength, encoded.bytes.length); + expect(encoded.contentLength, bodyBytes.length); expect(bodyText, contains('name="name"')); expect(bodyText, contains('name="avatar"; filename="a.txt"')); expect(bodyText, contains('alice')); @@ -123,15 +132,33 @@ void main() { expect(clone.get('a'), '2'); }); - test('escapes multipart header values', () { + test('escapes multipart header values', () async { final form = FormData() ..append('na"me', Blob.text('x'), filename: 'fi\r\nle.txt'); final encoded = form.encodeMultipart(boundary: 'b'); - final text = utf8.decode(encoded.bytes); + final text = utf8.decode(await encoded.bytes()); expect(text, contains('name="na\\"me"')); expect(text, contains('filename="fi\\r\\nle.txt"')); }); + + test( + 'multipart stream and bytes helper produce identical payload', + () async { + final form = FormData() + ..append('a', '1') + ..append('b', Blob.text('2'), filename: 'b.txt'); + + final encoded = form.encodeMultipart(boundary: 'z'); + final fromBytes = await encoded.bytes(); + final fromStream = BytesBuilder(copy: false); + await for (final chunk in encoded.stream) { + fromStream.add(chunk); + } + + expect(fromStream.takeBytes(), fromBytes); + }, + ); }); } diff --git a/test/public_api_surface_test.dart b/test/public_api_surface_test.dart index a37d1e2..1b123f3 100644 --- a/test/public_api_surface_test.dart +++ b/test/public_api_surface_test.dart @@ -1,3 +1,4 @@ +import 'package:block/block.dart' as block; import 'package:ht/ht.dart'; import 'package:test/test.dart'; @@ -13,6 +14,8 @@ void main() { final blob = Blob.text('hello'); final file = File([blob], 'hello.txt', type: 'text/plain'); final form = FormData()..append('file', file); + final multipart = form.encodeMultipart(boundary: 'api'); + final blockBody = block.Block(['block-body'], type: 'text/plain'); final request = Request.formData( Uri.parse('https://example.com/upload'), @@ -21,7 +24,7 @@ void main() { body: form, ); - final response = Response.bytes([1, 2, 3], status: status); + final response = Response(body: blockBody, status: status); final BodyInit init = 'x'; @@ -32,6 +35,8 @@ void main() { expect(await blob.text(), 'hello'); expect(file.name, 'hello.txt'); expect(request.headers.has('content-type'), isTrue); + expect(await multipart.bytes(), isNotEmpty); + expect(await response.text(), 'block-body'); expect(response.ok, isTrue); expect(init, 'x'); }); diff --git a/test/request_response_test.dart b/test/request_response_test.dart index a4d3072..1a76788 100644 --- a/test/request_response_test.dart +++ b/test/request_response_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:block/block.dart' as block; import 'package:ht/ht.dart'; import 'package:test/test.dart'; @@ -52,6 +53,19 @@ void main() { expect(await request.text(), contains('name="name"')); }); + test('accepts block body and infers content headers', () async { + final body = block.Block(['hello'], type: 'text/custom'); + final request = Request( + Uri.parse('https://example.com'), + method: 'POST', + body: body, + ); + + expect(request.headers.get('content-type'), 'text/custom'); + expect(request.headers.get('content-length'), '5'); + expect(await request.text(), 'hello'); + }); + test('cannot attach body to GET/HEAD/TRACE', () { expect( () => @@ -175,6 +189,15 @@ void main() { expect(response.headers.get('content-length'), isNotNull); }); + test('accepts block body and infers content headers', () async { + final body = block.Block(['payload'], type: 'application/custom'); + final response = Response(body: body); + + expect(response.headers.get('content-type'), 'application/custom'); + expect(response.headers.get('content-length'), '7'); + expect(await response.text(), 'payload'); + }); + test('empty response defaults to 204 and no body', () async { final response = Response.empty(); From 5ee60c4cffab8356bb50b88ffea151c8a509c6fa Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Feb 2026 04:09:45 +0800 Subject: [PATCH 2/4] refactor(fetch): simplify Blob as thin block adapter --- lib/src/fetch/blob.dart | 184 +++++----------------------------------- 1 file changed, 22 insertions(+), 162 deletions(-) diff --git a/lib/src/fetch/blob.dart b/lib/src/fetch/blob.dart index 0de72e3..c0570aa 100644 --- a/lib/src/fetch/blob.dart +++ b/lib/src/fetch/blob.dart @@ -6,59 +6,41 @@ import 'package:block/block.dart' as block; /// Binary large object. class Blob implements block.Block { Blob([Iterable parts = const [], String type = '']) - : this._fromPrepared(_prepareParts(parts), _normalizeType(type)); + : this._fromNormalized(_normalizeParts(parts), _normalizeType(type)); Blob.bytes(List bytes, {String type = ''}) - : this._fromInline(Uint8List.fromList(bytes), _normalizeType(type)); + : this._fromNormalized([ + Uint8List.fromList(bytes), + ], _normalizeType(type)); Blob.text( String text, { String type = 'text/plain;charset=utf-8', Encoding encoding = utf8, - }) : this._fromInline( + }) : this._fromNormalized([ Uint8List.fromList(encoding.encode(text)), - _normalizeType(type), - ); + ], _normalizeType(type)); - Blob._fromPrepared( - ({List parts, Uint8List? inlineBytes}) prepared, - String normalizedType, - ) : _blockParts = prepared.parts, - _inlineBytes = prepared.inlineBytes, - type = normalizedType; - - Blob._fromInline(Uint8List inlineBytes, String normalizedType) - : _blockParts = [inlineBytes], - _inlineBytes = inlineBytes, - type = normalizedType; + Blob._fromNormalized(List parts, String normalizedType) + : this._fromBlock( + block.Block(parts, type: normalizedType), + type: normalizedType, + ); - Blob._fromBlock( - block.Block block, { - required this.type, - Uint8List? inlineBytes, - }) : _block = block, - _blockParts = null, - _inlineBytes = inlineBytes; + Blob._fromBlock(this._inner, {required this.type}); - block.Block? _block; - final List? _blockParts; - final Uint8List? _inlineBytes; + final block.Block _inner; /// MIME type hint. @override final String type; @override - int get size => _inlineBytes?.length ?? _toBlock().size; + int get size => _inner.size; /// Returns a copy of underlying bytes. Future bytes() async { - final inline = _inlineBytes; - if (inline != null) { - return Uint8List.fromList(inline); - } - - return Uint8List.fromList(await _toBlock().arrayBuffer()); + return Uint8List.fromList(await _inner.arrayBuffer()); } @override @@ -66,13 +48,8 @@ class Blob implements block.Block { @override Future text([Encoding encoding = utf8]) async { - final inline = _inlineBytes; - if (inline != null) { - return encoding.decode(inline); - } - if (identical(encoding, utf8)) { - return _toBlock().text(); + return _inner.text(); } return encoding.decode(await bytes()); @@ -84,76 +61,25 @@ class Blob implements block.Block { throw ArgumentError.value(chunkSize, 'chunkSize', 'Must be > 0'); } - final inline = _inlineBytes; - if (inline != null) { - return _streamInline(inline, chunkSize); - } - - return _toBlock().stream(chunkSize: chunkSize); + return _inner.stream(chunkSize: chunkSize); } @override Blob slice(int start, [int? end, String? contentType]) { final normalizedType = _normalizeType(contentType ?? ''); - - final inline = _inlineBytes; - if (inline != null) { - final bounds = _normalizeSliceBounds(inline.length, start, end); - return Blob._fromInline( - Uint8List.sublistView(inline, bounds.start, bounds.end), - normalizedType, - ); - } - return Blob._fromBlock( - _toBlock().slice(start, end, normalizedType), + _inner.slice(start, end, normalizedType), type: normalizedType, ); } - block.Block _toBlock() { - final existing = _block; - if (existing != null) { - return existing; - } - - final created = block.Block(_blockParts ?? const [], type: type); - _block = created; - return created; - } - - static ({List parts, Uint8List? inlineBytes}) _prepareParts( - Iterable parts, - ) { - final normalized = []; - final inlineBuilder = BytesBuilder(copy: false); - var canInline = true; - - for (final part in parts) { - normalized.add(_normalizePart(part)); - - if (!canInline) { - continue; - } - - final bytes = _partInlineBytes(part); - if (bytes == null) { - canInline = false; - continue; - } - - inlineBuilder.add(bytes); - } - - return ( - parts: List.unmodifiable(normalized), - inlineBytes: canInline ? inlineBuilder.takeBytes() : null, - ); + static List _normalizeParts(Iterable parts) { + return List.unmodifiable(parts.map(_normalizePart)); } static Object _normalizePart(Object part) { if (part is Blob) { - return part; + return part._inner; } if (part is block.Block) { @@ -165,7 +91,7 @@ class Blob implements block.Block { } if (part is Uint8List) { - return part; + return Uint8List.fromList(part); } if (part is List) { @@ -183,72 +109,6 @@ class Blob implements block.Block { ); } - static Uint8List? _partInlineBytes(Object part) { - if (part is Blob) { - return part._inlineBytes; - } - - if (part is block.Block) { - return null; - } - - if (part is ByteBuffer) { - return part.asUint8List(); - } - - if (part is Uint8List) { - return part; - } - - if (part is List) { - return Uint8List.fromList(part); - } - - if (part is String) { - return Uint8List.fromList(utf8.encode(part)); - } - - return null; - } - - static Stream _streamInline( - Uint8List bytes, - int chunkSize, - ) async* { - var offset = 0; - while (offset < bytes.length) { - final nextOffset = (offset + chunkSize).clamp(0, bytes.length); - yield Uint8List.sublistView(bytes, offset, nextOffset); - offset = nextOffset; - } - } - - static ({int start, int end}) _normalizeSliceBounds( - int size, - int start, - int? end, - ) { - var normalizedStart = start; - var normalizedEnd = end ?? size; - - if (normalizedStart < 0) { - normalizedStart = size + normalizedStart; - } - - if (normalizedEnd < 0) { - normalizedEnd = size + normalizedEnd; - } - - normalizedStart = normalizedStart.clamp(0, size); - normalizedEnd = normalizedEnd.clamp(0, size); - - if (normalizedEnd < normalizedStart) { - normalizedEnd = normalizedStart; - } - - return (start: normalizedStart, end: normalizedEnd); - } - static String _normalizeType(String input) { final normalized = input.trim().toLowerCase(); if (normalized.isEmpty) { From b3629779014cb1912e30a7737a0382a9c534a3a4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Feb 2026 04:16:01 +0800 Subject: [PATCH 3/4] refactor(fetch): rewrite blob part normalization with switch expression --- lib/src/fetch/blob.dart | 42 +++++++++++++---------------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/lib/src/fetch/blob.dart b/lib/src/fetch/blob.dart index c0570aa..411f6de 100644 --- a/lib/src/fetch/blob.dart +++ b/lib/src/fetch/blob.dart @@ -78,35 +78,19 @@ class Blob implements block.Block { } static Object _normalizePart(Object part) { - if (part is Blob) { - return part._inner; - } - - if (part is block.Block) { - return part; - } - - if (part is ByteBuffer) { - return ByteData.sublistView(part.asUint8List()); - } - - if (part is Uint8List) { - return Uint8List.fromList(part); - } - - if (part is List) { - return Uint8List.fromList(part); - } - - if (part is String) { - return part; - } - - throw ArgumentError.value( - part, - 'parts', - 'Unsupported blob part type: ${part.runtimeType}', - ); + return switch (part) { + final Blob blob => blob._inner, + final block.Block blockPart => blockPart, + final ByteBuffer buffer => ByteData.sublistView(buffer.asUint8List()), + final Uint8List bytes => Uint8List.fromList(bytes), + final List bytes => Uint8List.fromList(bytes), + final String text => text, + _ => throw ArgumentError.value( + part, + 'parts', + 'Unsupported blob part type: ${part.runtimeType}', + ), + }; } static String _normalizeType(String input) { From 3e53ef763234b6ab602b1d0356b2563d9ab5f85c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Feb 2026 04:17:44 +0800 Subject: [PATCH 4/4] refactor(fetch): rewrite BodyData.fromInit with switch expression --- lib/src/fetch/body.dart | 100 +++++++++++++++------------------------- 1 file changed, 37 insertions(+), 63 deletions(-) diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index 2eeb909..5565cc9 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -80,73 +80,47 @@ final class BodyData { _branch = branch; factory BodyData.fromInit(Object? init) { - if (init == null) { - return BodyData.empty(); - } - - if (init is BodyData) { - return init.clone(); - } - - if (init is String) { - return BodyData.bytes( - utf8.encode(init), + return switch (init) { + null => BodyData.empty(), + final BodyData data => data.clone(), + final String text => BodyData.bytes( + utf8.encode(text), defaultContentType: 'text/plain; charset=utf-8', - ); - } - - if (init is Uint8List) { - return BodyData.bytes(init); - } - - if (init is ByteBuffer) { - return BodyData.bytes(init.asUint8List()); - } - - if (init is List) { - return BodyData.bytes(init); - } - - if (init is Blob) { - return BodyData.stream( - init.stream(), - defaultContentType: init.type.isEmpty ? null : init.type, - defaultContentLength: init.size, - ); - } - - if (init is block.Block) { - return BodyData.stream( - init.stream(), - defaultContentType: init.type.isEmpty ? null : init.type, - defaultContentLength: init.size, - ); - } - - if (init is URLSearchParams) { - return BodyData.bytes( - utf8.encode(init.toString()), + ), + final Uint8List bytes => BodyData.bytes(bytes), + final ByteBuffer buffer => BodyData.bytes(buffer.asUint8List()), + final List bytes => BodyData.bytes(bytes), + // Keep Blob ahead of block.Block for explicit fetch-type handling. + final Blob blob => _fromBlock(blob), + final block.Block blockBody => _fromBlock(blockBody), + final URLSearchParams params => BodyData.bytes( + utf8.encode(params.toString()), defaultContentType: 'application/x-www-form-urlencoded; charset=utf-8', - ); - } - - if (init is FormData) { - final payload = init.encodeMultipart(); - return BodyData.stream( - payload.stream, - defaultContentType: payload.contentType, - defaultContentLength: payload.contentLength, - ); - } + ), + final FormData formData => _fromFormData(formData), + final Stream> stream => BodyData.stream(stream), + _ => throw ArgumentError.value( + init, + 'init', + 'Unsupported body type: ${init.runtimeType}', + ), + }; + } - if (init is Stream>) { - return BodyData.stream(init); - } + static BodyData _fromBlock(block.Block value) { + return BodyData.stream( + value.stream(), + defaultContentType: value.type.isEmpty ? null : value.type, + defaultContentLength: value.size, + ); + } - throw ArgumentError.value( - init, - 'init', - 'Unsupported body type: ${init.runtimeType}', + static BodyData _fromFormData(FormData formData) { + final payload = formData.encodeMultipart(); + return BodyData.stream( + payload.stream, + defaultContentType: payload.contentType, + defaultContentLength: payload.contentLength, ); }