Skip to content

feat(fetch)!: integrate block-backed blob and stream-first multipart#6

Merged
medz merged 4 commits intomainfrom
feat/block-stream-refactor
Feb 6, 2026
Merged

feat(fetch)!: integrate block-backed blob and stream-first multipart#6
medz merged 4 commits intomainfrom
feat/block-stream-refactor

Conversation

@medz
Copy link
Owner

@medz medz commented Feb 6, 2026

Summary

  • Integrate package:block into ht binary primitives.
  • Make Blob directly compatible with block.Block.
  • Keep FormData.encodeMultipart() as the primary API and switch multipart payloads to stream-first MultipartBody.
  • Add BodyInit support for block.Block values in both Request and Response.

Breaking Changes

  • Blob.copyBytes() is removed.
  • Blob.slice now follows Web Blob semantics for negative indices.
  • MultipartBody.bytes field is replaced by async method bytes().
  • FormData.encodeMultipart() now returns a stream-first payload (stream, contentLength, contentType, bytes()).

Migration

Blob copy

// before
final bytes = blob.copyBytes();

// after
final bytes = await blob.bytes();

Multipart bytes

// before
final multipart = form.encodeMultipart();
final bytes = multipart.bytes;

// after
final multipart = form.encodeMultipart();
final bytes = await multipart.bytes();

Performance (local benchmark)

Measured on local machine, comparing main vs feat/block-stream-refactor (3-run average).

Metric main (ms) branch (ms) delta
blob_construct_slice_8mb_ms 0.99 6.98 +603.0%
blob_construct_slice_32mb_ms 3.47 20.81 +500.4%
blob_stream_32mb_ms 4.07 13.52 +231.9%
formdata_multipart_32mb_ms 7.91 24.43 +208.9%
request_response_block_8mb_ms 5.74 8.89 +54.7%

Decision

This PR intentionally keeps Blob as a thin Block-based adapter.
We do not add extra fast-path/caching optimizations in this PR to avoid higher implementation complexity and potential memory overhead.

Validation

  • dart format --output=none --set-exit-if-changed .
  • dart analyze
  • dart test
  • dart run example/main.dart

BREAKING CHANGE: Blob now implements block.Block and removes synchronous copyBytes(). Blob.slice follows Web Blob negative-index semantics. FormData.encodeMultipart now returns stream-first MultipartBody and MultipartBody.bytes is async bytes().
@coderabbitai
Copy link

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

This PR reimplements Blob as a block-backed type that implements block.Block, converts FormData multipart encoding to a stream-first MultipartBody with async bytes(), updates Blob.slice to follow Web Blob negative-index semantics, and adds BodyInit support for package:block Block in Request/Response. The package version and dependency were updated.

Changes

Cohort / File(s) Summary
Blob Block Implementation
lib/src/fetch/blob.dart
Blob now implements block.Block and delegates storage to an internal Block. Removed synchronous copyBytes(); added arrayBuffer(), adjusted bytes(), text(), stream() (non-async generator), revised slice() signature/semantics (negative indices), and private constructors/helpers for normalization.
FormData Stream-First Redesign
lib/src/fetch/form_data.dart
MultipartBody changed from byte-buffer to stream-backed via a factory; added stream getter and async bytes() method. FormData.encodeMultipart() now returns streaming MultipartBody. Added streaming encoder _encodeMultipart, length calculator _calculateMultipartLength, and helpers.
Body Handler Updates
lib/src/fetch/body.dart
BodyData.fromInit now treats Blob and block.Block as stream sources (uses stream()), propagates contentLength from blocks, and adjusts FormData handling to use stream payloads with contentLength/contentType propagation.
Configuration & Documentation
pubspec.yaml, CHANGELOG.md, README.md
Version bumped to 0.2.0, added dependency block: ^1.0.0, and updated docs/examples to show async bytes(), Block interop, and Blob slice semantics.
Tests & Coverage Updates
test/form_data_test.dart, test/public_api_surface_test.dart, test/request_response_test.dart
Tests added/updated to validate block.Block interoperability, negative-index Blob.slice, multipart streaming equivalence (MultipartBody.bytes() vs stream), and Request/Response support for block bodies with inferred content-type/length.

