summaryrefslogtreecommitdiff
path: root/ext/http/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'ext/http/lib.rs')
-rw-r--r--ext/http/lib.rs163
1 files changed, 161 insertions, 2 deletions
diff --git a/ext/http/lib.rs b/ext/http/lib.rs
index 312942303..b70bed464 100644
--- a/ext/http/lib.rs
+++ b/ext/http/lib.rs
@@ -1,6 +1,7 @@
// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license.
use bytes::Bytes;
+use cache_control::CacheControl;
use deno_core::error::custom_error;
use deno_core::error::AnyError;
use deno_core::futures::channel::mpsc;
@@ -34,6 +35,9 @@ use deno_core::ResourceId;
use deno_core::StringOrBuffer;
use deno_core::ZeroCopyBuf;
use deno_websocket::ws_create_server_stream;
+use flate2::write::GzEncoder;
+use flate2::Compression;
+use fly_accept_encoding::Encoding;
use hyper::server::conn::Http;
use hyper::service::Service;
use hyper::Body;
@@ -47,6 +51,7 @@ use std::cmp::min;
use std::error::Error;
use std::future::Future;
use std::io;
+use std::io::Write;
use std::mem::replace;
use std::mem::take;
use std::pin::Pin;
@@ -58,6 +63,8 @@ use tokio::io::AsyncRead;
use tokio::io::AsyncWrite;
use tokio::task::spawn_local;
+mod compressible;
+
pub fn init() -> Extension {
Extension::builder()
.js(include_js_files!(
@@ -292,6 +299,7 @@ struct HttpStreamResource {
conn: Rc<HttpConnResource>,
rd: AsyncRefCell<HttpRequestReader>,
wr: AsyncRefCell<HttpResponseWriter>,
+ accept_encoding: RefCell<Encoding>,
cancel_handle: CancelHandle,
}
@@ -305,6 +313,7 @@ impl HttpStreamResource {
conn: conn.clone(),
rd: HttpRequestReader::Headers(request).into(),
wr: HttpResponseWriter::Headers(response_tx).into(),
+ accept_encoding: RefCell::new(Encoding::Identity),
cancel_handle: CancelHandle::new(),
}
}
@@ -381,6 +390,14 @@ async fn op_http_accept(
_ => unreachable!(),
};
+ {
+ let mut accept_encoding = stream.accept_encoding.borrow_mut();
+ *accept_encoding = fly_accept_encoding::parse(request.headers())
+ .ok()
+ .flatten()
+ .unwrap_or(Encoding::Identity);
+ }
+
let method = request.method().to_string();
let headers = req_headers(request);
let url = req_url(request, conn.scheme(), conn.addr());
@@ -497,22 +514,164 @@ async fn op_http_write_headers(
let mut builder = Response::builder().status(status);
+ let mut body_compressible = false;
+ let mut headers_allow_compression = true;
+ let mut vary_header = None;
+ let mut etag_header = None;
+ let mut content_type_header = None;
+
builder.headers_mut().unwrap().reserve(headers.len());
for (key, value) in &headers {
+ match &*key.to_ascii_lowercase() {
+ b"cache-control" => {
+ if let Ok(value) = std::str::from_utf8(value) {
+ if let Some(cache_control) = CacheControl::from_value(value) {
+ // We skip compression if the cache-control header value is set to
+ // "no-transform"
+ if cache_control.no_transform {
+ headers_allow_compression = false;
+ }
+ }
+ } else {
+ headers_allow_compression = false;
+ }
+ }
+ b"content-range" => {
+ // we skip compression if the `content-range` header value is set, as it
+ // indicates the contents of the body were negotiated based directly
+ // with the user code and we can't compress the response
+ headers_allow_compression = false;
+ }
+ b"content-type" => {
+ if !value.is_empty() {
+ content_type_header = Some(value);
+ }
+ }
+ b"content-encoding" => {
+ // we don't compress if a content-encoding header was provided
+ headers_allow_compression = false;
+ }
+ // we store the values of ETag and Vary and skip adding them for now, as
+ // we may need to modify or change.
+ b"etag" => {
+ if !value.is_empty() {
+ etag_header = Some(value);
+ continue;
+ }
+ }
+ b"vary" => {
+ if !value.is_empty() {
+ vary_header = Some(value);
+ continue;
+ }
+ }
+ _ => {}
+ }
builder = builder.header(key.as_ref(), value.as_ref());
}
+ if headers_allow_compression {
+ body_compressible =
+ compressible::is_content_compressible(content_type_header);
+ }
+
let body: Response<Body>;
let new_wr: HttpResponseWriter;
match data {
Some(data) => {
- // If a buffer was passed, we use it to construct a response body.
- body = builder.body(data.into_bytes().into())?;
+ // Set Vary: Accept-Encoding header for direct body response.
+ // Note: we set the header irrespective of whether or not we compress the
+ // data to make sure cache services do not serve uncompressed data to
+ // clients that support compression.
+ let vary_value = if let Some(value) = vary_header {
+ if let Ok(value_str) = std::str::from_utf8(value.as_ref()) {
+ if !value_str.to_lowercase().contains("accept-encoding") {
+ format!("Accept-Encoding, {}", value_str)
+ } else {
+ value_str.to_string()
+ }
+ } else {
+ // the header value wasn't valid UTF8, so it would have been a
+ // problem anyways, so sending a default header.
+ "Accept-Encoding".to_string()
+ }
+ } else {
+ "Accept-Encoding".to_string()
+ };
+ builder = builder.header("vary", &vary_value);
+
+ let accepts_compression = matches!(
+ *stream.accept_encoding.borrow(),
+ Encoding::Brotli | Encoding::Gzip
+ );
+
+ let should_compress =
+ body_compressible && data.len() > 20 && accepts_compression;
+
+ if should_compress {
+ // If user provided a ETag header for uncompressed data, we need to
+ // ensure it is a Weak Etag header ("W/").
+ if let Some(value) = etag_header {
+ if let Ok(value_str) = std::str::from_utf8(value.as_ref()) {
+ if !value_str.starts_with("W/") {
+ builder = builder.header("etag", format!("W/{}", value_str));
+ } else {
+ builder = builder.header("etag", value.as_ref());
+ }
+ } else {
+ builder = builder.header("etag", value.as_ref());
+ }
+ }
+
+ match *stream.accept_encoding.borrow() {
+ Encoding::Brotli => {
+ builder = builder.header("content-encoding", "br");
+ // quality level 6 is based on google's nginx default value for
+ // on-the-fly compression
+ // https://github.com/google/ngx_brotli#brotli_comp_level
+ // lgwin 22 is equivalent to brotli window size of (2**22)-16 bytes
+ // (~4MB)
+ let mut writer =
+ brotli::CompressorWriter::new(Vec::new(), 4096, 6, 22);
+ writer.write_all(&data.into_bytes())?;
+ body = builder.body(writer.into_inner().into())?;
+ }
+ _ => {
+ assert_eq!(*stream.accept_encoding.borrow(), Encoding::Gzip);
+ builder = builder.header("content-encoding", "gzip");
+ // Gzip, after level 1, doesn't produce significant size difference.
+ // Probably the reason why nginx's default gzip compression level is
+ // 1.
+ // https://nginx.org/en/docs/http/ngx_http_gzip_module.html#gzip_comp_level
+ let mut writer = GzEncoder::new(Vec::new(), Compression::new(1));
+ writer.write_all(&data.into_bytes())?;
+ body = builder.body(writer.finish().unwrap().into())?;
+ }
+ }
+ } else {
+ if let Some(value) = etag_header {
+ builder = builder.header("etag", value.as_ref());
+ }
+ // If a buffer was passed, but isn't compressible, we use it to
+ // construct a response body.
+ body = builder.body(data.into_bytes().into())?;
+ }
new_wr = HttpResponseWriter::Closed;
}
None => {
// If no buffer was passed, the caller will stream the response body.
+
+ // TODO(@kitsonk) had compression for streamed bodies.
+
+ // Set the user provided ETag & Vary headers for a streaming response
+ if let Some(value) = etag_header {
+ builder = builder.header("etag", value.as_ref());
+ }
+ if let Some(value) = vary_header {
+ builder = builder.header("vary", value.as_ref());
+ }
+
let (body_tx, body_rx) = Body::channel();
body = builder.body(body_rx)?;
new_wr = HttpResponseWriter::Body(body_tx);