Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
edf5b40
refactor(fetch): split headers into native, web, and io implementations
medz Mar 11, 2026
277db3c
feat(internal): add stream tee utility
medz Mar 11, 2026
e1c2f9a
feat(fetch): split Blob into native, web, and io implementations
medz Mar 11, 2026
d47b5f5
refactor(fetch): narrow File parts to BlobPart
medz Mar 12, 2026
5d75a07
feat(fetch): add native Body implementation
medz Mar 12, 2026
0114088
test(fetch): rename body native test
medz Mar 12, 2026
c993610
refactor(fetch): move web utils under internal
medz Mar 12, 2026
5fe8f43
feat(fetch): split URLSearchParams into native and web implementations
medz Mar 12, 2026
7de23ce
feat(fetch): support native web URLSearchParams host
medz Mar 12, 2026
9cf3971
feat(fetch): add native FormData baseline
medz Mar 12, 2026
149fc27
feat(fetch): add native FormData parsing for urlencoded bodies
medz Mar 12, 2026
0334f23
chore(wip): checkpoint remaining request refactor work
medz Mar 12, 2026
66305a0
feat(fetch): parse native multipart form data
medz Mar 12, 2026
7a7d7fe
refactor(fetch): rename native multipart value types
medz Mar 12, 2026
71a4a1c
feat(fetch): add native FormData multipart encoding
medz Mar 12, 2026
ae08e2f
feat(fetch): complete native Request implementation
medz Mar 12, 2026
0697b04
feat(fetch): complete native Response implementation
medz Mar 13, 2026
b9f8c3e
refactor(fetch): switch public surface to native fetch types
medz Mar 13, 2026
9d40962
feat(fetch): add native Response MDN factories
medz Mar 13, 2026
2c0b0c7
feat(fetch): add io-backed Request implementation
medz Mar 13, 2026
0c4ba9a
feat(fetch): add js-backed Request implementation
medz Mar 13, 2026
d82b588
feat(fetch): add js-backed Response implementation
medz Mar 13, 2026
54ccaf5
feat(fetch): add io-backed Response implementation
medz Mar 13, 2026
6ee5f78
perf(fetch): share web host read helpers
medz Mar 13, 2026
bbace41
perf(fetch): remove redundant body list copy
medz Mar 13, 2026
a752a47
docs(readme): simplify API overview
medz Mar 13, 2026
57a4252
docs(readme): refresh fetch API examples
medz Mar 13, 2026
10d16d9
fix(fetch): iterate native web headers entries correctly
medz Mar 13, 2026
a8bfc7f
fix(fetch): preserve response clone metadata
medz Mar 13, 2026
a0347ce
fix(fetch): clone stream-backed bodies on copy
medz Mar 13, 2026
4f4f86e
fix(fetch): preserve MIME type when reading web blobs
medz Mar 13, 2026
1287a6c
fix(fetch): align js set-cookie header reads
medz Mar 13, 2026
87f6ce8
refactor(fetch): make body streams non-nullable
medz Mar 13, 2026
9b93688
style(test): format browser fetch tests
medz Mar 13, 2026
eacd0c3
fix(fetch): preserve web file lastModified metadata
medz Mar 13, 2026
2c8a371
fix(fetch): normalize js set-cookie header lookups
medz Mar 13, 2026
5eeaa1e
test(fetch): lock file-backed blob size semantics
medz Mar 13, 2026
bcd6a41
style(test): format web fetch utils browser test
medz Mar 13, 2026
7705c37
fix(fetch): preserve FormData set ordering
medz Mar 13, 2026
1f5af8b
fix(fetch): parse quoted multipart parameters safely
medz Mar 13, 2026
89964a7
fix(fetch): avoid CRLF unescaping in multipart params
medz Mar 13, 2026
7adf2e0
style(test): format FormData native tests
medz Mar 13, 2026
35b6e9b
docs(fetch): note blob io upstream migration path
medz Mar 13, 2026
3931884
test(fetch): cover multipart quote escape roundtrip
medz Mar 13, 2026
f2d8ec8
style(test): format multipart parser tests
medz Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 20 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,20 +22,12 @@ 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 |
| --- | --- |
| 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` |

Expand All @@ -52,9 +37,13 @@ The goal is to provide stable and reusable HTTP types and behavior contracts.
import 'package:ht/ht.dart';

Future<void> 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(
Expand Down Expand Up @@ -83,8 +72,14 @@ import 'package:ht/ht.dart';

Future<void> 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(<Object>['binary'], 'text/plain;charset=utf-8'),
'avatar.txt',
),
);

final multipart = form.encodeMultipart();
final bytes = await multipart.bytes();
Expand All @@ -107,12 +102,10 @@ import 'package:ht/ht.dart';
Future<void> main() async {
final body = block.Block(<Object>['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
}
```
Expand All @@ -123,7 +116,7 @@ Future<void> main() async {
interpreted from the end of the blob:

```dart
final blob = Blob.text('hello world');
final blob = Blob(<Object>['hello world'], 'text/plain;charset=utf-8');
final tail = blob.slice(-5);
print(await tail.text()); // world
```
Expand Down
12 changes: 8 additions & 4 deletions example/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import 'dart:convert';
import 'package:ht/ht.dart';

Future<void> 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')}');
Expand Down
6 changes: 2 additions & 4 deletions lib/ht.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
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';
export 'src/fetch/form_data.native.dart';
export 'src/fetch/headers.dart';
export 'src/fetch/request.dart';
export 'src/fetch/response.dart';
Expand Down
12 changes: 12 additions & 0 deletions lib/src/_internal/stream_tee.dart
Original file line number Diff line number Diff line change
@@ -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<T>, Stream<T>) streamTee<T>(Stream<T> source) {
final streams = StreamSplitter.splitFrom(source, 2);
return (streams[0], streams[1]);
}
74 changes: 74 additions & 0 deletions lib/src/_internal/web_fetch_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
@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<Uint8List> bytesFromWebPromise(JSPromise<JSUint8Array> promise) {
return promise.toDart.then((value) => value.toDart);
}

