Update Transfer-Encoding logic. (#66)

This should be the last CL in this unfortunate series. The adapter
requirements have been updated to consistently ensure that a Message's
body is in the chunked format if and only if its headers indicate that
it should be.

Closes #64
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d6d397..25101d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+## 0.6.7+2
+
+* Go back to auto-generating a `Content-Length` header when the length is known
+  ahead-of-time *and* the user hasn't explicitly specified a `Transfer-Encoding:
+  chunked`.
+
+* Clarify adapter requirements around transfer codings.
+
+* Make `shelf_io` consistent with the clarified adapter requirements. In
+  particular, it removes the `Transfer-Encoding` header from chunked requests,
+  and it doesn't apply additional chunking to responses that already declare
+  `Transfer-Encoding: chunked`.
+
 ## 0.6.7+1
 
 * Never auto-generate a `Content-Length` header.
diff --git a/README.md b/README.md
index c45554e..2e86c22 100644
--- a/README.md
+++ b/README.md
@@ -98,16 +98,7 @@
 
 [shelf_io.serve]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf-io@id_serve
 
-When implementing an adapter, some rules must be followed. The adapter must not
-pass the `url` or `handlerPath` parameters to [new shelf.Request][]; it should
-only pass `requestedUri`. If it passes the `context` parameter, all keys must
-begin with the adapter's package name followed by a period. If multiple headers
-with the same name are received, the adapter must collapse them into a single
-header separated by commas as per [RFC 2616 section 4.2][].
-
-[new shelf.Request]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf.Request@id_Request-
-
-[RFC 2616 section 4.2]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html
+### API Requirements
 
 An adapter must handle all errors from the handler, including the handler
 returning a `null` response. It should print each error to the console if
@@ -118,14 +109,6 @@
 user wants to return detailed error descriptions, they should explicitly include
 middleware to do so.
 
-An adapter should include information about itself in the Server header of the
-response by default. If the handler returns a response with the Server header
-set, that must take precedence over the adapter's default header.
-
-An adapter should include the Date header with the time the handler returns a
-response. If the handler returns a response with the Date header set, that must
-take precedence.
-
 An adapter should ensure that asynchronous errors thrown by the handler don't
 cause the application to crash, even if they aren't reported by the future
 chain. Specifically, these errors shouldn't be passed to the root zone's error
@@ -153,6 +136,55 @@
 
 [server]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf@id_Server
 
+### Request Requirements
+
+When implementing an adapter, some rules must be followed. The adapter must not
+pass the `url` or `handlerPath` parameters to [new shelf.Request][]; it should
+only pass `requestedUri`. If it passes the `context` parameter, all keys must
+begin with the adapter's package name followed by a period. If multiple headers
+with the same name are received, the adapter must collapse them into a single
+header separated by commas as per [RFC 2616 section 4.2][].
+
+[new shelf.Request]: http://www.dartdocs.org/documentation/shelf/latest/index.html#shelf/shelf.Request@id_Request-
+
+[RFC 2616 section 4.2]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html
+
+If the underlying request uses a chunked transfer coding, the adapter must
+decode the body before passing it to [new shelf.Request][] and should remove the
+`Transfer-Encoding` header. This ensures that message bodies are chunked if and
+only if the headers declare that they are.
+
+### Response Requirements
+
+An adapter must not add or modify any [entity headers][] for a response.
+
+[entity headers]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7.1
+
+If *none* of the following conditions are true, the adapter must apply
+[chunked transfer coding][] to a response's body and set its Transfer-Encoding header to `chunked`:
+
+* The status code is less than 200, or equal to 204 or 304.
+* A Content-Length header is provided.
+* The Content-Type header indicates the MIME type `multipart/byteranges`.
+* The Transfer-Encoding header is set to anything other than `identity`.
+
+[chunked transfer coding]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1
+
+Adapters may find the [`addChunkedEncoding()`][addChunkedEncoding] middleware
+useful for implementing this behavior, if the underlying server doesn't
+implement it manually.
+
+When responding to a HEAD request, the adapter must not emit an entity body.
+Otherwise, it shouldn't modify the entity body in any way.
+
+An adapter should include information about itself in the Server header of the
+response by default. If the handler returns a response with the Server header
+set, that must take precedence over the adapter's default header.
+
+An adapter should include the Date header with the time the handler returns a
+response. If the handler returns a response with the Date header set, that must
+take precedence.
+
 ## Inspiration
 
 * [Connect](http://www.senchalabs.org/connect/) for NodeJS.
diff --git a/lib/shelf.dart b/lib/shelf.dart
index 905d9cd..1a5f072 100644
--- a/lib/shelf.dart
+++ b/lib/shelf.dart
@@ -4,7 +4,8 @@
 
 export 'src/cascade.dart';
 export 'src/handler.dart';
-export 'src/handlers/logger.dart';
+export 'src/middleware/add_chunked_encoding.dart';
+export 'src/middleware/logger.dart';
 export 'src/hijack_exception.dart';
 export 'src/middleware.dart';
 export 'src/pipeline.dart';
diff --git a/lib/shelf_io.dart b/lib/shelf_io.dart
index 7019d2e..d66bbe0 100644
--- a/lib/shelf_io.dart
+++ b/lib/shelf_io.dart
@@ -18,6 +18,8 @@
 import 'dart:async';
 import 'dart:io';
 
+import 'package:collection/collection.dart';
+import 'package:http_parser/http_parser.dart';
 import 'package:stack_trace/stack_trace.dart';
 
 import 'shelf.dart';
@@ -116,6 +118,9 @@
     headers[k] = v.join(',');
   });
 
+  // Remove the Transfer-Encoding header per the adapter requirements.
+  headers.remove(HttpHeaders.TRANSFER_ENCODING);
+
   void onHijack(callback) {
     request.response
         .detachSocket(writeHeaders: false)
@@ -136,11 +141,36 @@
 
   httpResponse.statusCode = response.statusCode;
 
+  // An adapter must not add or modify the `Transfer-Encoding` parameter, but
+  // the Dart SDK sets it by default. Set this before we fill in
+  // [response.headers] so that the user or Shelf can explicitly override it if
+  // necessary.
+  httpResponse.headers.chunkedTransferEncoding = false;
+
   response.headers.forEach((header, value) {
     if (value == null) return;
     httpResponse.headers.set(header, value);
   });
 
+  var coding = response.headers['transfer-encoding'];
+  if (coding != null && !equalsIgnoreAsciiCase(coding, 'identity')) {
+    // If the response is already in a chunked encoding, de-chunk it because
+    // otherwise `dart:io` will try to add another layer of chunking.
+    //
+    // TODO(nweiz): Do this more cleanly when sdk#27886 is fixed.
+    response = response.change(
+        body: chunkedCoding.decoder.bind(response.read()));
+    httpResponse.headers.set(HttpHeaders.TRANSFER_ENCODING, 'chunked');
+  } else if (response.statusCode >= 200 &&
+      response.statusCode != 204 &&
+      response.statusCode != 304 &&
+      response.contentLength == null &&
+      response.mimeType != 'multipart/byteranges') {
+    // If the response isn't chunked yet and there's no other way to tell its
+    // length, enable `dart:io`'s chunked encoding.
+    httpResponse.headers.set(HttpHeaders.TRANSFER_ENCODING, 'chunked');
+  }
+
   if (!response.headers.containsKey(HttpHeaders.SERVER)) {
     httpResponse.headers.set(HttpHeaders.SERVER, 'dart:io with Shelf');
   }
@@ -149,9 +179,6 @@
     httpResponse.headers.date = new DateTime.now().toUtc();
   }
 
-  // Work around sdk#27660.
-  if (response.isEmpty) httpResponse.headers.chunkedTransferEncoding = false;
-
   return httpResponse
       .addStream(response.read())
       .then((_) => httpResponse.close());
diff --git a/lib/src/body.dart b/lib/src/body.dart
index 448cab8..77c3d1b 100644
--- a/lib/src/body.dart
+++ b/lib/src/body.dart
@@ -23,12 +23,11 @@
   /// encoding was used.
   final Encoding encoding;
 
-  /// If `true`, the stream returned by [read] will be empty.
-  ///
-  /// This may have false negatives, but it won't have false positives.
-  final bool isEmpty;
+  /// The length of the stream returned by [read], or `null` if that can't be
+  /// determined efficiently.
+  final int contentLength;
 
-  Body._(this._stream, this.encoding, this.isEmpty);
+  Body._(this._stream, this.encoding, this.contentLength);
 
   /// Converts [body] to a byte stream and wraps it in a [Body].
   ///
@@ -39,23 +38,25 @@
     if (body is Body) return body;
 
     Stream<List<int>> stream;
-    var isEmpty = false;
+    int contentLength;
     if (body == null) {
-      isEmpty = true;
+      contentLength = 0;
       stream = new Stream.fromIterable([]);
     } else if (body is String) {
-      isEmpty = body.isEmpty;
       if (encoding == null) {
         var encoded = UTF8.encode(body);
         // If the text is plain ASCII, don't modify the encoding. This means
-        // that an encoding of "text/plain" without a charset will stay put.
+        // that an encoding of "text/plain" will stay put.
         if (!_isPlainAscii(encoded, body.length)) encoding = UTF8;
+        contentLength = encoded.length;
         stream = new Stream.fromIterable([encoded]);
       } else {
-        stream = new Stream.fromIterable([encoding.encode(body)]);
+        var encoded = encoding.encode(body);
+        contentLength = encoded.length;
+        stream = new Stream.fromIterable([encoded]);
       }
     } else if (body is List) {
-      isEmpty = body.isEmpty;
+      contentLength = body.length;
       stream = new Stream.fromIterable([DelegatingList.typed(body)]);
     } else if (body is Stream) {
       stream = DelegatingStream.typed(body);
@@ -64,7 +65,7 @@
           'Stream.');
     }
 
-    return new Body._(stream, encoding, isEmpty);
+    return new Body._(stream, encoding, contentLength);
   }
 
   /// Returns whether [bytes] is plain ASCII.
diff --git a/lib/src/message.dart b/lib/src/message.dart
index 3350cb0..1f474fd 100644
--- a/lib/src/message.dart
+++ b/lib/src/message.dart
@@ -5,6 +5,7 @@
 import 'dart:async';
 import 'dart:convert';
 
+import 'package:collection/collection.dart';
 import 'package:http_parser/http_parser.dart';
 
 import 'body.dart';
@@ -13,6 +14,11 @@
 
 Body getBody(Message message) => message._body;
 
+/// The default set of headers for a message created with no body and no
+/// explicit headers.
+final _defaultHeaders = new ShelfUnmodifiableMap<String>(
+    {"content-length": "0"}, ignoreKeyCase: true);
+
 /// Represents logic shared between [Request] and [Response].
 abstract class Message {
   /// The HTTP headers.
@@ -41,7 +47,7 @@
   /// If `true`, the stream returned by [read] won't emit any bytes.
   ///
   /// This may have false negatives, but it won't have false positives.
-  bool get isEmpty => _body.isEmpty;
+  bool get isEmpty => _body.contentLength == 0;
 
   /// Creates a new [Message].
   ///
@@ -144,20 +150,39 @@
 Map<String, String> _adjustHeaders(
     Map<String, String> headers, Body body) {
   var sameEncoding = _sameEncoding(headers, body);
-  if (sameEncoding) return headers ?? const ShelfUnmodifiableMap.empty();
+  if (sameEncoding) {
+    if (body.contentLength == null ||
+        getHeader(headers, 'content-length') ==
+            body.contentLength.toString()) {
+      return headers ?? const ShelfUnmodifiableMap.empty();
+    } else if (body.contentLength == 0 &&
+        (headers == null || headers.isEmpty)) {
+      return _defaultHeaders;
+    }
+  }
 
   var newHeaders = headers == null
       ? new CaseInsensitiveMap<String>()
       : new CaseInsensitiveMap<String>.from(headers);
 
-  if (newHeaders['content-type'] == null) {
-    newHeaders['content-type'] =
-        'application/octet-stream; charset=${body.encoding.name}';
-  } else {
-    var contentType = new MediaType.parse(newHeaders['content-type'])
-        .change(parameters: {'charset': body.encoding.name});
-    newHeaders['content-type'] = contentType.toString();
+  if (!sameEncoding) {
+    if (newHeaders['content-type'] == null) {
+      newHeaders['content-type'] =
+          'application/octet-stream; charset=${body.encoding.name}';
+    } else {
+      var contentType = new MediaType.parse(newHeaders['content-type'])
+          .change(parameters: {'charset': body.encoding.name});
+      newHeaders['content-type'] = contentType.toString();
+    }
   }
+
+  if (body.contentLength != null) {
+    var coding = newHeaders['transfer-encoding'];
+    if (coding == null || equalsIgnoreAsciiCase(coding, 'identity')) {
+      newHeaders['content-length'] = body.contentLength.toString();
+    }
+  }
+
   return newHeaders;
 }
 
diff --git a/lib/src/middleware/add_chunked_encoding.dart b/lib/src/middleware/add_chunked_encoding.dart
new file mode 100644
index 0000000..90abb86
--- /dev/null
+++ b/lib/src/middleware/add_chunked_encoding.dart
@@ -0,0 +1,39 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:collection/collection.dart';
+import 'package:http_parser/http_parser.dart';
+
+import '../middleware.dart';
+
+/// Middleware that adds [chunked transfer coding][] to responses if none of the
+/// following conditions are true:
+///
+/// [chunked transfer coding]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1
+///
+/// * A Content-Length header is provided.
+/// * The Content-Type header indicates the MIME type `multipart/byteranges`.
+/// * The Transfer-Encoding header already includes the `chunked` coding.
+///
+/// This is intended for use by [Shelf adapters][] rather than end-users.
+///
+/// [Shelf adapters]: https://github.com/dart-lang/shelf#adapters
+final addChunkedEncoding = createMiddleware(responseHandler: (response) {
+  if (response.contentLength != null) return response;
+  if (response.statusCode < 200) return response;
+  if (response.statusCode == 204) return response;
+  if (response.statusCode == 304) return response;
+  if (response.mimeType == 'multipart/byteranges') return response;
+
+  // We only check the last coding here because HTTP requires that the chunked
+  // encoding be listed last.
+  var coding = response.headers['transfer-encoding'];
+  if (coding != null && !equalsIgnoreAsciiCase(coding, 'identity')) {
+    return response;
+  }
+
+  return response.change(
+      headers: {'transfer-encoding': 'chunked'},
+      body: chunkedCoding.encoder.bind(response.read()));
+});
diff --git a/lib/src/handlers/logger.dart b/lib/src/middleware/logger.dart
similarity index 100%
rename from lib/src/handlers/logger.dart
rename to lib/src/middleware/logger.dart
diff --git a/lib/src/response.dart b/lib/src/response.dart
index 214b005..a06bba9 100644
--- a/lib/src/response.dart
+++ b/lib/src/response.dart
@@ -44,9 +44,15 @@
   /// This indicates that the request has succeeded.
   ///
   /// [body] is the response body. It may be either a [String], a [List<int>], a
-  /// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
-  /// [encoding] is used to encode it to a [Stream<List<int>>]. It defaults to
-  /// UTF-8.
+  /// [Stream<List<int>>], or `null` to indicate no body.
+  ///
+  /// If the body is a [String], [encoding] is used to encode it to a
+  /// [Stream<List<int>>]. It defaults to UTF-8. If it's a [String], a
+  /// [List<int>], or `null`, the Content-Length header is set automatically
+  /// unless a Transfer-Encoding header is set. Otherwise, it's a
+  /// [Stream<List<int>>] and no Transfer-Encoding header is set, the adapter
+  /// will set the Transfer-Encoding header to "chunked" and apply the chunked
+  /// encoding to the body.
   ///
   /// If [encoding] is passed, the "encoding" field of the Content-Type header
   /// in [headers] will be set appropriately. If there is no existing
@@ -63,9 +69,15 @@
   /// automatically set as the Location header in [headers].
   ///
   /// [body] is the response body. It may be either a [String], a [List<int>], a
-  /// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
-  /// [encoding] is used to encode it to a [Stream<List<int>>]. It defaults to
-  /// UTF-8.
+  /// [Stream<List<int>>], or `null` to indicate no body.
+  ///
+  /// If the body is a [String], [encoding] is used to encode it to a
+  /// [Stream<List<int>>]. It defaults to UTF-8. If it's a [String], a
+  /// [List<int>], or `null`, the Content-Length header is set automatically
+  /// unless a Transfer-Encoding header is set. Otherwise, it's a
+  /// [Stream<List<int>>] and no Transfer-Encoding header is set, the adapter
+  /// will set the Transfer-Encoding header to "chunked" and apply the chunked
+  /// encoding to the body.
   ///
   /// If [encoding] is passed, the "encoding" field of the Content-Type header
   /// in [headers] will be set appropriately. If there is no existing
@@ -82,9 +94,15 @@
   /// automatically set as the Location header in [headers].
   ///
   /// [body] is the response body. It may be either a [String], a [List<int>], a
-  /// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
-  /// [encoding] is used to encode it to a [Stream<List<int>>]. It defaults to
-  /// UTF-8.
+  /// [Stream<List<int>>], or `null` to indicate no body.
+  ///
+  /// If the body is a [String], [encoding] is used to encode it to a
+  /// [Stream<List<int>>]. It defaults to UTF-8. If it's a [String], a
+  /// [List<int>], or `null`, the Content-Length header is set automatically
+  /// unless a Transfer-Encoding header is set. Otherwise, it's a
+  /// [Stream<List<int>>] and no Transfer-Encoding header is set, the adapter
+  /// will set the Transfer-Encoding header to "chunked" and apply the chunked
+  /// encoding to the body.
   ///
   /// If [encoding] is passed, the "encoding" field of the Content-Type header
   /// in [headers] will be set appropriately. If there is no existing
@@ -102,9 +120,15 @@
   /// [headers].
   ///
   /// [body] is the response body. It may be either a [String], a [List<int>], a
-  /// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
-  /// [encoding] is used to encode it to a [Stream<List<int>>]. It defaults to
-  /// UTF-8.
+  /// [Stream<List<int>>], or `null` to indicate no body.
+  ///
+  /// If the body is a [String], [encoding] is used to encode it to a
+  /// [Stream<List<int>>]. It defaults to UTF-8. If it's a [String], a
+  /// [List<int>], or `null`, the Content-Length header is set automatically
+  /// unless a Transfer-Encoding header is set. Otherwise, it's a
+  /// [Stream<List<int>>] and no Transfer-Encoding header is set, the adapter
+  /// will set the Transfer-Encoding header to "chunked" and apply the chunked
+  /// encoding to the body.
   ///
   /// If [encoding] is passed, the "encoding" field of the Content-Type header
   /// in [headers] will be set appropriately. If there is no existing
@@ -141,10 +165,16 @@
   ///
   /// This indicates that the server is refusing to fulfill the request.
   ///
-  /// [body] is the response body. It may be a [String], a [List<int>], a
-  /// [Stream<List<int>>], or `null`. If it's a [String], [encoding] is used to
-  /// encode it to a [Stream<List<int>>]. The default encoding is UTF-8. If it's
-  /// `null` or not passed, a default error message is used.
+  /// [body] is the response body. It may be either a [String], a [List<int>], a
+  /// [Stream<List<int>>], or `null` to indicate no body.
+  ///
+  /// If the body is a [String], [encoding] is used to encode it to a
+  /// [Stream<List<int>>]. It defaults to UTF-8. If it's a [String], a
+  /// [List<int>], or `null`, the Content-Length header is set automatically
+  /// unless a Transfer-Encoding header is set. Otherwise, it's a
+  /// [Stream<List<int>>] and no Transfer-Encoding header is set, the adapter
+  /// will set the Transfer-Encoding header to "chunked" and apply the chunked
+  /// encoding to the body.
   ///
   /// If [encoding] is passed, the "encoding" field of the Content-Type header
   /// in [headers] will be set appropriately. If there is no existing
@@ -161,10 +191,16 @@
   /// This indicates that the server didn't find any resource matching the
   /// requested URI.
   ///
-  /// [body] is the response body. It may be a [String], a [List<int>], a
-  /// [Stream<List<int>>], or `null`. If it's a [String], [encoding] is used to
-  /// encode it to a [Stream<List<int>>]. The default encoding is UTF-8. If it's
-  /// `null` or not passed, a default error message is used.
+  /// [body] is the response body. It may be either a [String], a [List<int>], a
+  /// [Stream<List<int>>], or `null` to indicate no body.
+  ///
+  /// If the body is a [String], [encoding] is used to encode it to a
+  /// [Stream<List<int>>]. It defaults to UTF-8. If it's a [String], a
+  /// [List<int>], or `null`, the Content-Length header is set automatically
+  /// unless a Transfer-Encoding header is set. Otherwise, it's a
+  /// [Stream<List<int>>] and no Transfer-Encoding header is set, the adapter
+  /// will set the Transfer-Encoding header to "chunked" and apply the chunked
+  /// encoding to the body.
   ///
   /// If [encoding] is passed, the "encoding" field of the Content-Type header
   /// in [headers] will be set appropriately. If there is no existing
@@ -181,10 +217,16 @@
   /// This indicates that the server had an internal error that prevented it
   /// from fulfilling the request.
   ///
-  /// [body] is the response body. It may be a [String], a [List<int>], a
-  /// [Stream<List<int>>], or `null`. If it's a [String], [encoding] is used to
-  /// encode it to a [Stream<List<int>>]. The default encoding is UTF-8. If it's
-  /// `null` or not passed, a default error message is used.
+  /// [body] is the response body. It may be either a [String], a [List<int>], a
+  /// [Stream<List<int>>], or `null` to indicate no body.
+  ///
+  /// If the body is a [String], [encoding] is used to encode it to a
+  /// [Stream<List<int>>]. It defaults to UTF-8. If it's a [String], a
+  /// [List<int>], or `null`, the Content-Length header is set automatically
+  /// unless a Transfer-Encoding header is set. Otherwise, it's a
+  /// [Stream<List<int>>] and no Transfer-Encoding header is set, the adapter
+  /// will set the Transfer-Encoding header to "chunked" and apply the chunked
+  /// encoding to the body.
   ///
   /// If [encoding] is passed, the "encoding" field of the Content-Type header
   /// in [headers] will be set appropriately. If there is no existing
@@ -201,9 +243,15 @@
   /// [statusCode] must be greater than or equal to 100.
   ///
   /// [body] is the response body. It may be either a [String], a [List<int>], a
-  /// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
-  /// [encoding] is used to encode it to a [Stream<List<int>>]. The default
-  /// encoding is UTF-8.
+  /// [Stream<List<int>>], or `null` to indicate no body.
+  ///
+  /// If the body is a [String], [encoding] is used to encode it to a
+  /// [Stream<List<int>>]. It defaults to UTF-8. If it's a [String], a
+  /// [List<int>], or `null`, the Content-Length header is set automatically
+  /// unless a Transfer-Encoding header is set. Otherwise, it's a
+  /// [Stream<List<int>>] and no Transfer-Encoding header is set, the adapter
+  /// will set the Transfer-Encoding header to "chunked" and apply the chunked
+  /// encoding to the body.
   ///
   /// If [encoding] is passed, the "encoding" field of the Content-Type header
   /// in [headers] will be set appropriately. If there is no existing
diff --git a/pubspec.yaml b/pubspec.yaml
index 89dbea5..7fdad5e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -8,7 +8,7 @@
 dependencies:
   async: '^1.10.0'
   collection: '^1.5.0'
-  http_parser: '>=1.0.0 <4.0.0'
+  http_parser: '^3.1.0'
   path: '^1.0.0'
   stack_trace: '^1.0.0'
   stream_channel: '^1.0.0'
diff --git a/test/add_chunked_encoding_test.dart b/test/add_chunked_encoding_test.dart
new file mode 100644
index 0000000..4b68df4
--- /dev/null
+++ b/test/add_chunked_encoding_test.dart
@@ -0,0 +1,71 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:shelf/shelf.dart';
+import 'package:test/test.dart';
+
+import 'test_util.dart';
+
+void main() {
+  test('adds chunked encoding with no transfer-encoding header', () async {
+    var response = await _chunkResponse(
+        new Response.ok(new Stream.fromIterable(["hi".codeUnits])));
+    expect(response.headers, containsPair('transfer-encoding', 'chunked'));
+    expect(response.readAsString(), completion(equals("2\r\nhi0\r\n\r\n")));
+  });
+
+  test('adds chunked encoding with transfer-encoding: identity', () async {
+    var response = await _chunkResponse(new Response.ok(
+        new Stream.fromIterable(["hi".codeUnits]),
+        headers: {'transfer-encoding': 'identity'}));
+    expect(response.headers, containsPair('transfer-encoding', 'chunked'));
+    expect(response.readAsString(), completion(equals("2\r\nhi0\r\n\r\n")));
+  });
+
+  test("doesn't add chunked encoding with content length", () async {
+    var response = await _chunkResponse(new Response.ok("hi"));
+    expect(response.headers, isNot(contains('transfer-encoding')));
+    expect(response.readAsString(), completion(equals("hi")));
+  });
+
+  test("doesn't add chunked encoding with status 1xx", () async {
+    var response = await _chunkResponse(
+        new Response(123, body: new Stream.empty()));
+    expect(response.headers, isNot(contains('transfer-encoding')));
+    expect(response.read().toList(), completion(isEmpty));
+  });
+
+  test("doesn't add chunked encoding with status 204", () async {
+    var response = await _chunkResponse(
+        new Response(204, body: new Stream.empty()));
+    expect(response.headers, isNot(contains('transfer-encoding')));
+    expect(response.read().toList(), completion(isEmpty));
+  });
+
+  test("doesn't add chunked encoding with status 304", () async {
+    var response = await _chunkResponse(
+        new Response(204, body: new Stream.empty()));
+    expect(response.headers, isNot(contains('transfer-encoding')));
+    expect(response.read().toList(), completion(isEmpty));
+  });
+
+  test("doesn't add chunked encoding with status 204", () async {
+    var response = await _chunkResponse(
+        new Response(204, body: new Stream.empty()));
+    expect(response.headers, isNot(contains('transfer-encoding')));
+    expect(response.read().toList(), completion(isEmpty));
+  });
+
+  test("doesn't add chunked encoding with status 204", () async {
+    var response = await _chunkResponse(
+        new Response(204, body: new Stream.empty()));
+    expect(response.headers, isNot(contains('transfer-encoding')));
+    expect(response.read().toList(), completion(isEmpty));
+  });
+}
+
+Future<Response> _chunkResponse(Response response) =>
+    addChunkedEncoding((_) => response)(null);
diff --git a/test/message_change_test.dart b/test/message_change_test.dart
index 6113203..638f9dc 100644
--- a/test/message_change_test.dart
+++ b/test/message_change_test.dart
@@ -73,7 +73,8 @@
 
     expect(copy.headers, {
       'test': 'test value',
-      'test2': 'test2 value'
+      'test2': 'test2 value',
+      'content-length': '0'
     });
   });
 
@@ -81,7 +82,7 @@
     var request = factory(headers: {'test': 'test value'});
     var copy = request.change(headers: {'test': 'new test value'});
 
-    expect(copy.headers, {'test': 'new test value'});
+    expect(copy.headers, {'test': 'new test value', 'content-length': '0'});
   });
 
   test('new context values are added', () {
diff --git a/test/message_test.dart b/test/message_test.dart
index 7685896..fee8849 100644
--- a/test/message_test.dart
+++ b/test/message_test.dart
@@ -36,9 +36,10 @@
       expect(message.headers, containsPair('FOO', 'bar'));
     });
 
-    test('null header value returns an empty unmodifiable map', () {
+    test('null header value becomes default', () {
       var message = _createMessage();
-      expect(message.headers, isEmpty);
+      expect(message.headers, equals({'content-length': '0'}));
+      expect(message.headers, containsPair('CoNtEnT-lEnGtH', '0'));
       expect(message.headers, same(_createMessage().headers));
       expect(() => message.headers['h1'] = 'value1', throwsUnsupportedError);
     });
@@ -157,41 +158,63 @@
     });
   });
 
-  group("isEmpty", () {
-    test("is true with a default body and without a content-length header", () {
+  group("content-length", () {
+    test("is 0 with a default body and without a content-length header", () {
       var request = _createMessage();
-      expect(request.isEmpty, isTrue);
+      expect(request.contentLength, 0);
     });
 
-    test("is true with an empty byte body", () {
-      var request = _createMessage(body: []);
-      expect(request.isEmpty, isTrue);
-    });
-
-    test("is true with an empty string body", () {
-      var request = _createMessage(body: '');
-      expect(request.isEmpty, isTrue);
-    });
-
-    test("is false for an empty stream body", () {
-      var request = _createMessage(body: new Stream.empty());
-      expect(request.isEmpty, isFalse);
-    });
-
-    test("is false for a non-empty byte body", () {
+    test("comes from a byte body", () {
       var request = _createMessage(body: [1, 2, 3]);
-      expect(request.isEmpty, isFalse);
+      expect(request.contentLength, 3);
     });
 
-    test("is false for a non-empty string body", () {
-      var request = _createMessage(body: "foo");
-      expect(request.isEmpty, isFalse);
+    test("comes from a string body", () {
+      var request = _createMessage(body: 'foobar');
+      expect(request.contentLength, 6);
     });
 
-    test("is false for a stream body even if a content length is passed", () {
+    test("is set based on byte length for a string body", () {
+      var request = _createMessage(body: 'fööbär');
+      expect(request.contentLength, 9);
+
+      request = _createMessage(body: 'fööbär', encoding: LATIN1);
+      expect(request.contentLength, 6);
+    });
+
+    test("is null for a stream body", () {
+      var request = _createMessage(body: new Stream.empty());
+      expect(request.contentLength, isNull);
+    });
+
+    test("uses the content-length header for a stream body", () {
       var request = _createMessage(
-          body: new Stream.empty(), headers: {'content-length': '0'});
-      expect(request.isEmpty, isFalse);
+          body: new Stream.empty(), headers: {'content-length': '42'});
+      expect(request.contentLength, 42);
+    });
+
+    test("real body length takes precedence over content-length header", () {
+      var request = _createMessage(
+          body: [1, 2, 3], headers: {'content-length': '42'});
+      expect(request.contentLength, 3);
+    });
+
+    test("is null for a chunked transfer encoding", () {
+      var request = _createMessage(
+          body: "1\r\na0\r\n\r\n", headers: {'transfer-encoding': 'chunked'});
+      expect(request.contentLength, isNull);
+    });
+
+    test("is null for a non-identity transfer encoding", () {
+      var request = _createMessage(
+          body: "1\r\na0\r\n\r\n", headers: {'transfer-encoding': 'custom'});
+      expect(request.contentLength, isNull);
+    });
+
+    test("is set for identity transfer encoding", () {
+      var request = _createMessage(
+          body: "1\r\na0\r\n\r\n", headers: {'transfer-encoding': 'identity'});
+      expect(request.contentLength, equals(9));
     });
   });
 
diff --git a/test/shelf_io_test.dart b/test/shelf_io_test.dart
index a3aabb2..3c90ea9 100644
--- a/test/shelf_io_test.dart
+++ b/test/shelf_io_test.dart
@@ -102,6 +102,26 @@
     });
   });
 
+  test('chunked requests are un-chunked', () {
+    _scheduleServer(expectAsync((request) {
+      expect(request.contentLength, isNull);
+      expect(request.method, 'POST');
+      expect(request.headers, isNot(contains(HttpHeaders.TRANSFER_ENCODING)));
+      expect(request.read().toList(), completion(equals([[1, 2, 3, 4]])));
+      return new Response.ok(null);
+    }));
+
+    schedule(() async {
+      var request = new http.StreamedRequest(
+          'POST', Uri.parse('http://localhost:$_serverPort'));
+      request.sink.add([1, 2, 3, 4]);
+      request.sink.close();
+
+      var response = await request.send();
+      expect(response.statusCode, HttpStatus.OK);
+    });
+  });
+
   test('custom response headers are received by the client', () {
     _scheduleServer((request) {
       return new Response.ok('Hello from /',
@@ -335,12 +355,97 @@
     });
   });
 
-  test("doesn't use a chunked transfer-encoding for a response with an empty "
-      "body", () {
-    _scheduleServer((request) => new Response.notModified());
+  group('chunked coding', () {
+    group('is added when the transfer-encoding header is', () {
+      test('unset', () {
+        _scheduleServer((request) {
+          return new Response.ok(new Stream.fromIterable([[1, 2, 3, 4]]));
+        });
 
-    return _scheduleGet().then((response) {
-      expect(response.headers, isNot(contains('transfer-encoding')));
+        return _scheduleGet().then((response) {
+          expect(response.headers,
+              containsPair(HttpHeaders.TRANSFER_ENCODING, 'chunked'));
+          expect(response.bodyBytes, equals([1, 2, 3, 4]));
+        });
+      });
+
+      test('"identity"', () {
+        _scheduleServer((request) {
+          return new Response.ok(new Stream.fromIterable([[1, 2, 3, 4]]),
+              headers: {HttpHeaders.TRANSFER_ENCODING: 'identity'});
+        });
+
+        return _scheduleGet().then((response) {
+          expect(response.headers,
+              containsPair(HttpHeaders.TRANSFER_ENCODING, 'chunked'));
+          expect(response.bodyBytes, equals([1, 2, 3, 4]));
+        });
+      });
+    });
+
+    test('is preserved when the transfer-encoding header is "chunked"', () {
+      _scheduleServer((request) {
+        return new Response.ok(
+            new Stream.fromIterable(["2\r\nhi0\r\n\r\n".codeUnits]),
+            headers: {HttpHeaders.TRANSFER_ENCODING: 'chunked'});
+      });
+
+      return _scheduleGet().then((response) {
+        expect(response.headers,
+            containsPair(HttpHeaders.TRANSFER_ENCODING, 'chunked'));
+        expect(response.body, equals("hi"));
+      });
+    });
+
+    group('is not added when', () {
+      test('content-length is set', () {
+        _scheduleServer((request) {
+          return new Response.ok(new Stream.fromIterable([[1, 2, 3, 4]]),
+              headers: {HttpHeaders.CONTENT_LENGTH: '4'});
+        });
+
+        return _scheduleGet().then((response) {
+          expect(response.headers,
+              isNot(contains(HttpHeaders.TRANSFER_ENCODING)));
+          expect(response.bodyBytes, equals([1, 2, 3, 4]));
+        });
+      });
+
+      test('status code is 1xx', () {
+        _scheduleServer((request) {
+          return new Response(123, body: new Stream.empty());
+        });
+
+        return _scheduleGet().then((response) {
+          expect(response.headers,
+              isNot(contains(HttpHeaders.TRANSFER_ENCODING)));
+          expect(response.body, isEmpty);
+        });
+      });
+
+      test('status code is 204', () {
+        _scheduleServer((request) {
+          return new Response(204, body: new Stream.empty());
+        });
+
+        return _scheduleGet().then((response) {
+          expect(response.headers,
+              isNot(contains(HttpHeaders.TRANSFER_ENCODING)));
+          expect(response.body, isEmpty);
+        });
+      });
+
+      test('status code is 304', () {
+        _scheduleServer((request) {
+          return new Response(304, body: new Stream.empty());
+        });
+
+        return _scheduleGet().then((response) {
+          expect(response.headers,
+              isNot(contains(HttpHeaders.TRANSFER_ENCODING)));
+          expect(response.body, isEmpty);
+        });
+      });
     });
   });
 
@@ -394,13 +499,6 @@
       () => http.get('http://localhost:$_serverPort/', headers: headers));
 }
 
-Future<http.Response> _scheduleHead({Map<String, String> headers}) {
-  if (headers == null) headers = {};
-
-  return schedule/*<Future<http.Response>>*/(
-      () => http.head('http://localhost:$_serverPort/', headers: headers));
-}
-
 Future<http.StreamedResponse> _schedulePost(
     {Map<String, String> headers, String body}) {
   return schedule/*<Future<http.StreamedResponse>>*/(() {