Sequence Diagram(s)

sequenceDiagram
    actor Caller
    participant FormData
    participant MultipartBody
    participant Stream as Stream<Uint8List>
    participant Bytes

    Caller->>FormData: encodeMultipart(boundary)
    activate FormData
    FormData->>MultipartBody: create via factory (streamFactory, boundary, contentLength)
    FormData-->>Caller: return MultipartBody
    deactivate FormData

    Caller->>MultipartBody: stream (getter)
    activate MultipartBody
    MultipartBody->>Stream: invoke streamFactory()
    Stream-->>MultipartBody: Stream<Uint8List>
    MultipartBody-->>Caller: return Stream<Uint8List>
    deactivate MultipartBody

    alt Materialize bytes
        Caller->>MultipartBody: bytes()
        activate MultipartBody
        MultipartBody->>Stream: collect chunks
        Stream->>Bytes: gather
        Bytes-->>MultipartBody: return Uint8List
        MultipartBody-->>Caller: Uint8List
        deactivate MultipartBody
    end
Loading
sequenceDiagram
    actor Caller
    participant Request
    participant BodyData
    participant Block as block.Block
    participant Stream as Stream<Uint8List>

    Caller->>Request: new(body: blockBody)
    activate Request
    Request->>BodyData: fromInit(blockBody)
    activate BodyData
    BodyData->>Block: blockBody.stream()
    Block-->>BodyData: Stream<Uint8List>
    BodyData->>BodyData: defaultContentType <- blockBody.type
    BodyData->>BodyData: defaultContentLength <- blockBody.size
    BodyData-->>Request: BodyData(stream, contentType, contentLength)
    deactivate BodyData
    Request-->>Caller: Request instance

    Caller->>Request: text()
    Request->>Stream: consume
    Stream-->>Caller: chunks -> decoded text
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 A Block-Backed Blessing
I tunneled through bytes and found a stream,
Blob dons Block shoes and hops into the dream.
Multipart sings in async light,
Tests clap paws — everything feels right! 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and clearly describes the main change: integrating block-backed Blob and stream-first multipart payload handling. It accurately reflects the core breaking changes and architectural shift throughout the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/block-stream-refactor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@pubspec.yaml`:
- Line 3: The pubspec dependency constraint for the package named "block" is
invalid (there is no 1.0.0 release); update the dependency entry for block in
pubspec.yaml from ^1.0.0 to ^0.0.3 (or the intended actual released version) so
pub get succeeds—locate the "block:" dependency line and replace the version
constraint accordingly.
🧹 Nitpick comments (4)
test/request_response_test.dart (1)

192-199: Good test for block body in Response — mirrors the Request test for completeness.

Consider also adding a test for Blob.slice with negative indices on a non-inline (block-backed) Blob to ensure the block path in slice() handles negative indices consistently. This ties into the concern flagged in blob.dart about potential inconsistency between the inline and block paths.

lib/src/fetch/form_data.dart (2)

89-91: Consider documenting or deprecating encodeMultipartStream.

Since this is now a pure alias for encodeMultipart, callers might be confused by two identical methods. If it exists only for backward compatibility, a @Deprecated annotation would signal intent and guide migration.


175-210: _calculateMultipartLength duplicates the structural logic of _encodeMultipart — divergence risk.

The header templates, boundary markers, and CRLF sequences are mirrored in both methods. If one is updated without the other, contentLength will silently diverge from the actual stream payload, causing subtle HTTP framing bugs for clients relying on Content-Length.

Consider extracting a shared representation of the part structure (e.g., a list of part descriptors) that both methods consume, or a single traversal that accepts a callback/visitor to either yield chunks or sum lengths.

test/form_data_test.dart (1)

73-95: Key assertion: contentLength vs actual bytes length (line 89).

This test is the primary safety net verifying that _calculateMultipartLength stays in sync with _encodeMultipart. Good that it's present — consider also adding a case with multi-byte UTF-8 characters (e.g., emoji or CJK) in field names/values to stress the length calculation.

name: ht
description: Fetch-style HTTP types and protocol abstractions for Dart.
version: 0.1.0
version: 0.2.0
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

pub.dev block dart package 1.0.0

💡 Result:

There is a Dart package named block on pub.dev, but it does not have a 1.0.0 release. The highest published stable version currently listed is 0.0.3. [1]

To use it, add (example):

dependencies:
  block: ^0.0.3

Package docs (API/usage) are here: [2]

Sources:
[1] pub.dev “block – All Versions” (pub.dev)
[2] pub.dev “block – Dart API docs” (pub.dev)

Citations:


🏁 Script executed:

# Find and examine pubspec.yaml
git ls-files pubspec.yaml

Repository: medz/ht

Length of output: 63


🏁 Script executed:

# Check the current state of pubspec.yaml
cat -n pubspec.yaml | head -30

Repository: medz/ht

Length of output: 705


🏁 Script executed:

# Check git diff to see what changed in pubspec.yaml
git diff HEAD~1 pubspec.yaml 2>/dev/null || git show HEAD:pubspec.yaml 2>/dev/null | head -30

Repository: medz/ht

Length of output: 530


Fix block dependency version: 1.0.0 does not exist.

The block package on pub.dev does not have a 1.0.0 release; the highest stable version is 0.0.3. Change block: ^1.0.0 to block: ^0.0.3 (or the intended version if a newer release is planned). The current constraint will fail during pub get.

🤖 Prompt for AI Agents
In `@pubspec.yaml` at line 3, The pubspec dependency constraint for the package
named "block" is invalid (there is no 1.0.0 release); update the dependency
entry for block in pubspec.yaml from ^1.0.0 to ^0.0.3 (or the intended actual
released version) so pub get succeeds—locate the "block:" dependency line and
replace the version constraint accordingly.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@lib/src/fetch/blob.dart`:
- Around line 89-91: The ByteBuffer branch in the blob normalization creates a
non-owning view (ByteData.sublistView(part.asUint8List())) which can be mutated
by callers; change the logic in the function handling ByteBuffer (the branch
that checks `if (part is ByteBuffer)`) to first make a defensive copy (e.g.,
create a new Uint8List via `Uint8List.fromList(part.asUint8List())` or clone the
buffer) and then return a ByteData view over that copied buffer so it behaves
consistently with the `Uint8List.fromList()` copies used in the other branches.
🧹 Nitpick comments (1)
lib/src/fetch/blob.dart (1)

