git.iko.soy/rowtype-yoga/purescript-fetch
on main help sign in
Allow request cancellation
commit5148ba42dbdc1d0d6218b32d936e0a8ceb78bcef authorMark Eibes <mark.eibes@gmail.com> dateWed 6 Dec 2023, 23:35 (3 years ago) parent2c6666e
6 files changed+126−26
packages.dhall+2−3 spago.dhall+2 src/Fetch.purs+27−12 test.dhall+9−1 test/Main.purs+2−3 test/Test/FetchSpec.purs+84−7
packages.dhall+2−3
@@ -1,6 +1,5 @@
11let upstream =
2− https://github.com/purescript/package-sets/releases/download/psc-0.15.12-20231116/packages.dhall
3− sha256:cf42bf64e7ee34092175dfd2f2fd8b02a9ab799fdf140e6a5d5a50d580f012f5
2+ https://github.com/purescript/package-sets/releases/download/psc-0.15.12-20231123/packages.dhall
3+ sha256:95ecd1a23305f270971f4d4f2040541559116de6e21aba773d368787f7f1ed35
44
55in upstream
6− with fetch-core.version = "v5.1.0"
spago.dhall+2
@@ -4,9 +4,11 @@
44 , "arraybuffer-types"
55 , "bifunctors"
66 , "effect"
7+ , "either"
78 , "foreign"
89 , "http-methods"
910 , "js-fetch"
11+ , "js-promise"
1012 , "js-promise-aff"
1113 , "maybe"
1214 , "newtype"
src/Fetch.purs+27−12
@@ -14,24 +14,28 @@ module Fetch
1414
1515import Prelude
1616
17+import Data.Either (Either(Left, Right))
1718import Data.HTTP.Method (Method(..))
18−import Effect.Aff (Aff)
19+import Effect.Aff (Aff, effectCanceler, makeAff)
1920import Effect.Class (liftEffect)
20−import JS.Fetch as Core
21−import JS.Fetch.Integrity (Integrity(..))
22−import JS.Fetch.Referrer (Referrer(..))
23−import JS.Fetch.Request as CoreRequest
24−import JS.Fetch.RequestCache (RequestCache(..))
25−import JS.Fetch.RequestCredentials (RequestCredentials(Omit, Include))
26−import JS.Fetch.RequestMode (RequestMode(Cors, NoCors, Navigate))
27−import Fetch.Internal.Headers (lookup, toHeaders, contains)
21+import Fetch.Internal.Headers (contains, lookup, toHeaders)
2822import Fetch.Internal.Request (class ToCoreRequestOptions, class ToCoreRequestOptionsConverter, class ToCoreRequestOptionsHelper, HighlevelRequestOptions, convertHelper, convertImpl, new)
29−import Fetch.Internal.Request as Request
3023import Fetch.Internal.RequestBody (class ToRequestBody, toRequestBody)
3124import Fetch.Internal.Response (Response, ResponseR, arrayBuffer, blob, body, json, text)
32−import Fetch.Internal.Response as Response
25+import JS.Fetch.AbortController (AbortController)
26+import JS.Fetch.Integrity (Integrity(..))
27+import JS.Fetch.Referrer (Referrer(..))
28+import JS.Fetch.RequestCache (RequestCache(..))
29+import JS.Fetch.RequestCredentials (RequestCredentials(Include, Omit))
30+import JS.Fetch.RequestMode (RequestMode(Cors, Navigate, NoCors))
3331import Prim.Row (class Union)
32+import JS.Fetch.AbortController (abort, new, signal) as AbortController
33+import JS.Fetch as Core
34+import JS.Fetch.Request as CoreRequest
35+import Promise (Promise, resolve, thenOrCatch) as Promise
3436import Promise.Aff as Promise.Aff
37+import Fetch.Internal.Request as Request
38+import Fetch.Internal.Response as Response
3539
3640-- | Implementation of `fetch`, see https://developer.mozilla.org/en-US/docs/Web/API/fetch
3741-- |
@@ -64,5 +68,16 @@ fetch
6468 -> Aff Response
6569fetch url r = do
6670 request <- liftEffect $ new url $ Request.convert r
67− cResponse <- Promise.Aff.toAffE $ Core.fetch request
71+ abortController <- AbortController.new # liftEffect
72+ let signal = AbortController.signal abortController
73+ cResponse <- toAbortableAff abortController =<< liftEffect (Core.fetchWithOptions request { signal })
6874 pure $ Response.convert cResponse
75+
76+toAbortableAff :: forall a. AbortController -> Promise.Promise a -> Aff a
77+toAbortableAff abortController p = makeAff \cb -> do
78+ void $ Promise.thenOrCatch
79+ (\a -> Promise.resolve <$> cb (Right a))
80+ (\e -> Promise.resolve <$> cb (Left (Promise.Aff.coerce e)))
81+ p
82+ pure $ effectCanceler (AbortController.abort abortController)
83+
test.dhall+9−1
@@ -5,10 +5,18 @@ in conf
55 , dependencies =
66 conf.dependencies
77 # [ "aff"
8+ , "datetime"
89 , "effect"
910 , "either"
11+ , "exceptions"
1012 , "spec"
11− , "spec-discovery"
1213 , "strings"
14+ , "node-buffer"
15+ , "node-event-emitter"
16+ , "node-http"
17+ , "node-net"
18+ , "node-streams"
19+ , "partial"
20+ , "refs"
1321 ]
1422 }
test/Main.purs+2−3
@@ -4,11 +4,10 @@ import Prelude
44
55import Effect (Effect)
66import Effect.Aff (launchAff_)
7−import Test.Spec.Discovery (discover)
87import Test.Spec.Reporter.Console (consoleReporter)
98import Test.Spec.Runner (runSpec)
9+import Test.FetchSpec (spec) as FetchSpec
1010
1111main :: Effect Unit
1212main = launchAff_ do
13− specs <- discover "\\..*Spec"
14− runSpec [ consoleReporter ] specs
13+ runSpec [ consoleReporter ] FetchSpec.spec
test/Test/FetchSpec.purs+84−7
@@ -3,7 +3,7 @@ module Test.FetchSpec where
33import Prelude
44
55import Data.ArrayBuffer.Types (Uint8Array)
6−import Data.Maybe (Maybe(..))
6+import Data.Maybe (Maybe(..), isNothing)
77import Fetch (Method(..), Referrer(..), RequestMode(..), fetch)
88import Fetch as Fetch
99import JS.Fetch.Duplex (Duplex(..))
@@ -11,9 +11,30 @@ import Foreign (unsafeFromForeign)
1111import Test.Spec (Spec, describe, it)
1212import Test.Spec.Assertions (shouldEqual, shouldSatisfy)
1313import Web.Streams.ReadableStream (ReadableStream)
14−import Effect.Aff (attempt)
15−import Data.Either (Either(Left), isLeft)
16−import Data.Bifunctor (bimap)
14+import Effect.Aff (attempt, forkAff, launchAff_)
15+import Data.Either (blush)
16+import Effect.Aff (delay, joinFiber, killFiber) as Aff
17+import Data.Time.Duration (Milliseconds(Milliseconds))
18+import Effect.Exception (Error, error)
19+import Effect.Exception (message) as Exception
20+import Effect.Ref (new, read, write) as Ref
21+import Effect.Class (liftEffect)
22+import Node.HTTP as HTTP
23+import Node.EventEmitter (once_)
24+import Node.HTTP.Server as Server
25+import Node.Net.Server (listenTcp)
26+import Node.HTTP.OutgoingMessage as OM
27+import Node.Net.Server as NetServer
28+import Node.HTTP.IncomingMessage as IM
29+import Node.HTTP.ServerResponse as ServerResponse
30+import Partial.Unsafe (unsafeCrashWith)
31+import Node.Encoding (Encoding(UTF8))
32+import Node.Stream as Stream
33+import Node.HTTP.Types (HttpServer', IMServer, IncomingMessage, ServerResponse)
34+import Effect (Effect)
35+import Node.HTTP.Server (closeAllConnections)
36+import Effect.Ref (Ref)
37+
1738
1839-- | Returns a new ReadableStream that allows the content of the Blob to be read.
1940foreign import helloWorldStream :: ReadableStream Uint8Array
@@ -69,6 +90,62 @@ spec =
6990 Fetch.contains "Content-Length" headers `shouldEqual` true
7091 status `shouldEqual` 200
7192
72− it "should catch errors" do
73− res <- attempt $ fetch "https://google.com/404" {}
74− (res # bimap show (const unit)) `shouldEqual` Left "NetworkError"
93+ it "can be cancelled" do
94+ requestCancelledRef <- Ref.new Nothing # liftEffect
95+ -- Start a server that records our cancellation
96+ server requestCancelledRef # liftEffect
97+ requestFiber <- forkAff $ fetch "http://localhost:54045/" {}
98+ Aff.delay (40.0 # Milliseconds)
99+ requestCancelled <- Ref.read requestCancelledRef # liftEffect
100+ requestCancelled `shouldSatisfy` isNothing
101+ -- Cancel the request
102+ Aff.killFiber (error "Fetch Abort Test") requestFiber
103+ result <- attempt $ Aff.joinFiber requestFiber
104+ (Exception.message <$> blush result) `shouldEqual` (Just "Fetch Abort Test")
105+ Aff.delay (80.0 # Milliseconds)
106+ requestCancelled <- Ref.read requestCancelledRef # liftEffect
107+ (Exception.message <$> requestCancelled) `shouldEqual` (Just "aborted")
108+ where
109+
110+ -- | A server that will respond to a request after 100ms
111+ -- | it records the first error on the request stream in the ref
112+ -- | Adapted from: [node-http](https://github.com/purescript-node/purescript-node-http/blob/master/test/Main.purs)
113+ server ref = do
114+ server <- HTTP.createServer
115+ server # once_ Server.requestH (respond ref (killServer server))
116+ let netServer = Server.toNetServer server
117+ listenTcp netServer { host: "localhost", port: 54045 }
118+ where
119+
120+ respond :: Ref (Maybe Error) -> Effect Unit -> IncomingMessage IMServer -> ServerResponse -> Effect Unit
121+ respond ref closeServer req res = do
122+ ServerResponse.setStatusCode 200 res
123+ let
124+ im = IM.toReadable req
125+ om = ServerResponse.toOutgoingMessage res
126+ outputStream = OM.toWriteable om
127+
128+ -- Handle errors on the input stream
129+ -- and save them to the ref
130+ im # once_ Stream.errorH \err -> do
131+ whenM (Stream.destroyed im) do
132+ Ref.write (Just err) ref # liftEffect
133+
134+ case IM.method req of
135+ "GET" -> launchAff_ do
136+ -- Let's pretend this is answering slowly
137+ Aff.delay (100.0 # Milliseconds)
138+ void $ Stream.writeString outputStream UTF8 "ok" # liftEffect
139+ Stream.end outputStream # liftEffect
140+ _ ->
141+ unsafeCrashWith "Unexpected HTTP method"
142+ launchAff_ do
143+ Aff.delay (110.0 # Milliseconds)
144+ closeServer # liftEffect
145+
146+
147+ killServer :: forall transmissionType. HttpServer' transmissionType -> Effect Unit
148+ killServer s = do
149+ let ns = Server.toNetServer s
150+ closeAllConnections s
151+ NetServer.close ns