Future<String> textFromWebPromise(JSPromise<JSString> promise) {
return promise.toDart.then((value) => value.toDart);
}

Future<Blob> blobFromWebPromise(
JSPromise<web.Blob> promise, {
String? type,
}) async {
final hostBlob = await promise.toDart;
return Blob(<Object>[hostBlob], type ?? hostBlob.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<JSAny?>).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(
<Object>[file],
file.name,
type: file.type,
lastModified: file.lastModified,
),
),
);
continue;
}

if (entry case final web.Blob blob) {
formData.append(key, Multipart.blob(Blob(<Object>[blob], blob.type)));
}
}

return formData;
}
36 changes: 36 additions & 0 deletions lib/src/_internal/web_stream_bridge.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@JS()
library;

import 'dart:js_interop';
import 'dart:typed_data';

import 'package:web/web.dart' as web;

Stream<Uint8List> 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();
}
}
121 changes: 121 additions & 0 deletions lib/src/_internal/web_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
@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<MapEntry<String, String>> entries) {
final headers = Headers();
for (final MapEntry(key: name, :value) in entries) {
headers.append(name, value);
}
return headers;
}

factory Headers.fromMap(Map<String, String> map) =>
Headers.fromEntries(map.entries);

factory Headers.fromMultiValueMap(Map<String, Iterable<String>> 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<Iterable<String>> 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<String>)> pairs,
) {
final headers = Headers();
for (final (name, values) in pairs) {
for (final value in values) {
headers.append(name, value);
}
}
return headers;
}
}

extension type Array<T extends JSAny?>._(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<MapEntry<String, String>> entries,
) {
final params = URLSearchParams();
for (final MapEntry(key: name, :value) in entries) {
params.append(name, value);
}
return params;
}

factory URLSearchParams.fromMap(Map<String, String> 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();
}
Loading
Loading