Skip to content

Commit

Permalink
New: requestObserver for Ajax requests; Also some fixes:
Browse files Browse the repository at this point in the history
- Fix: Ajax error message is not actually available
- Fix: delay xhr.open() so that readyStateChange fires with readyState = 1
  • Loading branch information
raquo committed Dec 30, 2020
1 parent c425fcc commit 24a57ee
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 22 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,8 @@ val $request = AjaxEventStream.get(
val $bytesLoaded = $progress.map2((xhr, ev) => ev.loaded)
```

In a similar manner, you can pass a `requestObserver` that will be called with the newly created `dom.XMLHttpRequest` just before the request is sent. This way you can save the pending request into a Var and e.g. `abort()` it if needed.

Warning: dom.XmlHttpRequest is an ugly, imperative JS construct. We set event callbacks for onload, onerror, onabort, ontimeout, and if requested, also for onprogress and onreadystatechange. Make sure you don't override Airstream's listeners, or this stream will not work properly.


Expand Down
123 changes: 101 additions & 22 deletions src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ import scala.scalajs.js
*
* @see [[dom.raw.XMLHttpRequest]] for a description of the parameters
*
* @param progressObserver - optional, pass Observer.empty if not needed.
* @param readyStateChangeObserver - optional, pass Observer.empty if not needed.
* @param requestObserver - called just before the request is sent
* @param progressObserver - called when progress is reported
* @param readyStateChangeObserver - called when readyState changes
*/
class AjaxEventStream(
method: String,
Expand All @@ -37,6 +38,7 @@ class AjaxEventStream(
headers: Map[String, String],
withCredentials: Boolean,
responseType: String,
requestObserver: Observer[dom.XMLHttpRequest] = Observer.empty,
progressObserver: Observer[(dom.XMLHttpRequest, dom.ProgressEvent)] = Observer.empty,
readyStateChangeObserver: Observer[dom.XMLHttpRequest] = Observer.empty
) extends EventStream[dom.XMLHttpRequest] {
Expand All @@ -46,7 +48,7 @@ class AjaxEventStream(
private var pendingRequest: Option[dom.XMLHttpRequest] = None

override protected[this] def onStart(): Unit = {
val request = AjaxEventStream.openRequest(method, url, timeout, headers, withCredentials, responseType)
val request = AjaxEventStream.initRequest(timeout, headers, withCredentials, responseType)

pendingRequest = Some(request)

Expand All @@ -64,14 +66,19 @@ class AjaxEventStream(
if ((status >= 200 && status < 300) || status == 304)
new Transaction(fireValue(request, _))
else
new Transaction(fireError(AjaxError(request, s"Bad HTTP response status code: $status"), _))
new Transaction(fireError(AjaxError(request, s"Ajax request failed: $status ${request.statusText}"), _))
}
}

request.onerror = (ev: dom.ErrorEvent) => {
request.onerror = (ev: dom.Event) => {
if (pendingRequest.contains(request)) {
pendingRequest = None
new Transaction(fireError(AjaxError(request, ev.message), _))

// @TODO I can't figure out how to get a detailed error message in this case.
// - `ev` is not actually a dom.ErrorEvent, but a useless dom.ProgressEvent
// - Reasons could be network, DNS, CORS, etc.

new Transaction(fireError(AjaxError(request, s"Ajax request failed: unknown reason."), _))
}
}

Expand Down Expand Up @@ -107,7 +114,12 @@ class AjaxEventStream(
}
}

AjaxEventStream.sendRequest(request, data)
if (requestObserver != Observer.empty) {
requestObserver.onNext(request)
}

// Actually initiate the network request
AjaxEventStream.sendRequest(request, method, url, data)
}

/** This stream will emit at most one event per request regardless of the outcome.
Expand All @@ -129,13 +141,20 @@ class AjaxEventStream(
object AjaxEventStream {

/** A more detailed version of [[dom.ext.AjaxException]] (no relation) */
sealed abstract class AjaxStreamException(val xhr: dom.XMLHttpRequest) extends Exception
sealed abstract class AjaxStreamException(val xhr: dom.XMLHttpRequest, message: String) extends Exception(message)

final case class AjaxError(override val xhr: dom.XMLHttpRequest, message: String) extends AjaxStreamException(xhr, message)

final case class AjaxTimeout(override val xhr: dom.XMLHttpRequest) extends AjaxStreamException(xhr, "Ajax request timed out.")

final case class AjaxError(override val xhr: dom.XMLHttpRequest, message: String) extends AjaxStreamException(xhr)
final case class AjaxAbort(override val xhr: dom.XMLHttpRequest) extends AjaxStreamException(xhr, "Ajax request was aborted.")

final case class AjaxTimeout(override val xhr: dom.XMLHttpRequest) extends AjaxStreamException(xhr)
// @TODO[API] I'm not sure that creating an Ajax request should result in a stream of responses.
// - Another alternative is that it should result in an object that exposes several streams, e.g. responseStream,
// progressStream, etc. - but it seems that with such an approach the usage would get more complicated as
// it would be hard to manage timing and laziness properly (e.g. for progressStream)

final case class AjaxAbort(override val xhr: dom.XMLHttpRequest) extends AjaxStreamException(xhr)
// @TODO[API] Consider API like AjaxEventStream(_.GET, url, ...) using something like dom.experimental.HttpMethod

/**
* Returns an [[EventStream]] that performs an HTTP `GET` request.
Expand All @@ -149,10 +168,22 @@ object AjaxEventStream {
headers: Map[String, String] = Map.empty,
withCredentials: Boolean = false,
responseType: String = "",
requestObserver: Observer[dom.XMLHttpRequest] = Observer.empty,
progressObserver: Observer[(dom.XMLHttpRequest, dom.ProgressEvent)] = Observer.empty,
readyStateChangeObserver: Observer[dom.XMLHttpRequest] = Observer.empty
): AjaxEventStream = {
new AjaxEventStream("GET", url, data, timeout, headers, withCredentials, responseType, progressObserver, readyStateChangeObserver)
new AjaxEventStream(
"GET",
url,
data,
timeout,
headers,
withCredentials,
responseType,
requestObserver,
progressObserver,
readyStateChangeObserver
)
}

/**
Expand All @@ -167,10 +198,22 @@ object AjaxEventStream {
headers: Map[String, String] = Map.empty,
withCredentials: Boolean = false,
responseType: String = "",
requestObserver: Observer[dom.XMLHttpRequest] = Observer.empty,
progressObserver: Observer[(dom.XMLHttpRequest, dom.ProgressEvent)] = Observer.empty,
readyStateChangeObserver: Observer[dom.XMLHttpRequest] = Observer.empty
): AjaxEventStream = {
new AjaxEventStream("POST", url, data, timeout, headers, withCredentials, responseType, progressObserver, readyStateChangeObserver)
new AjaxEventStream(
"POST",
url,
data,
timeout,
headers,
withCredentials,
responseType,
requestObserver,
progressObserver,
readyStateChangeObserver
)
}

/**
Expand All @@ -185,10 +228,22 @@ object AjaxEventStream {
headers: Map[String, String] = Map.empty,
withCredentials: Boolean = false,
responseType: String = "",
requestObserver: Observer[dom.XMLHttpRequest] = Observer.empty,
progressObserver: Observer[(dom.XMLHttpRequest, dom.ProgressEvent)] = Observer.empty,
readyStateChangeObserver: Observer[dom.XMLHttpRequest] = Observer.empty
): AjaxEventStream = {
new AjaxEventStream("PUT", url, data, timeout, headers, withCredentials, responseType, progressObserver, readyStateChangeObserver)
new AjaxEventStream(
"PUT",
url,
data,
timeout,
headers,
withCredentials,
responseType,
requestObserver,
progressObserver,
readyStateChangeObserver
)
}

/**
Expand All @@ -203,10 +258,22 @@ object AjaxEventStream {
headers: Map[String, String] = Map.empty,
withCredentials: Boolean = false,
responseType: String = "",
requestObserver: Observer[dom.XMLHttpRequest] = Observer.empty,
progressObserver: Observer[(dom.XMLHttpRequest, dom.ProgressEvent)] = Observer.empty,
readyStateChangeObserver: Observer[dom.XMLHttpRequest] = Observer.empty
): AjaxEventStream = {
new AjaxEventStream("PATCH", url, data, timeout, headers, withCredentials, responseType, progressObserver, readyStateChangeObserver)
new AjaxEventStream(
"PATCH",
url,
data,
timeout,
headers,
withCredentials,
responseType,
requestObserver,
progressObserver,
readyStateChangeObserver
)
}

/**
Expand All @@ -221,43 +288,55 @@ object AjaxEventStream {
headers: Map[String, String] = Map.empty,
withCredentials: Boolean = false,
responseType: String = "",
requestObserver: Observer[dom.XMLHttpRequest] = Observer.empty,
progressObserver: Observer[(dom.XMLHttpRequest, dom.ProgressEvent)] = Observer.empty,
readyStateChangeObserver: Observer[dom.XMLHttpRequest] = Observer.empty
): AjaxEventStream = {
new AjaxEventStream("DELETE", url, data, timeout, headers, withCredentials, responseType, progressObserver, readyStateChangeObserver)
new AjaxEventStream(
"DELETE",
url,
data,
timeout,
headers,
withCredentials,
responseType,
requestObserver,
progressObserver,
readyStateChangeObserver
)
}

/** Initializes and configures the XmlHttpRequest. This does not cause any network activity.
*
* Note: `data` is added later, when actually sending the request.
* Note: after initializing the request, you need to openRequest(), and then sendRequest()
*
* AjaxEventStream already does this internally. This is provided as a building block for custom logic.
*/
def openRequest(
method: String,
url: String,
def initRequest(
timeout: Int = 0,
headers: Map[String, String] = Map.empty,
withCredentials: Boolean = false,
responseType: String = ""
): dom.XMLHttpRequest = {
val request = new dom.XMLHttpRequest
request.open(method, url)
request.responseType = responseType
request.timeout = timeout.toDouble
request.withCredentials = withCredentials
headers.foreach(Function.tupled(request.setRequestHeader))
request
}

/** Initiates network request. The request should be configured with all the callbacks by this point.
/** The request should be initialized and configured with all the callbacks by this point.
*
* AjaxEventStream already does this internally. This is provided as a building block for custom logic.
*/
def sendRequest(
request: dom.XMLHttpRequest,
method: String,
url: String,
data: dom.ext.Ajax.InputData = null
): Unit = {
request.open(method, url)
if (data == null) request.send() else request.send(data)
}
}

0 comments on commit 24a57ee

Please sign in to comment.