42-47: Circular delegation between bytes() and arrayBuffer() is correct but worth noting.

bytes() calls _inner.arrayBuffer(), and arrayBuffer() calls bytes(). Since bytes() reaches into _inner (not this.arrayBuffer()), there is no infinite recursion — just a double-copy if someone calls arrayBuffer()bytes()Uint8List.fromList(await _inner.arrayBuffer()). This is fine given the doc says "returns a copy", but if you ever want to avoid the copy for arrayBuffer(), you could delegate arrayBuffer() directly to _inner.arrayBuffer() as well.

Comment on lines 89 to 91
if (part is ByteBuffer) {
return ByteData.sublistView(part.asUint8List());
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n lib/src/fetch/blob.dart | head -100 | tail -30

Repository: medz/ht

Length of output: 884


🏁 Script executed:

# Find the block.Block class
fd -t f 'block\.dart' --type f

Repository: medz/ht

Length of output: 33


🏁 Script executed:

# Search for block.Block class definition
ast-grep --pattern 'class Block {
  $$$
}'

Repository: medz/ht

Length of output: 33


🏁 Script executed:

fd 'block' -t f | grep -E '\.(dart|js)$'

Repository: medz/ht

Length of output: 33


🏁 Script executed:

# Search for Block class definition in Dart files
rg 'class Block' -t dart -A 10

