-
Notifications
You must be signed in to change notification settings - Fork 17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Chunked responses #107
Chunked responses #107
Changes from 1 commit
429ce17
7892229
3035c6e
03fa2b1
d0f8f36
3d99544
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
# Binary Example | ||
|
||
This is a basic example of sending binary data. It serves an image file as | ||
binary data on any URL. | ||
|
||
To run the server, run: | ||
|
||
```bash | ||
make example EXAMPLE=Binary | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
"use strict"; | ||
|
||
exports.stagger = function (start) { | ||
return function (end) { | ||
return function (delay) { | ||
var stream = new require('stream').Readable(); | ||
stream._read = function () {}; | ||
stream.push(start); | ||
setTimeout(function () { | ||
stream.push(end); | ||
stream.push(null); | ||
}, delay); | ||
return stream; | ||
}; | ||
}; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
module Examples.Chunked.Main where | ||
|
||
import Prelude | ||
|
||
import Effect.Console as Console | ||
import HTTPure as HTTPure | ||
import Node.Stream as Stream | ||
|
||
-- | Serve the example server on this port | ||
port :: Int | ||
port = 8091 | ||
|
||
-- | Shortcut for `show port` | ||
portS :: String | ||
portS = show port | ||
|
||
-- | Return a readable stream that emits the first string, then the second | ||
-- | string, with a delay in between given by the third argument | ||
foreign import stagger :: String -> String -> Int -> Stream.Readable () | ||
|
||
-- | Say 'hello world!' in chunks when run | ||
sayHello :: HTTPure.Request -> HTTPure.ResponseM | ||
sayHello _ = HTTPure.ok $ HTTPure.Chunked $ stagger "hello " "world!" 1000 | ||
|
||
-- | Boot up the server | ||
main :: HTTPure.ServerM | ||
main = HTTPure.serve port sayHello do | ||
Console.log $ " ┌────────────────────────────────────────────┐" | ||
Console.log $ " │ Server now up on port " <> portS <> " │" | ||
Console.log $ " │ │" | ||
Console.log $ " │ To test, run: │" | ||
Console.log $ " │ > curl -Nv localhost:" <> portS <> " │" | ||
Console.log $ " │ # => ... │" | ||
Console.log $ " │ # => < Transfer-Encoding: chunked │" | ||
Console.log $ " │ # => ... │" | ||
Console.log $ " │ # => hello │" | ||
Console.log $ " │ (1 second pause) │" | ||
Console.log $ " │ # => world! │" | ||
Console.log $ " └────────────────────────────────────────────┘" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
# Chunked Example | ||
|
||
This is a basic example of sending chunked data. It will return 'hello world' | ||
in two separate chunks spaced a second apart on any URL. | ||
|
||
To run the example server, run: | ||
|
||
```bash | ||
make example EXAMPLE=Chunked | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,14 @@ | ||
module HTTPure.Body | ||
( class Body | ||
, Chunked(..) | ||
, additionalHeaders | ||
, read | ||
, size | ||
, write | ||
) where | ||
|
||
import Prelude | ||
|
||
import Data.Either as Either | ||
import Data.Maybe as Maybe | ||
import Effect as Effect | ||
import Effect.Aff as Aff | ||
import Effect.Ref as Ref | ||
|
@@ -17,45 +17,67 @@ import Node.Encoding as Encoding | |
import Node.HTTP as HTTP | ||
import Node.Stream as Stream | ||
|
||
import HTTPure.Headers as Headers | ||
|
||
newtype Chunked = Chunked (Stream.Readable ()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have to use a newtype here, because typeclass instances don't support row definitions in their heads, and Reference: purescript/purescript#2196 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was just thinking, as another approach, instead of this, we work towards the API you mentioned in #104 (comment) and we could make a typeclass instance for something like this: (String -> Effect.Effect Unit) -> Aff.Aff Unit That would lead to an API that looks more like what you mentioned: router _ = HTTPure.ok \writeData -> do
writeData "hello"
writeData "world" I'd want to figure out some clean API around sending strings or binary data with this if we go this route. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In my opinion there should be an API for both streams and callback functions. Stream API will be useful when dealing with files on disk, and it could just use You can declare separate typeclass instances for functions with different parameter types, so it would be possible to support callback functions that generate strings and callback functions that generate binary chunks without newtype wrappers. (Although I'm not sure whether it would be actually good to have newtype wrappers in this case, to get better type mismatch errors.) The best possible signatures for the callback functions need to be worked out. There might e.g. be some state that the callback function needs to update, and supporting this would be nice without the user having to resort to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ... and it might be best to leave defining the callback API for later when there's actually a real use case for it. At least I don't have a use case in mind at the moment, the only thing I need is sane serving of static files. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I agree, both is good and I can revisit the callback API later. I would like to implement it before we release 1.0, but I'm fine with doing another minor release without it in the meantime. Also, per purescript/purescript#1510, there is after all a way to declare the typeclass for the stream without the newtype wrapper, so I'll get that change done in this PR. |
||
|
||
-- | Types that implement the `Body` class can be used as a body to an HTTPure | ||
-- | response, and can be used with all the response helpers. | ||
class Body b where | ||
|
||
-- | Given a body value, return an effect that maybe calculates a size. | ||
-- | TODO: This is a `Maybe` to support chunked transfer encoding. We still | ||
-- | need to add code to send the body using chunking if the effect resolves a | ||
-- | `Maybe.Nothing`. | ||
size :: b -> Effect.Effect (Maybe.Maybe Int) | ||
-- | Return any additional headers that need to be sent with this body type. | ||
-- | Things like `Content-Type`, `Content-Length`, and `Transfer-Encoding`. | ||
additionalHeaders :: b -> Effect.Effect Headers.Headers | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be a nice addition to document somewhere whether the user can override these additional headers by using e.g. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can do; in fact, they cannot be overridden with the current implementation but I should reverse that. For now, I'll document it here; I have a ticket open to revisit the guides (#106) before we release the next version and I'll make sure to add a note there about overriding default headers |
||
|
||
-- | Given a body value and a Node HTTP `Response` value, write the body value | ||
-- | to the Node response. | ||
write :: b -> HTTP.Response -> Aff.Aff Unit | ||
|
||
-- | The instance for `String` will convert the string to a buffer first in | ||
-- | order to determine it's size. This is to properly handle UTF-8 characters | ||
-- | in the string. Writing is simply implemented by writing the string to the | ||
-- | order to determine it's additional headers. This is to ensure that the | ||
-- | `Content-Length` header properly accounts for UTF-8 characters in the | ||
-- | string. Writing is simply implemented by writing the string to the | ||
-- | response stream and closing the response stream. | ||
instance bodyString :: Body String where | ||
size body = Buffer.fromString body Encoding.UTF8 >>= size | ||
|
||
additionalHeaders body = | ||
Buffer.fromString body Encoding.UTF8 >>= additionalHeaders | ||
|
||
write body response = Aff.makeAff \done -> do | ||
let stream = HTTP.responseAsStream response | ||
_ <- Stream.writeString stream Encoding.UTF8 body $ pure unit | ||
_ <- Stream.end stream $ pure unit | ||
done $ Either.Right unit | ||
pure Aff.nonCanceler | ||
|
||
-- | The instance for `Buffer` is trivial--to calculate size, we use | ||
-- | `Buffer.size`, and to send the response, we just write the buffer to the | ||
-- | stream and end the stream. | ||
-- | The instance for `Buffer` is trivial--we add a `Content-Length` header | ||
-- | using `Buffer.size`, and to send the response, we just write the buffer to | ||
-- | the stream and end the stream. | ||
instance bodyBuffer :: Body Buffer.Buffer where | ||
size = Buffer.size >>> map Maybe.Just | ||
|
||
additionalHeaders buf = do | ||
size <- Buffer.size buf | ||
pure $ Headers.header "Content-Length" $ show size | ||
|
||
write body response = Aff.makeAff \done -> do | ||
let stream = HTTP.responseAsStream response | ||
_ <- Stream.write stream body $ pure unit | ||
_ <- Stream.end stream $ pure unit | ||
done $ Either.Right unit | ||
pure Aff.nonCanceler | ||
|
||
-- | This instance can be used to send chunked data. Here, we add a | ||
-- | `Transfer-Encoding` header to indicate chunked data. To write the data, we | ||
-- | simply pipe the newtype-wrapped `Stream` to the response. | ||
instance bodyChunked :: Body Chunked where | ||
|
||
additionalHeaders _ = pure $ Headers.header "Transfer-Encoding" "chunked" | ||
|
||
write (Chunked body) response = Aff.makeAff \done -> do | ||
_ <- Stream.pipe body $ HTTP.responseAsStream response | ||
Stream.onEnd body $ done $ Either.Right unit | ||
pure Aff.nonCanceler | ||
|
||
-- | Extract the contents of the body of the HTTP `Request`. | ||
read :: HTTP.Request -> Aff.Aff String | ||
read request = Aff.makeAff \done -> do | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Coming originally from Elm, I cringe on examples (or any code) that require FFI. Would it make this example harder to comprehend or its point unclearer to e.g. read a file or run an external process to get the streaming response body?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, I totally 100% agree with you, and was really hesitant to do this. The reason I decided on this instead of reading a file were:
Creating a custom stream is something that
purescript-node-streams
should probably support. I'm waiting for a response to support custom stream creation purescript-node/purescript-node-streams#19; if they accept a PR to add custom streams to the API (since it's part of the Node API, I expect that they will) then we'll be able to get rid of the FFI code and go full native.I didn't want to use files as the example here, because once the file helpers are in place we will want folks to bias towards those over using the
Stream
API. Having an example that directly contradicts that will be confusing (this is probably true of the Binary example as well; once the file helpers are in place, I'd like to revisit that example and use something that isn't a file).Those points said, I think a nice compromise could be using an external process. I'll definitely look into making that change.