From edf5b40d6d8faab34d371a1054bacedeecac9188 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:31:33 +0800 Subject: [PATCH 01/45] refactor(fetch): split headers into native, web, and io implementations --- lib/src/fetch/headers.dart | 167 +----------------------------- lib/src/fetch/headers.io.dart | 143 +++++++++++++++++++++++++ lib/src/fetch/headers.js.dart | 97 +++++++++++++++++ lib/src/fetch/headers.native.dart | 149 ++++++++++++++++++++++++++ lib/src/fetch/request.dart | 21 ++-- lib/src/fetch/response.dart | 19 ++-- lib/src/fetch/web_utils.dart | 84 +++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 1 + test/headers_test.dart | 44 +++++--- test/request_response_test.dart | 12 +-- 11 files changed, 541 insertions(+), 198 deletions(-) create mode 100644 lib/src/fetch/headers.io.dart create mode 100644 lib/src/fetch/headers.js.dart create mode 100644 lib/src/fetch/headers.native.dart create mode 100644 lib/src/fetch/web_utils.dart diff --git a/lib/src/fetch/headers.dart b/lib/src/fetch/headers.dart index e652dff..458df90 100644 --- a/lib/src/fetch/headers.dart +++ b/lib/src/fetch/headers.dart @@ -1,162 +1,5 @@ -import 'dart:collection'; - -/// A mutable, case-insensitive HTTP headers collection. -class Headers extends IterableBase> { - Headers([Map? init]) { - if (init == null) { - return; - } - - for (final entry in init.entries) { - set(entry.key, entry.value); - } - } - - Headers.from(Headers other) { - _entries.addAll(other._entries.map((entry) => entry.copy())); - } - - factory Headers.fromEntries(Iterable> entries) { - final headers = Headers(); - for (final entry in entries) { - headers.append(entry.key, entry.value); - } - return headers; - } - - static final _tokenPattern = RegExp(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$"); - - final _entries = <_HeaderEntry>[]; - - /// Adds a header value. - void append(String name, Object value) { - final normalizedName = _normalizeAndValidateName(name); - final normalizedValue = _normalizeAndValidateValue(value); - _entries.add( - _HeaderEntry( - originalName: name.trim(), - normalizedName: normalizedName, - value: normalizedValue, - ), - ); - } - - /// Replaces all values for [name] with [value]. - void set(String name, Object value) { - delete(name); - append(name, value); - } - - /// Deletes all values by [name]. - void delete(String name) { - final normalizedName = _normalizeAndValidateName(name); - _entries.removeWhere((entry) => entry.normalizedName == normalizedName); - } - - /// Returns a merged value for [name]. - /// - /// For `set-cookie`, this returns the first cookie. Use [getSetCookie] - /// to retrieve all cookie values. - String? get(String name) { - final values = getAll(name); - if (values.isEmpty) { - return null; - } - - final normalizedName = _normalizeAndValidateName(name); - if (normalizedName == 'set-cookie') { - return values.first; - } - - return values.join(', '); - } - - /// Returns all values by [name], preserving insertion order. - List getAll(String name) { - final normalizedName = _normalizeAndValidateName(name); - return List.unmodifiable( - _entries - .where((entry) => entry.normalizedName == normalizedName) - .map((entry) => entry.value), - ); - } - - /// Returns all `set-cookie` values. - List getSetCookie() => getAll('set-cookie'); - - /// Returns whether [name] exists. - bool has(String name) { - final normalizedName = _normalizeAndValidateName(name); - return _entries.any((entry) => entry.normalizedName == normalizedName); - } - - /// Removes all headers. - void clear() => _entries.clear(); - - /// Creates a deep copy of this header collection. - Headers clone() => Headers.from(this); - - /// Returns normalized header names in insertion order (without duplicates). - Iterable names() sync* { - final seen = {}; - for (final entry in _entries) { - if (seen.add(entry.normalizedName)) { - yield entry.normalizedName; - } - } - } - - /// Returns a map representation where duplicate values are merged by `, `. - Map toMap() { - final result = {}; - for (final name in names()) { - result[name] = get(name)!; - } - return Map.unmodifiable(result); - } - - @override - Iterator> get iterator => _entries - .map( - (entry) => MapEntry(entry.normalizedName, entry.value), - ) - .iterator; - - static String _normalizeAndValidateName(String name) { - final normalized = name.trim().toLowerCase(); - if (normalized.isEmpty || !_tokenPattern.hasMatch(normalized)) { - throw ArgumentError.value(name, 'name', 'Invalid header name'); - } - return normalized; - } - - static String _normalizeAndValidateValue(Object value) { - final normalized = value.toString().trim(); - if (normalized.contains('\r') || normalized.contains('\n')) { - throw ArgumentError.value( - value, - 'value', - 'Header value must not contain CR/LF', - ); - } - return normalized; - } -} - -class _HeaderEntry { - const _HeaderEntry({ - required this.originalName, - required this.normalizedName, - required this.value, - }); - - final String originalName; - final String normalizedName; - final String value; - - _HeaderEntry copy() => _HeaderEntry( - originalName: originalName, - normalizedName: normalizedName, - value: value, - ); -} +export 'headers.native.dart' show HeadersInit; +export 'headers.native.dart' + if (dart.library.io) 'headers.io.dart' + if (dart.library.js_interop) 'headers.js.dart' + show Headers; diff --git a/lib/src/fetch/headers.io.dart b/lib/src/fetch/headers.io.dart new file mode 100644 index 0000000..e7717c5 --- /dev/null +++ b/lib/src/fetch/headers.io.dart @@ -0,0 +1,143 @@ +import 'dart:io'; + +import 'headers.native.dart' as native; + +sealed class HeadersHost { + const HeadersHost(this.value); + final T value; +} + +final class HttpHeadersHost extends HeadersHost { + const HttpHeadersHost(super.value); +} + +final class NativeHeadersHost extends HeadersHost { + const NativeHeadersHost(super.value); +} + +class Headers + with Iterable> + implements native.Headers { + const Headers._(this._host); + + factory Headers([native.HeadersInit? init]) { + final host = switch (init) { + final Headers headers => headers._host, + final HttpHeaders headers => HttpHeadersHost(headers), + _ => NativeHeadersHost(native.Headers(init)), + }; + + return Headers._(host); + } + + final HeadersHost _host; + + @override + Iterator> get iterator => entries().iterator; + + @override + void append(String name, String value) { + switch (_host) { + case final HttpHeadersHost host: + host.value.add(name, value); + case final NativeHeadersHost host: + host.value.append(name, value); + } + } + + @override + void delete(String name) { + switch (_host) { + case final HttpHeadersHost host: + host.value.removeAll(name); + case final NativeHeadersHost host: + host.value.delete(name); + } + } + + @override + Iterable> entries() sync* { + switch (_host) { + case final HttpHeadersHost host: + final entries = >[]; + host.value.forEach((name, values) { + for (final value in values) { + entries.add(MapEntry(name, value)); + } + }); + yield* entries; + case final NativeHeadersHost host: + yield* host.value.entries(); + } + } + + @override + String? get(String name) { + switch (_host) { + case final HttpHeadersHost host: + return name.toLowerCase() == 'set-cookie' + ? null + : host.value.value(name); + case final NativeHeadersHost host: + return host.value.get(name); + } + } + + @override + void set(String name, String value) { + switch (_host) { + case final HttpHeadersHost host: + host.value.set(name, value); + case final NativeHeadersHost host: + host.value.set(name, value); + } + } + + @override + Iterable getSetCookie() sync* { + switch (_host) { + case final HttpHeadersHost host: + yield* host.value[HttpHeaders.setCookieHeader] ?? const []; + case final NativeHeadersHost host: + yield* host.value.getSetCookie(); + } + } + + @override + bool has(String name) { + switch (_host) { + case final HttpHeadersHost host: + return host.value.value(name) != null; + case final NativeHeadersHost host: + return host.value.has(name); + } + } + + @override + Iterable keys() sync* { + switch (_host) { + case final HttpHeadersHost host: + final keys = []; + host.value.forEach((name, values) { + keys.add(name); + }); + yield* keys; + case final NativeHeadersHost host: + yield* host.value.keys(); + } + } + + @override + Iterable values() sync* { + switch (_host) { + case final HttpHeadersHost host: + final values = []; + host.value.forEach((name, entries) { + values.addAll(entries); + }); + yield* values; + case final NativeHeadersHost host: + yield* host.value.values(); + } + } +} diff --git a/lib/src/fetch/headers.js.dart b/lib/src/fetch/headers.js.dart new file mode 100644 index 0000000..900ec8f --- /dev/null +++ b/lib/src/fetch/headers.js.dart @@ -0,0 +1,97 @@ +import 'dart:js_interop'; + +import 'headers.native.dart' as native; +import 'web_utils.dart' as web; + +class Headers + with Iterable> + implements native.Headers { + const Headers._(this.host); + + factory Headers([native.HeadersInit? init]) { + final host = switch (init) { + null => web.Headers(), + Headers(:final host) => web.Headers(host), + final Iterable> entries => + web.Headers.fromEntries(entries), + final Map map => web.Headers.fromMap(map), + final Map> map => web.Headers.fromMultiValueMap( + map, + ), + final Iterable> pairs => web.Headers.fromStringPairs( + pairs, + ), + final Iterable<(String, String)> pairs => web.Headers.fromRecordPairs( + pairs, + ), + final Iterable<(String, Iterable)> pairs => + web.Headers.fromRecordMultiPairs(pairs), + _ => throw ArgumentError.value(init, 'init'), + }; + + return Headers._(host); + } + + final web.Headers host; + + @override + Iterator> get iterator => entries().iterator; + + @override + void append(String name, String value) => host.append(name, value); + + @override + void delete(String name) => host.delete(name); + + @override + Iterable> entries() sync* { + final iterator = host.entries(); + while (true) { + final result = iterator.next(); + if (result.done) break; + if (result.value == null || + result.value.isUndefinedOrNull || + web.Array.isArray(result.value!)) { + continue; + } + final [name, value] = (result.value as JSArray).toDart; + yield MapEntry(name.toDart, value.toDart); + } + } + + @override + String? get(String name) => host.get(name); + + @override + void set(String name, String value) => host.set(name, value); + + @override + Iterable getSetCookie() sync* { + for (final value in host.getSetCookie().toDart) { + yield value.toDart; + } + } + + @override + bool has(String name) => host.has(name); + + @override + Iterable keys() sync* { + final iterator = host.keys(); + while (true) { + final result = iterator.next(); + if (result.done) break; + yield (result.value as JSString).toDart; + } + } + + @override + Iterable values() sync* { + final iterator = host.values(); + while (true) { + final result = iterator.next(); + if (result.done) break; + yield (result.value as JSString).toDart; + } + } +} diff --git a/lib/src/fetch/headers.native.dart b/lib/src/fetch/headers.native.dart new file mode 100644 index 0000000..5e914ba --- /dev/null +++ b/lib/src/fetch/headers.native.dart @@ -0,0 +1,149 @@ +/// Constructor input accepted by headers implementations. +/// +/// Supported forms are: +/// - `null` +/// - another [Headers] +/// - an `Iterable>` +/// - a `Map` +/// - a `Map>` +/// - an `Iterable<(String, String)>` +/// - an `Iterable<(String, Iterable)>` +typedef HeadersInit = Object?; + +class Headers with Iterable> { + const Headers._(this._host); + + factory Headers([HeadersInit? init]) { + final headers = Headers._(>[]); + switch (init) { + case null: + return headers; + case final Headers upstream: + headers._host.addAll(upstream._host); + case final Iterable> entries: + for (final MapEntry(:key, :value) in entries) { + headers.append(key, value); + } + case final Map map: + for (final MapEntry(:key, :value) in map.entries) { + headers.append(key, value); + } + case final Map> map: + for (final MapEntry(:key, value: values) in map.entries) { + for (final value in values) { + headers.append(key, value); + } + } + case final Iterable> pairs: + for (final pair in pairs) { + final values = pair.toList(growable: false); + if (values.length != 2) { + throw ArgumentError.value( + pair, + 'init', + 'Header pairs must contain exactly two string items.', + ); + } + headers.append(values[0], values[1]); + } + case final Iterable<(String, String)> pairs: + for (final (name, value) in pairs) { + headers.append(name, value); + } + case final Iterable<(String, Iterable)> pairs: + for (final (name, values) in pairs) { + for (final value in values) { + headers.append(name, value); + } + } + default: + throw ArgumentError.value(init, 'init'); + } + + return headers; + } + + static final _tokenPattern = RegExp(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$"); + + final List> _host; + + @override + Iterator> get iterator => _host.iterator; + + void append(String name, String value) { + _host.add( + MapEntry( + _normalizeAndValidateName(name), + _normalizeAndValidateValue(value), + ), + ); + } + + void delete(String name) { + final normalizedName = _normalizeAndValidateName(name); + _host.removeWhere((entry) => entry.key == normalizedName); + } + + Iterable> entries() => _host; + + String? get(String name) { + final normalizedName = _normalizeAndValidateName(name); + if (normalizedName == 'set-cookie') { + return null; + } + + final values = _host + .where((entry) => entry.key == normalizedName) + .map((entry) => entry.value); + return values.isNotEmpty ? values.join(', ') : null; + } + + void set(String name, String value) { + final normalizedName = _normalizeAndValidateName(name); + _host + ..removeWhere((entry) => entry.key == normalizedName) + ..add(MapEntry(normalizedName, _normalizeAndValidateValue(value))); + } + + Iterable getSetCookie() { + return _host + .where((entry) => entry.key == 'set-cookie') + .map((entry) => entry.value); + } + + bool has(String name) { + final normalizedName = _normalizeAndValidateName(name); + return _host.any((entry) => entry.key == normalizedName); + } + + Iterable keys() { + final seen = {}; + return _host.map((entry) => entry.key).where(seen.add); + } + + Iterable values() { + return _host.map((entry) => entry.value); + } + + static String _normalizeAndValidateName(String name) { + final normalized = name.trim().toLowerCase(); + if (normalized.isEmpty || !_tokenPattern.hasMatch(normalized)) { + throw ArgumentError.value(name, 'name', 'Invalid header name'); + } + + return normalized; + } + + static String _normalizeAndValidateValue(String value) { + final normalized = value.trim(); + if (normalized.contains('\r') || normalized.contains('\n')) { + throw ArgumentError.value( + value, + 'value', + 'Header value must not contain CR/LF', + ); + } + + return normalized; + } +} diff --git a/lib/src/fetch/request.dart b/lib/src/fetch/request.dart index f622df5..02daca8 100644 --- a/lib/src/fetch/request.dart +++ b/lib/src/fetch/request.dart @@ -7,11 +7,10 @@ import 'url_search_params.dart'; /// Initialization options for [Request], aligned with Fetch `RequestInit`. class RequestInit { - RequestInit({this.method, Headers? headers, this.body}) - : headers = headers?.clone(); + RequestInit({this.method, this.headers, this.body}); final String? method; - final Headers? headers; + final HeadersInit? headers; final Object? body; } @@ -21,7 +20,7 @@ class Request with BodyMixin { : this._create( url: url, method: init?.method ?? 'GET', - headers: init?.headers?.clone() ?? Headers(), + headers: _headersFromInit(init?.headers), bodyData: BodyData.fromInit(init?.body), ); @@ -48,7 +47,7 @@ class Request with BodyMixin { factory Request.json(Uri url, Object? body, [RequestInit? init]) { final nextInit = _coerceInit(init, body: json.encode(body)); - final nextHeaders = nextInit.headers ?? Headers(); + final nextHeaders = _headersFromInit(nextInit.headers); if (!nextHeaders.has('content-type')) { nextHeaders.set('content-type', 'application/json; charset=utf-8'); } @@ -100,7 +99,7 @@ class Request with BodyMixin { return Request._internal( url: url, method: method, - headers: headers.clone(), + headers: Headers(headers.entries()), bodyData: bodyData.clone(), ); } @@ -108,11 +107,19 @@ class Request with BodyMixin { static RequestInit _coerceInit(RequestInit? init, {required Object? body}) { return RequestInit( method: init?.method ?? 'POST', - headers: init?.headers?.clone(), + headers: init?.headers, body: body, ); } + static Headers _headersFromInit(HeadersInit? init) { + return switch (init) { + null => Headers(), + final Headers headers => headers, + _ => Headers(init), + }; + } + static String _normalizeMethod(String value) { final normalized = value.trim().toUpperCase(); if (normalized.isEmpty) { diff --git a/lib/src/fetch/response.dart b/lib/src/fetch/response.dart index 746ccbe..c618a1f 100644 --- a/lib/src/fetch/response.dart +++ b/lib/src/fetch/response.dart @@ -6,12 +6,11 @@ import 'headers.dart'; /// Initialization options for [Response], aligned with Fetch `ResponseInit`. class ResponseInit { - ResponseInit({this.status, this.statusText, Headers? headers}) - : headers = headers?.clone(); + ResponseInit({this.status, this.statusText, this.headers}); final int? status; final String? statusText; - final Headers? headers; + final HeadersInit? headers; } /// Fetch-like HTTP response model. @@ -26,7 +25,7 @@ class Response with BodyMixin { required this.redirected, }) : status = _validateStatus(init?.status ?? HttpStatus.ok), statusText = init?.statusText ?? '', - headers = init?.headers?.clone() ?? Headers(), + headers = _headersFromInit(init?.headers), bodyData = BodyData.fromInit(body) { if (bodyData.hasBody && _statusDisallowsBody(status)) { throw ArgumentError.value( @@ -52,7 +51,7 @@ class Response with BodyMixin { } factory Response.json(Object? body, [ResponseInit? init]) { - final nextHeaders = init?.headers?.clone() ?? Headers(); + final nextHeaders = _headersFromInit(init?.headers); if (!nextHeaders.has('content-type')) { nextHeaders.set('content-type', 'application/json; charset=utf-8'); } @@ -119,7 +118,7 @@ class Response with BodyMixin { return Response._internal( status: status, statusText: statusText, - headers: headers.clone(), + headers: Headers(headers.entries()), bodyData: bodyData.clone(), url: url, redirected: redirected, @@ -135,6 +134,14 @@ class Response with BodyMixin { return status == 204 || status == 205 || status == 304; } + static Headers _headersFromInit(HeadersInit? init) { + return switch (init) { + null => Headers(), + final Headers headers => headers, + _ => Headers(init), + }; + } + void _applyDefaultBodyHeaders() { if (!bodyData.hasBody) { return; diff --git a/lib/src/fetch/web_utils.dart b/lib/src/fetch/web_utils.dart new file mode 100644 index 0000000..ac67e9a --- /dev/null +++ b/lib/src/fetch/web_utils.dart @@ -0,0 +1,84 @@ +@JS() +library; + +import 'dart:js_interop'; + +import 'package:web/web.dart' as web; + +extension type IteratorReturnResult(JSObject _) implements JSObject { + external bool get done; + external JSAny? get value; +} + +extension type ArrayIterator(JSObject _) implements JSObject { + external IteratorReturnResult next(); +} + +extension type Headers._(JSObject _) implements web.Headers { + external factory Headers([web.HeadersInit init]); + external ArrayIterator entries(); + external ArrayIterator keys(); + external ArrayIterator values(); + + factory Headers.fromEntries(Iterable> entries) { + final headers = Headers(); + for (final MapEntry(key: name, :value) in entries) { + headers.append(name, value); + } + return headers; + } + + factory Headers.fromMap(Map map) => + Headers.fromEntries(map.entries); + + factory Headers.fromMultiValueMap(Map> map) { + final headers = Headers(); + for (final MapEntry(:key, value: values) in map.entries) { + for (final value in values) { + headers.append(key, value); + } + } + return headers; + } + + factory Headers.fromStringPairs(Iterable> pairs) { + final headers = Headers(); + for (final kv in pairs) { + if (kv.length != 2) { + throw ArgumentError.value( + kv, + 'pairs', + 'Header pairs must contain exactly two string items.', + ); + } + + headers.append(kv.elementAt(0), kv.elementAt(1)); + } + + return headers; + } + + factory Headers.fromRecordPairs(Iterable<(String, String)> pairs) { + final headers = Headers(); + for (final (name, value) in pairs) { + headers.append(name, value); + } + return headers; + } + + factory Headers.fromRecordMultiPairs( + Iterable<(String, Iterable)> pairs, + ) { + final headers = Headers(); + for (final (name, values) in pairs) { + for (final value in values) { + headers.append(name, value); + } + } + return headers; + } +} + +extension type Array._(JSAny _) { + external static bool isArray(JSAny _); +} diff --git a/pubspec.lock b/pubspec.lock index 740486b..404977f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -354,7 +354,7 @@ packages: source: hosted version: "1.2.1" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/pubspec.yaml b/pubspec.yaml index 3a95795..e78b57c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: block: ^1.0.0 http_parser: ^4.1.2 mime: ^2.0.0 + web: ^1.1.1 dev_dependencies: lints: ^6.1.0 diff --git a/test/headers_test.dart b/test/headers_test.dart index f0d3bf0..3f62611 100644 --- a/test/headers_test.dart +++ b/test/headers_test.dart @@ -15,7 +15,13 @@ void main() { ..append('accept', 'application/json') ..append('accept', 'text/plain'); - expect(headers.getAll('accept'), ['application/json', 'text/plain']); + expect( + headers + .entries() + .where((entry) => entry.key == 'accept') + .map((entry) => entry.value), + ['application/json', 'text/plain'], + ); expect(headers.get('accept'), 'application/json, text/plain'); }); @@ -24,16 +30,16 @@ void main() { ..append('set-cookie', 'a=1') ..append('set-cookie', 'b=2'); - expect(headers.get('set-cookie'), 'a=1'); + expect(headers.get('set-cookie'), isNull); expect(headers.getSetCookie(), ['a=1', 'b=2']); }); - test('clone creates independent copy', () { + test('constructor from entries creates independent copy', () { final headers = Headers({'x-id': '1'}); - final clone = headers.clone()..set('x-id', '2'); + final copy = Headers(headers.entries())..set('x-id', '2'); expect(headers.get('x-id'), '1'); - expect(clone.get('x-id'), '2'); + expect(copy.get('x-id'), '2'); }); test('rejects invalid names', () { @@ -43,14 +49,20 @@ void main() { expect(() => headers.set('x-test', 'line\nbreak'), throwsArgumentError); }); - test('supports construction from entries and iteration', () { - final headers = Headers.fromEntries(>[ + test('supports construction from entry iterables and iteration', () { + final headers = Headers(>[ const MapEntry('X-A', '1'), const MapEntry('X-A', '2'), const MapEntry('X-B', '3'), ]); - expect(headers.getAll('x-a'), ['1', '2']); + expect( + headers + .entries() + .where((entry) => entry.key == 'x-a') + .map((entry) => entry.value), + ['1', '2'], + ); expect(headers.map((entry) => '${entry.key}:${entry.value}').toList(), [ 'x-a:1', 'x-a:2', @@ -58,25 +70,25 @@ void main() { ]); }); - test('names and toMap are normalized and deterministic', () { + test('keys and values are normalized and deterministic', () { final headers = Headers() ..append('X-A', '1') ..append('x-a', '2') ..append('X-B', '3'); - expect(headers.names().toList(), ['x-a', 'x-b']); - expect(headers.toMap(), {'x-a': '1, 2', 'x-b': '3'}); + expect(headers.keys().toList(), ['x-a', 'x-b']); + expect(headers.values().toList(), ['1', '2', '3']); }); - test('clear removes all values', () { + test('delete removes all values', () { final headers = Headers() ..append('x-a', '1') ..append('x-b', '2') - ..clear(); + ..delete('x-b'); - expect(headers.has('x-a'), isFalse); - expect(headers.getAll('x-b'), isEmpty); - expect(headers, isEmpty); + expect(headers.has('x-a'), isTrue); + expect(headers.get('x-b'), isNull); + expect(headers.keys().toList(), ['x-a']); }); }); } diff --git a/test/request_response_test.dart b/test/request_response_test.dart index d7f1d02..90e7906 100644 --- a/test/request_response_test.dart +++ b/test/request_response_test.dart @@ -103,7 +103,7 @@ void main() { await expectLater(request.text(), throwsStateError); }); - test('constructor clones headers input', () { + test('constructor reuses headers input', () { final source = Headers({'x-id': '1'}); final request = Request( Uri.parse('https://example.com'), @@ -113,8 +113,8 @@ void main() { source.set('x-id', '2'); request.headers.set('x-other', 'v'); - expect(request.headers.get('x-id'), '1'); - expect(source.has('x-other'), isFalse); + expect(request.headers.get('x-id'), '2'); + expect(source.has('x-other'), isTrue); }); test('clone fails after body has been consumed', () async { @@ -190,15 +190,15 @@ void main() { ); }); - test('constructor clones headers input', () { + test('constructor reuses headers input', () { final source = Headers({'x-id': '1'}); final response = Response.text('ok', ResponseInit(headers: source)); source.set('x-id', '2'); response.headers.set('x-other', 'v'); - expect(response.headers.get('x-id'), '1'); - expect(source.has('x-other'), isFalse); + expect(response.headers.get('x-id'), '2'); + expect(source.has('x-other'), isTrue); }); test('clone duplicates unread body', () async { From 277db3c0926258ce319ff57443b4ea7881212df6 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:08:57 +0800 Subject: [PATCH 02/45] feat(internal): add stream tee utility --- lib/src/_internal/stream_tee.dart | 12 +++++++++++ test/_internal/stream_tee_test.dart | 31 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 lib/src/_internal/stream_tee.dart create mode 100644 test/_internal/stream_tee_test.dart diff --git a/lib/src/_internal/stream_tee.dart b/lib/src/_internal/stream_tee.dart new file mode 100644 index 0000000..1527d08 --- /dev/null +++ b/lib/src/_internal/stream_tee.dart @@ -0,0 +1,12 @@ +import 'dart:async'; + +import 'package:async/async.dart'; + +/// Splits [source] into two output streams. +/// +/// Both branches observe the same source events and can be consumed +/// independently as single-subscription streams. +(Stream, Stream) streamTee(Stream source) { + final streams = StreamSplitter.splitFrom(source, 2); + return (streams[0], streams[1]); +} diff --git a/test/_internal/stream_tee_test.dart b/test/_internal/stream_tee_test.dart new file mode 100644 index 0000000..48febd8 --- /dev/null +++ b/test/_internal/stream_tee_test.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:ht/src/_internal/stream_tee.dart'; +import 'package:test/test.dart'; + +void main() { + group('streamTee', () { + test('duplicates a single-subscription stream', () async { + final (left, right) = streamTee(Stream.fromIterable([1, 2, 3])); + + expect(await left.toList(), [1, 2, 3]); + expect(await right.toList(), [1, 2, 3]); + }); + + test('duplicates a broadcast stream', () async { + final controller = StreamController.broadcast(sync: true); + final (left, right) = streamTee(controller.stream); + + final leftFuture = left.toList(); + final rightFuture = right.toList(); + + controller.add(1); + controller.add(2); + controller.add(3); + await controller.close(); + + expect(await leftFuture, [1, 2, 3]); + expect(await rightFuture, [1, 2, 3]); + }); + }); +} From e1c2f9a5fa92d42eb6c471cd4cf05588b80fdbf5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:59:34 +0800 Subject: [PATCH 03/45] feat(fetch): split Blob into native, web, and io implementations --- lib/src/fetch/blob.dart | 113 +-------------------- lib/src/fetch/blob.io.dart | 162 ++++++++++++++++++++++++++++++ lib/src/fetch/blob.js.dart | 31 ++++++ lib/src/fetch/blob.native.dart | 91 +++++++++++++++++ test/blob_io_test.dart | 68 +++++++++++++ test/blob_js_test.dart | 37 +++++++ test/form_data_test.dart | 39 ++++--- test/public_api_surface_test.dart | 2 +- 8 files changed, 419 insertions(+), 124 deletions(-) create mode 100644 lib/src/fetch/blob.io.dart create mode 100644 lib/src/fetch/blob.js.dart create mode 100644 lib/src/fetch/blob.native.dart create mode 100644 test/blob_io_test.dart create mode 100644 test/blob_js_test.dart diff --git a/lib/src/fetch/blob.dart b/lib/src/fetch/blob.dart index 411f6de..efd35fc 100644 --- a/lib/src/fetch/blob.dart +++ b/lib/src/fetch/blob.dart @@ -1,108 +1,5 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:block/block.dart' as block; - -/// Binary large object. -class Blob implements block.Block { - Blob([Iterable parts = const [], String type = '']) - : this._fromNormalized(_normalizeParts(parts), _normalizeType(type)); - - Blob.bytes(List bytes, {String type = ''}) - : this._fromNormalized([ - Uint8List.fromList(bytes), - ], _normalizeType(type)); - - Blob.text( - String text, { - String type = 'text/plain;charset=utf-8', - Encoding encoding = utf8, - }) : this._fromNormalized([ - Uint8List.fromList(encoding.encode(text)), - ], _normalizeType(type)); - - Blob._fromNormalized(List parts, String normalizedType) - : this._fromBlock( - block.Block(parts, type: normalizedType), - type: normalizedType, - ); - - Blob._fromBlock(this._inner, {required this.type}); - - final block.Block _inner; - - /// MIME type hint. - @override - final String type; - - @override - int get size => _inner.size; - - /// Returns a copy of underlying bytes. - Future bytes() async { - return Uint8List.fromList(await _inner.arrayBuffer()); - } - - @override - Future arrayBuffer() => bytes(); - - @override - Future text([Encoding encoding = utf8]) async { - if (identical(encoding, utf8)) { - return _inner.text(); - } - - return encoding.decode(await bytes()); - } - - @override - Stream stream({int chunkSize = 16 * 1024}) { - if (chunkSize <= 0) { - throw ArgumentError.value(chunkSize, 'chunkSize', 'Must be > 0'); - } - - return _inner.stream(chunkSize: chunkSize); - } - - @override - Blob slice(int start, [int? end, String? contentType]) { - final normalizedType = _normalizeType(contentType ?? ''); - return Blob._fromBlock( - _inner.slice(start, end, normalizedType), - type: normalizedType, - ); - } - - static List _normalizeParts(Iterable parts) { - return List.unmodifiable(parts.map(_normalizePart)); - } - - static Object _normalizePart(Object part) { - 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) { - final normalized = input.trim().toLowerCase(); - if (normalized.isEmpty) { - return ''; - } - - if (normalized.contains('\r') || normalized.contains('\n')) { - throw ArgumentError.value(input, 'type', 'Invalid blob type'); - } - - return normalized; - } -} +export 'blob.native.dart' show BlobPart; +export 'blob.native.dart' + if (dart.library.io) 'blob.io.dart' + if (dart.library.js_interop) 'blob.js.dart' + show Blob; diff --git a/lib/src/fetch/blob.io.dart b/lib/src/fetch/blob.io.dart new file mode 100644 index 0000000..fac6362 --- /dev/null +++ b/lib/src/fetch/blob.io.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; +import 'dart:io' as io; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:block/block.dart' as block; + +import 'blob.native.dart' as native; + +/// IO-backed [Blob] implementation. +/// +/// This extends the native detached baseline by accepting `dart:io File` +/// parts during construction. +class Blob extends native.Blob implements block.Block { + Blob([ + Iterable parts = const [], + String type = '', + ]) : super(_normalizeParts(parts), type); + + static List _normalizeParts(Iterable parts) => + parts.map(_normalizePart).toList(growable: false); + + static Object _normalizePart(native.BlobPart part) { + return switch (part) { + final Blob blob => blob, + final native.Blob blob => blob, + final io.File file => _FileBlock(file), + _ => native.Blob([part]), + }; + } +} + +/// Temporary downstream file-backed block until `block` exposes reusable +/// io-backed file primitives. See https://github.com/medz/block/issues/10. +final class _FileBlock implements block.Block { + _FileBlock(this._file, {int start = 0, int? length, this.type = ''}) + : _start = start, + _length = length ?? _file.lengthSync(); + + final io.File _file; + final int _start; + final int _length; + + @override + final String type; + + @override + int get size => _length; + + @override + _FileBlock slice(int start, [int? end, String? contentType]) { + final bounds = _normalizeSliceBounds(_length, start, end); + return _FileBlock( + _file, + start: _start + bounds.start, + length: bounds.length, + type: contentType ?? '', + ); + } + + @override + Future arrayBuffer() => _readRange(0, _length); + + @override + Future text() async => utf8.decode(await arrayBuffer()); + + @override + Stream stream({ + int chunkSize = block.Block.defaultStreamChunkSize, + }) async* { + _validateChunkSize(chunkSize); + if (_length == 0) { + return; + } + + final reader = await _file.open(mode: io.FileMode.read); + var remaining = _length; + + try { + await reader.setPosition(_start); + + while (remaining > 0) { + final toRead = min(chunkSize, remaining); + final chunk = await reader.read(toRead); + if (chunk.isEmpty) { + throw StateError( + 'Unexpected end of file while streaming ${_file.path}.', + ); + } + + yield chunk; + remaining -= chunk.length; + } + } finally { + try { + await reader.close(); + } catch (_) { + // best-effort cleanup. + } + } + } + + Future _readRange(int offset, int length) async { + _validateRange(_length, offset, length); + if (length == 0) { + return Uint8List(0); + } + + final reader = await _file.open(mode: io.FileMode.read); + try { + await reader.setPosition(_start + offset); + final bytes = await reader.read(length); + if (bytes.length != length) { + throw StateError( + 'Unexpected end of file while reading $length bytes from ${_file.path}.', + ); + } + return bytes; + } finally { + try { + await reader.close(); + } catch (_) { + // best-effort cleanup. + } + } + } + + static ({int start, int length}) _normalizeSliceBounds( + int size, + int start, + int? end, + ) { + final normalizedStart = start < 0 + ? (size + start).clamp(0, size) + : start.clamp(0, size); + final normalizedEnd = end == null + ? size + : end < 0 + ? (size + end).clamp(0, size) + : end.clamp(0, size); + final clampedEnd = normalizedEnd < normalizedStart + ? normalizedStart + : normalizedEnd; + return (start: normalizedStart, length: clampedEnd - normalizedStart); + } + + static void _validateChunkSize(int chunkSize) { + if (chunkSize <= 0) { + throw ArgumentError.value(chunkSize, 'chunkSize', 'Must be > 0'); + } + } + + static void _validateRange(int size, int offset, int length) { + if (offset < 0 || offset > size) { + throw RangeError.range(offset, 0, size, 'offset'); + } + + if (length < 0 || offset + length > size) { + throw RangeError.range(length, 0, size - offset, 'length'); + } + } +} diff --git a/lib/src/fetch/blob.js.dart b/lib/src/fetch/blob.js.dart new file mode 100644 index 0000000..fad5777 --- /dev/null +++ b/lib/src/fetch/blob.js.dart @@ -0,0 +1,31 @@ +import 'package:block/block.dart' as block; +import 'package:web/web.dart' as web; + +import 'blob.native.dart' as native; + +/// Web-backed [Blob] implementation. +/// +/// This extends the native detached baseline by accepting native `web.Blob` +/// and `web.File` parts during construction. +class Blob extends native.Blob implements block.Block { + Blob([ + Iterable parts = const [], + String type = '', + ]) : super([_toBlock(parts, type)], type); + + static block.Block _toBlock(Iterable parts, String type) { + return block.Block(_normalizeParts(parts), type: type); + } + + static List _normalizeParts(Iterable parts) => + parts.map(_normalizePart).toList(growable: false); + + static Object _normalizePart(native.BlobPart part) { + return switch (part) { + final Blob blob => blob, + final native.Blob blob => blob, + final web.Blob blob => blob, + _ => native.Blob([part]), + }; + } +} diff --git a/lib/src/fetch/blob.native.dart b/lib/src/fetch/blob.native.dart new file mode 100644 index 0000000..9458791 --- /dev/null +++ b/lib/src/fetch/blob.native.dart @@ -0,0 +1,91 @@ +import 'dart:typed_data'; + +import 'package:block/block.dart' as block; + +/// Constructor parts accepted by native [Blob]. +/// +/// Supported part types: +/// - [String] +/// - [Uint8List] +/// - [ByteBuffer] +/// - [ByteData] +/// - [Blob] +/// - [block.Block] +/// +/// Platform-specific extensions: +/// - `web.Blob` +/// - `web.File` +/// - `dart:io File` +/// +/// These platform-specific part types are added by other implementations. +typedef BlobPart = Object; + +/// Native detached binary large object. +class Blob implements block.Block { + Blob([Iterable parts = const [], String type = '']) + : this._fromNormalized(_normalizeParts(parts), type); + + Blob._fromNormalized(List parts, String normalizedType) + : this._fromBlock( + block.Block(parts, type: normalizedType), + type: normalizedType, + ); + + Blob._fromBlock(this._host, {required this.type}); + + final block.Block _host; + + @override + final String type; + + @override + int get size => _host.size; + + Future bytes() => _host.arrayBuffer(); + + @override + Future arrayBuffer() => _host.arrayBuffer(); + + @override + Future text() => _host.text(); + + @override + Stream stream({int chunkSize = 16 * 1024}) { + if (chunkSize <= 0) { + throw ArgumentError.value(chunkSize, 'chunkSize', 'Must be > 0'); + } + + return _host.stream(chunkSize: chunkSize); + } + + @override + Blob slice(int start, [int? end, String? contentType]) { + contentType ??= ''; + return Blob._fromBlock( + _host.slice(start, end, contentType), + type: contentType, + ); + } + + static List _normalizeParts(Iterable parts) => + parts.map(_normalizePart).toList(growable: false); + + static Object _normalizePart(BlobPart part) { + return switch (part) { + final Blob blob => blob._host, + final block.Block blockPart => blockPart, + final ByteBuffer buffer => ByteData.sublistView(buffer.asUint8List()), + final ByteData data => ByteData.sublistView( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ), + 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}', + ), + }; + } +} diff --git a/test/blob_io_test.dart b/test/blob_io_test.dart new file mode 100644 index 0000000..1c3cf6a --- /dev/null +++ b/test/blob_io_test.dart @@ -0,0 +1,68 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:ht/src/fetch/blob.io.dart' as io_blob; +import 'package:test/test.dart'; + +void main() { + group('Blob (io)', () { + test('accepts dart:io File parts lazily', () async { + final tempDir = await io.Directory.systemTemp.createTemp('ht_blob_io_'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final file = io.File('${tempDir.path}/payload.txt'); + await file.writeAsString('hello world'); + + final blob = io_blob.Blob([file], 'text/plain'); + + expect(blob.size, 11); + expect(blob.type, 'text/plain'); + expect(await blob.text(), 'hello world'); + }); + + test('slice reads the requested file range', () async { + final tempDir = await io.Directory.systemTemp.createTemp('ht_blob_io_'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final file = io.File('${tempDir.path}/payload.txt'); + await file.writeAsString('abcdef'); + + final blob = io_blob.Blob([file], 'text/plain'); + final slice = blob.slice(1, 4); + + expect(slice.size, 3); + expect(await slice.text(), 'bcd'); + }); + + test('stream respects chunkSize', () async { + final tempDir = await io.Directory.systemTemp.createTemp('ht_blob_io_'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final file = io.File('${tempDir.path}/payload.txt'); + await file.writeAsString('hello'); + + final blob = io_blob.Blob([file], 'text/plain'); + final chunks = await blob + .stream(chunkSize: 2) + .map(utf8.decode) + .toList(); + + expect(chunks, ['he', 'll', 'o']); + }); + }); +} diff --git a/test/blob_js_test.dart b/test/blob_js_test.dart new file mode 100644 index 0000000..c071e8c --- /dev/null +++ b/test/blob_js_test.dart @@ -0,0 +1,37 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; + +import 'package:ht/src/fetch/blob.js.dart' as js; +import 'package:test/test.dart'; +import 'package:web/web.dart' as web; + +void main() { + group('Blob (js)', () { + test('accepts native web.Blob parts', () async { + final part = web.Blob( + ['hello '.toJS].toJS, + web.BlobPropertyBag(type: 'text/plain'), + ); + + final blob = js.Blob([part, 'world'], 'text/plain'); + + expect(blob.type, 'text/plain'); + expect(await blob.text(), 'hello world'); + }); + + test('accepts native web.File parts', () async { + final part = web.File( + ['payload'.toJS].toJS, + 'payload.txt', + web.FilePropertyBag(type: 'text/plain'), + ); + + final blob = js.Blob([part], 'text/plain'); + + expect(blob.size, 7); + expect(await blob.text(), 'payload'); + }); + }); +} diff --git a/test/form_data_test.dart b/test/form_data_test.dart index e81740b..ccb690e 100644 --- a/test/form_data_test.dart +++ b/test/form_data_test.dart @@ -8,7 +8,7 @@ import 'package:test/test.dart'; void main() { group('Blob', () { test('supports text, bytes and slice', () async { - final blob = Blob.text('hello world'); + final blob = Blob(['hello world'], 'text/plain;charset=utf-8'); expect(blob.size, 11); expect(await blob.text(), 'hello world'); @@ -24,15 +24,15 @@ void main() { 'ab', Uint8List.fromList([99]), Uint8List.fromList([100]).buffer, - Blob.text('ef'), - ], ' TEXT/PLAIN '); + Blob(['ef'], 'text/plain;charset=utf-8'), + ], 'text/plain'); expect(await blob.text(), 'abcdef'); expect(blob.type, 'text/plain'); }); test('streams with chunk size', () async { - final blob = Blob.text('hello'); + final blob = Blob(['hello'], 'text/plain;charset=utf-8'); final chunks = await blob .stream(chunkSize: 2) .map((chunk) => utf8.decode(chunk)) @@ -40,12 +40,9 @@ void main() { expect(chunks, ['he', 'll', 'o']); }); - test('rejects invalid types and chunk size', () async { - expect( - () => Blob.text('x', type: 'text/plain\nfoo'), - throwsArgumentError, - ); - expect(() => Blob.text('x').stream(chunkSize: 0), throwsArgumentError); + test('rejects invalid chunk size', () async { + final blob = Blob(['x'], 'text/plain;charset=utf-8'); + expect(() => blob.stream(chunkSize: 0), throwsArgumentError); }); test('rejects unsupported part types', () { @@ -53,7 +50,7 @@ void main() { }); test('is compatible with block.Block interface', () async { - final blob = Blob.text('hello'); + final blob = Blob(['hello'], 'text/plain;charset=utf-8'); final block.Block blockView = blob; expect(await blockView.text(), 'hello'); expect(await blockView.slice(-2).text(), 'lo'); @@ -73,7 +70,11 @@ void main() { test('normalizes values and encodes multipart', () async { final form = FormData() ..append('name', 'alice') - ..append('avatar', Blob.text('binary'), filename: 'a.txt'); + ..append( + 'avatar', + Blob(['binary'], 'text/plain;charset=utf-8'), + filename: 'a.txt', + ); final avatar = form.get('avatar'); expect(avatar, isA()); @@ -111,7 +112,7 @@ void main() { test('normalizes blob and scalar values', () { final form = FormData() ..append('count', 42) - ..append('payload', Blob.text('x')) + ..append('payload', Blob(['x'], 'text/plain;charset=utf-8')) ..append('avatar', File(['a'], 'old.txt'), filename: 'new.txt'); expect(form.get('count'), '42'); @@ -134,7 +135,11 @@ void main() { test('escapes multipart header values', () async { final form = FormData() - ..append('na"me', Blob.text('x'), filename: 'fi\r\nle.txt'); + ..append( + 'na"me', + Blob(['x'], 'text/plain;charset=utf-8'), + filename: 'fi\r\nle.txt', + ); final encoded = form.encodeMultipart(boundary: 'b'); final text = utf8.decode(await encoded.bytes()); @@ -148,7 +153,11 @@ void main() { () async { final form = FormData() ..append('a', '1') - ..append('b', Blob.text('2'), filename: 'b.txt'); + ..append( + 'b', + Blob(['2'], 'text/plain;charset=utf-8'), + filename: 'b.txt', + ); final encoded = form.encodeMultipart(boundary: 'z'); final fromBytes = await encoded.bytes(); diff --git a/test/public_api_surface_test.dart b/test/public_api_surface_test.dart index cee0859..e667a18 100644 --- a/test/public_api_surface_test.dart +++ b/test/public_api_surface_test.dart @@ -13,7 +13,7 @@ void main() { final headers = Headers({'content-type': mime.toString()}); final params = URLSearchParams('a=1'); - final blob = Blob.text('hello'); + final blob = Blob(['hello'], 'text/plain;charset=utf-8'); final file = File([blob], 'hello.txt', type: 'text/plain'); final form = FormData()..append('file', file); final multipart = form.encodeMultipart(boundary: 'api'); From d47b5f56a1cd77c8542a81f971b74325a1aa48ae Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:02:51 +0800 Subject: [PATCH 04/45] refactor(fetch): narrow File parts to BlobPart --- lib/src/fetch/file.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/src/fetch/file.dart b/lib/src/fetch/file.dart index 5ee396f..92de1fc 100644 --- a/lib/src/fetch/file.dart +++ b/lib/src/fetch/file.dart @@ -2,9 +2,13 @@ import 'blob.dart'; /// File metadata wrapper over [Blob]. class File extends Blob { - File(Iterable parts, this.name, {String type = '', int? lastModified}) - : lastModified = lastModified ?? DateTime.now().millisecondsSinceEpoch, - super(parts, type); + File( + Iterable parts, + this.name, { + String type = '', + int? lastModified, + }) : lastModified = lastModified ?? DateTime.now().millisecondsSinceEpoch, + super(parts, type); final String name; final int lastModified; From 5d75a070de941baf832badfc1ef37a9a38affc1d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:18:19 +0800 Subject: [PATCH 05/45] feat(fetch): add native Body implementation --- lib/src/fetch/body.dart | 295 +++++++++++++++++-------------------- test/body_native_test.dart | 96 ++++++++++++ 2 files changed, 228 insertions(+), 163 deletions(-) create mode 100644 test/body_native_test.dart diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index fbf3ca1..386a837 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -1,104 +1,78 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; -import 'package:async/async.dart'; import 'package:block/block.dart' as block; +import '../_internal/stream_tee.dart'; import 'blob.dart'; import 'form_data.dart'; import 'url_search_params.dart'; -/// Request/response body initializer. -typedef BodyInit = Object; - -/// Fetch-like body behavior shared by request/response objects. -mixin BodyMixin { - BodyData get bodyData; - - /// Optional MIME hint used by [blob]. - String? get bodyMimeTypeHint => null; - - Stream? get body => bodyData.consumeAsStream(); - - bool get bodyUsed => bodyData.isUsed; - - Future bytes() => bodyData.consumeAsBytes(); - - Future text([Encoding encoding = utf8]) => - bodyData.consumeAsText(encoding); - - Future json() => bodyData.consumeAsJson(); - - Future blob() async { - return Blob.bytes( - await bytes(), - type: bodyMimeTypeHint ?? bodyData.defaultContentType ?? '', - ); - } -} - -/// Internal body storage that supports cloning and one-time consumption. -class BodyData { - BodyData.empty() - : _present = false, - _bytes = null, - _splitter = null, - _branch = null, - defaultContentType = null, - defaultContentLength = null; - - BodyData.bytes(List bytes, {this.defaultContentType}) - : _present = true, - _bytes = Uint8List.fromList(bytes), - _splitter = null, - _branch = null, - defaultContentLength = bytes.length; - - BodyData.stream( - Stream> stream, { - this.defaultContentType, - this.defaultContentLength, - }) : _present = true, - _bytes = null, - _splitter = StreamSplitter( - stream.map( - (chunk) => chunk is Uint8List ? chunk : Uint8List.fromList(chunk), - ), - ), - _branch = null { - _branch = _splitter!.split(); - } - - BodyData._fromSplit( - StreamSplitter splitter, - Stream branch, { - this.defaultContentType, - this.defaultContentLength, - }) : _present = true, - _bytes = null, - _splitter = splitter, - _branch = branch; - - factory BodyData.fromInit(Object? init) { +/// Constructor input accepted by body implementations. +/// +/// Standard detached forms: +/// - [String] +/// - [Uint8List] +/// - [ByteBuffer] +/// - [List] +/// - [Stream>] +/// - [Blob] +/// - [Body] +/// - [block.Block] +/// - [FormData] +/// - [URLSearchParams] +/// +/// Platform-specific extensions: +/// - `dart:io File` on io +/// +/// Native bodies normalize supported inputs into a detached [block.Block] +/// when possible. Platform implementations may accept additional host-backed +/// inputs before materialization. +typedef BodyInit = Object?; + +/// Native detached body implementation. +/// +/// This is the shared body baseline that web/io implementations can align to, +/// but it is intentionally not wired into the existing fetch types yet. +class Body extends Stream { + Body._({block.Block? blockHost, Stream? streamHost}) + : assert(blockHost != null || streamHost != null), + _blockHost = blockHost, + _streamHost = streamHost; + + factory Body([BodyInit? 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', + null => Body._(blockHost: block.Block(const [])), + final Body body => Body._( + blockHost: body._blockHost, + streamHost: body._streamHost, + ), + final String text => Body._( + blockHost: block.Block([text], type: 'text/plain;charset=utf-8'), + ), + final Uint8List bytes => Body._(blockHost: block.Block([bytes])), + final ByteBuffer buffer => Body._( + blockHost: block.Block([buffer.asUint8List()]), + ), + final List bytes => Body._( + blockHost: block.Block([Uint8List.fromList(bytes)]), + ), + final Blob blob => Body._(blockHost: blob), + final block.Block blockHost => Body._(blockHost: blockHost), + final URLSearchParams params => Body._( + blockHost: block.Block([ + params.toString(), + ], type: 'application/x-www-form-urlencoded;charset=utf-8'), + ), + final FormData formData => Body._( + streamHost: formData.encodeMultipart().stream, ), - 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', + final Stream> stream => Body._( + streamHost: stream.map( + (chunk) => chunk is Uint8List ? chunk : Uint8List.fromList(chunk), + ), ), - final FormData formData => _fromFormData(formData), - final Stream> stream => BodyData.stream(stream), _ => throw ArgumentError.value( init, 'init', @@ -107,113 +81,108 @@ class BodyData { }; } - static BodyData _fromBlock(block.Block value) { - return BodyData.stream( - value.stream(), - defaultContentType: value.type.isEmpty ? null : value.type, - defaultContentLength: value.size, - ); - } - - static BodyData _fromFormData(FormData formData) { - final payload = formData.encodeMultipart(); - return BodyData.stream( - payload.stream, - defaultContentType: payload.contentType, - defaultContentLength: payload.contentLength, - ); - } - - final bool _present; - final Uint8List? _bytes; - final StreamSplitter? _splitter; - Stream? _branch; - + final block.Block? _blockHost; + Stream? _streamHost; bool _used = false; - /// Default content type inferred from body input. - final String? defaultContentType; - - /// Default content length inferred from body input. - final int? defaultContentLength; - - bool get hasBody => _present; - - bool get isUsed => _used; - - Stream? consumeAsStream() { - if (!_present) { - return null; + Stream? get stream async* { + final blockHost = _blockHost; + final streamHost = _streamHost; + if (blockHost == null && streamHost == null) { + return; } - return _consumeAsStream(); - } - - Future consumeAsBytes() async { _startConsumption(); - if (_bytes != null) { - return Uint8List.fromList(_bytes); + if (blockHost != null) { + yield* blockHost.stream(); + return; } - if (_branch == null) { - return Uint8List(0); + if (streamHost != null) { + yield* streamHost; } + } + + bool get bodyUsed => _used; + + Future bytes() async { + final stream = this.stream; + if (stream == null) return Uint8List(0); final builder = BytesBuilder(copy: false); - await for (final chunk in _branch!) { + await for (final chunk in stream) { builder.add(chunk); } return builder.takeBytes(); } - Future consumeAsText([Encoding encoding = utf8]) async { - return encoding.decode(await consumeAsBytes()); + Future text([Encoding encoding = utf8]) async { + return encoding.decode(await bytes()); } - Future consumeAsJson() async { - final decoded = json.decode(await consumeAsText()); - return decoded as T; + Future json() { + return text().then((text) => jsonDecode(text) as T); } - BodyData clone() { - if (_used) { - throw StateError('Body has already been consumed.'); + Future blob() async { + final blockHost = _blockHost; + if (blockHost != null) { + _startConsumption(); + if (blockHost case final Blob blob) { + return blob; + } + + return Blob([blockHost], blockHost.type); } - if (!_present) { - return BodyData.empty(); + return Blob([await bytes()]); + } + + Body clone() { + if (_used) { + throw StateError('Body has already been consumed.'); } - if (_bytes != null) { - return BodyData.bytes(_bytes, defaultContentType: defaultContentType); + final blockHost = _blockHost; + if (blockHost != null) { + return Body._(blockHost: blockHost); } - final splitter = _splitter; - if (splitter == null) { - return BodyData.empty(); + final streamHost = _streamHost; + if (streamHost != null) { + final (left, right) = streamTee(streamHost); + _streamHost = left; + return Body._(streamHost: right); } - return BodyData._fromSplit( - splitter, - splitter.split(), - defaultContentType: defaultContentType, - defaultContentLength: defaultContentLength, - ); + throw StateError('Body has no host.'); } - Stream _consumeAsStream() async* { - _startConsumption(); - - if (_bytes != null) { - yield Uint8List.fromList(_bytes); - return; + @override + StreamSubscription listen( + void Function(Uint8List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + final stream = this.stream; + if (stream == null) { + return Stream.empty().listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); } - if (_branch != null) { - yield* _branch!; - } + return stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); } void _startConsumption() { diff --git a/test/body_native_test.dart b/test/body_native_test.dart new file mode 100644 index 0000000..3d085ff --- /dev/null +++ b/test/body_native_test.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +import 'package:block/block.dart' as block; +import 'package:ht/src/fetch/body.dart'; +import 'package:ht/src/fetch/url_search_params.dart'; +import 'package:test/test.dart'; + +void main() { + group('Body', () { + test('string bodies decode as text and bytes', () async { + final body = Body('hello'); + + expect(await body.text(), 'hello'); + expect(body.bodyUsed, isTrue); + }); + + test('json decodes through text()', () async { + final body = Body('{"ok":true}'); + + expect(await body.json>(), {'ok': true}); + }); + + test('URLSearchParams bodies serialize as form text', () async { + final params = URLSearchParams() + ..append('a', '1') + ..append('b', '2'); + + final body = Body(params); + + expect(await body.text(), 'a=1&b=2'); + }); + + test('block bodies can be converted back to Blob', () async { + final body = Body(block.Block(['payload'], type: 'text/plain')); + final blob = await body.blob(); + + expect(blob.type, 'text/plain'); + expect(await blob.text(), 'payload'); + expect(body.bodyUsed, isTrue); + }); + + test('clone tees unread stream bodies', () async { + final body = Body( + Stream>.fromIterable(>[ + utf8.encode('hello '), + utf8.encode('world'), + ]), + ); + + final clone = body.clone(); + + expect(await body.text(), 'hello world'); + expect(await clone.text(), 'hello world'); + }); + + test('blob consumes stream bodies and returns a Blob view', () async { + final body = Body( + Stream>.fromIterable(>[ + utf8.encode('hello '), + utf8.encode('world'), + ]), + ); + + final blob = await body.blob(); + + expect(await blob.text(), 'hello world'); + expect(body.bodyUsed, isTrue); + }); + + test('empty bodies return empty bytes and become used when consumed', () async { + final body = Body(); + + expect(body.bodyUsed, isFalse); + expect(await body.bytes(), isEmpty); + expect(body.bodyUsed, isTrue); + }); + + test('consumption is single-use', () async { + final body = Body('once'); + + expect(await body.text(), 'once'); + await expectLater(body.text(), throwsStateError); + }); + + test('clone fails after body has been consumed', () async { + final body = Body('x'); + await body.text(); + + expect(() => body.clone(), throwsStateError); + }); + + test('rejects unsupported body types', () { + expect(() => Body(DateTime(2024)), throwsArgumentError); + }); + }); +} From 01140882119b5896d9e7b25d0b750e3ac05bb793 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:19:02 +0800 Subject: [PATCH 06/45] test(fetch): rename body native test --- test/{body_native_test.dart => body_test.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{body_native_test.dart => body_test.dart} (100%) diff --git a/test/body_native_test.dart b/test/body_test.dart similarity index 100% rename from test/body_native_test.dart rename to test/body_test.dart From c993610cb7cc00c719fedc52d9feaee14c08c758 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:19:45 +0800 Subject: [PATCH 07/45] refactor(fetch): move web utils under internal --- lib/src/{fetch => _internal}/web_utils.dart | 0 lib/src/fetch/headers.js.dart | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename lib/src/{fetch => _internal}/web_utils.dart (100%) diff --git a/lib/src/fetch/web_utils.dart b/lib/src/_internal/web_utils.dart similarity index 100% rename from lib/src/fetch/web_utils.dart rename to lib/src/_internal/web_utils.dart diff --git a/lib/src/fetch/headers.js.dart b/lib/src/fetch/headers.js.dart index 900ec8f..9105550 100644 --- a/lib/src/fetch/headers.js.dart +++ b/lib/src/fetch/headers.js.dart @@ -1,7 +1,7 @@ import 'dart:js_interop'; +import '../_internal/web_utils.dart' as web; import 'headers.native.dart' as native; -import 'web_utils.dart' as web; class Headers with Iterable> From 5fe8f43ee9313f7c8014bb2ca884167e116e0a46 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:03:43 +0800 Subject: [PATCH 08/45] feat(fetch): split URLSearchParams into native and web implementations --- lib/src/_internal/web_utils.dart | 24 ++++ lib/src/fetch/url_search_params.dart | 135 +----------------- lib/src/fetch/url_search_params.js.dart | 116 +++++++++++++++ lib/src/fetch/url_search_params.native.dart | 149 ++++++++++++++++++++ 4 files changed, 291 insertions(+), 133 deletions(-) create mode 100644 lib/src/fetch/url_search_params.js.dart create mode 100644 lib/src/fetch/url_search_params.native.dart diff --git a/lib/src/_internal/web_utils.dart b/lib/src/_internal/web_utils.dart index ac67e9a..3ee6c2a 100644 --- a/lib/src/_internal/web_utils.dart +++ b/lib/src/_internal/web_utils.dart @@ -82,3 +82,27 @@ extension type Headers._(JSObject _) implements web.Headers { extension type Array._(JSAny _) { external static bool isArray(JSAny _); } + +extension type URLSearchParams._(JSObject _) implements web.URLSearchParams { + external factory URLSearchParams([JSAny init]); + + external ArrayIterator entries(); + external ArrayIterator keys(); + external ArrayIterator values(); + + @JS('toString') + external String stringify(); + + factory URLSearchParams.fromEntries( + Iterable> entries, + ) { + final params = URLSearchParams(); + for (final MapEntry(key: name, :value) in entries) { + params.append(name, value); + } + return params; + } + + factory URLSearchParams.fromMap(Map map) => + URLSearchParams.fromEntries(map.entries); +} diff --git a/lib/src/fetch/url_search_params.dart b/lib/src/fetch/url_search_params.dart index 4702eaf..2ab32f4 100644 --- a/lib/src/fetch/url_search_params.dart +++ b/lib/src/fetch/url_search_params.dart @@ -1,133 +1,2 @@ -import 'dart:collection'; - -/// Query parameter collection compatible with the MDN URLSearchParams model. -class URLSearchParams extends IterableBase> { - URLSearchParams([Object? init]) { - if (init == null) { - return; - } - - if (init is String) { - _parse(init); - return; - } - - if (init is URLSearchParams) { - _entries.addAll( - init._entries.map((entry) => MapEntry(entry.key, entry.value)), - ); - return; - } - - if (init is Map) { - for (final entry in init.entries) { - append(entry.key, entry.value); - } - return; - } - - if (init is Iterable>) { - for (final entry in init) { - append(entry.key, entry.value); - } - return; - } - - throw ArgumentError.value( - init, - 'init', - 'Expected String, URLSearchParams, Map, or entries', - ); - } - - final _entries = >[]; - - void append(String name, String value) { - _entries.add(MapEntry(name, value)); - } - - void delete(String name, [String? value]) { - if (value == null) { - _entries.removeWhere((entry) => entry.key == name); - return; - } - - _entries.removeWhere((entry) => entry.key == name && entry.value == value); - } - - String? get(String name) { - for (final entry in _entries) { - if (entry.key == name) { - return entry.value; - } - } - return null; - } - - List getAll(String name) { - return List.unmodifiable( - _entries.where((entry) => entry.key == name).map((entry) => entry.value), - ); - } - - bool has(String name, [String? value]) { - if (value == null) { - return _entries.any((entry) => entry.key == name); - } - - return _entries.any((entry) => entry.key == name && entry.value == value); - } - - void set(String name, String value) { - delete(name); - append(name, value); - } - - void sort() { - _entries.sort((a, b) => a.key.compareTo(b.key)); - } - - URLSearchParams clone() => URLSearchParams(this); - - @override - Iterator> get iterator => - List>.unmodifiable(_entries).iterator; - - @override - String toString() { - return _entries - .map((entry) => '${_encode(entry.key)}=${_encode(entry.value)}') - .join('&'); - } - - void _parse(String input) { - final source = input.startsWith('?') ? input.substring(1) : input; - if (source.isEmpty) { - return; - } - - for (final part in source.split('&')) { - if (part.isEmpty) { - continue; - } - - final index = part.indexOf('='); - if (index == -1) { - append(_decode(part), ''); - } else { - append( - _decode(part.substring(0, index)), - _decode(part.substring(index + 1)), - ); - } - } - } - - static String _decode(String value) { - return Uri.decodeQueryComponent(value.replaceAll('+', ' ')); - } - - static String _encode(String value) { - return Uri.encodeQueryComponent(value).replaceAll('%20', '+'); - } -} +export 'url_search_params.native.dart' + if (dart.library.js_interop) 'url_search_params.js.dart'; diff --git a/lib/src/fetch/url_search_params.js.dart b/lib/src/fetch/url_search_params.js.dart new file mode 100644 index 0000000..14d3a3c --- /dev/null +++ b/lib/src/fetch/url_search_params.js.dart @@ -0,0 +1,116 @@ +@JS() +library; + +import 'dart:js_interop'; + +import 'url_search_params.native.dart' as native; +import '../_internal/web_utils.dart' as web; + +class URLSearchParams + with Iterable> + implements native.URLSearchParams { + const URLSearchParams._(this._host); + + factory URLSearchParams([Object? init]) { + final host = switch (init) { + null => web.URLSearchParams(), + final String source => web.URLSearchParams(source.toJS), + URLSearchParams(:final _host) => web.URLSearchParams(_host), + final native.URLSearchParams params => web.URLSearchParams.fromEntries( + params, + ), + final Map map => web.URLSearchParams.fromMap(map), + final Iterable> entries => + web.URLSearchParams.fromEntries(entries), + _ => throw ArgumentError.value( + init, + 'init', + 'Expected String, URLSearchParams, Map, or entries', + ), + }; + + return URLSearchParams._(host); + } + + final web.URLSearchParams _host; + + @override + Iterator> get iterator => _entries().iterator; + + @override + int get size => _host.size; + + Iterable> _entries() sync* { + final iterator = _host.entries(); + while (true) { + final result = iterator.next(); + if (result.done) break; + final [name, value] = (result.value as JSArray).toDart; + yield MapEntry(name.toDart, value.toDart); + } + } + + @override + Iterable> entries() => _entries(); + + @override + void append(String name, String value) => _host.append(name, value); + + @override + void delete(String name, [String? value]) { + if (value == null) { + _host.delete(name); + return; + } + + _host.delete(name, value); + } + + @override + String? get(String name) => _host.get(name); + + @override + List getAll(String name) => _host + .getAll(name) + .toDart + .map((value) => value.toDart) + .toList(growable: false); + + @override + bool has(String name, [String? value]) { + if (value == null) { + return _host.has(name); + } + + return _host.has(name, value); + } + + @override + void set(String name, String value) => _host.set(name, value); + + @override + void sort() => _host.sort(); + + @override + Iterable keys() sync* { + final iterator = _host.keys(); + while (true) { + final result = iterator.next(); + if (result.done) break; + yield (result.value as JSString).toDart; + } + } + + @override + Iterable values() sync* { + final iterator = _host.values(); + while (true) { + final result = iterator.next(); + if (result.done) break; + yield (result.value as JSString).toDart; + } + } + + @override + String toString() => _host.stringify(); +} diff --git a/lib/src/fetch/url_search_params.native.dart b/lib/src/fetch/url_search_params.native.dart new file mode 100644 index 0000000..3b06105 --- /dev/null +++ b/lib/src/fetch/url_search_params.native.dart @@ -0,0 +1,149 @@ +/// Query parameter collection compatible with the MDN URLSearchParams model. +/// +/// Supported constructor input forms: +/// - [String] +/// - [URLSearchParams] +/// - [Map] +/// - [Iterable>] +class URLSearchParams with Iterable> { + URLSearchParams([Object? init]) { + if (init == null) return; + + if (init is String) { + _parse(init); + return; + } + + if (init is URLSearchParams) { + _entries.addAll( + init._entries.map((entry) => MapEntry(entry.key, entry.value)), + ); + return; + } + + if (init is Map) { + for (final entry in init.entries) { + append(entry.key, entry.value); + } + return; + } + + if (init is Iterable>) { + for (final entry in init) { + append(entry.key, entry.value); + } + return; + } + + throw ArgumentError.value( + init, + 'init', + 'Expected String, URLSearchParams, Map, or entries', + ); + } + + final _entries = >[]; + + int get size => _entries.length; + + void append(String name, String value) { + _entries.add(MapEntry(name, value)); + } + + void delete(String name, [String? value]) { + if (value == null) { + _entries.removeWhere((entry) => entry.key == name); + return; + } + + _entries.removeWhere((entry) => entry.key == name && entry.value == value); + } + + Iterable> entries() => this; + + String? get(String name) { + for (final entry in _entries) { + if (entry.key == name) { + return entry.value; + } + } + return null; + } + + List getAll(String name) { + return List.unmodifiable( + _entries.where((entry) => entry.key == name).map((entry) => entry.value), + ); + } + + bool has(String name, [String? value]) { + if (value == null) { + return _entries.any((entry) => entry.key == name); + } + + return _entries.any((entry) => entry.key == name && entry.value == value); + } + + Iterable keys() sync* { + for (final MapEntry(:key) in _entries) { + yield key; + } + } + + void set(String name, String value) { + delete(name); + append(name, value); + } + + void sort() { + _entries.sort((a, b) => a.key.compareTo(b.key)); + } + + Iterable values() sync* { + for (final MapEntry(:value) in _entries) { + yield value; + } + } + + @override + Iterator> get iterator => + List>.unmodifiable(_entries).iterator; + + @override + String toString() { + return _entries + .map((entry) => '${_encode(entry.key)}=${_encode(entry.value)}') + .join('&'); + } + + void _parse(String input) { + final source = input.startsWith('?') ? input.substring(1) : input; + if (source.isEmpty) { + return; + } + + for (final part in source.split('&')) { + if (part.isEmpty) { + continue; + } + + final index = part.indexOf('='); + if (index == -1) { + append(_decode(part), ''); + } else { + append( + _decode(part.substring(0, index)), + _decode(part.substring(index + 1)), + ); + } + } + } + + static String _decode(String value) { + return Uri.decodeQueryComponent(value.replaceAll('+', ' ')); + } + + static String _encode(String value) { + return Uri.encodeQueryComponent(value).replaceAll('%20', '+'); + } +} From 7de23ce3cc859e2794c5a820ea401157cc7f9d8c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:11:51 +0800 Subject: [PATCH 09/45] feat(fetch): support native web URLSearchParams host --- lib/src/fetch/url_search_params.js.dart | 22 +++++++++++--------- test/url_search_params_js_test.dart | 23 +++++++++++++++++++++ test/url_search_params_test.dart | 27 +++++++++++++++++++++---- 3 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 test/url_search_params_js_test.dart diff --git a/lib/src/fetch/url_search_params.js.dart b/lib/src/fetch/url_search_params.js.dart index 14d3a3c..dbad10a 100644 --- a/lib/src/fetch/url_search_params.js.dart +++ b/lib/src/fetch/url_search_params.js.dart @@ -3,8 +3,10 @@ library; import 'dart:js_interop'; +import 'package:web/web.dart' as dom; + import 'url_search_params.native.dart' as native; -import '../_internal/web_utils.dart' as web; +import '../_internal/web_utils.dart' as web_utils; class URLSearchParams with Iterable> @@ -13,15 +15,15 @@ class URLSearchParams factory URLSearchParams([Object? init]) { final host = switch (init) { - null => web.URLSearchParams(), - final String source => web.URLSearchParams(source.toJS), - URLSearchParams(:final _host) => web.URLSearchParams(_host), - final native.URLSearchParams params => web.URLSearchParams.fromEntries( - params, - ), - final Map map => web.URLSearchParams.fromMap(map), + null => web_utils.URLSearchParams(), + final String source => web_utils.URLSearchParams(source.toJS), + URLSearchParams(:final _host) => web_utils.URLSearchParams(_host), + final dom.URLSearchParams host => web_utils.URLSearchParams(host), + final native.URLSearchParams params => + web_utils.URLSearchParams.fromEntries(params), + final Map map => web_utils.URLSearchParams.fromMap(map), final Iterable> entries => - web.URLSearchParams.fromEntries(entries), + web_utils.URLSearchParams.fromEntries(entries), _ => throw ArgumentError.value( init, 'init', @@ -32,7 +34,7 @@ class URLSearchParams return URLSearchParams._(host); } - final web.URLSearchParams _host; + final web_utils.URLSearchParams _host; @override Iterator> get iterator => _entries().iterator; diff --git a/test/url_search_params_js_test.dart b/test/url_search_params_js_test.dart new file mode 100644 index 0000000..ae4a487 --- /dev/null +++ b/test/url_search_params_js_test.dart @@ -0,0 +1,23 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; + +import 'package:ht/src/fetch/url_search_params.dart'; +import 'package:test/test.dart'; +import 'package:web/web.dart' as web; + +void main() { + group('URLSearchParams (js)', () { + test('accepts native web.URLSearchParams host', () { + final upstream = web.URLSearchParams('?a=1&a=2&b=3'.toJS); + final wrapped = URLSearchParams(upstream); + + expect(wrapped.get('a'), '1'); + expect(wrapped.getAll('a'), ['1', '2']); + expect(wrapped.get('b'), '3'); + expect(wrapped.size, 3); + expect(wrapped.toString(), 'a=1&a=2&b=3'); + }); + }); +} diff --git a/test/url_search_params_test.dart b/test/url_search_params_test.dart index 7a6d26c..1464d46 100644 --- a/test/url_search_params_test.dart +++ b/test/url_search_params_test.dart @@ -1,4 +1,4 @@ -import 'package:ht/ht.dart'; +import 'package:ht/src/fetch/url_search_params.dart'; import 'package:test/test.dart'; void main() { @@ -48,12 +48,31 @@ void main() { expect(params.has('a', '2'), isFalse); }); - test('clone is independent', () { + test('copy construction is independent', () { final params = URLSearchParams('a=1'); - final clone = params.clone()..set('a', '2'); + final copy = URLSearchParams(params)..set('a', '2'); expect(params.get('a'), '1'); - expect(clone.get('a'), '2'); + expect(copy.get('a'), '2'); + }); + + test('exposes size, entries, keys, and values', () { + final params = URLSearchParams('?a=1&a=2&b=3'); + + expect(params.size, 3); + expect( + params + .entries() + .map((entry) => [entry.key, entry.value]) + .toList(growable: false), + [ + ['a', '1'], + ['a', '2'], + ['b', '3'], + ], + ); + expect(params.keys(), ['a', 'a', 'b']); + expect(params.values(), ['1', '2', '3']); }); test('handles key without equal-sign', () { From 9cf3971ff19556ac1c7fb11db1ce4891c7eb8da5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:12:15 +0800 Subject: [PATCH 10/45] feat(fetch): add native FormData baseline --- lib/src/fetch/form_data.native.dart | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 lib/src/fetch/form_data.native.dart diff --git a/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart new file mode 100644 index 0000000..9425438 --- /dev/null +++ b/lib/src/fetch/form_data.native.dart @@ -0,0 +1,79 @@ +import 'blob.dart'; +import 'file.dart'; + +sealed class MultipartBody { + const MultipartBody(); + + const factory MultipartBody.text(String value) = TextMultipartBody; + factory MultipartBody.blob(Blob value, [String? filename]) => + BlobMultipartBody(value, filename); +} + +final class TextMultipartBody extends MultipartBody { + const TextMultipartBody(this.value); + + final String value; +} + +final class BlobMultipartBody extends File implements MultipartBody { + BlobMultipartBody(Blob value, [String? filename]) + : filename = switch (value) { + final File file => filename ?? file.name, + _ => filename ?? 'blob', + }, + super([value], value.type); + + final String filename; +} + +class FormData with Iterable> { + final _entries = >[]; + + @override + Iterator> get iterator => _entries.iterator; + + Iterable> entries() => this; + + Iterable keys() sync* { + for (final MapEntry(:key) in _entries) { + yield key; + } + } + + Iterable values() sync* { + for (final MapEntry(:value) in _entries) { + yield value; + } + } + + MultipartBody? get(String name) { + for (final entry in _entries) { + if (entry.key == name) { + return entry.value; + } + } + + return null; + } + + List getAll(String name) { + return List.unmodifiable( + _entries.where((entry) => entry.key == name).map((entry) => entry.value), + ); + } + + bool has(String name) => _entries.any((entry) => entry.key == name); + + void append(String name, MultipartBody value) { + _entries.add(MapEntry(name, value)); + } + + void delete(String name) { + _entries.removeWhere((entry) => entry.key == name); + } + + void set(String name, MultipartBody value) { + delete(name); + append(name, value); + } +} From 149fc27da281dee6459a917356b2cecb6f82052a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:15:38 +0800 Subject: [PATCH 11/45] feat(fetch): add native FormData parsing for urlencoded bodies --- lib/src/fetch/form_data.native.dart | 34 +++++++++++++++++++++++++++++ test/form_data_native_test.dart | 32 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 test/form_data_native_test.dart diff --git a/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart index 9425438..447f913 100644 --- a/lib/src/fetch/form_data.native.dart +++ b/lib/src/fetch/form_data.native.dart @@ -1,5 +1,7 @@ import 'blob.dart'; +import 'body.dart'; import 'file.dart'; +import 'url_search_params.dart'; sealed class MultipartBody { const MultipartBody(); @@ -27,6 +29,19 @@ final class BlobMultipartBody extends File implements MultipartBody { } class FormData with Iterable> { + static Future parse(Body body, {String? contentType}) async { + final essence = _contentTypeEssence(contentType); + return switch (essence) { + 'application/x-www-form-urlencoded' => _parseUrlEncoded(body), + 'multipart/form-data' => throw UnimplementedError( + 'Multipart form-data parsing is not implemented yet.', + ), + _ => throw UnsupportedError( + 'Unsupported form content type: ${contentType ?? '(missing)'}', + ), + }; + } + final _entries = >[]; @override @@ -76,4 +91,23 @@ class FormData with Iterable> { delete(name); append(name, value); } + + static Future _parseUrlEncoded(Body body) async { + final params = URLSearchParams(await body.text()); + final formData = FormData(); + for (final MapEntry(:key, :value) in params.entries()) { + formData.append(key, MultipartBody.text(value)); + } + return formData; + } + + static String _contentTypeEssence(String? contentType) { + if (contentType == null) return ''; + + final separator = contentType.indexOf(';'); + final essence = separator == -1 + ? contentType + : contentType.substring(0, separator); + return essence.trim().toLowerCase(); + } } diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart new file mode 100644 index 0000000..9598857 --- /dev/null +++ b/test/form_data_native_test.dart @@ -0,0 +1,32 @@ +import 'package:ht/src/fetch/body.dart'; +import 'package:ht/src/fetch/form_data.native.dart'; +import 'package:test/test.dart'; + +void main() { + group('FormData.parse (native)', () { + test('parses application/x-www-form-urlencoded bodies', () async { + final formData = await FormData.parse( + Body('a=1&a=2&hello=world+x'), + contentType: 'application/x-www-form-urlencoded', + ); + + expect(formData.get('a'), isA()); + expect((formData.get('a')! as TextMultipartBody).value, '1'); + expect( + formData.getAll('a').map((value) => (value as TextMultipartBody).value), + ['1', '2'], + ); + expect((formData.get('hello')! as TextMultipartBody).value, 'world x'); + }); + + test('accepts content-type parameters when parsing urlencoded bodies', () async { + final formData = await FormData.parse( + Body('name=seven+du'), + contentType: + 'application/x-www-form-urlencoded; charset=utf-8', + ); + + expect((formData.get('name')! as TextMultipartBody).value, 'seven du'); + }); + }); +} From 0334f23645104f41c4e8f6ce5a226e46b42f9861 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:19:42 +0800 Subject: [PATCH 12/45] chore(wip): checkpoint remaining request refactor work --- lib/ht.dart | 4 +- lib/src/_internal/web_utils.dart | 13 ++ lib/src/fetch/request.dart | 29 ++- lib/src/fetch/request.native.dart | 343 ++++++++++++++++++++++++++++++ 4 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 lib/src/fetch/request.native.dart diff --git a/lib/ht.dart b/lib/ht.dart index 9859de8..7613831 100644 --- a/lib/ht.dart +++ b/lib/ht.dart @@ -1,11 +1,9 @@ -library; - export 'src/core/http_method.dart'; export 'src/core/http_status.dart'; export 'src/core/http_version.dart'; export 'src/core/mime_type.dart'; -export 'src/fetch/body.dart' show BodyInit, BodyMixin; +export 'src/fetch/body.dart'; export 'src/fetch/blob.dart'; export 'src/fetch/file.dart'; export 'src/fetch/form_data.dart'; diff --git a/lib/src/_internal/web_utils.dart b/lib/src/_internal/web_utils.dart index 3ee6c2a..f8062b8 100644 --- a/lib/src/_internal/web_utils.dart +++ b/lib/src/_internal/web_utils.dart @@ -106,3 +106,16 @@ extension type URLSearchParams._(JSObject _) implements web.URLSearchParams { factory URLSearchParams.fromMap(Map map) => URLSearchParams.fromEntries(map.entries); } + +extension type FormData._(JSObject _) implements web.FormData { + external factory FormData([ + web.HTMLFormElement form, + web.HTMLElement? submitter, + ]); + + factory FormData.fromHost(web.FormData host) => FormData._(host); + + external ArrayIterator entries(); + external ArrayIterator keys(); + external ArrayIterator values(); +} diff --git a/lib/src/fetch/request.dart b/lib/src/fetch/request.dart index 02daca8..3f9bba6 100644 --- a/lib/src/fetch/request.dart +++ b/lib/src/fetch/request.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'body.dart'; +import 'blob.dart'; import 'form_data.dart'; import 'headers.dart'; import 'url_search_params.dart'; @@ -15,13 +16,13 @@ class RequestInit { } /// Fetch-like HTTP request model. -class Request with BodyMixin { +class Request { Request(Uri url, [RequestInit? init]) : this._create( url: url, method: init?.method ?? 'GET', headers: _headersFromInit(init?.headers), - bodyData: BodyData.fromInit(init?.body), + bodyData: Body(init?.body), ); Request._create({ @@ -89,11 +90,22 @@ class Request with BodyMixin { /// Mutable request headers. final Headers headers; - @override - final BodyData bodyData; + final Body bodyData; + + Stream? get body => bodyData.hasBody ? bodyData.stream : null; + bool get bodyUsed => bodyData.bodyUsed; + Future bytes() => bodyData.bytes(); + Future text([Encoding encoding = utf8]) => bodyData.text(encoding); + Future json() => bodyData.json(); + Future blob() async { + final blob = await bodyData.blob(); + final type = headers.get('content-type'); + if (type == null || type.isEmpty || blob.type == type) { + return blob; + } - @override - String? get bodyMimeTypeHint => headers.get('content-type'); + return Blob([blob], type); + } Request clone() { return Request._internal( @@ -114,7 +126,6 @@ class Request with BodyMixin { static Headers _headersFromInit(HeadersInit? init) { return switch (init) { - null => Headers(), final Headers headers => headers, _ => Headers(init), }; @@ -144,9 +155,7 @@ class Request with BodyMixin { } void _applyDefaultBodyHeaders() { - if (!bodyData.hasBody) { - return; - } + if (!bodyData.hasBody) return; final inferredType = bodyData.defaultContentType; if (inferredType != null && !headers.has('content-type')) { diff --git a/lib/src/fetch/request.native.dart b/lib/src/fetch/request.native.dart new file mode 100644 index 0000000..4c4d1c3 --- /dev/null +++ b/lib/src/fetch/request.native.dart @@ -0,0 +1,343 @@ +import 'dart:typed_data'; + +import '../core/http_method.dart'; +import 'body.dart'; +import 'blob.dart'; +import 'form_data.dart'; +import 'headers.dart'; + +enum RequestMode { + cors('cors'), + navigate('navigate'), + noCors('no-cors'), + sameOrigin('same-origin'); + + const RequestMode(this.value); + + final String value; +} + +enum RequestCredentials { + omit('omit'), + sameOrigin('same-origin'), + include('include'); + + const RequestCredentials(this.value); + + final String value; +} + +enum RequestCache { + default_('default'), + noStore('no-store'), + reload('reload'), + noCache('no-cache'), + forceCache('force-cache'), + onlyIfCached('only-if-cached'); + + const RequestCache(this.value); + + final String value; +} + +enum RequestRedirect { + follow('follow'), + error('error'), + manual('manual'); + + const RequestRedirect(this.value); + + final String value; +} + +enum RequestReferrerPolicy { + noReferrer('no-referrer'), + noReferrerWhenDowngrade('no-referrer-when-downgrade'), + sameOrigin('same-origin'), + origin('origin'), + strictOrigin('strict-origin'), + originWhenCrossOrigin('origin-when-cross-origin'), + strictOriginWhenCrossOrigin('strict-origin-when-cross-origin'), + unsafeUrl('unsafe-url'); + + const RequestReferrerPolicy(this.value); + + final String value; +} + +enum RequestDuplex { + half('half'); + + const RequestDuplex(this.value); + + final String value; +} + +sealed class RequestInput { + const RequestInput(); + + const factory RequestInput.request(Request value) = RequestRequestInput; + const factory RequestInput.string(String value) = StringRequestInput; + const factory RequestInput.uri(Uri value) = UriRequestInput; +} + +final class RequestRequestInput extends RequestInput { + const RequestRequestInput(this.value); + + final Request value; +} + +final class StringRequestInput extends RequestInput { + const StringRequestInput(this.value); + + final String value; +} + +final class UriRequestInput extends RequestInput { + const UriRequestInput(this.value); + + final Uri value; +} + +/// Initialization options for [Request], aligned with the MDN Fetch +/// `RequestInit` surface. +class RequestInit { + RequestInit({ + this.method, + this.headers, + this.body, + this.referrer, + this.referrerPolicy, + this.mode, + this.credentials, + this.cache, + this.redirect, + this.integrity, + this.keepalive, + this.duplex, + }); + + final HttpMethod? method; + final HeadersInit? headers; + final BodyInit? body; + final String? referrer; + final RequestReferrerPolicy? referrerPolicy; + final RequestMode? mode; + final RequestCredentials? credentials; + final RequestCache? cache; + final RequestRedirect? redirect; + final String? integrity; + final bool? keepalive; + final RequestDuplex? duplex; +} + +/// Native request contract shell aligned with the MDN `Request` surface. +class Request { + Request(RequestInput input, [RequestInit? init]) + : headers = _headersFromInput(input, init?.headers), + body = _bodyFromInput(input, init?.body), + cache = _cacheFromInput(input, init?.cache), + credentials = _credentialsFromInput(input, init?.credentials), + destination = _destinationFromInput(input), + duplex = _duplexFromInput(input, init?.duplex), + integrity = _integrityFromInput(input, init?.integrity), + isHistoryNavigation = _isHistoryNavigationFromInput(input), + keepalive = _keepaliveFromInput(input, init?.keepalive), + method = _methodFromInput(input, init?.method), + mode = _modeFromInput(input, init?.mode), + redirect = _redirectFromInput(input, init?.redirect), + referrer = _referrerFromInput(input, init?.referrer), + referrerPolicy = _referrerPolicyFromInput(input, init?.referrerPolicy), + url = _urlFromInput(input); + + final Headers headers; + final Body? body; + final RequestCache cache; + final RequestCredentials credentials; + final String destination; + final RequestDuplex duplex; + final String integrity; + final bool isHistoryNavigation; + final bool keepalive; + final HttpMethod method; + final RequestMode mode; + final RequestRedirect redirect; + final String referrer; + final RequestReferrerPolicy? referrerPolicy; + final String url; + + bool get bodyUsed => body?.bodyUsed ?? false; + + Future arrayBuffer() => bytes(); + + Future blob() async { + final blob = switch (body) { + final Body body => await body.blob(), + null => Blob(), + }; + + final type = headers.get('content-type'); + if (type == null || type.isEmpty || blob.type == type) { + return blob; + } + + return Blob([blob], type); + } + + Future bytes() async { + return switch (body) { + Blob(:final bytes) => bytes(), + _ => Uint8List(0), + }; + } + + Future formData() => throw UnimplementedError(); + + Future json() { + return switch (body) { + final Body body => body.json(), + null => Future.error( + const FormatException('Cannot decode JSON from an empty body.'), + ), + }; + } + + Future text() { + return switch (body) { + final Body body => body.text(), + null => Future.value(''), + }; + } + + Request clone() => throw UnimplementedError(); + + static Headers _headersFromInput(RequestInput input, HeadersInit? init) { + if (init != null) return Headers(init); + return switch (input) { + RequestRequestInput(:final value) => Headers(value.headers), + _ => Headers(), + }; + } + + static Body? _bodyFromInput(RequestInput input, BodyInit? init) { + if (init != null) return Body(init); + return switch (input) { + RequestRequestInput(:final value) => value.body?.clone(), + _ => null, + }; + } + + static RequestCache _cacheFromInput(RequestInput input, RequestCache? init) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.cache, + _ => RequestCache.default_, + }; + } + + static RequestCredentials _credentialsFromInput( + RequestInput input, + RequestCredentials? init, + ) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.credentials, + _ => RequestCredentials.sameOrigin, + }; + } + + static String _destinationFromInput(RequestInput input) { + return switch (input) { + RequestRequestInput(:final value) => value.destination, + _ => '', + }; + } + + static RequestDuplex _duplexFromInput( + RequestInput input, + RequestDuplex? init, + ) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.duplex, + _ => RequestDuplex.half, + }; + } + + static String _integrityFromInput(RequestInput input, String? init) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.integrity, + _ => '', + }; + } + + static bool _isHistoryNavigationFromInput(RequestInput input) { + return switch (input) { + RequestRequestInput(:final value) => value.isHistoryNavigation, + _ => false, + }; + } + + static bool _keepaliveFromInput(RequestInput input, bool? init) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.keepalive, + _ => false, + }; + } + + static HttpMethod _methodFromInput(RequestInput input, HttpMethod? init) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.method, + _ => HttpMethod.get, + }; + } + + static RequestMode _modeFromInput(RequestInput input, RequestMode? init) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.mode, + _ => RequestMode.cors, + }; + } + + static RequestRedirect _redirectFromInput( + RequestInput input, + RequestRedirect? init, + ) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.redirect, + _ => RequestRedirect.follow, + }; + } + + static String _referrerFromInput(RequestInput input, String? init) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.referrer, + _ => 'about:client', + }; + } + + static RequestReferrerPolicy? _referrerPolicyFromInput( + RequestInput input, + RequestReferrerPolicy? init, + ) { + if (init != null) return init; + return switch (input) { + RequestRequestInput(:final value) => value.referrerPolicy, + _ => null, + }; + } + + static String _urlFromInput(RequestInput input) { + return switch (input) { + RequestRequestInput(:final value) => value.url, + StringRequestInput(:final value) => Uri.parse(value).toString(), + UriRequestInput(:final value) => value.toString(), + }; + } +} From 66305a034d0ab1a4e7ddce167606b271fc8b1740 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:24:04 +0800 Subject: [PATCH 13/45] feat(fetch): parse native multipart form data --- lib/src/fetch/form_data.native.dart | 219 +++++++++++++++++++++++++++- test/form_data_native_test.dart | 62 +++++++- 2 files changed, 272 insertions(+), 9 deletions(-) diff --git a/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart index 447f913..30ee3f1 100644 --- a/lib/src/fetch/form_data.native.dart +++ b/lib/src/fetch/form_data.native.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'blob.dart'; import 'body.dart'; import 'file.dart'; @@ -23,7 +26,14 @@ final class BlobMultipartBody extends File implements MultipartBody { final File file => filename ?? file.name, _ => filename ?? 'blob', }, - super([value], value.type); + super( + [value], + switch (value) { + final File file => filename ?? file.name, + _ => filename ?? 'blob', + }, + type: value.type, + ); final String filename; } @@ -33,9 +43,8 @@ class FormData with Iterable> { final essence = _contentTypeEssence(contentType); return switch (essence) { 'application/x-www-form-urlencoded' => _parseUrlEncoded(body), - 'multipart/form-data' => throw UnimplementedError( - 'Multipart form-data parsing is not implemented yet.', - ), + 'multipart/form-data' => _parseMultipart(body, contentType: contentType), + '' => throw UnsupportedError('Unsupported form content type: (missing)'), _ => throw UnsupportedError( 'Unsupported form content type: ${contentType ?? '(missing)'}', ), @@ -101,6 +110,70 @@ class FormData with Iterable> { return formData; } + static Future _parseMultipart( + Body body, { + required String? contentType, + }) async { + final boundary = _boundaryFromContentType(contentType); + if (boundary == null || boundary.isEmpty) { + throw const FormatException( + 'Missing multipart boundary in content-type.', + ); + } + + final bytes = await body.bytes(); + final boundaryMarker = utf8.encode('--$boundary'); + final boundaryPrefix = utf8.encode('\r\n--$boundary'); + final headerSeparator = utf8.encode('\r\n\r\n'); + final formData = FormData(); + + var offset = _indexOf(bytes, boundaryMarker); + if (offset == -1) { + throw const FormatException('Multipart body does not contain boundary.'); + } + + while (offset != -1) { + offset += boundaryMarker.length; + + if (_matches(bytes, offset, utf8.encode('--'))) { + break; + } + + if (!_matches(bytes, offset, utf8.encode('\r\n'))) { + throw const FormatException('Invalid multipart boundary separator.'); + } + offset += 2; + + final headerEnd = _indexOf(bytes, headerSeparator, offset); + if (headerEnd == -1) { + throw const FormatException('Invalid multipart part headers.'); + } + + final headers = _parsePartHeaders(bytes.sublist(offset, headerEnd)); + final contentStart = headerEnd + headerSeparator.length; + final nextBoundary = _indexOf(bytes, boundaryPrefix, contentStart); + if (nextBoundary == -1) { + throw const FormatException( + 'Multipart part is missing a closing boundary.', + ); + } + + final contentBytes = Uint8List.sublistView( + bytes, + contentStart, + nextBoundary, + ); + formData.append( + _fieldNameFromPartHeaders(headers), + _partBodyFromHeaders(headers, contentBytes), + ); + + offset = nextBoundary + 2; + } + + return formData; + } + static String _contentTypeEssence(String? contentType) { if (contentType == null) return ''; @@ -110,4 +183,142 @@ class FormData with Iterable> { : contentType.substring(0, separator); return essence.trim().toLowerCase(); } + + static String? _boundaryFromContentType(String? contentType) { + if (contentType == null) return null; + + final segments = contentType.split(';'); + for (final rawSegment in segments.skip(1)) { + final segment = rawSegment.trim(); + if (!segment.toLowerCase().startsWith('boundary=')) { + continue; + } + + final value = segment.substring('boundary='.length).trim(); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + return value.substring(1, value.length - 1); + } + return value; + } + + return null; + } + + static Map _parsePartHeaders(Uint8List bytes) { + final text = latin1.decode(bytes); + final headers = {}; + + for (final line in text.split('\r\n')) { + final separator = line.indexOf(':'); + if (separator <= 0) { + continue; + } + + final name = line.substring(0, separator).trim().toLowerCase(); + final value = line.substring(separator + 1).trim(); + headers[name] = value; + } + + return headers; + } + + static String _fieldNameFromPartHeaders(Map headers) { + final disposition = headers['content-disposition']; + if (disposition == null) { + throw const FormatException( + 'Multipart part is missing content-disposition.', + ); + } + + final parameters = _parseHeaderParameters(disposition); + final name = parameters['name']; + if (name == null || name.isEmpty) { + throw const FormatException( + 'Multipart part is missing content-disposition name.', + ); + } + + return name; + } + + static MultipartBody _partBodyFromHeaders( + Map headers, + Uint8List bytes, + ) { + final disposition = headers['content-disposition']; + if (disposition == null) { + throw const FormatException( + 'Multipart part is missing content-disposition.', + ); + } + + final parameters = _parseHeaderParameters(disposition); + final filename = parameters['filename']; + if (filename != null) { + return MultipartBody.blob( + Blob([bytes], headers['content-type'] ?? ''), + filename, + ); + } + + return MultipartBody.text(utf8.decode(bytes)); + } + + static Map _parseHeaderParameters(String value) { + final parameters = {}; + for (final segment in value.split(';').skip(1)) { + final separator = segment.indexOf('='); + if (separator == -1) { + continue; + } + + final name = segment.substring(0, separator).trim().toLowerCase(); + var parameterValue = segment.substring(separator + 1).trim(); + if (parameterValue.length >= 2 && + parameterValue.startsWith('"') && + parameterValue.endsWith('"')) { + parameterValue = parameterValue.substring(1, parameterValue.length - 1); + } + + parameters[name] = _unescapeHeaderValue(parameterValue); + } + + return parameters; + } + + static String _unescapeHeaderValue(String value) { + return value + .replaceAll(r'\\', '\\') + .replaceAll(r'\"', '"') + .replaceAll(r'\r', '\r') + .replaceAll(r'\n', '\n'); + } + + static int _indexOf(List haystack, List needle, [int start = 0]) { + if (needle.isEmpty) { + return start <= haystack.length ? start : -1; + } + + for (var i = start; i <= haystack.length - needle.length; i++) { + if (_matches(haystack, i, needle)) { + return i; + } + } + + return -1; + } + + static bool _matches(List haystack, int start, List needle) { + if (start < 0 || start + needle.length > haystack.length) { + return false; + } + + for (var i = 0; i < needle.length; i++) { + if (haystack[start + i] != needle[i]) { + return false; + } + } + + return true; + } } diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index 9598857..6daa2ff 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -1,4 +1,6 @@ import 'package:ht/src/fetch/body.dart'; +import 'package:ht/src/fetch/blob.dart'; +import 'package:ht/src/fetch/form_data.dart' as legacy; import 'package:ht/src/fetch/form_data.native.dart'; import 'package:test/test.dart'; @@ -19,14 +21,64 @@ void main() { expect((formData.get('hello')! as TextMultipartBody).value, 'world x'); }); - test('accepts content-type parameters when parsing urlencoded bodies', () async { + test( + 'accepts content-type parameters when parsing urlencoded bodies', + () async { + final formData = await FormData.parse( + Body('name=seven+du'), + contentType: 'application/x-www-form-urlencoded; charset=utf-8', + ); + + expect((formData.get('name')! as TextMultipartBody).value, 'seven du'); + }, + ); + + test('parses multipart/form-data text entries', () async { + final encoded = + (legacy.FormData() + ..append('a', '1') + ..append('a', '2') + ..append('hello', 'world')) + .encodeMultipart(boundary: 'test-boundary'); + + final formData = await FormData.parse( + Body(encoded.stream), + contentType: encoded.contentType, + ); + + expect(formData.get('a'), isA()); + expect((formData.get('a')! as TextMultipartBody).value, '1'); + expect( + formData.getAll('a').map((value) => (value as TextMultipartBody).value), + ['1', '2'], + ); + expect((formData.get('hello')! as TextMultipartBody).value, 'world'); + }); + + test('parses multipart/form-data blob entries', () async { + final encoded = + (legacy.FormData() + ..append('title', 'avatar') + ..append( + 'file', + Blob(['binary'], 'text/plain;charset=utf-8'), + filename: 'a.txt', + )) + .encodeMultipart(boundary: 'blob-boundary'); + final formData = await FormData.parse( - Body('name=seven+du'), - contentType: - 'application/x-www-form-urlencoded; charset=utf-8', + Body(encoded.stream), + contentType: encoded.contentType, ); - expect((formData.get('name')! as TextMultipartBody).value, 'seven du'); + expect((formData.get('title')! as TextMultipartBody).value, 'avatar'); + + final file = formData.get('file'); + expect(file, isA()); + final blob = file! as BlobMultipartBody; + expect(blob.filename, 'a.txt'); + expect(blob.type, 'text/plain;charset=utf-8'); + expect(await blob.text(), 'binary'); }); }); } From 7a7d7fea9feafba0655d56cc217799c1268dc322 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:24:39 +0800 Subject: [PATCH 14/45] refactor(fetch): rename native multipart value types --- lib/src/fetch/form_data.native.dart | 48 ++++++++++++++--------------- test/form_data_native_test.dart | 24 +++++++-------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart index 30ee3f1..b647659 100644 --- a/lib/src/fetch/form_data.native.dart +++ b/lib/src/fetch/form_data.native.dart @@ -6,22 +6,22 @@ import 'body.dart'; import 'file.dart'; import 'url_search_params.dart'; -sealed class MultipartBody { - const MultipartBody(); +sealed class Multipart { + const Multipart(); - const factory MultipartBody.text(String value) = TextMultipartBody; - factory MultipartBody.blob(Blob value, [String? filename]) => - BlobMultipartBody(value, filename); + const factory Multipart.text(String value) = TextMultipart; + factory Multipart.blob(Blob value, [String? filename]) => + BlobMultipart(value, filename); } -final class TextMultipartBody extends MultipartBody { - const TextMultipartBody(this.value); +final class TextMultipart extends Multipart { + const TextMultipart(this.value); final String value; } -final class BlobMultipartBody extends File implements MultipartBody { - BlobMultipartBody(Blob value, [String? filename]) +final class BlobMultipart extends File implements Multipart { + BlobMultipart(Blob value, [String? filename]) : filename = switch (value) { final File file => filename ?? file.name, _ => filename ?? 'blob', @@ -38,7 +38,7 @@ final class BlobMultipartBody extends File implements MultipartBody { final String filename; } -class FormData with Iterable> { +class FormData with Iterable> { static Future parse(Body body, {String? contentType}) async { final essence = _contentTypeEssence(contentType); return switch (essence) { @@ -51,12 +51,12 @@ class FormData with Iterable> { }; } - final _entries = >[]; + final _entries = >[]; @override - Iterator> get iterator => _entries.iterator; + Iterator> get iterator => _entries.iterator; - Iterable> entries() => this; + Iterable> entries() => this; Iterable keys() sync* { for (final MapEntry(:key) in _entries) { @@ -64,13 +64,13 @@ class FormData with Iterable> { } } - Iterable values() sync* { + Iterable values() sync* { for (final MapEntry(:value) in _entries) { yield value; } } - MultipartBody? get(String name) { + Multipart? get(String name) { for (final entry in _entries) { if (entry.key == name) { return entry.value; @@ -80,23 +80,23 @@ class FormData with Iterable> { return null; } - List getAll(String name) { - return List.unmodifiable( + List getAll(String name) { + return List.unmodifiable( _entries.where((entry) => entry.key == name).map((entry) => entry.value), ); } bool has(String name) => _entries.any((entry) => entry.key == name); - void append(String name, MultipartBody value) { - _entries.add(MapEntry(name, value)); + void append(String name, Multipart value) { + _entries.add(MapEntry(name, value)); } void delete(String name) { _entries.removeWhere((entry) => entry.key == name); } - void set(String name, MultipartBody value) { + void set(String name, Multipart value) { delete(name); append(name, value); } @@ -105,7 +105,7 @@ class FormData with Iterable> { final params = URLSearchParams(await body.text()); final formData = FormData(); for (final MapEntry(:key, :value) in params.entries()) { - formData.append(key, MultipartBody.text(value)); + formData.append(key, Multipart.text(value)); } return formData; } @@ -241,7 +241,7 @@ class FormData with Iterable> { return name; } - static MultipartBody _partBodyFromHeaders( + static Multipart _partBodyFromHeaders( Map headers, Uint8List bytes, ) { @@ -255,13 +255,13 @@ class FormData with Iterable> { final parameters = _parseHeaderParameters(disposition); final filename = parameters['filename']; if (filename != null) { - return MultipartBody.blob( + return Multipart.blob( Blob([bytes], headers['content-type'] ?? ''), filename, ); } - return MultipartBody.text(utf8.decode(bytes)); + return Multipart.text(utf8.decode(bytes)); } static Map _parseHeaderParameters(String value) { diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index 6daa2ff..9d40fcd 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -12,13 +12,13 @@ void main() { contentType: 'application/x-www-form-urlencoded', ); - expect(formData.get('a'), isA()); - expect((formData.get('a')! as TextMultipartBody).value, '1'); + expect(formData.get('a'), isA()); + expect((formData.get('a')! as TextMultipart).value, '1'); expect( - formData.getAll('a').map((value) => (value as TextMultipartBody).value), + formData.getAll('a').map((value) => (value as TextMultipart).value), ['1', '2'], ); - expect((formData.get('hello')! as TextMultipartBody).value, 'world x'); + expect((formData.get('hello')! as TextMultipart).value, 'world x'); }); test( @@ -29,7 +29,7 @@ void main() { contentType: 'application/x-www-form-urlencoded; charset=utf-8', ); - expect((formData.get('name')! as TextMultipartBody).value, 'seven du'); + expect((formData.get('name')! as TextMultipart).value, 'seven du'); }, ); @@ -46,13 +46,13 @@ void main() { contentType: encoded.contentType, ); - expect(formData.get('a'), isA()); - expect((formData.get('a')! as TextMultipartBody).value, '1'); + expect(formData.get('a'), isA()); + expect((formData.get('a')! as TextMultipart).value, '1'); expect( - formData.getAll('a').map((value) => (value as TextMultipartBody).value), + formData.getAll('a').map((value) => (value as TextMultipart).value), ['1', '2'], ); - expect((formData.get('hello')! as TextMultipartBody).value, 'world'); + expect((formData.get('hello')! as TextMultipart).value, 'world'); }); test('parses multipart/form-data blob entries', () async { @@ -71,11 +71,11 @@ void main() { contentType: encoded.contentType, ); - expect((formData.get('title')! as TextMultipartBody).value, 'avatar'); + expect((formData.get('title')! as TextMultipart).value, 'avatar'); final file = formData.get('file'); - expect(file, isA()); - final blob = file! as BlobMultipartBody; + expect(file, isA()); + final blob = file! as BlobMultipart; expect(blob.filename, 'a.txt'); expect(blob.type, 'text/plain;charset=utf-8'); expect(await blob.text(), 'binary'); From 71a4a1c0fefbd287b5d550e5f80d2ca01effb27d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:12:30 +0800 Subject: [PATCH 15/45] feat(fetch): add native FormData multipart encoding --- lib/src/fetch/form_data.native.dart | 136 ++++++++++++++++++++++++++++ test/form_data_native_test.dart | 36 ++++++++ 2 files changed, 172 insertions(+) diff --git a/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart index b647659..4eff119 100644 --- a/lib/src/fetch/form_data.native.dart +++ b/lib/src/fetch/form_data.native.dart @@ -1,9 +1,11 @@ import 'dart:convert'; +import 'dart:math'; import 'dart:typed_data'; import 'blob.dart'; import 'body.dart'; import 'file.dart'; +import 'headers.dart'; import 'url_search_params.dart'; sealed class Multipart { @@ -38,6 +40,36 @@ final class BlobMultipart extends File implements Multipart { final String filename; } +final class EncodedFormData { + EncodedFormData._({ + required Stream Function() streamFactory, + required this.boundary, + required this.contentLength, + }) : _streamFactory = streamFactory; + + final Stream Function() _streamFactory; + final String boundary; + final int contentLength; + + String get contentType => 'multipart/form-data; boundary=$boundary'; + + Stream get stream => _streamFactory(); + + Future bytes() async { + final builder = BytesBuilder(copy: false); + await for (final chunk in _streamFactory()) { + builder.add(chunk); + } + return builder.takeBytes(); + } + + void applyTo(Headers headers) { + headers + ..set('content-type', contentType) + ..set('content-length', contentLength.toString()); + } +} + class FormData with Iterable> { static Future parse(Body body, {String? contentType}) async { final essence = _contentTypeEssence(contentType); @@ -101,6 +133,19 @@ class FormData with Iterable> { append(name, value); } + EncodedFormData encodeMultipart({String? boundary}) { + final safeBoundary = boundary ?? _generateBoundary(); + final snapshot = _entries + .map((entry) => MapEntry(entry.key, entry.value)) + .toList(growable: false); + + return EncodedFormData._( + streamFactory: () => _encodeMultipart(snapshot, safeBoundary), + boundary: safeBoundary, + contentLength: _calculateMultipartLength(snapshot, safeBoundary), + ); + } + static Future _parseUrlEncoded(Body body) async { final params = URLSearchParams(await body.text()); final formData = FormData(); @@ -321,4 +366,95 @@ class FormData with Iterable> { return true; } + + static String _escapeHeaderValue(String value) { + return value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\r', '\\r') + .replaceAll('\n', '\\n'); + } + + static String _generateBoundary() { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final random = Random.secure(); + final suffix = List.generate( + 24, + (_) => alphabet[random.nextInt(alphabet.length)], + growable: false, + ).join(); + return '----ht-$suffix'; + } + + static Stream _encodeMultipart( + List> entries, + String boundary, + ) async* { + for (final entry in entries) { + yield _utf8('--$boundary\r\n'); + + switch (entry.value) { + case final BlobMultipart blob: + yield _utf8( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"; ' + 'filename="${_escapeHeaderValue(blob.filename)}"\r\n', + ); + + final type = blob.type.isEmpty ? 'application/octet-stream' : blob.type; + yield _utf8('Content-Type: $type\r\n\r\n'); + yield* blob.stream(); + yield _utf8('\r\n'); + case final TextMultipart text: + yield _utf8( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"\r\n\r\n', + ); + yield _utf8(text.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'); + + switch (entry.value) { + case final BlobMultipart blob: + total += _utf8Length( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"; ' + 'filename="${_escapeHeaderValue(blob.filename)}"\r\n', + ); + + final type = blob.type.isEmpty ? 'application/octet-stream' : blob.type; + total += _utf8Length('Content-Type: $type\r\n\r\n'); + total += blob.size; + total += _utf8Length('\r\n'); + case final TextMultipart text: + total += _utf8Length( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"\r\n\r\n', + ); + total += _utf8Length(text.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/test/form_data_native_test.dart b/test/form_data_native_test.dart index 9d40fcd..f7e9a09 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -1,7 +1,10 @@ +import 'dart:typed_data'; + import 'package:ht/src/fetch/body.dart'; import 'package:ht/src/fetch/blob.dart'; import 'package:ht/src/fetch/form_data.dart' as legacy; import 'package:ht/src/fetch/form_data.native.dart'; +import 'package:ht/src/fetch/headers.dart'; import 'package:test/test.dart'; void main() { @@ -81,4 +84,37 @@ void main() { expect(await blob.text(), 'binary'); }); }); + + group('FormData.encodeMultipart (native)', () { + test('returns encoded multipart metadata and payload', () async { + final encoded = + (FormData() + ..append('name', Multipart.text('alice')) + ..append( + 'avatar', + Multipart.blob( + Blob(['binary'], 'text/plain;charset=utf-8'), + 'a.txt', + ), + )) + .encodeMultipart(boundary: 'native-boundary'); + + final headers = Headers(); + encoded.applyTo(headers); + + expect( + headers.get('content-type'), + 'multipart/form-data; boundary=native-boundary', + ); + expect(headers.get('content-length'), encoded.contentLength.toString()); + + final bytes = await encoded.bytes(); + final fromStream = BytesBuilder(copy: false); + await for (final chunk in encoded.stream) { + fromStream.add(chunk); + } + + expect(fromStream.takeBytes(), bytes); + }); + }); } From ae08e2fa2e1220a7f45eadd2e480ee5465669dde Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:32:39 +0800 Subject: [PATCH 16/45] feat(fetch): complete native Request implementation --- lib/src/fetch/request.native.dart | 40 +++++- test/request_native_test.dart | 225 ++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 test/request_native_test.dart diff --git a/lib/src/fetch/request.native.dart b/lib/src/fetch/request.native.dart index 4c4d1c3..b1ec004 100644 --- a/lib/src/fetch/request.native.dart +++ b/lib/src/fetch/request.native.dart @@ -3,7 +3,7 @@ import 'dart:typed_data'; import '../core/http_method.dart'; import 'body.dart'; import 'blob.dart'; -import 'form_data.dart'; +import 'form_data.native.dart'; import 'headers.dart'; enum RequestMode { @@ -184,14 +184,24 @@ class Request { return Blob([blob], type); } - Future bytes() async { + Future bytes() { return switch (body) { - Blob(:final bytes) => bytes(), - _ => Uint8List(0), + final Body body => body.bytes(), + null => Future.value(Uint8List(0)), }; } - Future formData() => throw UnimplementedError(); + Future formData() { + return switch (body) { + final Body body => FormData.parse( + body, + contentType: headers.get('content-type'), + ), + null => Future.error( + const FormatException('Cannot decode form data from an empty body.'), + ), + }; + } Future json() { return switch (body) { @@ -209,7 +219,25 @@ class Request { }; } - Request clone() => throw UnimplementedError(); + Request clone() { + return Request( + RequestInput.string(url), + RequestInit( + method: method, + headers: Headers(headers), + body: body?.clone(), + referrer: referrer, + referrerPolicy: referrerPolicy, + mode: mode, + credentials: credentials, + cache: cache, + redirect: redirect, + integrity: integrity, + keepalive: keepalive, + duplex: duplex, + ), + ); + } static Headers _headersFromInput(RequestInput input, HeadersInit? init) { if (init != null) return Headers(init); diff --git a/test/request_native_test.dart b/test/request_native_test.dart new file mode 100644 index 0000000..e2d076e --- /dev/null +++ b/test/request_native_test.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; + +import 'package:ht/src/core/http_method.dart'; +import 'package:ht/src/fetch/blob.dart'; +import 'package:ht/src/fetch/form_data.native.dart'; +import 'package:ht/src/fetch/headers.dart'; +import 'package:ht/src/fetch/request.native.dart'; +import 'package:test/test.dart'; + +void main() { + group('Request (native)', () { + test('defaults metadata for string input', () { + final request = Request(RequestInput.string('https://example.com')); + + expect(request.url, 'https://example.com'); + expect(request.method, HttpMethod.get); + expect(request.headers.entries(), isEmpty); + expect(request.body, isNull); + expect(request.cache, RequestCache.default_); + expect(request.credentials, RequestCredentials.sameOrigin); + expect(request.destination, ''); + expect(request.duplex, RequestDuplex.half); + expect(request.integrity, ''); + expect(request.isHistoryNavigation, isFalse); + expect(request.keepalive, isFalse); + expect(request.mode, RequestMode.cors); + expect(request.redirect, RequestRedirect.follow); + expect(request.referrer, 'about:client'); + expect(request.referrerPolicy, isNull); + }); + + test('inherits from input request and allows init overrides', () async { + final upstream = Request( + RequestInput.uri(Uri.parse('https://example.com/base')), + RequestInit( + method: HttpMethod.post, + headers: Headers({'x-upstream': '1'}), + body: 'payload', + cache: RequestCache.reload, + credentials: RequestCredentials.include, + duplex: RequestDuplex.half, + integrity: 'sha256-abc', + keepalive: true, + mode: RequestMode.sameOrigin, + redirect: RequestRedirect.manual, + referrer: 'https://referrer.example', + referrerPolicy: RequestReferrerPolicy.origin, + ), + ); + + final request = Request( + RequestInput.request(upstream), + RequestInit( + method: HttpMethod.put, + headers: Headers({'x-override': '2'}), + cache: RequestCache.noStore, + referrer: 'https://override.example', + ), + ); + + expect(request.url, 'https://example.com/base'); + expect(request.method, HttpMethod.put); + expect(request.headers.get('x-upstream'), isNull); + expect(request.headers.get('x-override'), '2'); + expect(request.cache, RequestCache.noStore); + expect(request.credentials, RequestCredentials.include); + expect(request.duplex, RequestDuplex.half); + expect(request.integrity, 'sha256-abc'); + expect(request.keepalive, isTrue); + expect(request.mode, RequestMode.sameOrigin); + expect(request.redirect, RequestRedirect.manual); + expect(request.referrer, 'https://override.example'); + expect(request.referrerPolicy, RequestReferrerPolicy.origin); + expect(await request.text(), 'payload'); + }); + + test('bytes, text, json and arrayBuffer delegate to body', () async { + final textRequest = Request( + RequestInput.string('https://example.com/text'), + RequestInit( + method: HttpMethod.post, + headers: Headers({'content-type': 'application/json'}), + body: '{"ok":true}', + ), + ); + + expect(await textRequest.text(), '{"ok":true}'); + + final bytesRequest = Request( + RequestInput.string('https://example.com/bytes'), + RequestInit(body: utf8.encode('hello')), + ); + expect(utf8.decode(await bytesRequest.bytes()), 'hello'); + + final arrayBufferRequest = Request( + RequestInput.string('https://example.com/array-buffer'), + RequestInit(body: utf8.encode('hello')), + ); + expect(utf8.decode(await arrayBufferRequest.arrayBuffer()), 'hello'); + + final parsedRequest = Request( + RequestInput.string('https://example.com/parsed'), + RequestInit(body: '{"ok":true}'), + ); + expect(await parsedRequest.json>(), {'ok': true}); + + final emptyRequest = Request(RequestInput.string('https://example.com')); + expect(await emptyRequest.text(), ''); + expect(await emptyRequest.bytes(), isEmpty); + await expectLater(emptyRequest.json(), throwsFormatException); + }); + + test('blob prefers explicit content-type header', () async { + final request = Request( + RequestInput.string('https://example.com/blob'), + RequestInit( + headers: Headers({'content-type': 'application/custom'}), + body: 'hello', + ), + ); + + final blob = await request.blob(); + expect(blob.type, 'application/custom'); + expect(await blob.text(), 'hello'); + }); + + test('formData parses application/x-www-form-urlencoded request bodies', () async { + final request = Request( + RequestInput.string('https://example.com/form'), + RequestInit( + method: HttpMethod.post, + headers: Headers({ + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', + }), + body: 'a=1&a=2&hello=world+x', + ), + ); + + final formData = await request.formData(); + + expect((formData.get('a')! as TextMultipart).value, '1'); + expect( + formData.getAll('a').map((value) => (value as TextMultipart).value), + ['1', '2'], + ); + expect((formData.get('hello')! as TextMultipart).value, 'world x'); + }); + + test('formData parses multipart request bodies', () async { + final encoded = + (FormData() + ..append('name', Multipart.text('alice')) + ..append( + 'avatar', + Multipart.blob( + Blob(['binary'], 'text/plain;charset=utf-8'), + 'a.txt', + ), + )) + .encodeMultipart(boundary: 'request-boundary'); + + final headers = Headers()..set('content-type', encoded.contentType); + final request = Request( + RequestInput.string('https://example.com/upload'), + RequestInit( + method: HttpMethod.post, + headers: headers, + body: encoded.stream, + ), + ); + + final formData = await request.formData(); + + expect((formData.get('name')! as TextMultipart).value, 'alice'); + final avatar = formData.get('avatar'); + expect(avatar, isA()); + final blob = avatar! as BlobMultipart; + expect(blob.filename, 'a.txt'); + expect(blob.type, 'text/plain;charset=utf-8'); + expect(await blob.text(), 'binary'); + }); + + test('clone duplicates unread stream bodies and metadata', () async { + final request = Request( + RequestInput.string('https://example.com/clone'), + RequestInit( + method: HttpMethod.post, + headers: Headers({'x-id': '1'}), + body: Stream>.fromIterable(>[ + utf8.encode('hello '), + utf8.encode('world'), + ]), + cache: RequestCache.noCache, + credentials: RequestCredentials.include, + keepalive: true, + redirect: RequestRedirect.error, + referrer: 'https://referrer.example', + ), + ); + + final clone = request.clone(); + + expect(clone.url, request.url); + expect(clone.method, request.method); + expect(clone.headers.get('x-id'), '1'); + expect(clone.cache, RequestCache.noCache); + expect(clone.credentials, RequestCredentials.include); + expect(clone.keepalive, isTrue); + expect(clone.redirect, RequestRedirect.error); + expect(clone.referrer, 'https://referrer.example'); + expect(await request.text(), 'hello world'); + expect(await clone.text(), 'hello world'); + }); + + test('clone fails after body has been consumed', () async { + final request = Request( + RequestInput.string('https://example.com/clone'), + RequestInit(body: 'used'), + ); + + expect(await request.text(), 'used'); + expect(() => request.clone(), throwsStateError); + }); + }); +} From 0697b0480be7254b52bddd8ecb4d8fc6b2b246a0 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:29:34 +0800 Subject: [PATCH 17/45] feat(fetch): complete native Response implementation --- lib/src/fetch/response.native.dart | 132 ++++++++++++++++++++++++ test/response_native_test.dart | 155 +++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 lib/src/fetch/response.native.dart create mode 100644 test/response_native_test.dart diff --git a/lib/src/fetch/response.native.dart b/lib/src/fetch/response.native.dart new file mode 100644 index 0000000..0e6cca5 --- /dev/null +++ b/lib/src/fetch/response.native.dart @@ -0,0 +1,132 @@ +import 'dart:typed_data'; + +import '../core/http_status.dart'; +import 'blob.dart'; +import 'body.dart'; +import 'form_data.native.dart'; +import 'headers.dart'; + +enum ResponseType { + basic('basic'), + cors('cors'), + default_('default'), + error('error'), + opaque('opaque'), + opaqueRedirect('opaqueredirect'); + + const ResponseType(this.value); + + final String value; +} + +class ResponseInit { + const ResponseInit({this.status, this.statusText, this.headers}); + + final int? status; + final String? statusText; + final HeadersInit? headers; +} + +class Response { + Response([BodyInit? body, ResponseInit? init]) + : body = _bodyFromInit(body), + headers = _headersFromInit(init?.headers), + status = _validateStatus(init?.status ?? HttpStatus.ok), + statusText = init?.statusText ?? '', + ok = HttpStatus.isSuccess(init?.status ?? HttpStatus.ok), + redirected = false, + type = ResponseType.default_, + url = ''; + + final Body? body; + final Headers headers; + final bool ok; + final bool redirected; + final int status; + final String statusText; + final ResponseType type; + final String url; + + bool get bodyUsed => body?.bodyUsed ?? false; + + Future arrayBuffer() => bytes(); + + Future blob() async { + final blob = switch (body) { + final Body body => await body.blob(), + null => Blob(), + }; + + final type = headers.get('content-type'); + if (type == null || type.isEmpty || blob.type == type) { + return blob; + } + + return Blob([blob], type); + } + + Future bytes() { + return switch (body) { + final Body body => body.bytes(), + null => Future.value(Uint8List(0)), + }; + } + + Future formData() { + return switch (body) { + final Body body => FormData.parse( + body, + contentType: headers.get('content-type'), + ), + null => Future.error( + const FormatException('Cannot decode form data from an empty body.'), + ), + }; + } + + Future json() { + return switch (body) { + final Body body => body.json(), + null => Future.error( + const FormatException('Cannot decode JSON from an empty body.'), + ), + }; + } + + Future text() { + return switch (body) { + final Body body => body.text(), + null => Future.value(''), + }; + } + + Response clone() { + return Response( + body?.clone(), + ResponseInit( + status: status, + statusText: statusText, + headers: Headers(headers), + ), + ); + } + + static Body? _bodyFromInit(BodyInit? init) { + return switch (init) { + null => null, + _ => Body(init), + }; + } + + static Headers _headersFromInit(HeadersInit? init) { + return switch (init) { + null => Headers(), + _ => Headers(init), + }; + } + + static int _validateStatus(int status) { + HttpStatus.validate(status); + return status; + } +} diff --git a/test/response_native_test.dart b/test/response_native_test.dart new file mode 100644 index 0000000..469268e --- /dev/null +++ b/test/response_native_test.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; + +import 'package:ht/src/fetch/blob.dart'; +import 'package:ht/src/fetch/form_data.native.dart'; +import 'package:ht/src/fetch/headers.dart'; +import 'package:ht/src/fetch/response.native.dart'; +import 'package:test/test.dart'; + +void main() { + group('Response (native)', () { + test('defaults metadata for empty responses', () async { + final response = Response(); + + expect(response.status, 200); + expect(response.statusText, ''); + expect(response.ok, isTrue); + expect(response.headers.entries(), isEmpty); + expect(response.body, isNull); + expect(response.redirected, isFalse); + expect(response.type, ResponseType.default_); + expect(response.url, ''); + expect(response.bodyUsed, isFalse); + expect(await response.text(), ''); + expect(await response.bytes(), isEmpty); + }); + + test('respects init status, statusText and headers', () async { + final response = Response( + 'payload', + ResponseInit( + status: 201, + statusText: 'Created', + headers: Headers({'x-id': '1'}), + ), + ); + + expect(response.status, 201); + expect(response.statusText, 'Created'); + expect(response.ok, isTrue); + expect(response.headers.get('x-id'), '1'); + expect(await response.text(), 'payload'); + }); + + test('bytes, text, json and arrayBuffer delegate to body', () async { + final textResponse = Response('{"ok":true}'); + expect(await textResponse.text(), '{"ok":true}'); + + final bytesResponse = Response(utf8.encode('hello')); + expect(utf8.decode(await bytesResponse.bytes()), 'hello'); + + final arrayBufferResponse = Response(utf8.encode('hello')); + expect(utf8.decode(await arrayBufferResponse.arrayBuffer()), 'hello'); + + final jsonResponse = Response('{"ok":true}'); + expect(await jsonResponse.json>(), {'ok': true}); + + final emptyResponse = Response(); + await expectLater(emptyResponse.json(), throwsFormatException); + }); + + test('blob prefers explicit content-type header', () async { + final response = Response( + 'hello', + ResponseInit( + headers: Headers({'content-type': 'application/custom'}), + ), + ); + + final blob = await response.blob(); + expect(blob.type, 'application/custom'); + expect(await blob.text(), 'hello'); + }); + + test('formData parses application/x-www-form-urlencoded responses', () async { + final response = Response( + 'a=1&a=2&hello=world+x', + ResponseInit( + headers: Headers({ + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', + }), + ), + ); + + final formData = await response.formData(); + + expect((formData.get('a')! as TextMultipart).value, '1'); + expect( + formData.getAll('a').map((value) => (value as TextMultipart).value), + ['1', '2'], + ); + expect((formData.get('hello')! as TextMultipart).value, 'world x'); + }); + + test('formData parses multipart responses', () async { + final encoded = + (FormData() + ..append('name', Multipart.text('alice')) + ..append( + 'avatar', + Multipart.blob( + Blob(['binary'], 'text/plain;charset=utf-8'), + 'a.txt', + ), + )) + .encodeMultipart(boundary: 'response-boundary'); + + final headers = Headers()..set('content-type', encoded.contentType); + final response = Response( + encoded.stream, + ResponseInit( + headers: headers, + ), + ); + + final formData = await response.formData(); + + expect((formData.get('name')! as TextMultipart).value, 'alice'); + final avatar = formData.get('avatar'); + expect(avatar, isA()); + final blob = avatar! as BlobMultipart; + expect(blob.filename, 'a.txt'); + expect(blob.type, 'text/plain;charset=utf-8'); + expect(await blob.text(), 'binary'); + }); + + test('clone duplicates unread stream bodies and metadata', () async { + final response = Response( + Stream>.fromIterable(>[ + utf8.encode('hello '), + utf8.encode('world'), + ]), + ResponseInit( + status: 202, + statusText: 'Accepted', + headers: Headers({'x-id': '1'}), + ), + ); + + final clone = response.clone(); + + expect(clone.status, 202); + expect(clone.statusText, 'Accepted'); + expect(clone.headers.get('x-id'), '1'); + expect(await response.text(), 'hello world'); + expect(await clone.text(), 'hello world'); + }); + + test('clone fails after body has been consumed', () async { + final response = Response('used'); + + expect(await response.text(), 'used'); + expect(() => response.clone(), throwsStateError); + }); + }); +} From b9f8c3e51cb00cfae99740e61a431bcb71c5747f Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:43:44 +0800 Subject: [PATCH 18/45] refactor(fetch): switch public surface to native fetch types --- example/main.dart | 17 +- lib/ht.dart | 6 +- lib/src/fetch/body.dart | 2 +- lib/src/fetch/form_data.dart | 216 ------------------------ lib/src/fetch/form_data.native.dart | 8 +- lib/src/fetch/request.dart | 170 ------------------- lib/src/fetch/response.dart | 160 ------------------ test/_internal/stream_tee_test.dart | 7 +- test/form_data_native_test.dart | 19 ++- test/form_data_test.dart | 173 -------------------- test/public_api_surface_test.dart | 15 +- test/request_response_test.dart | 244 ---------------------------- 12 files changed, 44 insertions(+), 993 deletions(-) delete mode 100644 lib/src/fetch/form_data.dart delete mode 100644 lib/src/fetch/request.dart delete mode 100644 lib/src/fetch/response.dart delete mode 100644 test/form_data_test.dart delete mode 100644 test/request_response_test.dart diff --git a/example/main.dart b/example/main.dart index a74376f..b634c99 100644 --- a/example/main.dart +++ b/example/main.dart @@ -3,10 +3,19 @@ import 'dart:convert'; import 'package:ht/ht.dart'; Future main() async { - final request = Request.json(Uri.parse('https://api.example.com/tasks'), { - 'title': 'Ship ht', - 'priority': 'high', - }); + final request = Request( + RequestInput.uri(Uri.parse('https://api.example.com/tasks')), + RequestInit( + method: HttpMethod.post, + headers: Headers({ + 'content-type': 'application/json; charset=utf-8', + }), + body: jsonEncode({ + 'title': 'Ship ht', + 'priority': 'high', + }), + ), + ); print('Request: ${request.method} ${request.url}'); print('Request content-type: ${request.headers.get('content-type')}'); diff --git a/lib/ht.dart b/lib/ht.dart index 7613831..8a20717 100644 --- a/lib/ht.dart +++ b/lib/ht.dart @@ -6,8 +6,8 @@ export 'src/core/mime_type.dart'; export 'src/fetch/body.dart'; export 'src/fetch/blob.dart'; export 'src/fetch/file.dart'; -export 'src/fetch/form_data.dart'; +export 'src/fetch/form_data.native.dart'; export 'src/fetch/headers.dart'; -export 'src/fetch/request.dart'; -export 'src/fetch/response.dart'; +export 'src/fetch/request.native.dart'; +export 'src/fetch/response.native.dart'; export 'src/fetch/url_search_params.dart'; diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index 386a837..23f0a1a 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -6,7 +6,7 @@ import 'package:block/block.dart' as block; import '../_internal/stream_tee.dart'; import 'blob.dart'; -import 'form_data.dart'; +import 'form_data.native.dart'; import 'url_search_params.dart'; /// Constructor input accepted by body implementations. diff --git a/lib/src/fetch/form_data.dart b/lib/src/fetch/form_data.dart deleted file mode 100644 index 1c7f72e..0000000 --- a/lib/src/fetch/form_data.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'blob.dart'; -import 'file.dart'; - -/// Multipart body payload generated from [FormData]. -class MultipartBody { - MultipartBody._({ - required Stream Function() streamFactory, - required this.boundary, - required this.contentLength, - }) : _streamFactory = streamFactory; - - final Stream Function() _streamFactory; - final String boundary; - final int contentLength; - - String get contentType => 'multipart/form-data; boundary=$boundary'; - 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. -class FormData extends IterableBase> { - final _entries = >[]; - - void append(String name, Object value, {String? filename}) { - _entries.add(MapEntry(name, _normalizeValue(value, filename: filename))); - } - - void set(String name, Object value, {String? filename}) { - delete(name); - append(name, value, filename: filename); - } - - void delete(String name) { - _entries.removeWhere((entry) => entry.key == name); - } - - Object? get(String name) { - for (final entry in _entries) { - if (entry.key == name) { - return entry.value; - } - } - - return null; - } - - List getAll(String name) { - return List.unmodifiable( - _entries.where((entry) => entry.key == name).map((entry) => entry.value), - ); - } - - bool has(String name) => _entries.any((entry) => entry.key == name); - - FormData clone() { - final next = FormData(); - for (final entry in _entries) { - next.append(entry.key, entry.value); - } - return next; - } - - MultipartBody encodeMultipart({String? boundary}) { - final safeBoundary = boundary ?? _generateBoundary(); - final snapshot = List>.unmodifiable( - _entries.map((entry) => MapEntry(entry.key, entry.value)), - ); - - return MultipartBody._( - streamFactory: () => _encodeMultipart(snapshot, safeBoundary), - boundary: safeBoundary, - contentLength: _calculateMultipartLength(snapshot, safeBoundary), - ); - } - - MultipartBody encodeMultipartStream({String? boundary}) { - return encodeMultipart(boundary: boundary); - } - - @override - Iterator> get iterator => - List>.unmodifiable(_entries).iterator; - - static Object _normalizeValue(Object value, {String? filename}) { - if (value is File) { - if (filename == null) { - return value; - } - - return File( - [value], - filename, - type: value.type, - lastModified: value.lastModified, - ); - } - - if (value is Blob) { - return File([value], filename ?? 'blob', type: value.type); - } - - if (value is String) { - return value; - } - - return value.toString(); - } - - static String _escapeHeaderValue(String value) { - return value - .replaceAll('\\', '\\\\') - .replaceAll('"', '\\"') - .replaceAll('\r', '\\r') - .replaceAll('\n', '\\n'); - } - - static String _generateBoundary() { - const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; - final random = Random.secure(); - final suffix = List.generate( - 24, - (_) => alphabet[random.nextInt(alphabet.length)], - growable: false, - ).join(); - 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/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart index 4eff119..59f2e88 100644 --- a/lib/src/fetch/form_data.native.dart +++ b/lib/src/fetch/form_data.native.dart @@ -401,7 +401,9 @@ class FormData with Iterable> { 'filename="${_escapeHeaderValue(blob.filename)}"\r\n', ); - final type = blob.type.isEmpty ? 'application/octet-stream' : blob.type; + final type = blob.type.isEmpty + ? 'application/octet-stream' + : blob.type; yield _utf8('Content-Type: $type\r\n\r\n'); yield* blob.stream(); yield _utf8('\r\n'); @@ -435,7 +437,9 @@ class FormData with Iterable> { 'filename="${_escapeHeaderValue(blob.filename)}"\r\n', ); - final type = blob.type.isEmpty ? 'application/octet-stream' : blob.type; + final type = blob.type.isEmpty + ? 'application/octet-stream' + : blob.type; total += _utf8Length('Content-Type: $type\r\n\r\n'); total += blob.size; total += _utf8Length('\r\n'); diff --git a/lib/src/fetch/request.dart b/lib/src/fetch/request.dart deleted file mode 100644 index 3f9bba6..0000000 --- a/lib/src/fetch/request.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:convert'; - -import 'body.dart'; -import 'blob.dart'; -import 'form_data.dart'; -import 'headers.dart'; -import 'url_search_params.dart'; - -/// Initialization options for [Request], aligned with Fetch `RequestInit`. -class RequestInit { - RequestInit({this.method, this.headers, this.body}); - - final String? method; - final HeadersInit? headers; - final Object? body; -} - -/// Fetch-like HTTP request model. -class Request { - Request(Uri url, [RequestInit? init]) - : this._create( - url: url, - method: init?.method ?? 'GET', - headers: _headersFromInit(init?.headers), - bodyData: Body(init?.body), - ); - - Request._create({ - required this.url, - required String method, - required this.headers, - required this.bodyData, - }) : method = _normalizeMethod(method) { - _validateMethodAndBody(); - _applyDefaultBodyHeaders(); - } - - Request._internal({ - required this.url, - required this.method, - required this.headers, - required this.bodyData, - }); - - factory Request.text(Uri url, String body, [RequestInit? init]) { - return Request(url, _coerceInit(init, body: body)); - } - - factory Request.json(Uri url, Object? body, [RequestInit? init]) { - final nextInit = _coerceInit(init, body: json.encode(body)); - final nextHeaders = _headersFromInit(nextInit.headers); - if (!nextHeaders.has('content-type')) { - nextHeaders.set('content-type', 'application/json; charset=utf-8'); - } - - return Request._create( - url: url, - method: nextInit.method ?? 'POST', - headers: nextHeaders, - bodyData: BodyData.fromInit(nextInit.body), - ); - } - - factory Request.bytes(Uri url, List body, [RequestInit? init]) { - return Request(url, _coerceInit(init, body: body)); - } - - factory Request.stream(Uri url, Stream> body, [RequestInit? init]) { - return Request(url, _coerceInit(init, body: body)); - } - - factory Request.searchParams( - Uri url, - URLSearchParams body, [ - RequestInit? init, - ]) { - return Request(url, _coerceInit(init, body: body)); - } - - factory Request.formData(Uri url, FormData body, [RequestInit? init]) { - return Request(url, _coerceInit(init, body: body)); - } - - /// Target URL. - final Uri url; - - /// HTTP method in upper-case wire format. - final String method; - - /// Mutable request headers. - final Headers headers; - - final Body bodyData; - - Stream? get body => bodyData.hasBody ? bodyData.stream : null; - bool get bodyUsed => bodyData.bodyUsed; - Future bytes() => bodyData.bytes(); - Future text([Encoding encoding = utf8]) => bodyData.text(encoding); - Future json() => bodyData.json(); - Future blob() async { - final blob = await bodyData.blob(); - final type = headers.get('content-type'); - if (type == null || type.isEmpty || blob.type == type) { - return blob; - } - - return Blob([blob], type); - } - - Request clone() { - return Request._internal( - url: url, - method: method, - headers: Headers(headers.entries()), - bodyData: bodyData.clone(), - ); - } - - static RequestInit _coerceInit(RequestInit? init, {required Object? body}) { - return RequestInit( - method: init?.method ?? 'POST', - headers: init?.headers, - body: body, - ); - } - - static Headers _headersFromInit(HeadersInit? init) { - return switch (init) { - final Headers headers => headers, - _ => Headers(init), - }; - } - - static String _normalizeMethod(String value) { - final normalized = value.trim().toUpperCase(); - if (normalized.isEmpty) { - throw ArgumentError.value( - value, - 'method', - 'Request method cannot be empty', - ); - } - - return normalized; - } - - static bool _methodAllowsBody(String method) { - return method != 'GET' && method != 'HEAD' && method != 'TRACE'; - } - - void _validateMethodAndBody() { - if (!_methodAllowsBody(method) && bodyData.hasBody) { - throw ArgumentError('HTTP $method requests cannot include a body.'); - } - } - - void _applyDefaultBodyHeaders() { - if (!bodyData.hasBody) return; - - final inferredType = bodyData.defaultContentType; - if (inferredType != null && !headers.has('content-type')) { - headers.set('content-type', inferredType); - } - - final inferredLength = bodyData.defaultContentLength; - if (inferredLength != null && !headers.has('content-length')) { - headers.set('content-length', inferredLength.toString()); - } - } -} diff --git a/lib/src/fetch/response.dart b/lib/src/fetch/response.dart deleted file mode 100644 index c618a1f..0000000 --- a/lib/src/fetch/response.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'dart:convert'; - -import '../core/http_status.dart'; -import 'body.dart'; -import 'headers.dart'; - -/// Initialization options for [Response], aligned with Fetch `ResponseInit`. -class ResponseInit { - ResponseInit({this.status, this.statusText, this.headers}); - - final int? status; - final String? statusText; - final HeadersInit? headers; -} - -/// Fetch-like HTTP response model. -class Response with BodyMixin { - Response([Object? body, ResponseInit? init]) - : this._create(body, init, url: null, redirected: false); - - Response._create( - Object? body, - ResponseInit? init, { - required this.url, - required this.redirected, - }) : status = _validateStatus(init?.status ?? HttpStatus.ok), - statusText = init?.statusText ?? '', - headers = _headersFromInit(init?.headers), - bodyData = BodyData.fromInit(body) { - if (bodyData.hasBody && _statusDisallowsBody(status)) { - throw ArgumentError.value( - status, - 'status', - 'Responses with status $status must not include a body', - ); - } - _applyDefaultBodyHeaders(); - } - - Response._internal({ - required this.status, - required this.statusText, - required this.headers, - required this.bodyData, - required this.url, - required this.redirected, - }); - - factory Response.text(String body, [ResponseInit? init]) { - return Response(body, init); - } - - factory Response.json(Object? body, [ResponseInit? init]) { - final nextHeaders = _headersFromInit(init?.headers); - if (!nextHeaders.has('content-type')) { - nextHeaders.set('content-type', 'application/json; charset=utf-8'); - } - - return Response( - json.encode(body), - ResponseInit( - status: init?.status, - statusText: init?.statusText, - headers: nextHeaders, - ), - ); - } - - factory Response.bytes(List body, [ResponseInit? init]) { - return Response(body, init); - } - - factory Response.redirect(Uri location, [int status = HttpStatus.found]) { - if (!const {301, 302, 303, 307, 308}.contains(status)) { - throw ArgumentError.value( - status, - 'status', - 'Redirect response must use one of 301, 302, 303, 307, 308', - ); - } - - final nextHeaders = Headers()..set('location', location.toString()); - - return Response._create( - null, - ResponseInit(status: status, headers: nextHeaders), - url: null, - redirected: false, - ); - } - - factory Response.empty([ResponseInit? init]) { - return Response( - null, - ResponseInit( - status: init?.status ?? HttpStatus.noContent, - statusText: init?.statusText, - headers: init?.headers, - ), - ); - } - - final int status; - final String statusText; - final Headers headers; - final Uri? url; - final bool redirected; - - @override - final BodyData bodyData; - - bool get ok => HttpStatus.isSuccess(status); - - @override - String? get bodyMimeTypeHint => headers.get('content-type'); - - Response clone() { - return Response._internal( - status: status, - statusText: statusText, - headers: Headers(headers.entries()), - bodyData: bodyData.clone(), - url: url, - redirected: redirected, - ); - } - - static int _validateStatus(int status) { - HttpStatus.validate(status); - return status; - } - - static bool _statusDisallowsBody(int status) { - return status == 204 || status == 205 || status == 304; - } - - static Headers _headersFromInit(HeadersInit? init) { - return switch (init) { - null => Headers(), - final Headers headers => headers, - _ => Headers(init), - }; - } - - void _applyDefaultBodyHeaders() { - if (!bodyData.hasBody) { - return; - } - - final inferredType = bodyData.defaultContentType; - if (inferredType != null && !headers.has('content-type')) { - headers.set('content-type', inferredType); - } - - final inferredLength = bodyData.defaultContentLength; - if (inferredLength != null && !headers.has('content-length')) { - headers.set('content-length', inferredLength.toString()); - } - } -} diff --git a/test/_internal/stream_tee_test.dart b/test/_internal/stream_tee_test.dart index 48febd8..d20b79a 100644 --- a/test/_internal/stream_tee_test.dart +++ b/test/_internal/stream_tee_test.dart @@ -19,9 +19,10 @@ void main() { final leftFuture = left.toList(); final rightFuture = right.toList(); - controller.add(1); - controller.add(2); - controller.add(3); + controller + ..add(1) + ..add(2) + ..add(3); await controller.close(); expect(await leftFuture, [1, 2, 3]); diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index f7e9a09..bbfbb99 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -2,7 +2,6 @@ import 'dart:typed_data'; import 'package:ht/src/fetch/body.dart'; import 'package:ht/src/fetch/blob.dart'; -import 'package:ht/src/fetch/form_data.dart' as legacy; import 'package:ht/src/fetch/form_data.native.dart'; import 'package:ht/src/fetch/headers.dart'; import 'package:test/test.dart'; @@ -38,10 +37,10 @@ void main() { test('parses multipart/form-data text entries', () async { final encoded = - (legacy.FormData() - ..append('a', '1') - ..append('a', '2') - ..append('hello', 'world')) + (FormData() + ..append('a', Multipart.text('1')) + ..append('a', Multipart.text('2')) + ..append('hello', Multipart.text('world'))) .encodeMultipart(boundary: 'test-boundary'); final formData = await FormData.parse( @@ -60,12 +59,14 @@ void main() { test('parses multipart/form-data blob entries', () async { final encoded = - (legacy.FormData() - ..append('title', 'avatar') + (FormData() + ..append('title', Multipart.text('avatar')) ..append( 'file', - Blob(['binary'], 'text/plain;charset=utf-8'), - filename: 'a.txt', + Multipart.blob( + Blob(['binary'], 'text/plain;charset=utf-8'), + 'a.txt', + ), )) .encodeMultipart(boundary: 'blob-boundary'); diff --git a/test/form_data_test.dart b/test/form_data_test.dart deleted file mode 100644 index ccb690e..0000000 --- a/test/form_data_test.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:block/block.dart' as block; -import 'package:ht/ht.dart'; -import 'package:test/test.dart'; - -void main() { - group('Blob', () { - test('supports text, bytes and slice', () async { - final blob = Blob(['hello world'], 'text/plain;charset=utf-8'); - expect(blob.size, 11); - expect(await blob.text(), 'hello world'); - - 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 { - final blob = Blob([ - 'ab', - Uint8List.fromList([99]), - Uint8List.fromList([100]).buffer, - Blob(['ef'], 'text/plain;charset=utf-8'), - ], 'text/plain'); - - expect(await blob.text(), 'abcdef'); - expect(blob.type, 'text/plain'); - }); - - test('streams with chunk size', () async { - final blob = Blob(['hello'], 'text/plain;charset=utf-8'); - final chunks = await blob - .stream(chunkSize: 2) - .map((chunk) => utf8.decode(chunk)) - .toList(); - expect(chunks, ['he', 'll', 'o']); - }); - - test('rejects invalid chunk size', () async { - final blob = Blob(['x'], 'text/plain;charset=utf-8'); - expect(() => blob.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(['hello'], 'text/plain;charset=utf-8'); - final block.Block blockView = blob; - expect(await blockView.text(), 'hello'); - expect(await blockView.slice(-2).text(), 'lo'); - }); - }); - - group('File', () { - test('stores metadata', () { - final file = File(['abc'], 'a.txt', type: 'text/plain'); - expect(file.name, 'a.txt'); - expect(file.type, 'text/plain'); - expect(file.lastModified, greaterThan(0)); - }); - }); - - group('FormData', () { - test('normalizes values and encodes multipart', () async { - final form = FormData() - ..append('name', 'alice') - ..append( - 'avatar', - Blob(['binary'], 'text/plain;charset=utf-8'), - filename: 'a.txt', - ); - - final avatar = form.get('avatar'); - expect(avatar, isA()); - - final encoded = form.encodeMultipart(boundary: 'test-boundary'); - final bodyBytes = await encoded.bytes(); - final bodyText = utf8.decode(bodyBytes); - - expect( - encoded.contentType, - 'multipart/form-data; boundary=test-boundary', - ); - expect(encoded.contentLength, bodyBytes.length); - expect(bodyText, contains('name="name"')); - expect(bodyText, contains('name="avatar"; filename="a.txt"')); - expect(bodyText, contains('alice')); - expect(bodyText, contains('binary')); - expect(bodyText.endsWith('--test-boundary--\r\n'), isTrue); - }); - - test('set and delete provide deterministic mutations', () { - final form = FormData() - ..append('a', '1') - ..append('a', '2') - ..set('a', '3'); - - expect(form.getAll('a'), ['3']); - expect(form.has('a'), isTrue); - - form.delete('a'); - expect(form.has('a'), isFalse); - expect(form.get('a'), isNull); - }); - - test('normalizes blob and scalar values', () { - final form = FormData() - ..append('count', 42) - ..append('payload', Blob(['x'], 'text/plain;charset=utf-8')) - ..append('avatar', File(['a'], 'old.txt'), filename: 'new.txt'); - - expect(form.get('count'), '42'); - - final payload = form.get('payload')! as File; - expect(payload.name, 'blob'); - - final avatar = form.get('avatar')! as File; - expect(avatar.name, 'new.txt'); - }); - - test('clone is independent for entry mutations', () { - final form = FormData()..append('a', '1'); - - final clone = form.clone()..set('a', '2'); - - expect(form.get('a'), '1'); - expect(clone.get('a'), '2'); - }); - - test('escapes multipart header values', () async { - final form = FormData() - ..append( - 'na"me', - Blob(['x'], 'text/plain;charset=utf-8'), - filename: 'fi\r\nle.txt', - ); - - final encoded = form.encodeMultipart(boundary: 'b'); - 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(['2'], 'text/plain;charset=utf-8'), - 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 e667a18..e076ed2 100644 --- a/test/public_api_surface_test.dart +++ b/test/public_api_surface_test.dart @@ -8,26 +8,25 @@ void main() { const status = HttpStatus.ok; const version = HttpVersion.http11; final mime = MimeType.json; - final requestInit = RequestInit(method: method.value); + final requestInit = RequestInit(method: method); final responseInit = ResponseInit(status: status); final headers = Headers({'content-type': mime.toString()}); final params = URLSearchParams('a=1'); final blob = Blob(['hello'], 'text/plain;charset=utf-8'); final file = File([blob], 'hello.txt', type: 'text/plain'); - final form = FormData()..append('file', file); + final form = FormData()..append('file', Multipart.blob(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'), - form, - RequestInit(method: requestInit.method, headers: headers), + final request = Request( + RequestInput.uri(Uri.parse('https://example.com/upload')), + RequestInit(method: requestInit.method, headers: headers, body: form), ); final response = Response(blockBody, responseInit); - final BodyInit init = 'x'; + final Object init = 'x'; expect(method.toString(), 'POST'); expect(version.value, 'HTTP/1.1'); @@ -35,7 +34,7 @@ void main() { expect(params.get('a'), '1'); expect(await blob.text(), 'hello'); expect(file.name, 'hello.txt'); - expect(requestInit.method, 'POST'); + expect(requestInit.method, HttpMethod.post); expect(responseInit.status, 200); expect(request.headers.has('content-type'), isTrue); expect(await multipart.bytes(), isNotEmpty); diff --git a/test/request_response_test.dart b/test/request_response_test.dart deleted file mode 100644 index 90e7906..0000000 --- a/test/request_response_test.dart +++ /dev/null @@ -1,244 +0,0 @@ -import 'dart:convert'; - -import 'package:block/block.dart' as block; -import 'package:ht/ht.dart'; -import 'package:test/test.dart'; - -void main() { - group('Request', () { - test('json request infers headers', () { - final request = Request.json(Uri.parse('https://example.com'), {'x': 1}); - - expect(request.method, 'POST'); - expect( - request.headers.get('content-type'), - 'application/json; charset=utf-8', - ); - expect(request.headers.get('content-length'), isNotNull); - }); - - test('search params request infers form encoding headers', () async { - final params = URLSearchParams() - ..append('a', '1') - ..append('b', '2'); - - final request = Request.searchParams( - Uri.parse('https://example.com'), - params, - ); - - expect( - request.headers.get('content-type'), - 'application/x-www-form-urlencoded; charset=utf-8', - ); - expect(await request.text(), 'a=1&b=2'); - }); - - test('form-data request infers multipart headers', () async { - final form = FormData()..append('name', 'alice'); - - final request = Request.formData(Uri.parse('https://example.com'), form); - - expect( - request.headers.get('content-type'), - startsWith('multipart/form-data; boundary='), - ); - expect(request.headers.get('content-length'), isNotNull); - 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'), - RequestInit(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( - () => Request( - Uri.parse('https://example.com'), - RequestInit(method: 'GET', body: 'x'), - ), - throwsArgumentError, - ); - }); - - test('clone duplicates unread stream body', () async { - final request = Request.stream( - Uri.parse('https://example.com'), - Stream>.fromIterable(>[ - utf8.encode('hello '), - utf8.encode('world'), - ]), - ); - - final clone = request.clone(); - expect(await request.text(), 'hello world'); - expect(await clone.text(), 'hello world'); - }); - - test('body stream marks bodyUsed and is single-consume', () async { - final request = Request.text( - Uri.parse('https://example.com'), - 'streamed', - ); - - expect(request.bodyUsed, isFalse); - final bytes = await request.body!.expand((chunk) => chunk).toList(); - expect(utf8.decode(bytes), 'streamed'); - expect(request.bodyUsed, isTrue); - await expectLater(request.text(), throwsStateError); - }); - - test('body can only be consumed once', () async { - final request = Request.text(Uri.parse('https://example.com'), 'once'); - - expect(await request.text(), 'once'); - await expectLater(request.text(), throwsStateError); - }); - - test('constructor reuses headers input', () { - final source = Headers({'x-id': '1'}); - final request = Request( - Uri.parse('https://example.com'), - RequestInit(method: 'POST', headers: source), - ); - - source.set('x-id', '2'); - request.headers.set('x-other', 'v'); - - expect(request.headers.get('x-id'), '2'); - expect(source.has('x-other'), isTrue); - }); - - test('clone fails after body has been consumed', () async { - final request = Request.text(Uri.parse('https://example.com'), 'x'); - await request.text(); - - expect(() => request.clone(), throwsStateError); - }); - - test('request with no body exposes null stream and empty bytes', () async { - final request = Request(Uri.parse('https://example.com')); - expect(request.body, isNull); - expect(request.bodyUsed, isFalse); - expect(await request.bytes(), isEmpty); - expect(request.bodyUsed, isTrue); - }); - - test('rejects unsupported body types', () { - expect( - () => Request( - Uri.parse('https://example.com'), - RequestInit(method: 'POST', body: DateTime(2024)), - ), - throwsArgumentError, - ); - }); - }); - - group('Response', () { - test('json response infers headers and ok status', () { - final response = Response.json({'ok': true}); - - expect(response.status, 200); - expect(response.statusText, ''); - expect(response.ok, isTrue); - expect( - response.headers.get('content-type'), - 'application/json; charset=utf-8', - ); - 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); - - 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(); - - expect(response.status, HttpStatus.noContent); - expect(response.statusText, ''); - expect(response.body, isNull); - expect(await response.bytes(), isEmpty); - }); - - test('redirect response sets location and redirect metadata', () { - final response = Response.redirect(Uri.parse('https://example.com/next')); - expect(response.redirected, isFalse); - expect(response.url, isNull); - expect(response.headers.get('location'), 'https://example.com/next'); - expect(response.status, 302); - }); - - test('redirect factory rejects non-redirect status', () { - expect( - () => Response.redirect(Uri.parse('https://example.com'), 200), - throwsArgumentError, - ); - }); - - test('constructor reuses headers input', () { - final source = Headers({'x-id': '1'}); - final response = Response.text('ok', ResponseInit(headers: source)); - - source.set('x-id', '2'); - response.headers.set('x-other', 'v'); - - expect(response.headers.get('x-id'), '2'); - expect(source.has('x-other'), isTrue); - }); - - test('clone duplicates unread body', () async { - final response = Response.text('payload'); - final clone = response.clone(); - - expect(await response.text(), 'payload'); - expect(await clone.text(), 'payload'); - }); - - test('blob type prefers explicit content-type header', () async { - final response = Response( - 'hello', - ResponseInit(headers: Headers({'content-type': 'application/custom'})), - ); - - final blob = await response.blob(); - expect(blob.type, 'application/custom'); - expect(await blob.text(), 'hello'); - }); - - test('validates status range', () { - expect( - () => Response(null, ResponseInit(status: 99)), - throwsArgumentError, - ); - expect( - () => Response(null, ResponseInit(status: 600)), - throwsArgumentError, - ); - }); - - test('rejects body for null-body statuses', () { - for (final status in const [204, 205, 304]) { - expect( - () => Response('payload', ResponseInit(status: status)), - throwsArgumentError, - reason: 'status=$status', - ); - } - }); - }); -} From 9d40962a2687c83948b4e4dcbad205f5dcbb43fb Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:47:36 +0800 Subject: [PATCH 19/45] feat(fetch): add native Response MDN factories --- lib/src/fetch/response.native.dart | 59 ++++++++++++++++++++++++++++++ test/response_native_test.dart | 36 ++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/lib/src/fetch/response.native.dart b/lib/src/fetch/response.native.dart index 0e6cca5..ccbc847 100644 --- a/lib/src/fetch/response.native.dart +++ b/lib/src/fetch/response.native.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import '../core/http_status.dart'; @@ -38,6 +39,64 @@ class Response { type = ResponseType.default_, url = ''; + Response._internal({ + required this.body, + required this.headers, + required this.ok, + required this.redirected, + required this.status, + required this.statusText, + required this.type, + required this.url, + }); + + factory Response.error() { + return Response._internal( + body: null, + headers: Headers(), + ok: false, + redirected: false, + status: 0, + statusText: '', + type: ResponseType.error, + url: '', + ); + } + + factory Response.json(Object? data, [ResponseInit? init]) { + final headers = _headersFromInit(init?.headers); + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json; charset=utf-8'); + } + + return Response( + jsonEncode(data), + ResponseInit( + status: init?.status, + statusText: init?.statusText, + headers: headers, + ), + ); + } + + factory Response.redirect(Uri url, [int status = HttpStatus.found]) { + if (!const {301, 302, 303, 307, 308}.contains(status)) { + throw ArgumentError.value( + status, + 'status', + 'Redirect response must use one of 301, 302, 303, 307, 308', + ); + } + + return Response( + null, + ResponseInit( + status: status, + headers: Headers()..set('location', url.toString()), + ), + ); + } + final Body? body; final Headers headers; final bool ok; diff --git a/test/response_native_test.dart b/test/response_native_test.dart index 469268e..6c9dbcf 100644 --- a/test/response_native_test.dart +++ b/test/response_native_test.dart @@ -8,6 +8,17 @@ import 'package:test/test.dart'; void main() { group('Response (native)', () { + test('error factory creates an error response', () async { + final response = Response.error(); + + expect(response.type, ResponseType.error); + expect(response.status, 0); + expect(response.statusText, ''); + expect(response.ok, isFalse); + expect(response.headers.entries(), isEmpty); + expect(await response.text(), ''); + }); + test('defaults metadata for empty responses', () async { final response = Response(); @@ -41,6 +52,31 @@ void main() { expect(await response.text(), 'payload'); }); + test('json factory encodes payload and sets content-type', () async { + final response = Response.json({'ok': true}); + + expect(response.status, 200); + expect(response.ok, isTrue); + expect( + response.headers.get('content-type'), + 'application/json; charset=utf-8', + ); + expect(await response.json>(), {'ok': true}); + }); + + test('redirect factory sets location and validates status', () { + final response = Response.redirect(Uri.parse('https://example.com/next')); + + expect(response.status, 302); + expect(response.redirected, isFalse); + expect(response.headers.get('location'), 'https://example.com/next'); + + expect( + () => Response.redirect(Uri.parse('https://example.com/next'), 200), + throwsArgumentError, + ); + }); + test('bytes, text, json and arrayBuffer delegate to body', () async { final textResponse = Response('{"ok":true}'); expect(await textResponse.text(), '{"ok":true}'); From 2c0b0c738f8be4c832962450992d3e890d238dc2 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:05:27 +0800 Subject: [PATCH 20/45] feat(fetch): add io-backed Request implementation --- lib/ht.dart | 2 +- lib/src/fetch/request.dart | 16 ++ lib/src/fetch/request.io.dart | 275 ++++++++++++++++++++++++++++++++++ test/request_io_test.dart | 84 +++++++++++ 4 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 lib/src/fetch/request.dart create mode 100644 lib/src/fetch/request.io.dart create mode 100644 test/request_io_test.dart diff --git a/lib/ht.dart b/lib/ht.dart index 8a20717..216b1ef 100644 --- a/lib/ht.dart +++ b/lib/ht.dart @@ -8,6 +8,6 @@ export 'src/fetch/blob.dart'; export 'src/fetch/file.dart'; export 'src/fetch/form_data.native.dart'; export 'src/fetch/headers.dart'; -export 'src/fetch/request.native.dart'; +export 'src/fetch/request.dart'; export 'src/fetch/response.native.dart'; export 'src/fetch/url_search_params.dart'; diff --git a/lib/src/fetch/request.dart b/lib/src/fetch/request.dart new file mode 100644 index 0000000..c8d6597 --- /dev/null +++ b/lib/src/fetch/request.dart @@ -0,0 +1,16 @@ +export 'request.native.dart' + show + RequestInit, + RequestInput, + RequestRequestInput, + StringRequestInput, + UriRequestInput, + RequestMode, + RequestCredentials, + RequestCache, + RequestRedirect, + RequestReferrerPolicy, + RequestDuplex; +export 'request.native.dart' + if (dart.library.io) 'request.io.dart' + show Request; diff --git a/lib/src/fetch/request.io.dart b/lib/src/fetch/request.io.dart new file mode 100644 index 0000000..2841965 --- /dev/null +++ b/lib/src/fetch/request.io.dart @@ -0,0 +1,275 @@ +import 'dart:io' as io; +import 'dart:typed_data'; + +import '../core/http_method.dart'; +import 'body.dart'; +import 'blob.dart'; +import 'form_data.native.dart'; +import 'headers.io.dart' as io_headers; +import 'request.native.dart' as native; + +sealed class RequestHost { + const RequestHost(this.value); + + final T value; +} + +final class HttpRequestHost extends RequestHost { + const HttpRequestHost(super.value); +} + +final class NativeRequestHost extends RequestHost { + const NativeRequestHost(super.value); +} + +class Request implements native.Request { + Request._(this._host); + + factory Request(Object? input, [native.RequestInit? init]) { + final host = switch ((input, init)) { + (final Request request, _) => request._host, + (final io.HttpRequest request, null) => HttpRequestHost(request), + (final native.Request request, _) => NativeRequestHost(request), + _ => NativeRequestHost(_toNativeRequest(input, init)), + }; + + return Request._(host); + } + + final RequestHost _host; + io_headers.Headers? _headers; + Body? _body; + + @override + io_headers.Headers get headers { + final headers = _headers; + if (headers != null) return headers; + + return _headers = switch (_host) { + final HttpRequestHost host => io_headers.Headers(host.value.headers), + final NativeRequestHost host => io_headers.Headers(host.value.headers), + }; + } + + @override + Body? get body { + final body = _body; + if (body != null) return body; + + return switch (_host) { + final HttpRequestHost host => _body = Body(host.value), + final NativeRequestHost host => host.value.body, + }; + } + + @override + bool get bodyUsed => body?.bodyUsed ?? false; + + @override + native.RequestCache get cache { + return switch (_host) { + final HttpRequestHost _ => native.RequestCache.default_, + final NativeRequestHost host => host.value.cache, + }; + } + + @override + native.RequestCredentials get credentials { + return switch (_host) { + final HttpRequestHost _ => native.RequestCredentials.sameOrigin, + final NativeRequestHost host => host.value.credentials, + }; + } + + @override + String get destination { + return switch (_host) { + final HttpRequestHost _ => '', + final NativeRequestHost host => host.value.destination, + }; + } + + @override + native.RequestDuplex get duplex { + return switch (_host) { + final HttpRequestHost _ => native.RequestDuplex.half, + final NativeRequestHost host => host.value.duplex, + }; + } + + @override + String get integrity { + return switch (_host) { + final HttpRequestHost _ => '', + final NativeRequestHost host => host.value.integrity, + }; + } + + @override + bool get isHistoryNavigation { + return switch (_host) { + final HttpRequestHost _ => false, + final NativeRequestHost host => host.value.isHistoryNavigation, + }; + } + + @override + bool get keepalive { + return switch (_host) { + final HttpRequestHost host => host.value.persistentConnection, + final NativeRequestHost host => host.value.keepalive, + }; + } + + @override + HttpMethod get method { + return switch (_host) { + final HttpRequestHost host => HttpMethod.parse(host.value.method), + final NativeRequestHost host => host.value.method, + }; + } + + @override + native.RequestMode get mode { + return switch (_host) { + final HttpRequestHost _ => native.RequestMode.cors, + final NativeRequestHost host => host.value.mode, + }; + } + + @override + native.RequestRedirect get redirect { + return switch (_host) { + final HttpRequestHost _ => native.RequestRedirect.follow, + final NativeRequestHost host => host.value.redirect, + }; + } + + @override + String get referrer { + return switch (_host) { + final HttpRequestHost _ => 'about:client', + final NativeRequestHost host => host.value.referrer, + }; + } + + @override + native.RequestReferrerPolicy? get referrerPolicy { + return switch (_host) { + final HttpRequestHost _ => null, + final NativeRequestHost host => host.value.referrerPolicy, + }; + } + + @override + String get url { + return switch (_host) { + final HttpRequestHost host => host.value.requestedUri.toString(), + final NativeRequestHost host => host.value.url, + }; + } + + @override + Future arrayBuffer() => bytes(); + + @override + Future blob() async { + final blob = switch (body) { + final Body body => await body.blob(), + null => Blob(), + }; + + final type = headers.get('content-type'); + if (type == null || type.isEmpty || blob.type == type) { + return blob; + } + + return Blob([blob], type); + } + + @override + Future bytes() { + return switch (body) { + final Body body => body.bytes(), + null => Future.value(Uint8List(0)), + }; + } + + @override + Future formData() { + return switch (body) { + final Body body => FormData.parse( + body, + contentType: headers.get('content-type'), + ), + null => Future.error( + const FormatException('Cannot decode form data from an empty body.'), + ), + }; + } + + @override + Future json() { + return switch (body) { + final Body body => body.json(), + null => Future.error( + const FormatException('Cannot decode JSON from an empty body.'), + ), + }; + } + + @override + Future text() { + return switch (body) { + final Body body => body.text(), + null => Future.value(''), + }; + } + + @override + Request clone() { + final body = this.body; + return Request( + native.Request( + native.RequestInput.string(url), + native.RequestInit( + method: method, + headers: io_headers.Headers(headers), + body: body?.clone(), + referrer: referrer, + referrerPolicy: referrerPolicy, + mode: mode, + credentials: credentials, + cache: cache, + redirect: redirect, + integrity: integrity, + keepalive: keepalive, + duplex: duplex, + ), + ), + ); + } + + static native.Request _toNativeRequest( + Object? input, + native.RequestInit? init, + ) { + return switch (input) { + final native.Request request => request, + final native.RequestInput requestInput => native.Request( + requestInput, + init, + ), + final String value => native.Request( + native.RequestInput.string(value), + init, + ), + final Uri value => native.Request(native.RequestInput.uri(value), init), + _ => throw ArgumentError.value( + input, + 'input', + 'Unsupported request input: ${input.runtimeType}', + ), + }; + } +} diff --git a/test/request_io_test.dart b/test/request_io_test.dart new file mode 100644 index 0000000..bb5f298 --- /dev/null +++ b/test/request_io_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:ht/src/core/http_method.dart'; +import 'package:ht/src/fetch/request.io.dart' as io_request; +import 'package:ht/src/fetch/request.native.dart' as native; +import 'package:test/test.dart'; + +void main() { + group('Request (io)', () { + test('wraps HttpRequest without copying headers or body eagerly', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + final port = server.port; + + final requestFuture = server.first; + + final client = HttpClient(); + addTearDown(client.close); + + final clientRequest = await client.post( + InternetAddress.loopbackIPv4.host, + port, + '/upload?q=1', + ); + clientRequest.headers.set('content-type', 'text/plain;charset=utf-8'); + clientRequest.headers.add('x-id', '1'); + clientRequest.write('hello world'); + final clientResponseFuture = clientRequest.close(); + + final httpRequest = await requestFuture; + final request = io_request.Request(httpRequest); + + expect(request.method, HttpMethod.post); + expect(request.url, 'http://127.0.0.1:$port/upload?q=1'); + expect(request.keepalive, isTrue); + expect(request.cache, native.RequestCache.default_); + expect(request.headers.get('content-type'), 'text/plain;charset=utf-8'); + expect(request.headers.get('x-id'), '1'); + expect(request.bodyUsed, isFalse); + expect(await request.text(), 'hello world'); + expect(request.bodyUsed, isTrue); + + httpRequest.response + ..statusCode = HttpStatus.noContent + ..close(); + + final clientResponse = await clientResponseFuture; + await clientResponse.drain(); + }); + + test('clone tees HttpRequest body streams', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + final port = server.port; + + final requestFuture = server.first; + + final client = HttpClient(); + addTearDown(client.close); + + final clientRequest = await client.post( + InternetAddress.loopbackIPv4.host, + port, + '/clone', + ); + clientRequest.write('hello world'); + final clientResponseFuture = clientRequest.close(); + + final httpRequest = await requestFuture; + final request = io_request.Request(httpRequest); + final clone = request.clone(); + + expect(await request.text(), 'hello world'); + expect(await clone.text(), 'hello world'); + + httpRequest.response + ..statusCode = HttpStatus.noContent + ..close(); + + final clientResponse = await clientResponseFuture; + await clientResponse.drain(); + }); + }); +} From 0c4ba9aca9955e6d98b80b4a415829819859000b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:12:00 +0800 Subject: [PATCH 21/45] feat(fetch): add js-backed Request implementation --- lib/src/_internal/web_stream_bridge.dart | 36 +++ lib/src/fetch/headers.js.dart | 3 + lib/src/fetch/request.dart | 1 + lib/src/fetch/request.js.dart | 350 +++++++++++++++++++++++ test/request_js_test.dart | 60 ++++ 5 files changed, 450 insertions(+) create mode 100644 lib/src/_internal/web_stream_bridge.dart create mode 100644 lib/src/fetch/request.js.dart create mode 100644 test/request_js_test.dart diff --git a/lib/src/_internal/web_stream_bridge.dart b/lib/src/_internal/web_stream_bridge.dart new file mode 100644 index 0000000..3775e99 --- /dev/null +++ b/lib/src/_internal/web_stream_bridge.dart @@ -0,0 +1,36 @@ +@JS() +library; + +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +Stream dartByteStreamFromWebReadableStream( + web.ReadableStream stream, +) async* { + final reader = stream.getReader() as web.ReadableStreamDefaultReader; + + try { + while (true) { + final result = await reader.read().toDart; + if (result.done) { + break; + } + + final value = result.value; + if (value == null || value.isUndefinedOrNull) { + continue; + } + + final bytes = (value as JSUint8Array).toDart; + if (bytes.isEmpty) { + continue; + } + + yield bytes; + } + } finally { + reader.releaseLock(); + } +} diff --git a/lib/src/fetch/headers.js.dart b/lib/src/fetch/headers.js.dart index 9105550..5978e55 100644 --- a/lib/src/fetch/headers.js.dart +++ b/lib/src/fetch/headers.js.dart @@ -1,5 +1,7 @@ import 'dart:js_interop'; +import 'package:web/web.dart' as dom; + import '../_internal/web_utils.dart' as web; import 'headers.native.dart' as native; @@ -12,6 +14,7 @@ class Headers final host = switch (init) { null => web.Headers(), Headers(:final host) => web.Headers(host), + final dom.Headers headers => web.Headers(headers), final Iterable> entries => web.Headers.fromEntries(entries), final Map map => web.Headers.fromMap(map), diff --git a/lib/src/fetch/request.dart b/lib/src/fetch/request.dart index c8d6597..a69e291 100644 --- a/lib/src/fetch/request.dart +++ b/lib/src/fetch/request.dart @@ -12,5 +12,6 @@ export 'request.native.dart' RequestReferrerPolicy, RequestDuplex; export 'request.native.dart' + if (dart.library.js_interop) 'request.js.dart' if (dart.library.io) 'request.io.dart' show Request; diff --git a/lib/src/fetch/request.js.dart b/lib/src/fetch/request.js.dart new file mode 100644 index 0000000..fde64e4 --- /dev/null +++ b/lib/src/fetch/request.js.dart @@ -0,0 +1,350 @@ +@JS() +library; + +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import '../_internal/web_stream_bridge.dart'; +import '../core/http_method.dart'; +import 'body.dart'; +import 'blob.dart'; +import 'form_data.native.dart'; +import 'headers.js.dart' as js_headers; +import 'request.native.dart' as native; + +sealed class RequestHost { + const RequestHost(this.value); + + final T value; +} + +final class WebRequestHost extends RequestHost { + const WebRequestHost(super.value); +} + +final class NativeRequestHost extends RequestHost { + const NativeRequestHost(super.value); +} + +class Request implements native.Request { + Request._(this._host); + + factory Request(Object? input, [native.RequestInit? init]) { + final host = switch ((input, init)) { + (final Request request, _) => request._host, + (final web.Request request, null) => WebRequestHost(request), + (final native.Request request, _) => NativeRequestHost(request), + _ => NativeRequestHost(_toNativeRequest(input, init)), + }; + + return Request._(host); + } + + final RequestHost _host; + js_headers.Headers? _headers; + Body? _body; + + @override + js_headers.Headers get headers { + final headers = _headers; + if (headers != null) return headers; + + return _headers = switch (_host) { + final WebRequestHost host => js_headers.Headers(host.value.headers), + final NativeRequestHost host => js_headers.Headers(host.value.headers), + }; + } + + @override + Body? get body { + final body = _body; + if (body != null) return body; + + return switch (_host) { + final WebRequestHost host => switch (host.value.body) { + final web.ReadableStream stream => _body = Body( + dartByteStreamFromWebReadableStream(stream), + ), + null => null, + }, + final NativeRequestHost host => host.value.body, + }; + } + + @override + bool get bodyUsed { + return switch (_host) { + final WebRequestHost host => host.value.bodyUsed, + final NativeRequestHost host => host.value.bodyUsed, + }; + } + + @override + native.RequestCache get cache { + return switch (_host) { + final WebRequestHost host => _requestCacheFromValue(host.value.cache), + final NativeRequestHost host => host.value.cache, + }; + } + + @override + native.RequestCredentials get credentials { + return switch (_host) { + final WebRequestHost host => + _requestCredentialsFromValue(host.value.credentials), + final NativeRequestHost host => host.value.credentials, + }; + } + + @override + String get destination { + return switch (_host) { + final WebRequestHost host => host.value.destination, + final NativeRequestHost host => host.value.destination, + }; + } + + @override + native.RequestDuplex get duplex { + return switch (_host) { + final WebRequestHost _ => native.RequestDuplex.half, + final NativeRequestHost host => host.value.duplex, + }; + } + + @override + String get integrity { + return switch (_host) { + final WebRequestHost host => host.value.integrity, + final NativeRequestHost host => host.value.integrity, + }; + } + + @override + bool get isHistoryNavigation { + return switch (_host) { + final WebRequestHost host => host.value.isHistoryNavigation, + final NativeRequestHost host => host.value.isHistoryNavigation, + }; + } + + @override + bool get keepalive { + return switch (_host) { + final WebRequestHost host => host.value.keepalive, + final NativeRequestHost host => host.value.keepalive, + }; + } + + @override + HttpMethod get method { + return switch (_host) { + final WebRequestHost host => HttpMethod.parse(host.value.method), + final NativeRequestHost host => host.value.method, + }; + } + + @override + native.RequestMode get mode { + return switch (_host) { + final WebRequestHost host => _requestModeFromValue(host.value.mode), + final NativeRequestHost host => host.value.mode, + }; + } + + @override + native.RequestRedirect get redirect { + return switch (_host) { + final WebRequestHost host => _requestRedirectFromValue(host.value.redirect), + final NativeRequestHost host => host.value.redirect, + }; + } + + @override + String get referrer { + return switch (_host) { + final WebRequestHost host => host.value.referrer, + final NativeRequestHost host => host.value.referrer, + }; + } + + @override + native.RequestReferrerPolicy? get referrerPolicy { + return switch (_host) { + final WebRequestHost host => + _requestReferrerPolicyFromValue(host.value.referrerPolicy), + final NativeRequestHost host => host.value.referrerPolicy, + }; + } + + @override + String get url { + return switch (_host) { + final WebRequestHost host => host.value.url, + final NativeRequestHost host => host.value.url, + }; + } + + @override + Future arrayBuffer() => bytes(); + + @override + Future blob() async { + final blob = switch (body) { + final Body body => await body.blob(), + null => Blob(), + }; + + final type = headers.get('content-type'); + if (type == null || type.isEmpty || blob.type == type) { + return blob; + } + + return Blob([blob], type); + } + + @override + Future bytes() { + return switch (body) { + final Body body => body.bytes(), + null => Future.value(Uint8List(0)), + }; + } + + @override + Future formData() { + return switch (body) { + final Body body => FormData.parse( + body, + contentType: headers.get('content-type'), + ), + null => Future.error( + const FormatException('Cannot decode form data from an empty body.'), + ), + }; + } + + @override + Future json() { + return switch (body) { + final Body body => body.json(), + null => Future.error( + const FormatException('Cannot decode JSON from an empty body.'), + ), + }; + } + + @override + Future text() { + return switch (body) { + final Body body => body.text(), + null => Future.value(''), + }; + } + + @override + Request clone() { + return switch (_host) { + final WebRequestHost host => Request(host.value.clone()), + final NativeRequestHost host => Request(host.value.clone()), + }; + } + + static native.Request _toNativeRequest( + Object? input, + native.RequestInit? init, + ) { + return switch (input) { + final native.Request request => request, + final native.RequestInput requestInput => native.Request(requestInput, init), + final String url => native.Request(native.RequestInput.string(url), init), + final Uri url => native.Request(native.RequestInput.uri(url), init), + final web.Request request => _nativeRequestFromWebRequest(request, init), + _ => throw ArgumentError.value(input, 'input'), + }; + } + + static native.Request _nativeRequestFromWebRequest( + web.Request request, + native.RequestInit? init, + ) { + final wrapped = Request(request); + final body = wrapped.body; + + return native.Request( + native.RequestInput.string(wrapped.url), + native.RequestInit( + method: init?.method ?? wrapped.method, + headers: init?.headers ?? js_headers.Headers(wrapped.headers), + body: init?.body ?? body?.clone(), + referrer: init?.referrer ?? wrapped.referrer, + referrerPolicy: init?.referrerPolicy ?? wrapped.referrerPolicy, + mode: init?.mode ?? wrapped.mode, + credentials: init?.credentials ?? wrapped.credentials, + cache: init?.cache ?? wrapped.cache, + redirect: init?.redirect ?? wrapped.redirect, + integrity: init?.integrity ?? wrapped.integrity, + keepalive: init?.keepalive ?? wrapped.keepalive, + duplex: init?.duplex ?? wrapped.duplex, + ), + ); + } + + static native.RequestMode _requestModeFromValue(String value) { + return switch (value) { + 'navigate' => native.RequestMode.navigate, + 'no-cors' => native.RequestMode.noCors, + 'same-origin' => native.RequestMode.sameOrigin, + _ => native.RequestMode.cors, + }; + } + + static native.RequestCredentials _requestCredentialsFromValue(String value) { + return switch (value) { + 'omit' => native.RequestCredentials.omit, + 'include' => native.RequestCredentials.include, + _ => native.RequestCredentials.sameOrigin, + }; + } + + static native.RequestCache _requestCacheFromValue(String value) { + return switch (value) { + 'no-store' => native.RequestCache.noStore, + 'reload' => native.RequestCache.reload, + 'no-cache' => native.RequestCache.noCache, + 'force-cache' => native.RequestCache.forceCache, + 'only-if-cached' => native.RequestCache.onlyIfCached, + _ => native.RequestCache.default_, + }; + } + + static native.RequestRedirect _requestRedirectFromValue(String value) { + return switch (value) { + 'error' => native.RequestRedirect.error, + 'manual' => native.RequestRedirect.manual, + _ => native.RequestRedirect.follow, + }; + } + + static native.RequestReferrerPolicy? _requestReferrerPolicyFromValue( + String value, + ) { + return switch (value) { + '' => null, + 'no-referrer' => native.RequestReferrerPolicy.noReferrer, + 'no-referrer-when-downgrade' => + native.RequestReferrerPolicy.noReferrerWhenDowngrade, + 'same-origin' => native.RequestReferrerPolicy.sameOrigin, + 'origin' => native.RequestReferrerPolicy.origin, + 'strict-origin' => native.RequestReferrerPolicy.strictOrigin, + 'origin-when-cross-origin' => + native.RequestReferrerPolicy.originWhenCrossOrigin, + 'strict-origin-when-cross-origin' => + native.RequestReferrerPolicy.strictOriginWhenCrossOrigin, + 'unsafe-url' => native.RequestReferrerPolicy.unsafeUrl, + _ => null, + }; + } +} diff --git a/test/request_js_test.dart b/test/request_js_test.dart new file mode 100644 index 0000000..82eca76 --- /dev/null +++ b/test/request_js_test.dart @@ -0,0 +1,60 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; + +import 'package:ht/src/core/http_method.dart'; +import 'package:ht/src/fetch/request.js.dart'; +import 'package:ht/src/fetch/request.native.dart' as native; +import 'package:test/test.dart'; +import 'package:web/web.dart' as web; + +void main() { + group('Request (js)', () { + test('accepts native web.Request host', () async { + final upstream = web.Request( + 'https://example.com/upload?x=1'.toJS, + web.RequestInit( + method: 'POST', + headers: {'content-type': 'text/plain'}.jsify()! as web.HeadersInit, + body: 'hello world'.toJS, + keepalive: true, + redirect: 'manual', + cache: 'reload', + credentials: 'include', + duplex: 'half', + referrer: 'https://ref.example/path', + referrerPolicy: 'origin', + ), + ); + + final request = Request(upstream); + + expect(request.method, HttpMethod.post); + expect(request.url, 'https://example.com/upload?x=1'); + expect(request.keepalive, isTrue); + expect(request.cache, native.RequestCache.reload); + expect(request.credentials, native.RequestCredentials.include); + expect(request.redirect, native.RequestRedirect.manual); + expect(request.referrer, 'about:client'); + expect(request.referrerPolicy, native.RequestReferrerPolicy.origin); + expect(request.headers.get('content-type'), 'text/plain'); + expect(request.bodyUsed, isFalse); + expect(await request.text(), 'hello world'); + expect(request.bodyUsed, isTrue); + }); + + test('clone tees a wrapped web.Request body', () async { + final upstream = web.Request( + 'https://example.com/clone'.toJS, + web.RequestInit(method: 'POST', body: 'cloned body'.toJS), + ); + + final request = Request(upstream); + final clone = request.clone(); + + expect(await request.text(), 'cloned body'); + expect(await clone.text(), 'cloned body'); + }); + }); +} From d82b588b49ce6295665b12eee225b9f519b37b78 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:14:36 +0800 Subject: [PATCH 22/45] feat(fetch): add js-backed Response implementation --- lib/ht.dart | 2 +- lib/src/fetch/response.dart | 4 + lib/src/fetch/response.js.dart | 218 +++++++++++++++++++++++++++++++++ test/response_js_test.dart | 59 +++++++++ 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 lib/src/fetch/response.dart create mode 100644 lib/src/fetch/response.js.dart create mode 100644 test/response_js_test.dart diff --git a/lib/ht.dart b/lib/ht.dart index 216b1ef..2fdf498 100644 --- a/lib/ht.dart +++ b/lib/ht.dart @@ -9,5 +9,5 @@ export 'src/fetch/file.dart'; export 'src/fetch/form_data.native.dart'; export 'src/fetch/headers.dart'; export 'src/fetch/request.dart'; -export 'src/fetch/response.native.dart'; +export 'src/fetch/response.dart'; export 'src/fetch/url_search_params.dart'; diff --git a/lib/src/fetch/response.dart b/lib/src/fetch/response.dart new file mode 100644 index 0000000..d1829dc --- /dev/null +++ b/lib/src/fetch/response.dart @@ -0,0 +1,4 @@ +export 'response.native.dart' show ResponseInit, ResponseType; +export 'response.native.dart' + if (dart.library.js_interop) 'response.js.dart' + show Response; diff --git a/lib/src/fetch/response.js.dart b/lib/src/fetch/response.js.dart new file mode 100644 index 0000000..d44f7ea --- /dev/null +++ b/lib/src/fetch/response.js.dart @@ -0,0 +1,218 @@ +@JS() +library; + +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import '../_internal/web_stream_bridge.dart'; +import 'blob.dart'; +import 'body.dart'; +import 'form_data.native.dart'; +import 'headers.js.dart' as js_headers; +import 'response.native.dart' as native; + +sealed class ResponseHost { + const ResponseHost(this.value); + + final T value; +} + +final class WebResponseHost extends ResponseHost { + const WebResponseHost(super.value); +} + +final class NativeResponseHost extends ResponseHost { + const NativeResponseHost(super.value); +} + +class Response implements native.Response { + Response._(this._host); + + factory Response([Object? body, native.ResponseInit? init]) { + final host = switch ((body, init)) { + (final Response response, _) => response._host, + (final web.Response response, null) => WebResponseHost(response), + (final native.Response response, _) => NativeResponseHost(response), + _ => NativeResponseHost(native.Response(body, init)), + }; + + return Response._(host); + } + + factory Response.error() => Response._(WebResponseHost(web.Response.error())); + + factory Response.json(Object? data, [native.ResponseInit? init]) { + return Response(native.Response.json(data, init)); + } + + factory Response.redirect(Uri url, [int status = 302]) { + return Response._( + WebResponseHost(web.Response.redirect(url.toString(), status)), + ); + } + + final ResponseHost _host; + js_headers.Headers? _headers; + Body? _body; + + @override + js_headers.Headers get headers { + final headers = _headers; + if (headers != null) return headers; + + return _headers = switch (_host) { + final WebResponseHost host => js_headers.Headers(host.value.headers), + final NativeResponseHost host => js_headers.Headers(host.value.headers), + }; + } + + @override + Body? get body { + final body = _body; + if (body != null) return body; + + return switch (_host) { + final WebResponseHost host => switch (host.value.body) { + final web.ReadableStream stream => _body = Body( + dartByteStreamFromWebReadableStream(stream), + ), + null => null, + }, + final NativeResponseHost host => host.value.body, + }; + } + + @override + bool get bodyUsed { + return switch (_host) { + final WebResponseHost host => host.value.bodyUsed, + final NativeResponseHost host => host.value.bodyUsed, + }; + } + + @override + bool get ok { + return switch (_host) { + final WebResponseHost host => host.value.ok, + final NativeResponseHost host => host.value.ok, + }; + } + + @override + bool get redirected { + return switch (_host) { + final WebResponseHost host => host.value.redirected, + final NativeResponseHost host => host.value.redirected, + }; + } + + @override + int get status { + return switch (_host) { + final WebResponseHost host => host.value.status, + final NativeResponseHost host => host.value.status, + }; + } + + @override + String get statusText { + return switch (_host) { + final WebResponseHost host => host.value.statusText, + final NativeResponseHost host => host.value.statusText, + }; + } + + @override + native.ResponseType get type { + return switch (_host) { + final WebResponseHost host => _responseTypeFromValue(host.value.type), + final NativeResponseHost host => host.value.type, + }; + } + + @override + String get url { + return switch (_host) { + final WebResponseHost host => host.value.url, + final NativeResponseHost host => host.value.url, + }; + } + + @override + Future arrayBuffer() => bytes(); + + @override + Future blob() async { + final blob = switch (body) { + final Body body => await body.blob(), + null => Blob(), + }; + + final type = headers.get('content-type'); + if (type == null || type.isEmpty || blob.type == type) { + return blob; + } + + return Blob([blob], type); + } + + @override + Future bytes() { + return switch (body) { + final Body body => body.bytes(), + null => Future.value(Uint8List(0)), + }; + } + + @override + Future formData() { + return switch (body) { + final Body body => FormData.parse( + body, + contentType: headers.get('content-type'), + ), + null => Future.error( + const FormatException('Cannot decode form data from an empty body.'), + ), + }; + } + + @override + Future json() { + return switch (body) { + final Body body => body.json(), + null => Future.error( + const FormatException('Cannot decode JSON from an empty body.'), + ), + }; + } + + @override + Future text() { + return switch (body) { + final Body body => body.text(), + null => Future.value(''), + }; + } + + @override + Response clone() { + return switch (_host) { + final WebResponseHost host => Response(host.value.clone()), + final NativeResponseHost host => Response(host.value.clone()), + }; + } + + static native.ResponseType _responseTypeFromValue(String value) { + return switch (value) { + 'basic' => native.ResponseType.basic, + 'cors' => native.ResponseType.cors, + 'error' => native.ResponseType.error, + 'opaque' => native.ResponseType.opaque, + 'opaqueredirect' => native.ResponseType.opaqueRedirect, + _ => native.ResponseType.default_, + }; + } +} diff --git a/test/response_js_test.dart b/test/response_js_test.dart new file mode 100644 index 0000000..2a33bf8 --- /dev/null +++ b/test/response_js_test.dart @@ -0,0 +1,59 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; + +import 'package:ht/src/fetch/response.js.dart'; +import 'package:ht/src/fetch/response.native.dart' as native; +import 'package:test/test.dart'; +import 'package:web/web.dart' as web; + +void main() { + group('Response (js)', () { + test('accepts native web.Response host', () async { + final upstream = web.Response( + 'hello world'.toJS, + web.ResponseInit( + status: 201, + statusText: 'Created', + headers: {'content-type': 'text/plain'}.jsify()! as web.HeadersInit, + ), + ); + + final response = Response(upstream); + + expect(response.status, 201); + expect(response.statusText, 'Created'); + expect(response.ok, isTrue); + expect(response.type, native.ResponseType.default_); + expect(response.headers.get('content-type'), 'text/plain'); + expect(response.bodyUsed, isFalse); + expect(await response.text(), 'hello world'); + expect(response.bodyUsed, isTrue); + }); + + test('clone tees a wrapped web.Response body', () async { + final upstream = web.Response('cloned body'.toJS); + + final response = Response(upstream); + final clone = response.clone(); + + expect(await response.text(), 'cloned body'); + expect(await clone.text(), 'cloned body'); + }); + + test('supports MDN static factories', () async { + final error = Response.error(); + expect(error.type, native.ResponseType.error); + expect(error.status, 0); + + final redirect = Response.redirect(Uri.parse('https://example.com/next')); + expect(redirect.status, 302); + expect(redirect.headers.get('location'), 'https://example.com/next'); + + final json = Response.json({'ok': true}); + expect(json.headers.get('content-type'), contains('application/json')); + expect(await json.text(), '{"ok":true}'); + }); + }); +} From 54ccaf52dba2339ef79836d51600755c0f4de2a5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:17:36 +0800 Subject: [PATCH 23/45] feat(fetch): add io-backed Response implementation --- lib/src/fetch/response.dart | 1 + lib/src/fetch/response.io.dart | 203 +++++++++++++++++++++++++++++++++ test/response_io_test.dart | 102 +++++++++++++++++ 3 files changed, 306 insertions(+) create mode 100644 lib/src/fetch/response.io.dart create mode 100644 test/response_io_test.dart diff --git a/lib/src/fetch/response.dart b/lib/src/fetch/response.dart index d1829dc..3a7f408 100644 --- a/lib/src/fetch/response.dart +++ b/lib/src/fetch/response.dart @@ -1,4 +1,5 @@ export 'response.native.dart' show ResponseInit, ResponseType; export 'response.native.dart' + if (dart.library.io) 'response.io.dart' if (dart.library.js_interop) 'response.js.dart' show Response; diff --git a/lib/src/fetch/response.io.dart b/lib/src/fetch/response.io.dart new file mode 100644 index 0000000..1c3c15a --- /dev/null +++ b/lib/src/fetch/response.io.dart @@ -0,0 +1,203 @@ +import 'dart:io' as io; +import 'dart:typed_data'; + +import '../core/http_status.dart'; +import 'blob.dart'; +import 'body.dart'; +import 'form_data.native.dart'; +import 'headers.io.dart' as io_headers; +import 'response.native.dart' as native; + +sealed class ResponseHost { + const ResponseHost(this.value); + + final T value; +} + +final class HttpClientResponseHost extends ResponseHost { + const HttpClientResponseHost(super.value); +} + +final class NativeResponseHost extends ResponseHost { + const NativeResponseHost(super.value); +} + +class Response implements native.Response { + Response._(this._host); + + factory Response([Object? body, native.ResponseInit? init]) { + final host = switch ((body, init)) { + (final Response response, _) => response._host, + (final io.HttpClientResponse response, null) => HttpClientResponseHost( + response, + ), + (final native.Response response, _) => NativeResponseHost(response), + _ => NativeResponseHost(native.Response(body, init)), + }; + + return Response._(host); + } + + factory Response.error() => Response(native.Response.error()); + + factory Response.json(Object? data, [native.ResponseInit? init]) { + return Response(native.Response.json(data, init)); + } + + factory Response.redirect(Uri url, [int status = io.HttpStatus.found]) { + return Response(native.Response.redirect(url, status)); + } + + final ResponseHost _host; + io_headers.Headers? _headers; + Body? _body; + + @override + io_headers.Headers get headers { + final headers = _headers; + if (headers != null) return headers; + + return _headers = switch (_host) { + final HttpClientResponseHost host => io_headers.Headers( + host.value.headers, + ), + final NativeResponseHost host => io_headers.Headers(host.value.headers), + }; + } + + @override + Body? get body { + final body = _body; + if (body != null) return body; + + return switch (_host) { + final HttpClientResponseHost host => _body = Body(host.value), + final NativeResponseHost host => host.value.body, + }; + } + + @override + bool get bodyUsed => body?.bodyUsed ?? false; + + @override + bool get ok { + return switch (_host) { + final HttpClientResponseHost host => HttpStatus.isSuccess( + host.value.statusCode, + ), + final NativeResponseHost host => host.value.ok, + }; + } + + @override + bool get redirected { + return switch (_host) { + final HttpClientResponseHost host => host.value.redirects.isNotEmpty, + final NativeResponseHost host => host.value.redirected, + }; + } + + @override + int get status { + return switch (_host) { + final HttpClientResponseHost host => host.value.statusCode, + final NativeResponseHost host => host.value.status, + }; + } + + @override + String get statusText { + return switch (_host) { + final HttpClientResponseHost host => host.value.reasonPhrase, + final NativeResponseHost host => host.value.statusText, + }; + } + + @override + native.ResponseType get type { + return switch (_host) { + final HttpClientResponseHost _ => native.ResponseType.default_, + final NativeResponseHost host => host.value.type, + }; + } + + @override + String get url { + return switch (_host) { + final HttpClientResponseHost _ => '', + final NativeResponseHost host => host.value.url, + }; + } + + @override + Future arrayBuffer() => bytes(); + + @override + Future blob() async { + final blob = switch (body) { + final Body body => await body.blob(), + null => Blob(), + }; + + final type = headers.get('content-type'); + if (type == null || type.isEmpty || blob.type == type) { + return blob; + } + + return Blob([blob], type); + } + + @override + Future bytes() { + return switch (body) { + final Body body => body.bytes(), + null => Future.value(Uint8List(0)), + }; + } + + @override + Future formData() { + return switch (body) { + final Body body => FormData.parse( + body, + contentType: headers.get('content-type'), + ), + null => Future.error( + const FormatException('Cannot decode form data from an empty body.'), + ), + }; + } + + @override + Future json() { + return switch (body) { + final Body body => body.json(), + null => Future.error( + const FormatException('Cannot decode JSON from an empty body.'), + ), + }; + } + + @override + Future text() { + return switch (body) { + final Body body => body.text(), + null => Future.value(''), + }; + } + + @override + Response clone() { + final body = this.body; + return Response( + native.Response( + body?.clone(), + native.ResponseInit( + status: status, + statusText: statusText, + headers: io_headers.Headers(headers), + ), + ), + ); + } +} diff --git a/test/response_io_test.dart b/test/response_io_test.dart new file mode 100644 index 0000000..e270104 --- /dev/null +++ b/test/response_io_test.dart @@ -0,0 +1,102 @@ +import 'dart:io' as io; + +import 'package:ht/src/fetch/response.io.dart'; +import 'package:ht/src/fetch/response.native.dart' as native; +import 'package:test/test.dart'; + +void main() { + group('Response (io)', () { + test('wraps HttpClientResponse without copying headers or body eagerly', () async { + final server = await io.HttpServer.bind(io.InternetAddress.loopbackIPv4, 0); + + addTearDown(server.close); + + server.listen((request) { + request.response.statusCode = io.HttpStatus.created; + request.response.headers.set('content-type', 'text/plain'); + request.response.headers.set('x-test', 'response-io'); + request.response.write('hello response'); + request.response.close(); + }); + + final client = io.HttpClient(); + addTearDown(client.close); + + final httpRequest = await client.getUrl( + Uri.parse('http://${server.address.host}:${server.port}/'), + ); + final httpResponse = await httpRequest.close(); + + final response = Response(httpResponse); + + expect(response.status, io.HttpStatus.created); + expect(response.ok, isTrue); + expect(response.type, native.ResponseType.default_); + expect(response.redirected, isFalse); + expect(response.headers.get('content-type'), 'text/plain'); + expect(response.headers.get('x-test'), 'response-io'); + expect(response.bodyUsed, isFalse); + expect(await response.text(), 'hello response'); + expect(response.bodyUsed, isTrue); + }); + + test('marks redirected when HttpClient followed redirects', () async { + final server = await io.HttpServer.bind(io.InternetAddress.loopbackIPv4, 0); + + addTearDown(server.close); + + server.listen((request) { + if (request.uri.path == '/redirect') { + request.response.statusCode = io.HttpStatus.found; + request.response.headers.set( + io.HttpHeaders.locationHeader, + '/final', + ); + request.response.close(); + return; + } + + request.response.write('ok'); + request.response.close(); + }); + + final client = io.HttpClient(); + addTearDown(client.close); + + final httpRequest = await client.getUrl( + Uri.parse('http://${server.address.host}:${server.port}/redirect'), + ); + final httpResponse = await httpRequest.close(); + + final response = Response(httpResponse); + + expect(response.redirected, isTrue); + expect(await response.text(), 'ok'); + }); + + test('clone tees HttpClientResponse body streams', () async { + final server = await io.HttpServer.bind(io.InternetAddress.loopbackIPv4, 0); + + addTearDown(server.close); + + server.listen((request) { + request.response.write('cloned response'); + request.response.close(); + }); + + final client = io.HttpClient(); + addTearDown(client.close); + + final httpRequest = await client.getUrl( + Uri.parse('http://${server.address.host}:${server.port}/'), + ); + final httpResponse = await httpRequest.close(); + + final response = Response(httpResponse); + final clone = response.clone(); + + expect(await response.text(), 'cloned response'); + expect(await clone.text(), 'cloned response'); + }); + }); +} From 6ee5f78839b381913123bc8a8b5bae222c25044d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:28:45 +0800 Subject: [PATCH 24/45] perf(fetch): share web host read helpers --- lib/src/_internal/web_fetch_utils.dart | 66 ++++++++++++++++++++++ lib/src/fetch/request.js.dart | 73 ++++++++++++++++-------- lib/src/fetch/response.js.dart | 57 +++++++++++++------ test/request_js_test.dart | 77 ++++++++++++++++++++++++++ test/response_js_test.dart | 67 ++++++++++++++++++++++ 5 files changed, 301 insertions(+), 39 deletions(-) create mode 100644 lib/src/_internal/web_fetch_utils.dart diff --git a/lib/src/_internal/web_fetch_utils.dart b/lib/src/_internal/web_fetch_utils.dart new file mode 100644 index 0000000..fd726f3 --- /dev/null +++ b/lib/src/_internal/web_fetch_utils.dart @@ -0,0 +1,66 @@ +@JS() +library; + +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import '../fetch/blob.dart'; +import '../fetch/file.dart'; +import '../fetch/form_data.native.dart'; +import 'web_utils.dart' as web_utils; + +Future bytesFromWebPromise(JSPromise promise) { + return promise.toDart.then((value) => value.toDart); +} + +Future textFromWebPromise(JSPromise promise) { + return promise.toDart.then((value) => value.toDart); +} + +Future blobFromWebPromise( + JSPromise promise, { + String? type, +}) async { + return Blob([await promise.toDart], type ?? ''); +} + +FormData formDataFromWebHost(web.FormData host) { + final formData = FormData(); + final iterator = web_utils.FormData.fromHost(host).entries(); + + while (true) { + final result = iterator.next(); + if (result.done) break; + final value = result.value; + if (value == null || value.isUndefinedOrNull) { + continue; + } + + final [name, entry] = (value as JSArray).toDart; + final key = (name as JSString).toDart; + if (entry == null || entry.isUndefinedOrNull) { + continue; + } + + if (entry.typeofEquals('string')) { + formData.append(key, Multipart.text((entry as JSString).toDart)); + continue; + } + + if (entry case final web.File file) { + formData.append( + key, + Multipart.blob(File([file], file.name, type: file.type)), + ); + continue; + } + + if (entry case final web.Blob blob) { + formData.append(key, Multipart.blob(Blob([blob], blob.type))); + } + } + + return formData; +} diff --git a/lib/src/fetch/request.js.dart b/lib/src/fetch/request.js.dart index fde64e4..1955e02 100644 --- a/lib/src/fetch/request.js.dart +++ b/lib/src/fetch/request.js.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:web/web.dart' as web; +import '../_internal/web_fetch_utils.dart' as web_fetch; import '../_internal/web_stream_bridge.dart'; import '../core/http_method.dart'; import 'body.dart'; @@ -92,8 +93,9 @@ class Request implements native.Request { @override native.RequestCredentials get credentials { return switch (_host) { - final WebRequestHost host => - _requestCredentialsFromValue(host.value.credentials), + final WebRequestHost host => _requestCredentialsFromValue( + host.value.credentials, + ), final NativeRequestHost host => host.value.credentials, }; } @@ -157,7 +159,9 @@ class Request implements native.Request { @override native.RequestRedirect get redirect { return switch (_host) { - final WebRequestHost host => _requestRedirectFromValue(host.value.redirect), + final WebRequestHost host => _requestRedirectFromValue( + host.value.redirect, + ), final NativeRequestHost host => host.value.redirect, }; } @@ -173,8 +177,9 @@ class Request implements native.Request { @override native.RequestReferrerPolicy? get referrerPolicy { return switch (_host) { - final WebRequestHost host => - _requestReferrerPolicyFromValue(host.value.referrerPolicy), + final WebRequestHost host => _requestReferrerPolicyFromValue( + host.value.referrerPolicy, + ), final NativeRequestHost host => host.value.referrerPolicy, }; } @@ -192,9 +197,15 @@ class Request implements native.Request { @override Future blob() async { - final blob = switch (body) { - final Body body => await body.blob(), - null => Blob(), + final blob = await switch (_host) { + final WebRequestHost host => web_fetch.blobFromWebPromise( + host.value.blob(), + type: headers.get('content-type'), + ), + _ => switch (body) { + final Body body => body.blob(), + null => Future.value(Blob()), + }, }; final type = headers.get('content-type'); @@ -207,22 +218,32 @@ class Request implements native.Request { @override Future bytes() { - return switch (body) { - final Body body => body.bytes(), - null => Future.value(Uint8List(0)), + return switch (_host) { + final WebRequestHost host => web_fetch.bytesFromWebPromise( + host.value.bytes(), + ), + _ => switch (body) { + final Body body => body.bytes(), + null => Future.value(Uint8List(0)), + }, }; } @override Future formData() { - return switch (body) { - final Body body => FormData.parse( - body, - contentType: headers.get('content-type'), - ), - null => Future.error( - const FormatException('Cannot decode form data from an empty body.'), + return switch (_host) { + final WebRequestHost host => host.value.formData().toDart.then( + web_fetch.formDataFromWebHost, ), + _ => switch (body) { + final Body body => FormData.parse( + body, + contentType: headers.get('content-type'), + ), + null => Future.error( + const FormatException('Cannot decode form data from an empty body.'), + ), + }, }; } @@ -238,9 +259,14 @@ class Request implements native.Request { @override Future text() { - return switch (body) { - final Body body => body.text(), - null => Future.value(''), + return switch (_host) { + final WebRequestHost host => web_fetch.textFromWebPromise( + host.value.text(), + ), + _ => switch (body) { + final Body body => body.text(), + null => Future.value(''), + }, }; } @@ -258,7 +284,10 @@ class Request implements native.Request { ) { return switch (input) { final native.Request request => request, - final native.RequestInput requestInput => native.Request(requestInput, init), + final native.RequestInput requestInput => native.Request( + requestInput, + init, + ), final String url => native.Request(native.RequestInput.string(url), init), final Uri url => native.Request(native.RequestInput.uri(url), init), final web.Request request => _nativeRequestFromWebRequest(request, init), diff --git a/lib/src/fetch/response.js.dart b/lib/src/fetch/response.js.dart index d44f7ea..fb633ca 100644 --- a/lib/src/fetch/response.js.dart +++ b/lib/src/fetch/response.js.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:web/web.dart' as web; +import '../_internal/web_fetch_utils.dart' as web_fetch; import '../_internal/web_stream_bridge.dart'; import 'blob.dart'; import 'body.dart'; @@ -145,9 +146,15 @@ class Response implements native.Response { @override Future blob() async { - final blob = switch (body) { - final Body body => await body.blob(), - null => Blob(), + final blob = await switch (_host) { + final WebResponseHost host => web_fetch.blobFromWebPromise( + host.value.blob(), + type: headers.get('content-type'), + ), + _ => switch (body) { + final Body body => body.blob(), + null => Future.value(Blob()), + }, }; final type = headers.get('content-type'); @@ -160,22 +167,33 @@ class Response implements native.Response { @override Future bytes() { - return switch (body) { - final Body body => body.bytes(), - null => Future.value(Uint8List(0)), + return switch (_host) { + final WebResponseHost host => web_fetch.bytesFromWebPromise( + host.value.bytes(), + ), + _ => switch (body) { + final Body body => body.bytes(), + null => Future.value(Uint8List(0)), + }, }; } @override Future formData() { - return switch (body) { - final Body body => FormData.parse( - body, - contentType: headers.get('content-type'), - ), - null => Future.error( - const FormatException('Cannot decode form data from an empty body.'), - ), + return switch (_host) { + final WebResponseHost host => host.value + .formData() + .toDart + .then(web_fetch.formDataFromWebHost), + _ => switch (body) { + final Body body => FormData.parse( + body, + contentType: headers.get('content-type'), + ), + null => Future.error( + const FormatException('Cannot decode form data from an empty body.'), + ), + }, }; } @@ -191,9 +209,14 @@ class Response implements native.Response { @override Future text() { - return switch (body) { - final Body body => body.text(), - null => Future.value(''), + return switch (_host) { + final WebResponseHost host => web_fetch.textFromWebPromise( + host.value.text(), + ), + _ => switch (body) { + final Body body => body.text(), + null => Future.value(''), + }, }; } diff --git a/test/request_js_test.dart b/test/request_js_test.dart index 82eca76..81e4fe5 100644 --- a/test/request_js_test.dart +++ b/test/request_js_test.dart @@ -1,9 +1,13 @@ @TestOn('browser') library; +import 'dart:convert'; import 'dart:js_interop'; +import 'dart:typed_data'; import 'package:ht/src/core/http_method.dart'; +import 'package:ht/src/fetch/file.dart'; +import 'package:ht/src/fetch/form_data.native.dart'; import 'package:ht/src/fetch/request.js.dart'; import 'package:ht/src/fetch/request.native.dart' as native; import 'package:test/test.dart'; @@ -56,5 +60,78 @@ void main() { expect(await request.text(), 'cloned body'); expect(await clone.text(), 'cloned body'); }); + + test('reads bytes directly from web host', () async { + final upstream = web.Request( + 'https://example.com/bytes'.toJS, + web.RequestInit(method: 'POST', body: 'hello bytes'.toJS), + ); + + final request = Request(upstream); + + expect(await request.bytes(), Uint8List.fromList(utf8.encode('hello bytes'))); + expect(request.bodyUsed, isTrue); + }); + + test('reads blob directly from web host', () async { + final upstream = web.Request( + 'https://example.com/blob'.toJS, + web.RequestInit( + method: 'POST', + headers: {'content-type': 'text/plain'}.jsify()! as web.HeadersInit, + body: 'hello blob'.toJS, + ), + ); + + final request = Request(upstream); + final blob = await request.blob(); + + expect(blob.type, 'text/plain'); + expect(await blob.text(), 'hello blob'); + expect(request.bodyUsed, isTrue); + }); + + test('reads formData directly from web host', () async { + final formData = web.FormData() + ..append('a', '1'.toJS) + ..append('a', '2'.toJS); + final formRequest = Request( + web.Request( + 'https://example.com/form'.toJS, + web.RequestInit(method: 'POST', body: formData), + ), + ); + + final parsed = await formRequest.formData(); + final values = parsed.getAll('a'); + expect(values, hasLength(2)); + expect((values[0] as TextMultipart).value, '1'); + expect((values[1] as TextMultipart).value, '2'); + }); + + test('maps web.File formData entries to BlobMultipart', () async { + final file = web.File( + ['payload'.toJS].toJS, + 'payload.txt', + web.FilePropertyBag(type: 'text/plain'), + ); + final formData = web.FormData()..append('file', file); + final request = Request( + web.Request( + 'https://example.com/upload'.toJS, + web.RequestInit(method: 'POST', body: formData), + ), + ); + + final parsed = await request.formData(); + final part = parsed.get('file'); + + expect(part, isA()); + expect(part, isA()); + expect((part as BlobMultipart).filename, 'payload.txt'); + expect(part.name, 'payload.txt'); + expect(part.type, 'text/plain'); + expect(await part.text(), 'payload'); + }); }); } diff --git a/test/response_js_test.dart b/test/response_js_test.dart index 2a33bf8..238facf 100644 --- a/test/response_js_test.dart +++ b/test/response_js_test.dart @@ -1,8 +1,12 @@ @TestOn('browser') library; +import 'dart:convert'; import 'dart:js_interop'; +import 'dart:typed_data'; +import 'package:ht/src/fetch/file.dart'; +import 'package:ht/src/fetch/form_data.native.dart'; import 'package:ht/src/fetch/response.js.dart'; import 'package:ht/src/fetch/response.native.dart' as native; import 'package:test/test.dart'; @@ -42,6 +46,33 @@ void main() { expect(await clone.text(), 'cloned body'); }); + test('reads bytes directly from web host', () async { + final response = Response(web.Response('hello bytes'.toJS)); + + expect( + await response.bytes(), + Uint8List.fromList(utf8.encode('hello bytes')), + ); + expect(response.bodyUsed, isTrue); + }); + + test('reads blob directly from web host', () async { + final response = Response( + web.Response( + 'hello blob'.toJS, + web.ResponseInit( + headers: {'content-type': 'text/plain'}.jsify()! as web.HeadersInit, + ), + ), + ); + + final blob = await response.blob(); + + expect(blob.type, 'text/plain'); + expect(await blob.text(), 'hello blob'); + expect(response.bodyUsed, isTrue); + }); + test('supports MDN static factories', () async { final error = Response.error(); expect(error.type, native.ResponseType.error); @@ -55,5 +86,41 @@ void main() { expect(json.headers.get('content-type'), contains('application/json')); expect(await json.text(), '{"ok":true}'); }); + + test('reads formData directly from web host', () async { + final formResponse = Response( + web.Response( + 'a=1'.toJS, + web.ResponseInit( + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }.jsify()! as web.HeadersInit, + ), + ), + ); + + final parsed = await formResponse.formData(); + expect((parsed.get('a') as TextMultipart).value, '1'); + }); + + test('maps web.File formData entries to BlobMultipart', () async { + final file = web.File( + ['payload'.toJS].toJS, + 'payload.txt', + web.FilePropertyBag(type: 'text/plain'), + ); + final formData = web.FormData()..append('file', file); + final response = Response(web.Response(formData)); + + final parsed = await response.formData(); + final part = parsed.get('file'); + + expect(part, isA()); + expect(part, isA()); + expect((part as BlobMultipart).filename, 'payload.txt'); + expect(part.name, 'payload.txt'); + expect(part.type, 'text/plain'); + expect(await part.text(), 'payload'); + }); }); } From bbace41cd62d25bcc78398700a0ee526f9840f97 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:34:29 +0800 Subject: [PATCH 25/45] perf(fetch): remove redundant body list copy --- lib/src/fetch/body.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index 23f0a1a..e3236b0 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -55,9 +55,7 @@ class Body extends Stream { final ByteBuffer buffer => Body._( blockHost: block.Block([buffer.asUint8List()]), ), - final List bytes => Body._( - blockHost: block.Block([Uint8List.fromList(bytes)]), - ), + final List bytes => Body._(blockHost: block.Block([bytes])), final Blob blob => Body._(blockHost: blob), final block.Block blockHost => Body._(blockHost: blockHost), final URLSearchParams params => Body._( From a752a4791b408beb345a1d1d4ae5d5f9fa265d2c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:38:45 +0800 Subject: [PATCH 26/45] docs(readme): simplify API overview --- README.md | 17 +------ example/main.dart | 9 +--- lib/src/fetch/response.js.dart | 7 ++- test/blob_io_test.dart | 5 +- test/body_test.dart | 17 ++++--- test/form_data_native_test.dart | 2 +- test/request_js_test.dart | 5 +- test/request_native_test.dart | 45 +++++++++-------- test/response_io_test.dart | 87 ++++++++++++++++++--------------- test/response_js_test.dart | 6 +-- test/response_native_test.dart | 52 +++++++++----------- 11 files changed, 121 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 97e47c0..c56a7fb 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,6 @@ This package focuses on the **type and semantics layer** only. It does not implement an HTTP client or server runtime. -## Features - -- Fetch-style primitives: `Request`, `RequestInit`, `Response`, `ResponseInit`, `Headers`, `URLSearchParams`, `Blob`, `File`, `FormData` -- Protocol helpers: `HttpMethod`, `HttpStatus`, `HttpVersion`, `MimeType` -- Consistent body-read semantics (single-consume), clone semantics, and header normalization -- Designed as a shared HTTP type layer for downstream client/server frameworks - ## Installation ```bash @@ -29,15 +22,7 @@ dependencies: ht: ^0.2.0 ``` -## Scope - -- No HTTP client implementation -- No HTTP server implementation -- No routing or middleware framework - -The goal is to provide stable and reusable HTTP types and behavior contracts. - -## Core API +## APIs | Category | Types | | --- | --- | diff --git a/example/main.dart b/example/main.dart index b634c99..92c4c49 100644 --- a/example/main.dart +++ b/example/main.dart @@ -7,13 +7,8 @@ Future main() async { RequestInput.uri(Uri.parse('https://api.example.com/tasks')), RequestInit( method: HttpMethod.post, - headers: Headers({ - 'content-type': 'application/json; charset=utf-8', - }), - body: jsonEncode({ - 'title': 'Ship ht', - 'priority': 'high', - }), + headers: Headers({'content-type': 'application/json; charset=utf-8'}), + body: jsonEncode({'title': 'Ship ht', 'priority': 'high'}), ), ); diff --git a/lib/src/fetch/response.js.dart b/lib/src/fetch/response.js.dart index fb633ca..a3b6897 100644 --- a/lib/src/fetch/response.js.dart +++ b/lib/src/fetch/response.js.dart @@ -181,10 +181,9 @@ class Response implements native.Response { @override Future formData() { return switch (_host) { - final WebResponseHost host => host.value - .formData() - .toDart - .then(web_fetch.formDataFromWebHost), + final WebResponseHost host => host.value.formData().toDart.then( + web_fetch.formDataFromWebHost, + ), _ => switch (body) { final Body body => FormData.parse( body, diff --git a/test/blob_io_test.dart b/test/blob_io_test.dart index 1c3cf6a..7041bb5 100644 --- a/test/blob_io_test.dart +++ b/test/blob_io_test.dart @@ -57,10 +57,7 @@ void main() { await file.writeAsString('hello'); final blob = io_blob.Blob([file], 'text/plain'); - final chunks = await blob - .stream(chunkSize: 2) - .map(utf8.decode) - .toList(); + final chunks = await blob.stream(chunkSize: 2).map(utf8.decode).toList(); expect(chunks, ['he', 'll', 'o']); }); diff --git a/test/body_test.dart b/test/body_test.dart index 3d085ff..b7baebb 100644 --- a/test/body_test.dart +++ b/test/body_test.dart @@ -67,13 +67,16 @@ void main() { expect(body.bodyUsed, isTrue); }); - test('empty bodies return empty bytes and become used when consumed', () async { - final body = Body(); - - expect(body.bodyUsed, isFalse); - expect(await body.bytes(), isEmpty); - expect(body.bodyUsed, isTrue); - }); + test( + 'empty bodies return empty bytes and become used when consumed', + () async { + final body = Body(); + + expect(body.bodyUsed, isFalse); + expect(await body.bytes(), isEmpty); + expect(body.bodyUsed, isTrue); + }, + ); test('consumption is single-use', () async { final body = Body('once'); diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index bbfbb99..05dc6b7 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -31,7 +31,7 @@ void main() { contentType: 'application/x-www-form-urlencoded; charset=utf-8', ); - expect((formData.get('name')! as TextMultipart).value, 'seven du'); + expect((formData.get('name')! as TextMultipart).value, 'seven du'); }, ); diff --git a/test/request_js_test.dart b/test/request_js_test.dart index 81e4fe5..d89e9df 100644 --- a/test/request_js_test.dart +++ b/test/request_js_test.dart @@ -69,7 +69,10 @@ void main() { final request = Request(upstream); - expect(await request.bytes(), Uint8List.fromList(utf8.encode('hello bytes'))); + expect( + await request.bytes(), + Uint8List.fromList(utf8.encode('hello bytes')), + ); expect(request.bodyUsed, isTrue); }); diff --git a/test/request_native_test.dart b/test/request_native_test.dart index e2d076e..233c502 100644 --- a/test/request_native_test.dart +++ b/test/request_native_test.dart @@ -124,27 +124,30 @@ void main() { expect(await blob.text(), 'hello'); }); - test('formData parses application/x-www-form-urlencoded request bodies', () async { - final request = Request( - RequestInput.string('https://example.com/form'), - RequestInit( - method: HttpMethod.post, - headers: Headers({ - 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', - }), - body: 'a=1&a=2&hello=world+x', - ), - ); - - final formData = await request.formData(); - - expect((formData.get('a')! as TextMultipart).value, '1'); - expect( - formData.getAll('a').map((value) => (value as TextMultipart).value), - ['1', '2'], - ); - expect((formData.get('hello')! as TextMultipart).value, 'world x'); - }); + test( + 'formData parses application/x-www-form-urlencoded request bodies', + () async { + final request = Request( + RequestInput.string('https://example.com/form'), + RequestInit( + method: HttpMethod.post, + headers: Headers({ + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', + }), + body: 'a=1&a=2&hello=world+x', + ), + ); + + final formData = await request.formData(); + + expect((formData.get('a')! as TextMultipart).value, '1'); + expect( + formData.getAll('a').map((value) => (value as TextMultipart).value), + ['1', '2'], + ); + expect((formData.get('hello')! as TextMultipart).value, 'world x'); + }, + ); test('formData parses multipart request bodies', () async { final encoded = diff --git a/test/response_io_test.dart b/test/response_io_test.dart index e270104..3902950 100644 --- a/test/response_io_test.dart +++ b/test/response_io_test.dart @@ -6,52 +6,58 @@ import 'package:test/test.dart'; void main() { group('Response (io)', () { - test('wraps HttpClientResponse without copying headers or body eagerly', () async { - final server = await io.HttpServer.bind(io.InternetAddress.loopbackIPv4, 0); - - addTearDown(server.close); - - server.listen((request) { - request.response.statusCode = io.HttpStatus.created; - request.response.headers.set('content-type', 'text/plain'); - request.response.headers.set('x-test', 'response-io'); - request.response.write('hello response'); - request.response.close(); - }); - - final client = io.HttpClient(); - addTearDown(client.close); - - final httpRequest = await client.getUrl( - Uri.parse('http://${server.address.host}:${server.port}/'), - ); - final httpResponse = await httpRequest.close(); - - final response = Response(httpResponse); - - expect(response.status, io.HttpStatus.created); - expect(response.ok, isTrue); - expect(response.type, native.ResponseType.default_); - expect(response.redirected, isFalse); - expect(response.headers.get('content-type'), 'text/plain'); - expect(response.headers.get('x-test'), 'response-io'); - expect(response.bodyUsed, isFalse); - expect(await response.text(), 'hello response'); - expect(response.bodyUsed, isTrue); - }); + test( + 'wraps HttpClientResponse without copying headers or body eagerly', + () async { + final server = await io.HttpServer.bind( + io.InternetAddress.loopbackIPv4, + 0, + ); + + addTearDown(server.close); + + server.listen((request) { + request.response.statusCode = io.HttpStatus.created; + request.response.headers.set('content-type', 'text/plain'); + request.response.headers.set('x-test', 'response-io'); + request.response.write('hello response'); + request.response.close(); + }); + + final client = io.HttpClient(); + addTearDown(client.close); + + final httpRequest = await client.getUrl( + Uri.parse('http://${server.address.host}:${server.port}/'), + ); + final httpResponse = await httpRequest.close(); + + final response = Response(httpResponse); + + expect(response.status, io.HttpStatus.created); + expect(response.ok, isTrue); + expect(response.type, native.ResponseType.default_); + expect(response.redirected, isFalse); + expect(response.headers.get('content-type'), 'text/plain'); + expect(response.headers.get('x-test'), 'response-io'); + expect(response.bodyUsed, isFalse); + expect(await response.text(), 'hello response'); + expect(response.bodyUsed, isTrue); + }, + ); test('marks redirected when HttpClient followed redirects', () async { - final server = await io.HttpServer.bind(io.InternetAddress.loopbackIPv4, 0); + final server = await io.HttpServer.bind( + io.InternetAddress.loopbackIPv4, + 0, + ); addTearDown(server.close); server.listen((request) { if (request.uri.path == '/redirect') { request.response.statusCode = io.HttpStatus.found; - request.response.headers.set( - io.HttpHeaders.locationHeader, - '/final', - ); + request.response.headers.set(io.HttpHeaders.locationHeader, '/final'); request.response.close(); return; } @@ -75,7 +81,10 @@ void main() { }); test('clone tees HttpClientResponse body streams', () async { - final server = await io.HttpServer.bind(io.InternetAddress.loopbackIPv4, 0); + final server = await io.HttpServer.bind( + io.InternetAddress.loopbackIPv4, + 0, + ); addTearDown(server.close); diff --git a/test/response_js_test.dart b/test/response_js_test.dart index 238facf..5a1449b 100644 --- a/test/response_js_test.dart +++ b/test/response_js_test.dart @@ -92,9 +92,9 @@ void main() { web.Response( 'a=1'.toJS, web.ResponseInit( - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }.jsify()! as web.HeadersInit, + headers: + {'content-type': 'application/x-www-form-urlencoded'}.jsify()! + as web.HeadersInit, ), ), ); diff --git a/test/response_native_test.dart b/test/response_native_test.dart index 6c9dbcf..00c2bcf 100644 --- a/test/response_native_test.dart +++ b/test/response_native_test.dart @@ -97,9 +97,7 @@ void main() { test('blob prefers explicit content-type header', () async { final response = Response( 'hello', - ResponseInit( - headers: Headers({'content-type': 'application/custom'}), - ), + ResponseInit(headers: Headers({'content-type': 'application/custom'})), ); final blob = await response.blob(); @@ -107,25 +105,28 @@ void main() { expect(await blob.text(), 'hello'); }); - test('formData parses application/x-www-form-urlencoded responses', () async { - final response = Response( - 'a=1&a=2&hello=world+x', - ResponseInit( - headers: Headers({ - 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', - }), - ), - ); - - final formData = await response.formData(); - - expect((formData.get('a')! as TextMultipart).value, '1'); - expect( - formData.getAll('a').map((value) => (value as TextMultipart).value), - ['1', '2'], - ); - expect((formData.get('hello')! as TextMultipart).value, 'world x'); - }); + test( + 'formData parses application/x-www-form-urlencoded responses', + () async { + final response = Response( + 'a=1&a=2&hello=world+x', + ResponseInit( + headers: Headers({ + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', + }), + ), + ); + + final formData = await response.formData(); + + expect((formData.get('a')! as TextMultipart).value, '1'); + expect( + formData.getAll('a').map((value) => (value as TextMultipart).value), + ['1', '2'], + ); + expect((formData.get('hello')! as TextMultipart).value, 'world x'); + }, + ); test('formData parses multipart responses', () async { final encoded = @@ -141,12 +142,7 @@ void main() { .encodeMultipart(boundary: 'response-boundary'); final headers = Headers()..set('content-type', encoded.contentType); - final response = Response( - encoded.stream, - ResponseInit( - headers: headers, - ), - ); + final response = Response(encoded.stream, ResponseInit(headers: headers)); final formData = await response.formData(); From 57a425274dd574b8a0141c0fe447815b4a3926ce Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:40:26 +0800 Subject: [PATCH 27/45] docs(readme): refresh fetch API examples --- README.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c56a7fb..507d6d9 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ dependencies: | Category | Types | | --- | --- | | Protocol | `HttpMethod`, `HttpStatus`, `HttpVersion`, `MimeType` | -| Message | `Request`, `RequestInit`, `Response`, `ResponseInit`, `BodyMixin`, `BodyInit` | +| Message | `Request`, `RequestInit`, `Response`, `ResponseInit`, `Body`, `BodyInit` | | Header/URL | `Headers`, `URLSearchParams` | | Binary/Form | `Blob`, `File`, `FormData` | @@ -37,9 +37,13 @@ dependencies: import 'package:ht/ht.dart'; Future main() async { - final request = Request.json( - Uri.parse('https://api.example.com/tasks'), - {'title': 'rewrite ht'}, + final request = Request( + RequestInput.uri(Uri.parse('https://api.example.com/tasks')), + RequestInit( + method: HttpMethod.post, + headers: Headers({'content-type': 'application/json; charset=utf-8'}), + body: '{"title":"rewrite ht"}', + ), ); final response = Response.json( @@ -68,8 +72,14 @@ import 'package:ht/ht.dart'; Future main() async { final form = FormData() - ..append('name', 'alice') - ..append('avatar', Blob.text('binary'), filename: 'avatar.txt'); + ..append('name', Multipart.text('alice')) + ..append( + 'avatar', + Multipart.blob( + Blob(['binary'], 'text/plain;charset=utf-8'), + 'avatar.txt', + ), + ); final multipart = form.encodeMultipart(); final bytes = await multipart.bytes(); @@ -92,12 +102,10 @@ import 'package:ht/ht.dart'; Future main() async { final body = block.Block(['hello'], type: 'text/plain'); final request = Request( - Uri.parse('https://example.com'), - RequestInit(method: 'POST', body: body), + RequestInput.uri(Uri.parse('https://example.com')), + RequestInit(method: HttpMethod.post, body: body), ); - print(request.headers.get('content-type')); // text/plain - print(request.headers.get('content-length')); // 5 print(await request.text()); // hello } ``` @@ -108,7 +116,7 @@ Future main() async { interpreted from the end of the blob: ```dart -final blob = Blob.text('hello world'); +final blob = Blob(['hello world'], 'text/plain;charset=utf-8'); final tail = blob.slice(-5); print(await tail.text()); // world ``` From 10d16d9a7915d16c1e9fc1a61cc2a95d6ff6f506 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:13:17 +0800 Subject: [PATCH 28/45] fix(fetch): iterate native web headers entries correctly --- lib/src/fetch/headers.js.dart | 7 ++++--- test/headers_js_test.dart | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 test/headers_js_test.dart diff --git a/lib/src/fetch/headers.js.dart b/lib/src/fetch/headers.js.dart index 5978e55..d4eb638 100644 --- a/lib/src/fetch/headers.js.dart +++ b/lib/src/fetch/headers.js.dart @@ -52,9 +52,10 @@ class Headers while (true) { final result = iterator.next(); if (result.done) break; - if (result.value == null || - result.value.isUndefinedOrNull || - web.Array.isArray(result.value!)) { + if (result.value == null || result.value.isUndefinedOrNull) { + continue; + } + if (!web.Array.isArray(result.value!)) { continue; } final [name, value] = (result.value as JSArray).toDart; diff --git a/test/headers_js_test.dart b/test/headers_js_test.dart new file mode 100644 index 0000000..0e1c26b --- /dev/null +++ b/test/headers_js_test.dart @@ -0,0 +1,23 @@ +@TestOn('browser') +library; + +import 'package:ht/src/fetch/headers.js.dart'; +import 'package:test/test.dart'; +import 'package:web/web.dart' as web; + +void main() { + group('Headers (js)', () { + test('iterates entries from a native web.Headers host', () { + final headers = Headers( + web.Headers() + ..append('x-a', '1') + ..append('x-b', '2'), + ); + + expect( + headers.entries().map((entry) => '${entry.key}:${entry.value}').toList(), + ['x-a:1', 'x-b:2'], + ); + }); + }); +} From a8bfc7f3b9f8de9adc78807064c38f2000f74284 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:14:33 +0800 Subject: [PATCH 29/45] fix(fetch): preserve response clone metadata --- lib/src/fetch/response.io.dart | 20 +++++++++++--------- lib/src/fetch/response.native.dart | 16 +++++++++------- test/response_io_test.dart | 11 +++++++++++ test/response_native_test.dart | 11 +++++++++++ 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/lib/src/fetch/response.io.dart b/lib/src/fetch/response.io.dart index 1c3c15a..84265c9 100644 --- a/lib/src/fetch/response.io.dart +++ b/lib/src/fetch/response.io.dart @@ -188,16 +188,18 @@ class Response implements native.Response { @override Response clone() { - final body = this.body; - return Response( - native.Response( - body?.clone(), - native.ResponseInit( - status: status, - statusText: statusText, - headers: io_headers.Headers(headers), + return switch (_host) { + final NativeResponseHost host => Response(host.value.clone()), + final HttpClientResponseHost _ => Response( + native.Response( + body?.clone(), + native.ResponseInit( + status: status, + statusText: statusText, + headers: io_headers.Headers(headers), + ), ), ), - ); + }; } } diff --git a/lib/src/fetch/response.native.dart b/lib/src/fetch/response.native.dart index ccbc847..d0bc13a 100644 --- a/lib/src/fetch/response.native.dart +++ b/lib/src/fetch/response.native.dart @@ -160,13 +160,15 @@ class Response { } Response clone() { - return Response( - body?.clone(), - ResponseInit( - status: status, - statusText: statusText, - headers: Headers(headers), - ), + return Response._internal( + body: body?.clone(), + headers: Headers(headers), + ok: ok, + redirected: redirected, + status: status, + statusText: statusText, + type: type, + url: url, ); } diff --git a/test/response_io_test.dart b/test/response_io_test.dart index 3902950..e1b5f80 100644 --- a/test/response_io_test.dart +++ b/test/response_io_test.dart @@ -6,6 +6,17 @@ import 'package:test/test.dart'; void main() { group('Response (io)', () { + test('preserves native error clone semantics', () { + final response = Response(native.Response.error()); + + expect(() => response.clone(), returnsNormally); + + final clone = response.clone(); + expect(clone.type, native.ResponseType.error); + expect(clone.status, 0); + expect(clone.ok, isFalse); + }); + test( 'wraps HttpClientResponse without copying headers or body eagerly', () async { diff --git a/test/response_native_test.dart b/test/response_native_test.dart index 00c2bcf..38a57d5 100644 --- a/test/response_native_test.dart +++ b/test/response_native_test.dart @@ -19,6 +19,17 @@ void main() { expect(await response.text(), ''); }); + test('clone preserves error responses', () { + final response = Response.error(); + + expect(() => response.clone(), returnsNormally); + + final clone = response.clone(); + expect(clone.type, ResponseType.error); + expect(clone.status, 0); + expect(clone.ok, isFalse); + }); + test('defaults metadata for empty responses', () async { final response = Response(); From a0347ce27b3523df61b94f518cb47e1590340c13 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:16:10 +0800 Subject: [PATCH 30/45] fix(fetch): clone stream-backed bodies on copy --- lib/src/fetch/body.dart | 5 +---- test/body_test.dart | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index e3236b0..b7e726e 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -44,10 +44,7 @@ class Body extends Stream { factory Body([BodyInit? init]) { return switch (init) { null => Body._(blockHost: block.Block(const [])), - final Body body => Body._( - blockHost: body._blockHost, - streamHost: body._streamHost, - ), + final Body body => body.clone(), final String text => Body._( blockHost: block.Block([text], type: 'text/plain;charset=utf-8'), ), diff --git a/test/body_test.dart b/test/body_test.dart index b7baebb..572ea7b 100644 --- a/test/body_test.dart +++ b/test/body_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:block/block.dart' as block; @@ -53,6 +54,23 @@ void main() { expect(await clone.text(), 'hello world'); }); + test('copying a stream-backed body preserves independent reads', () async { + final controller = StreamController>(); + scheduleMicrotask(() async { + controller + ..add(utf8.encode('hello ')) + ..add(utf8.encode('copy')); + await controller.close(); + }); + + final body = Body(controller.stream); + + final copy = Body(body); + + expect(await body.text(), 'hello copy'); + expect(await copy.text(), 'hello copy'); + }); + test('blob consumes stream bodies and returns a Blob view', () async { final body = Body( Stream>.fromIterable(>[ From 4f4f86e493c1683f9889e6ec143c0817494f8fe4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:17:04 +0800 Subject: [PATCH 31/45] fix(fetch): preserve MIME type when reading web blobs --- lib/src/_internal/web_fetch_utils.dart | 3 ++- test/blob_js_test.dart | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/src/_internal/web_fetch_utils.dart b/lib/src/_internal/web_fetch_utils.dart index fd726f3..759aaa7 100644 --- a/lib/src/_internal/web_fetch_utils.dart +++ b/lib/src/_internal/web_fetch_utils.dart @@ -23,7 +23,8 @@ Future blobFromWebPromise( JSPromise promise, { String? type, }) async { - return Blob([await promise.toDart], type ?? ''); + final hostBlob = await promise.toDart; + return Blob([hostBlob], type ?? hostBlob.type); } FormData formDataFromWebHost(web.FormData host) { diff --git a/test/blob_js_test.dart b/test/blob_js_test.dart index c071e8c..cb60231 100644 --- a/test/blob_js_test.dart +++ b/test/blob_js_test.dart @@ -3,6 +3,7 @@ library; import 'dart:js_interop'; +import 'package:ht/src/_internal/web_fetch_utils.dart' as web_fetch; import 'package:ht/src/fetch/blob.js.dart' as js; import 'package:test/test.dart'; import 'package:web/web.dart' as web; @@ -33,5 +34,19 @@ void main() { expect(blob.size, 7); expect(await blob.text(), 'payload'); }); + + test('preserves host blob MIME type when no override is provided', () async { + final response = web.Response( + 'payload'.toJS, + web.ResponseInit( + headers: {'content-type': 'text/plain'}.jsify()! as web.HeadersInit, + ), + ); + + final blob = await web_fetch.blobFromWebPromise(response.blob()); + + expect(blob.type, 'text/plain'); + expect(await blob.text(), 'payload'); + }); }); } From 1287a6c55ccea53fc0daf78e14ec22070cf4ba93 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:17:54 +0800 Subject: [PATCH 32/45] fix(fetch): align js set-cookie header reads --- lib/src/fetch/headers.js.dart | 5 ++++- test/headers_js_test.dart | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/src/fetch/headers.js.dart b/lib/src/fetch/headers.js.dart index d4eb638..6527398 100644 --- a/lib/src/fetch/headers.js.dart +++ b/lib/src/fetch/headers.js.dart @@ -64,7 +64,10 @@ class Headers } @override - String? get(String name) => host.get(name); + String? get(String name) { + if (name.toLowerCase() == 'set-cookie') return null; + return host.get(name); + } @override void set(String name, String value) => host.set(name, value); diff --git a/test/headers_js_test.dart b/test/headers_js_test.dart index 0e1c26b..2754f7e 100644 --- a/test/headers_js_test.dart +++ b/test/headers_js_test.dart @@ -19,5 +19,16 @@ void main() { ['x-a:1', 'x-b:2'], ); }); + + test('does not expose set-cookie through get()', () { + final headers = Headers( + web.Headers() + ..append('set-cookie', 'a=1') + ..append('set-cookie', 'b=2'), + ); + + expect(headers.get('set-cookie'), isNull); + expect(headers.getSetCookie(), ['a=1', 'b=2']); + }); }); } From 87f6ce8b1c21acc7861a7944ae6dbf7e08963986 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:19:48 +0800 Subject: [PATCH 33/45] refactor(fetch): make body streams non-nullable --- lib/src/fetch/body.dart | 15 +-------------- test/body_test.dart | 11 ++++++++++- test/request_io_test.dart | 11 +++++++++++ 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index b7e726e..988febc 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -80,7 +80,7 @@ class Body extends Stream { Stream? _streamHost; bool _used = false; - Stream? get stream async* { + Stream get stream async* { final blockHost = _blockHost; final streamHost = _streamHost; if (blockHost == null && streamHost == null) { @@ -102,9 +102,6 @@ class Body extends Stream { bool get bodyUsed => _used; Future bytes() async { - final stream = this.stream; - if (stream == null) return Uint8List(0); - final builder = BytesBuilder(copy: false); await for (final chunk in stream) { builder.add(chunk); @@ -162,16 +159,6 @@ class Body extends Stream { void Function()? onDone, bool? cancelOnError, }) { - final stream = this.stream; - if (stream == null) { - return Stream.empty().listen( - onData, - onError: onError, - onDone: onDone, - cancelOnError: cancelOnError, - ); - } - return stream.listen( onData, onError: onError, diff --git a/test/body_test.dart b/test/body_test.dart index 572ea7b..2876588 100644 --- a/test/body_test.dart +++ b/test/body_test.dart @@ -1,11 +1,14 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:block/block.dart' as block; import 'package:ht/src/fetch/body.dart'; import 'package:ht/src/fetch/url_search_params.dart'; import 'package:test/test.dart'; +Stream expectNonNullableStream(Stream stream) => stream; + void main() { group('Body', () { test('string bodies decode as text and bytes', () async { @@ -89,10 +92,16 @@ void main() { 'empty bodies return empty bytes and become used when consumed', () async { final body = Body(); + final stream = expectNonNullableStream(body.stream); expect(body.bodyUsed, isFalse); - expect(await body.bytes(), isEmpty); + expect(await stream.toList(), isEmpty); expect(body.bodyUsed, isTrue); + + final bytesBody = Body(); + expect(bytesBody.bodyUsed, isFalse); + expect(await bytesBody.bytes(), isEmpty); + expect(bytesBody.bodyUsed, isTrue); }, ); diff --git a/test/request_io_test.dart b/test/request_io_test.dart index bb5f298..68194a7 100644 --- a/test/request_io_test.dart +++ b/test/request_io_test.dart @@ -7,6 +7,17 @@ import 'package:test/test.dart'; void main() { group('Request (io)', () { + test('caches body for wrapped native requests', () { + final request = io_request.Request( + native.Request( + const native.RequestInput.string('https://example.com'), + native.RequestInit(body: 'payload'), + ), + ); + + expect(identical(request.body, request.body), isTrue); + }); + test('wraps HttpRequest without copying headers or body eagerly', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(server.close); From 9b936883ca2142672238470f7c5a32bf9685f7a5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:28:39 +0800 Subject: [PATCH 34/45] style(test): format browser fetch tests --- test/blob_js_test.dart | 29 ++++++++++++++++------------- test/headers_js_test.dart | 5 ++++- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/test/blob_js_test.dart b/test/blob_js_test.dart index cb60231..1b07a2f 100644 --- a/test/blob_js_test.dart +++ b/test/blob_js_test.dart @@ -35,18 +35,21 @@ void main() { expect(await blob.text(), 'payload'); }); - test('preserves host blob MIME type when no override is provided', () async { - final response = web.Response( - 'payload'.toJS, - web.ResponseInit( - headers: {'content-type': 'text/plain'}.jsify()! as web.HeadersInit, - ), - ); - - final blob = await web_fetch.blobFromWebPromise(response.blob()); - - expect(blob.type, 'text/plain'); - expect(await blob.text(), 'payload'); - }); + test( + 'preserves host blob MIME type when no override is provided', + () async { + final response = web.Response( + 'payload'.toJS, + web.ResponseInit( + headers: {'content-type': 'text/plain'}.jsify()! as web.HeadersInit, + ), + ); + + final blob = await web_fetch.blobFromWebPromise(response.blob()); + + expect(blob.type, 'text/plain'); + expect(await blob.text(), 'payload'); + }, + ); }); } diff --git a/test/headers_js_test.dart b/test/headers_js_test.dart index 2754f7e..803d1cd 100644 --- a/test/headers_js_test.dart +++ b/test/headers_js_test.dart @@ -15,7 +15,10 @@ void main() { ); expect( - headers.entries().map((entry) => '${entry.key}:${entry.value}').toList(), + headers + .entries() + .map((entry) => '${entry.key}:${entry.value}') + .toList(), ['x-a:1', 'x-b:2'], ); }); From eacd0c30ababaac725fac4b0e893026a1c866523 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:03:30 +0800 Subject: [PATCH 35/45] fix(fetch): preserve web file lastModified metadata --- lib/src/_internal/web_fetch_utils.dart | 9 +++++- lib/src/fetch/form_data.native.dart | 4 +++ test/_internal/web_fetch_utils_test.dart | 40 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 test/_internal/web_fetch_utils_test.dart diff --git a/lib/src/_internal/web_fetch_utils.dart b/lib/src/_internal/web_fetch_utils.dart index 759aaa7..369acc6 100644 --- a/lib/src/_internal/web_fetch_utils.dart +++ b/lib/src/_internal/web_fetch_utils.dart @@ -53,7 +53,14 @@ FormData formDataFromWebHost(web.FormData host) { if (entry case final web.File file) { formData.append( key, - Multipart.blob(File([file], file.name, type: file.type)), + Multipart.blob( + File( + [file], + file.name, + type: file.type, + lastModified: file.lastModified, + ), + ), ); continue; } diff --git a/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart index 59f2e88..eb33787 100644 --- a/lib/src/fetch/form_data.native.dart +++ b/lib/src/fetch/form_data.native.dart @@ -35,6 +35,10 @@ final class BlobMultipart extends File implements Multipart { _ => filename ?? 'blob', }, type: value.type, + lastModified: switch (value) { + final File file => file.lastModified, + _ => null, + }, ); final String filename; diff --git a/test/_internal/web_fetch_utils_test.dart b/test/_internal/web_fetch_utils_test.dart new file mode 100644 index 0000000..3c763c0 --- /dev/null +++ b/test/_internal/web_fetch_utils_test.dart @@ -0,0 +1,40 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; + +import 'package:ht/src/_internal/web_fetch_utils.dart'; +import 'package:ht/src/fetch/file.dart'; +import 'package:ht/src/fetch/form_data.native.dart'; +import 'package:test/test.dart'; +import 'package:web/web.dart' as web; + +void main() { + group('web_fetch_utils', () { + test('preserves web.File lastModified when converting FormData hosts', () { + const lastModified = 1_710_000_000_000; + final host = web.FormData() + ..append( + 'file', + web.File( + ['payload'.toJS].toJS, + 'payload.txt', + web.FilePropertyBag( + type: 'text/plain', + lastModified: lastModified, + ), + ), + ); + + final formData = formDataFromWebHost(host); + final part = formData.get('file'); + final file = part as File; + + expect(part, isA()); + expect(part, isA()); + expect(file.name, 'payload.txt'); + expect(file.lastModified, lastModified); + expect(file.type, 'text/plain'); + }); + }); +} From 2c8a3710f3fd42bce79187137bd8f20dbbc81ed5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:03:58 +0800 Subject: [PATCH 36/45] fix(fetch): normalize js set-cookie header lookups --- lib/src/fetch/headers.js.dart | 2 +- test/headers_js_test.dart | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/src/fetch/headers.js.dart b/lib/src/fetch/headers.js.dart index 6527398..5e557bd 100644 --- a/lib/src/fetch/headers.js.dart +++ b/lib/src/fetch/headers.js.dart @@ -65,7 +65,7 @@ class Headers @override String? get(String name) { - if (name.toLowerCase() == 'set-cookie') return null; + if (name.trim().toLowerCase() == 'set-cookie') return null; return host.get(name); } diff --git a/test/headers_js_test.dart b/test/headers_js_test.dart index 803d1cd..0566988 100644 --- a/test/headers_js_test.dart +++ b/test/headers_js_test.dart @@ -33,5 +33,16 @@ void main() { expect(headers.get('set-cookie'), isNull); expect(headers.getSetCookie(), ['a=1', 'b=2']); }); + + test('does not expose set-cookie through get() with padded names', () { + final headers = Headers( + web.Headers() + ..append('set-cookie', 'a=1') + ..append('set-cookie', 'b=2'), + ); + + expect(headers.get(' set-cookie '), isNull); + expect(headers.getSetCookie(), ['a=1', 'b=2']); + }); }); } From 5eeaa1ece0dcb0fee14c52a3aba0947e28fa49f9 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:06:48 +0800 Subject: [PATCH 37/45] test(fetch): lock file-backed blob size semantics --- test/blob_io_test.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/blob_io_test.dart b/test/blob_io_test.dart index 7041bb5..de386e2 100644 --- a/test/blob_io_test.dart +++ b/test/blob_io_test.dart @@ -61,5 +61,24 @@ void main() { expect(chunks, ['he', 'll', 'o']); }); + + test('captures file length at construction time', () async { + final tempDir = await io.Directory.systemTemp.createTemp('ht_blob_io_'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final file = io.File('${tempDir.path}/payload.txt'); + await file.writeAsString('hello'); + + final blob = io_blob.Blob([file], 'text/plain'); + + await file.writeAsString('hello world'); + + expect(blob.size, 5); + expect(await blob.text(), 'hello'); + }); }); } From bcd6a419e2386a8e18376a2a274559e10d7924e4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:07:47 +0800 Subject: [PATCH 38/45] style(test): format web fetch utils browser test --- test/_internal/web_fetch_utils_test.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/_internal/web_fetch_utils_test.dart b/test/_internal/web_fetch_utils_test.dart index 3c763c0..363be56 100644 --- a/test/_internal/web_fetch_utils_test.dart +++ b/test/_internal/web_fetch_utils_test.dart @@ -19,10 +19,7 @@ void main() { web.File( ['payload'.toJS].toJS, 'payload.txt', - web.FilePropertyBag( - type: 'text/plain', - lastModified: lastModified, - ), + web.FilePropertyBag(type: 'text/plain', lastModified: lastModified), ), ); From 7705c371191fa1ed0fb7330d06ea7b723e47ce98 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:24:38 +0800 Subject: [PATCH 39/45] fix(fetch): preserve FormData set ordering --- lib/src/fetch/form_data.native.dart | 14 ++++++++++++-- test/form_data_native_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart index eb33787..6a15823 100644 --- a/lib/src/fetch/form_data.native.dart +++ b/lib/src/fetch/form_data.native.dart @@ -133,8 +133,18 @@ class FormData with Iterable> { } void set(String name, Multipart value) { - delete(name); - append(name, value); + final firstIndex = _entries.indexWhere((entry) => entry.key == name); + if (firstIndex == -1) { + append(name, value); + return; + } + + _entries[firstIndex] = MapEntry(name, value); + for (var index = _entries.length - 1; index > firstIndex; index--) { + if (_entries[index].key == name) { + _entries.removeAt(index); + } + } } EncodedFormData encodeMultipart({String? boundary}) { diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index 05dc6b7..af5c19c 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -118,4 +118,26 @@ void main() { expect(fromStream.takeBytes(), bytes); }); }); + + group('FormData mutation semantics (native)', () { + test('set replaces the first matching entry in place', () { + final formData = FormData() + ..append('a', Multipart.text('1')) + ..append('b', Multipart.text('2')) + ..append('a', Multipart.text('3')) + ..set('a', Multipart.text('x')); + + final entries = formData + .entries() + .map( + (entry) => ( + entry.key, + (entry.value as TextMultipart).value, + ), + ) + .toList(); + + expect(entries, [('a', 'x'), ('b', '2')]); + }); + }); } From 1f5af8bd13a09ea34dd6c9f5b9a086a384d6da2d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:25:20 +0800 Subject: [PATCH 40/45] fix(fetch): parse quoted multipart parameters safely --- lib/src/fetch/form_data.native.dart | 42 ++++++++++++++++++++++++++++- test/form_data_native_test.dart | 22 +++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart index 6a15823..3c98b1a 100644 --- a/lib/src/fetch/form_data.native.dart +++ b/lib/src/fetch/form_data.native.dart @@ -325,7 +325,7 @@ class FormData with Iterable> { static Map _parseHeaderParameters(String value) { final parameters = {}; - for (final segment in value.split(';').skip(1)) { + for (final segment in _splitHeaderParameters(value).skip(1)) { final separator = segment.indexOf('='); if (separator == -1) { continue; @@ -345,6 +345,46 @@ class FormData with Iterable> { return parameters; } + static List _splitHeaderParameters(String value) { + final segments = []; + final buffer = StringBuffer(); + var quoted = false; + var escaped = false; + + for (final rune in value.runes) { + final char = String.fromCharCode(rune); + + if (escaped) { + buffer.write(char); + escaped = false; + continue; + } + + if (char == r'\') { + buffer.write(char); + escaped = true; + continue; + } + + if (char == '"') { + buffer.write(char); + quoted = !quoted; + continue; + } + + if (char == ';' && !quoted) { + segments.add(buffer.toString()); + buffer.clear(); + continue; + } + + buffer.write(char); + } + + segments.add(buffer.toString()); + return segments; + } + static String _unescapeHeaderValue(String value) { return value .replaceAll(r'\\', '\\') diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index af5c19c..e70b664 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -84,6 +84,28 @@ void main() { expect(blob.type, 'text/plain;charset=utf-8'); expect(await blob.text(), 'binary'); }); + + test('parses quoted multipart parameters containing semicolons', () async { + const boundary = 'quoted-boundary'; + final body = Body( + '--$boundary\r\n' + 'Content-Disposition: form-data; name="file"; filename="a;b.txt"\r\n' + 'Content-Type: text/plain\r\n' + '\r\n' + 'payload\r\n' + '--$boundary--\r\n', + ); + + final formData = await FormData.parse( + body, + contentType: 'multipart/form-data; boundary=$boundary', + ); + + final part = formData.get('file'); + expect(part, isA()); + expect((part as BlobMultipart).filename, 'a;b.txt'); + expect(await part.text(), 'payload'); + }); }); group('FormData.encodeMultipart (native)', () { From 89964a771f5c4484f68ea7603176749f487170df Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:26:44 +0800 Subject: [PATCH 41/45] fix(fetch): avoid CRLF unescaping in multipart params --- lib/src/fetch/form_data.native.dart | 6 +----- test/form_data_native_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/src/fetch/form_data.native.dart b/lib/src/fetch/form_data.native.dart index 3c98b1a..1accc5f 100644 --- a/lib/src/fetch/form_data.native.dart +++ b/lib/src/fetch/form_data.native.dart @@ -386,11 +386,7 @@ class FormData with Iterable> { } static String _unescapeHeaderValue(String value) { - return value - .replaceAll(r'\\', '\\') - .replaceAll(r'\"', '"') - .replaceAll(r'\r', '\r') - .replaceAll(r'\n', '\n'); + return value.replaceAll(r'\\', '\\').replaceAll(r'\"', '"'); } static int _indexOf(List haystack, List needle, [int start = 0]) { diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index e70b664..0df546e 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -106,6 +106,28 @@ void main() { expect((part as BlobMultipart).filename, 'a;b.txt'); expect(await part.text(), 'payload'); }); + + test('does not unescape plain text CRLF sequences in quoted parameters', () async { + const boundary = 'escaped-boundary'; + final body = Body( + '--$boundary\r\n' + 'Content-Disposition: form-data; name="file"; filename="a\\nb.txt"\r\n' + 'Content-Type: text/plain\r\n' + '\r\n' + 'payload\r\n' + '--$boundary--\r\n', + ); + + final formData = await FormData.parse( + body, + contentType: 'multipart/form-data; boundary=$boundary', + ); + + final part = formData.get('file'); + expect(part, isA()); + expect((part as BlobMultipart).filename, r'a\nb.txt'); + expect(await part.text(), 'payload'); + }); }); group('FormData.encodeMultipart (native)', () { From 7adf2e0f0467c77beb795986c0c97d34399666e7 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:27:37 +0800 Subject: [PATCH 42/45] style(test): format FormData native tests --- test/form_data_native_test.dart | 48 ++++++++++++++++----------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index 0df546e..6d94d73 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -107,27 +107,30 @@ void main() { expect(await part.text(), 'payload'); }); - test('does not unescape plain text CRLF sequences in quoted parameters', () async { - const boundary = 'escaped-boundary'; - final body = Body( - '--$boundary\r\n' - 'Content-Disposition: form-data; name="file"; filename="a\\nb.txt"\r\n' - 'Content-Type: text/plain\r\n' - '\r\n' - 'payload\r\n' - '--$boundary--\r\n', - ); + test( + 'does not unescape plain text CRLF sequences in quoted parameters', + () async { + const boundary = 'escaped-boundary'; + final body = Body( + '--$boundary\r\n' + 'Content-Disposition: form-data; name="file"; filename="a\\nb.txt"\r\n' + 'Content-Type: text/plain\r\n' + '\r\n' + 'payload\r\n' + '--$boundary--\r\n', + ); - final formData = await FormData.parse( - body, - contentType: 'multipart/form-data; boundary=$boundary', - ); + final formData = await FormData.parse( + body, + contentType: 'multipart/form-data; boundary=$boundary', + ); - final part = formData.get('file'); - expect(part, isA()); - expect((part as BlobMultipart).filename, r'a\nb.txt'); - expect(await part.text(), 'payload'); - }); + final part = formData.get('file'); + expect(part, isA()); + expect((part as BlobMultipart).filename, r'a\nb.txt'); + expect(await part.text(), 'payload'); + }, + ); }); group('FormData.encodeMultipart (native)', () { @@ -173,12 +176,7 @@ void main() { final entries = formData .entries() - .map( - (entry) => ( - entry.key, - (entry.value as TextMultipart).value, - ), - ) + .map((entry) => (entry.key, (entry.value as TextMultipart).value)) .toList(); expect(entries, [('a', 'x'), ('b', '2')]); From 35b6e9b72eb04f38bb6acd3a184da8aeabb8deaa Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:03:31 +0800 Subject: [PATCH 43/45] docs(fetch): note blob io upstream migration path --- lib/src/fetch/blob.io.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/fetch/blob.io.dart b/lib/src/fetch/blob.io.dart index fac6362..9f017d3 100644 --- a/lib/src/fetch/blob.io.dart +++ b/lib/src/fetch/blob.io.dart @@ -24,6 +24,8 @@ class Blob extends native.Blob implements block.Block { return switch (part) { final Blob blob => blob, final native.Blob blob => blob, + // Downstream shim until block exposes a reusable public file-backed + // primitive: https://github.com/medz/block/issues/10 final io.File file => _FileBlock(file), _ => native.Blob([part]), }; @@ -31,7 +33,8 @@ class Blob extends native.Blob implements block.Block { } /// Temporary downstream file-backed block until `block` exposes reusable -/// io-backed file primitives. See https://github.com/medz/block/issues/10. +/// io-backed file primitives. Once https://github.com/medz/block/issues/10 is +/// available, this wrapper should be replaced with the upstream implementation. final class _FileBlock implements block.Block { _FileBlock(this._file, {int start = 0, int? length, this.type = ''}) : _start = start, From 3931884802824bba936f31986267f395039ad503 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:05:14 +0800 Subject: [PATCH 44/45] test(fetch): cover multipart quote escape roundtrip --- test/form_data_native_test.dart | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index 6d94d73..a17f12c 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -131,6 +131,32 @@ void main() { expect(await part.text(), 'payload'); }, ); + + test( + 'preserves literal backslash-quote sequences in quoted parameters', + () async { + final encoded = + (FormData() + ..append( + 'file', + Multipart.blob( + Blob(['payload'], 'text/plain'), + 'a\\"b.txt', + ), + )) + .encodeMultipart(boundary: 'quote-escape-boundary'); + + final formData = await FormData.parse( + Body(encoded.stream), + contentType: encoded.contentType, + ); + + final part = formData.get('file'); + expect(part, isA()); + expect((part as BlobMultipart).filename, 'a\\"b.txt'); + expect(await part.text(), 'payload'); + }, + ); }); group('FormData.encodeMultipart (native)', () { From f2d8ec82342eb38455a9b2d40724f6dec5174b68 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:15:19 +0800 Subject: [PATCH 45/45] style(test): format multipart parser tests --- test/form_data_native_test.dart | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/form_data_native_test.dart b/test/form_data_native_test.dart index a17f12c..00a35ca 100644 --- a/test/form_data_native_test.dart +++ b/test/form_data_native_test.dart @@ -136,14 +136,13 @@ void main() { 'preserves literal backslash-quote sequences in quoted parameters', () async { final encoded = - (FormData() - ..append( - 'file', - Multipart.blob( - Blob(['payload'], 'text/plain'), - 'a\\"b.txt', - ), - )) + (FormData()..append( + 'file', + Multipart.blob( + Blob(['payload'], 'text/plain'), + 'a\\"b.txt', + ), + )) .encodeMultipart(boundary: 'quote-escape-boundary'); final formData = await FormData.parse(