Repository: medz/ht

Length of output: 33


🏁 Script executed:

# Also check the import statements in blob.dart to understand where block comes from
head -30 lib/src/fetch/blob.dart

Repository: medz/ht

Length of output: 971


🏁 Script executed:

# Check pubspec.yaml for block package and its source
cat pubspec.yaml | grep -A 5 -B 5 block

Repository: medz/ht

Length of output: 198


🏁 Script executed:

# Check if block is a local package
ls -la | grep -i block

Repository: medz/ht

Length of output: 33


🏁 Script executed:

# Search for any Block class definition or implementation in the repo
rg 'class Block' -t dart -B 2 -A 15

Repository: medz/ht

Length of output: 33


🌐 Web query:

Dart block package pub.dev version 1.0.0

💡 Result:

On pub.dev, the block package (publisher medz.dev) does not have a 1.0.0 release. As of February 6, 2026, the available stable versions are 0.0.3 (latest), 0.0.2, 0.0.1, and 0.0.0. [1]

If you meant the popular state-management package bloc (no “k”), tell me and I’ll look up its 1.0.0 release.

Sources: [1]


🌐 Web query:

Dart block package ByteData sublistView defensive copy

💡 Result:

ByteData.sublistView(...) is not a defensive copy. It creates a view into the same underlying ByteBuffer (zero-copy), so mutations are shared through the buffer. (api.dart.dev)

If you need a defensive copy, copy the bytes into a new buffer:

import 'dart:typed_data';

// Defensive copy of a ByteData range:
ByteData copyByteDataRange(TypedData data, int start, int end) {
  final view = Uint8List.sublistView(data, start, end);   // view (no copy)
  final bytes = Uint8List.fromList(view);                 // copy
  return ByteData.sublistView(bytes);                     // view on the copied buffer
}

Why this matters for package:block: the package explicitly optimizes for slicing without copying / sharing backing storage (e.g., slice() can share a backing file/view for larger slices), so you should not assume slices or stored parts are isolated unless you copy them yourself. (pub.dev)

Citations:


ByteBuffer normalization creates a view rather than a defensive copy, unlike Uint8List and List<int>.

Line 90 wraps the buffer in a ByteData view without copying, while lines 94 and 98 copy their inputs with Uint8List.fromList(). Since the block package explicitly optimizes for storage sharing without defensive copying, a caller who mutates the original ByteBuffer after constructing the Blob could corrupt blob contents. For consistency and safety, make a copy:

Proposed fix
   if (part is ByteBuffer) {
-    return ByteData.sublistView(part.asUint8List());
+    return Uint8List.fromList(part.asUint8List());
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (part is ByteBuffer) {
return ByteData.sublistView(part.asUint8List());
}
if (part is ByteBuffer) {
return Uint8List.fromList(part.asUint8List());
}
🤖 Prompt for AI Agents
In `@lib/src/fetch/blob.dart` around lines 89 - 91, The ByteBuffer branch in the
blob normalization creates a non-owning view
(ByteData.sublistView(part.asUint8List())) which can be mutated by callers;
change the logic in the function handling ByteBuffer (the branch that checks `if
(part is ByteBuffer)`) to first make a defensive copy (e.g., create a new
Uint8List via `Uint8List.fromList(part.asUint8List())` or clone the buffer) and
then return a ByteData view over that copied buffer so it behaves consistently
with the `Uint8List.fromList()` copies used in the other branches.

@medz medz merged commit bc6d4ea into main Feb 6, 2026
2 checks passed
@medz medz deleted the feat/block-stream-refactor branch February 6, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant