From 694e53d9b0cc29b279a53f5f6cb3c2285ae12fcf Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Sat, 12 Dec 2020 18:57:28 -0800 Subject: [PATCH 01/42] New: Var.updater and Var.tryUpdater. Fixes #41 --- README.md | 21 ++- .../com/raquo/airstream/signal/Var.scala | 47 +++++- .../com/raquo/airstream/signal/VarSpec.scala | 135 ++++++++++++++++++ 3 files changed, 196 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 84904ad5..67531a82 100644 --- a/README.md +++ b/README.md @@ -496,7 +496,26 @@ Creating a Var is straightforward: `Var(initialValue)`, `Var.fromTry(tryValue)`. You can update a Var using one of its methods: `set(value)`, `setTry(Try(value))`, `update(currentValue => nextValue)`, `tryUpdate(currentValueTry => Try(nextValue))`. Note that `update` will throw if the Var's current value is an error (thus `tryUpdate`). -Every Var also provides an Observer (`.writer`) that you can use where an Observer is expected, or if you want to provide your code with write-only access to a Var. +##### Observers Feeding into Var + +Every Var provides a `writer` which is an Observer that writes input values into the Var. It may be useful to provide your code with write-only access to a Var, or to a subset of the data in the Var by means of the Observer's `contramap` method. + +In addition to `writer`, Var also offers `updater`s, making it easy to create an Observer that updates the Var based on both the Observer's input value and the Var's current value: + +```scala +val v = Var(List(1, 2, 3)) +val adder = v.updater[Int]((currValue, nextInput) => currValue :+ nextInput) + +adder.onNext(4) +v.now() // List(1, 2, 3, 4) + +val inputStream: EventStream[Int] = ??? + +inputStream.foreach(adder) +inputStream --> adder // Laminar syntax +``` + +`updater` will fail to update if the Var is in a failed state, for those cases we have `tryUpdater`. ##### Reading Values from a Var diff --git a/src/main/scala/com/raquo/airstream/signal/Var.scala b/src/main/scala/com/raquo/airstream/signal/Var.scala index b718fc2e..bc40365e 100644 --- a/src/main/scala/com/raquo/airstream/signal/Var.scala +++ b/src/main/scala/com/raquo/airstream/signal/Var.scala @@ -4,7 +4,7 @@ import com.raquo.airstream.core.{Observer, Transaction} import com.raquo.airstream.signal.Var.VarSignal import com.raquo.airstream.util.hasDuplicateTupleKeys -import scala.util.{Success, Try} +import scala.util.{Failure, Success, Try} /** Var is a container for a Writeable Signal – sort of like EventBus, but for Signals. * @@ -24,6 +24,45 @@ class Var[A] private(private[this] var currentValue: Try[A]) { new Transaction(setCurrentValue(nextTry, _)) } + /** An observer much like writer, but can compose input events with the current value of the var, for example: + * + * val v = Var(List(1, 2, 3)) + * val appender = v.updater((acc, nextItem) => acc :+ nextItem) + * appender.onNext(4) // v now contains List(1, 2, 3, 4) + * + * @param mod (currValue, nextInput) => nextValue + */ + def updater[B](mod: (A, B) => A): Observer[B] = Observer.fromTry { case nextInputTry => + new Transaction(trx => nextInputTry match { + case Success(nextInput) => + val unsafeValue = try { + now() + } catch { + case err: Throwable => + throw new Exception("Unable to update a failed Var. Consider Var#tryUpdater instead.", err) + } + val nextValue = Try(mod(unsafeValue, nextInput)) // this does catch exceptions in mod + setCurrentValue(nextValue, trx) + case Failure(err) => + setCurrentValue(Failure[A](err), trx) + }) + } + + // @TODO[Scala3] When we don't need 2.12, remove 'case' from all PartialFunction instances that don't need it (e.g. Observer.fromTry) + + /** @param mod (currValue, nextInput) => nextValue + * Note: Must not throw! + */ + def tryUpdater[B](mod: (Try[A], B) => Try[A]): Observer[B] = Observer.fromTry { case nextInputTry => + new Transaction(trx => nextInputTry match { + case Success(nextInput) => + val nextValue = mod(currentValue, nextInput) + setCurrentValue(nextValue, trx) + case Failure(err) => + setCurrentValue(Failure[A](err), trx) + }) + } + @inline def set(value: A): Unit = writer.onNext(value) @inline def setTry(tryValue: Try[A]): Unit = writer.onTry(tryValue) @@ -33,10 +72,6 @@ class Var[A] private(private[this] var currentValue: Try[A]) { /** @param mod Note: guarded against exceptions * @throws Exception if currentValue is a Failure */ def update(mod: A => A): Unit = { - // setCurrentValue(currentValue.fold[Try[A]]( - // err => throw VarUpdateError(cause = err), - // value => Try(mod(value)) - // )) //println(s"> init trx from Var.update") new Transaction(trx => { val unsafeValue = try { @@ -55,7 +90,7 @@ class Var[A] private(private[this] var currentValue: Try[A]) { def tryUpdate(mod: Try[A] => Try[A]): Unit = { //println(s"> init trx from Var.tryUpdate") new Transaction(trx => { - val nextValue = mod(currentValue) // Note: this does catch exceptions in mod(unsafeValue) + val nextValue = mod(currentValue) setCurrentValue(nextValue, trx) }) } diff --git a/src/test/scala/com/raquo/airstream/signal/VarSpec.scala b/src/test/scala/com/raquo/airstream/signal/VarSpec.scala index e2932e37..fc8e35e9 100644 --- a/src/test/scala/com/raquo/airstream/signal/VarSpec.scala +++ b/src/test/scala/com/raquo/airstream/signal/VarSpec.scala @@ -594,4 +594,139 @@ class VarSpec extends UnitSpec with BeforeAndAfter { reset() } + + it("updater") { + + val err1 = new Exception("err1") + + val err2 = new Exception("err2") + + val v = Var(List(1)) + + val adder = v.updater[Int]((acc, newItem) => acc :+ newItem) + + val failedUpdater = v.updater[Int]((_, _) => throw err1) + + val doubler = v.updater[Unit]((acc, _) => acc ++ acc) + + // -- + + v.now() shouldBe List(1) + + // -- + + adder.onNext(2) + + v.now() shouldBe List(1, 2) + + // -- + + adder.onError(err2) + + v.tryNow() shouldBe Failure(err2) + + // -- + + adder.onNext(3) + + v.tryNow() shouldBe Failure(err2) + + // -- + + v.set(List(0)) + + v.now() shouldBe List(0) + + // -- + + failedUpdater.onNext(1) + + v.tryNow() shouldBe Failure(err1) + + // -- + + failedUpdater.onNext(1) + + v.tryNow() shouldBe Failure(err1) + + // -- + + v.set(List(1, 2)) + doubler.onNext(()) + + v.now() shouldBe List(1, 2, 1, 2) + } + + it("tryUpdater") { + + val err1 = new Exception("err1") + + val err2 = new Exception("err2") + + val resetErr = new Exception("resetErr") + + val v = Var(List(1)) + + val adder = v.tryUpdater[Int] { (acc, newItem) => + println(acc) + acc + .map(_ :+ newItem) + .recover { case `resetErr` => List(0) } + } + + val errer = v.tryUpdater[Int]((_, _) => Failure(resetErr)) + + // -- + + v.now() shouldBe List(1) + + // -- + + adder.onNext(2) + + v.now() shouldBe List(1, 2) + + // -- + + errer.onNext(3) + + v.tryNow() shouldBe Failure(resetErr) + + // -- + + adder.onNext(1) + + v.tryNow() shouldBe Success(List(0)) + + // -- + + adder.onError(err1) + + v.tryNow() shouldBe Failure(err1) + + // -- + + v.set(List(-1)) + + v.now() shouldBe List(-1) + + // -- + + errer.onNext(1) + + v.tryNow() shouldBe Failure(resetErr) + + // -- + + errer.onNext(1) + + v.tryNow() shouldBe Failure(resetErr) + + // -- + + v.set(List(1, 2)) + adder.onNext(3) + + v.now() shouldBe List(1, 2, 3) + } } From bf18b864964847fea67769f9989a236adae18202 Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Sun, 13 Dec 2020 00:16:49 -0800 Subject: [PATCH 02/42] New: Make `sample` and `withCurrentValueOf` available on Signal. Fixes #45 --- README.md | 2 + .../SampleCombineEventStream2.scala | 3 + .../signal/SampleCombineSignal2.scala | 36 +++++ .../com/raquo/airstream/signal/Signal.scala | 16 ++ .../signal/SampleCombineSignal2Spec.scala | 148 ++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 src/main/scala/com/raquo/airstream/signal/SampleCombineSignal2.scala create mode 100644 src/test/scala/com/raquo/airstream/signal/SampleCombineSignal2Spec.scala diff --git a/README.md b/README.md index 67531a82..eed8f17f 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ You can use `stream.withCurrentValueOf(signal).map((lastStreamEvent, signalCurre If you don't need lastStreamEvent, use `stream.sample(signal).map(signalCurrentValue => ???)` instead. Note: both of these output streams will emit only when `stream` emits, as documented in the code. If you want updates from signal to also trigger an event, look into the `combineWith` operator. +`withCurrentValueOf` and `sample` operators are also available on signals, not just streams. + If you want to get a Signal's current value without the complications of sampling, or even if you just want to make sure that a Signal is started, just call `observe` on it. That will add a noop observer to the signal, and return a `SignalViewer` instance which being a `StrictSignal`, does expose `now()` and `tryNow()` methods that safely provide you with its current value. diff --git a/src/main/scala/com/raquo/airstream/eventstream/SampleCombineEventStream2.scala b/src/main/scala/com/raquo/airstream/eventstream/SampleCombineEventStream2.scala index 06e84163..a2f28544 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/SampleCombineEventStream2.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/SampleCombineEventStream2.scala @@ -26,12 +26,15 @@ class SampleCombineEventStream2[A, B, O]( parentObservers.push( InternalParentObserver.fromTry[A](samplingStream, (nextSamplingValue, transaction) => { maybeSamplingValue = Some(nextSamplingValue) + // Update `maybeCombinedValue` and mark the combined observable as pending internalObserver.onTry(combinator(nextSamplingValue, sampledSignal.tryNow()), transaction) }), InternalParentObserver.fromTry[B](sampledSignal, (nextSampledValue, _) => { // Update combined value, but only if sampling stream already emitted a value. // So we only update the value if we know that this observable will syncFire. maybeSamplingValue.foreach { lastSamplingValue => + // Update `maybeCombinedValue` + // - We need this if `sampledSignal` fires after the combined observable has already been marked as pending maybeCombinedValue = Some(combinator(lastSamplingValue, nextSampledValue)) } }) diff --git a/src/main/scala/com/raquo/airstream/signal/SampleCombineSignal2.scala b/src/main/scala/com/raquo/airstream/signal/SampleCombineSignal2.scala new file mode 100644 index 00000000..a786c46e --- /dev/null +++ b/src/main/scala/com/raquo/airstream/signal/SampleCombineSignal2.scala @@ -0,0 +1,36 @@ +package com.raquo.airstream.signal + +import com.raquo.airstream.features.{CombineObservable, InternalParentObserver} + +import scala.util.Try + +/** This signal emits the combined value when samplingSignal is updated. + * sampledSignal's current/"latest" value is used. + * + * Works similar to Rx's "withLatestFrom", except without glitches (see a diamond case test for this in GlitchSpec). + * + * @param combinator Note: Must not throw. Must have no side effects. Can be executed more than once per transaction. + */ +class SampleCombineSignal2[A, B, O]( + samplingSignal: Signal[A], + sampledSignal: Signal[B], + combinator: (Try[A], Try[B]) => Try[O] +) extends Signal[O] with CombineObservable[O] { + + override protected[airstream] val topoRank: Int = (samplingSignal.topoRank max sampledSignal.topoRank) + 1 + + override protected[this] def initialValue: Try[O] = combinator(samplingSignal.tryNow(), sampledSignal.tryNow()) + + parentObservers.push( + InternalParentObserver.fromTry[A](samplingSignal, (nextSamplingValue, transaction) => { + // Update `maybeCombinedValue` and mark the combined observable as pending + internalObserver.onTry(combinator(nextSamplingValue, sampledSignal.tryNow()), transaction) + }), + InternalParentObserver.fromTry[B](sampledSignal, (nextSampledValue, _) => { + // Update `maybeCombinedValue` + // - We need this if `sampledSignal` fires after the combined observable has already been marked as pending + maybeCombinedValue = Some(combinator(samplingSignal.tryNow(), nextSampledValue)) + }) + ) + +} diff --git a/src/main/scala/com/raquo/airstream/signal/Signal.scala b/src/main/scala/com/raquo/airstream/signal/Signal.scala index 9cd7ac1f..27a3fee6 100644 --- a/src/main/scala/com/raquo/airstream/signal/Signal.scala +++ b/src/main/scala/com/raquo/airstream/signal/Signal.scala @@ -59,6 +59,22 @@ trait Signal[+A] extends Observable[A] { ) } + def withCurrentValueOf[B](signal: Signal[B]): Signal[(A, B)] = { + new SampleCombineSignal2[A, B, (A, B)]( + samplingSignal = this, + sampledSignal = signal, + combinator = CombineObservable.guardedCombinator((_, _)) + ) + } + + def sample[B](signal: Signal[B]): Signal[B] = { + new SampleCombineSignal2[A, B, B]( + samplingSignal = this, + sampledSignal = signal, + combinator = CombineObservable.guardedCombinator((_, sampledValue) => sampledValue) + ) + } + def changes: EventStream[A] = new MapEventStream[A, A](parent = this, project = identity, recover = None) /** diff --git a/src/test/scala/com/raquo/airstream/signal/SampleCombineSignal2Spec.scala b/src/test/scala/com/raquo/airstream/signal/SampleCombineSignal2Spec.scala new file mode 100644 index 00000000..423666bd --- /dev/null +++ b/src/test/scala/com/raquo/airstream/signal/SampleCombineSignal2Spec.scala @@ -0,0 +1,148 @@ +package com.raquo.airstream.signal + +import com.raquo.airstream.UnitSpec +import com.raquo.airstream.core.Observer +import com.raquo.airstream.eventbus.EventBus +import com.raquo.airstream.fixtures.{Calculation, Effect, TestableOwner} + +import scala.collection.mutable + +/** See also – diamond test case for this in GlitchSpec */ +class SampleCombineSignal2Spec extends UnitSpec { + + it("gets current value of Signal") { + + implicit val testOwner: TestableOwner = new TestableOwner + + val samplingVar = Var(100) + val sampledVar = Var(10) + + val calculations = mutable.Buffer[Calculation[Int]]() + val effects = mutable.Buffer[Effect[Int]]() + + val sampledSignal = sampledVar.signal.map(Calculation.log("sampled", calculations)) + + val combinedSignal = samplingVar.signal + .map(Calculation.log("sampling", calculations)) + .withCurrentValueOf(sampledSignal) + .map2(_ + _) + .map(Calculation.log("combined", calculations)) + + val sampledObserver = Observer[Int](effects += Effect("sampled", _)) + val combinedObserver = Observer[Int](effects += Effect("combined", _)) + + // -- + + calculations shouldEqual mutable.Buffer() + effects shouldEqual mutable.Buffer() + + // -- + + val subCombined = combinedSignal.addObserver(combinedObserver) + + calculations shouldEqual mutable.Buffer( + Calculation("sampling", 100), + Calculation("sampled", 10), + Calculation("combined", 110), + ) + effects shouldEqual mutable.Buffer( + Effect("combined", 110), + ) + + calculations.clear() + effects.clear() + + // -- + + samplingVar.writer.onNext(200) + + calculations shouldEqual mutable.Buffer( + Calculation("sampling", 200), + Calculation("combined", 210) + ) + effects shouldEqual mutable.Buffer( + Effect("combined", 210) + ) + + calculations.clear() + effects.clear() + + // -- + + sampledVar.writer.onNext(20) + + calculations shouldEqual mutable.Buffer( + Calculation("sampled", 20) + ) + effects shouldEqual mutable.Buffer() + + calculations.clear() + + // -- + + samplingVar.writer.onNext(300) + + calculations shouldEqual mutable.Buffer( + Calculation("sampling", 300), + Calculation("combined", 320) + ) + effects shouldEqual mutable.Buffer( + Effect("combined", 320) + ) + + calculations.clear() + effects.clear() + + // -- + + subCombined.kill() + sampledSignal.addObserver(sampledObserver) + + calculations shouldEqual mutable.Buffer() + effects shouldEqual mutable.Buffer( + Effect("sampled", 20) + ) + + effects.clear() + + // -- + + sampledVar.writer.onNext(30) + + calculations shouldEqual mutable.Buffer( + Calculation("sampled", 30) + ) + effects shouldEqual mutable.Buffer( + Effect("sampled", 30) + ) + + calculations.clear() + effects.clear() + + // -- + + combinedSignal.addObserver(combinedObserver) + + calculations shouldEqual mutable.Buffer() + effects shouldEqual mutable.Buffer( + Effect("combined", 320) // @TODO[API] This could be 330 if we implement https://github.com/raquo/Airstream/issues/43 + ) + + effects.clear() + + // -- + + samplingVar.writer.onNext(400) + + calculations shouldEqual mutable.Buffer( + Calculation("sampling", 400), + Calculation("combined", 430) + ) + effects shouldEqual mutable.Buffer( + Effect("combined", 430) + ) + + calculations.clear() + effects.clear() + } +} From eaca9cac0adfa059ecc8cbfe1ed466a5dc381d93 Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Fri, 25 Dec 2020 16:23:59 -0800 Subject: [PATCH 03/42] Fix: Delay operator now clears the pending queue when stopped Previously, if you stopped the delayed stream and then immediately started it, delayed events scheduled before the stream was stopped would fire after it was re-started if their delays did not complete while the stream was stopped. --- .../eventstream/DelayEventStream.scala | 19 ++++- .../eventstream/DelayEventStreamSpec.scala | 75 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/test/scala/com/raquo/airstream/eventstream/DelayEventStreamSpec.scala diff --git a/src/main/scala/com/raquo/airstream/eventstream/DelayEventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/DelayEventStream.scala index cf888dd7..f35379f6 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/DelayEventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/DelayEventStream.scala @@ -4,6 +4,7 @@ import com.raquo.airstream.core.Transaction import com.raquo.airstream.features.{InternalNextErrorObserver, SingleParentObservable} import scala.scalajs.js +import scala.scalajs.js.timers.SetTimeoutHandle class DelayEventStream[A]( override protected val parent: EventStream[A], @@ -13,16 +14,30 @@ class DelayEventStream[A]( /** Async stream, so reset rank */ override protected[airstream] val topoRank: Int = 1 + private val timerHandles: js.Array[SetTimeoutHandle] = js.Array() + override protected[airstream] def onNext(nextValue: A, transaction: Transaction): Unit = { - js.timers.setTimeout(delayMillis) { + var timerHandle: SetTimeoutHandle = null + timerHandle = js.timers.setTimeout(delayMillis) { //println(s"> init trx from DelayEventStream.onNext($nextValue)") + timerHandles.splice(timerHandles.indexOf(timerHandle), deleteCount = 1) // Remove handle new Transaction(fireValue(nextValue, _)) } + timerHandles.push(timerHandle) } override def onError(nextError: Throwable, transaction: Transaction): Unit = { - js.timers.setTimeout(delayMillis) { + var timerHandle: SetTimeoutHandle = null + timerHandle = js.timers.setTimeout(delayMillis) { + timerHandles.splice(timerHandles.indexOf(timerHandle), deleteCount = 1) // Remove handle new Transaction(fireError(nextError, _)) } + timerHandles.push(timerHandle) + } + + override protected[this] def onStop(): Unit = { + timerHandles.foreach(js.timers.clearTimeout) + timerHandles.length = 0 // Clear array + super.onStop() } } diff --git a/src/test/scala/com/raquo/airstream/eventstream/DelayEventStreamSpec.scala b/src/test/scala/com/raquo/airstream/eventstream/DelayEventStreamSpec.scala new file mode 100644 index 00000000..ed225fd0 --- /dev/null +++ b/src/test/scala/com/raquo/airstream/eventstream/DelayEventStreamSpec.scala @@ -0,0 +1,75 @@ +package com.raquo.airstream.eventstream + +import com.raquo.airstream.AsyncUnitSpec +import com.raquo.airstream.core.Observer +import com.raquo.airstream.eventbus.EventBus +import com.raquo.airstream.fixtures.{Effect, TestableOwner} +import org.scalatest.BeforeAndAfter + +import scala.collection.mutable + +class DelayEventStreamSpec extends AsyncUnitSpec with BeforeAndAfter { + + implicit val owner = new TestableOwner + + val effects = mutable.Buffer[Effect[Int]]() + + val obs1 = Observer[Int](effects += Effect("obs1", _)) + + before { + owner.killSubscriptions() + effects.clear() + } + + + it("events are delayed, and purged on stop") { + val bus = new EventBus[Int] + val stream = bus.events.delay(30) + + val sub = stream.addObserver(obs1) + + delay { + effects shouldEqual mutable.Buffer() + + // -- + + bus.writer.onNext(1) + + effects shouldEqual mutable.Buffer() + + }.flatMap[Unit] { _ => + delay(30) { + effects shouldEqual mutable.Buffer(Effect("obs1", 1)) + effects.clear() + + bus.writer.onNext(2) + bus.writer.onNext(3) + + effects shouldEqual mutable.Buffer() + } + }.flatMap[Unit] { _ => + delay(30) { + effects shouldEqual mutable.Buffer(Effect("obs1", 2), Effect("obs1", 3)) + effects.clear() + + bus.writer.onNext(4) + bus.writer.onNext(5) + + sub.kill() // this kills pending events even if we immediately restart + + effects shouldEqual mutable.Buffer() + + stream.addObserver(obs1) + + bus.writer.onNext(6) + } + }.flatMap { _ => + delay(40) { // a bit extra margin for the last check just to be sure that we caught any events + effects shouldEqual mutable.Buffer(Effect("obs1", 6)) + effects.clear() + assert(true) + } + } + } + +} From 66d3c9577a4fd0dfbc2f131de16e0386867adcda Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Fri, 25 Dec 2020 18:51:50 -0800 Subject: [PATCH 04/42] Fix: Debounce operator now clears timeout on stop Same reasoning as delay operator in prev commit --- .../raquo/airstream/eventstream/DebounceEventStream.scala | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/scala/com/raquo/airstream/eventstream/DebounceEventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/DebounceEventStream.scala index 834cc6f4..cf281f26 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/DebounceEventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/DebounceEventStream.scala @@ -38,4 +38,10 @@ class DebounceEventStream[A]( } ) } + + override protected[this] def onStop(): Unit = { + maybeLastTimeoutHandle.foreach(js.timers.clearTimeout) + maybeLastTimeoutHandle = js.undefined + super.onStop() + } } From 31493bf83a19ddb14d39458ded164f58f515653f Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Sun, 27 Dec 2020 05:44:13 +0530 Subject: [PATCH 05/42] New: AjaxEventStream --- .../raquo/airstream/web/AjaxEventStream.scala | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala diff --git a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala new file mode 100644 index 00000000..99905d86 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala @@ -0,0 +1,153 @@ +package com.raquo.airstream.web + +import com.raquo.airstream.core.Transaction +import com.raquo.airstream.eventstream.EventStream +import org.scalajs.dom + +/** + * [[AjaxEventStream]] performs an HTTP request and emits the [[dom.XMLHttpRequest]]/[[dom.ext.AjaxException]] on + * success/failure. + * + * The network request is delayed until start. In other words, the network request is not performed if the stream is + * never started. + * + * On restart, a new request is performed and the subsequent success/failure is propagated downstream. + */ +class AjaxEventStream( + method: String, + url: String, + data: dom.ext.Ajax.InputData, + timeout: Int, + headers: Map[String, String], + withCredentials: Boolean, + responseType: String +) extends EventStream[dom.XMLHttpRequest] { + + protected[airstream] val topoRank: Int = 1 + + override protected[this] def onStart(): Unit = { + // the implementation mirrors dom.ext.Ajax.apply + val req = new dom.XMLHttpRequest + req.onreadystatechange = (_: dom.Event) => { + if (isStarted && req.readyState == 4) { + val status = req.status + if ((status >= 200 && status < 300) || status == 304) + new Transaction(fireValue(req, _)) + else + new Transaction(fireError(dom.ext.AjaxException(req), _)) + } + } + req.open(method, url) + req.responseType = responseType + req.timeout = timeout.toDouble + req.withCredentials = withCredentials + headers.foreach(Function.tupled(req.setRequestHeader)) + if (data == null) req.send() else req.send(data) + } +} + +object AjaxEventStream { + + /** + * Returns an [[EventStream]] that performs an HTTP request and emits the + * [[dom.XMLHttpRequest]]/[[dom.ext.AjaxException]] on success/failure. + * + * The network request is delayed until start. In other words, the network request is not performed if the stream is + * never started. + * + * On restart, a new request is performed and the subsequent success/failure is propagated downstream. + * + * @see [[dom.raw.XMLHttpRequest]] for a description of the parameters + */ + def apply( + method: String, + url: String, + data: dom.ext.Ajax.InputData, + timeout: Int, + headers: Map[String, String], + withCredentials: Boolean, + responseType: String + ): EventStream[dom.XMLHttpRequest] = { + new AjaxEventStream(method, url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `GET` request. + * + * @see [[apply]] + */ + def get( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("GET", url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `POST` request. + * + * @see [[apply]] + */ + def post( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("POST", url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `PUT` request. + * + * @see [[apply]] + */ + def put( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("PUT", url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `PATCH` request. + * + * @see [[apply]] + */ + def patch( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("PATCH", url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `DELETE` request. + * + * @see [[apply]] + */ + def delete( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("DELETE", url, data, timeout, headers, withCredentials, responseType) + } +} From d703b2f2cd98820a00c4e8aa0ec46b16bb174b0b Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Sat, 26 Dec 2020 18:13:37 -0800 Subject: [PATCH 06/42] API: Clear pending ajax request when stream is stopped Same reasoning as Delay, see previous commit --- .../com/raquo/airstream/web/AjaxEventStream.scala | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala index 99905d86..005fbec2 100644 --- a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala @@ -25,11 +25,15 @@ class AjaxEventStream( protected[airstream] val topoRank: Int = 1 + private var pendingRequest: Option[dom.XMLHttpRequest] = None + override protected[this] def onStart(): Unit = { // the implementation mirrors dom.ext.Ajax.apply val req = new dom.XMLHttpRequest + pendingRequest = Some(req) req.onreadystatechange = (_: dom.Event) => { - if (isStarted && req.readyState == 4) { + if (pendingRequest.contains(req) && req.readyState == 4) { + pendingRequest = None val status = req.status if ((status >= 200 && status < 300) || status == 304) new Transaction(fireValue(req, _)) @@ -44,6 +48,11 @@ class AjaxEventStream( headers.foreach(Function.tupled(req.setRequestHeader)) if (data == null) req.send() else req.send(data) } + + override protected[this] def onStop(): Unit = { + pendingRequest = None + super.onStop() + } } object AjaxEventStream { From 42adddb10da4345189b9af8f36c8fe4dfe056a45 Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Sat, 26 Dec 2020 18:26:06 -0800 Subject: [PATCH 07/42] New: DomEventStream --- .../raquo/airstream/web/DomEventStream.scala | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/main/scala/com/raquo/airstream/web/DomEventStream.scala diff --git a/src/main/scala/com/raquo/airstream/web/DomEventStream.scala b/src/main/scala/com/raquo/airstream/web/DomEventStream.scala new file mode 100644 index 00000000..36d53ba4 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/web/DomEventStream.scala @@ -0,0 +1,50 @@ +package com.raquo.airstream.web + +import com.raquo.airstream.core.Transaction +import com.raquo.airstream.eventstream.EventStream +import org.scalajs.dom + +import scala.scalajs.js + +/** + * This stream, when started, registers an event listener on a specific target + * like a DOM element, document, or window, and re-emits all events sent to the listener. + * + * When this stream is stopped, the listener is removed. + * + * @param eventTarget any DOM event target, e.g. element, document, or window + * @param eventKey DOM event name, e.g. "click", "input", "change" + * @param useCapture See section about "useCapture" in https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + * + */ +class DomEventStream[Ev <: dom.Event]( + eventTarget: dom.EventTarget, + eventKey: String, + useCapture: Boolean +) extends EventStream[Ev] { + + override protected[airstream] val topoRank: Int = 1 + + val eventHandler: js.Function1[Ev, Unit] = { ev => + new Transaction(fireValue(ev, _)) + } + + override protected[this] def onStart(): Unit = { + eventTarget.addEventListener(eventKey, eventHandler, useCapture) + } + + override protected[this] def onStop(): Unit = { + eventTarget.removeEventListener(eventKey, eventHandler, useCapture) + } +} + +object DomEventStream { + + def apply[Ev <: dom.Event]( + eventTarget: dom.EventTarget, + eventKey: String, + useCapture: Boolean = false + ): EventStream[Ev] = { + new DomEventStream[Ev](eventTarget, eventKey, useCapture) + } +} From 167e2db4ce996867e57fb3d47468e71cfedc78f2 Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Sat, 26 Dec 2020 18:47:02 -0800 Subject: [PATCH 08/42] Docs: Add new web streams --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/README.md b/README.md index eed8f17f..330295e7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ I created Airstream because I found existing solutions were not suitable for bui * [EventBus](#eventbus) * [Var](#var) * [Val](#val) + * [Ajax](#ajax) + * [Websockets](#websockets) + * [DOM Events](#dom-events) * [Custom Observables](#custom-observables) * [FRP Glitches](#frp-glitches) * [Other Libraries](#other-libraries) @@ -563,12 +566,54 @@ Remember that this atomicity guarantee only applies to failures which would have Val is useful when a component wants to accept either a Signal or a constant value as input. You can just wrap your constant in a Val, and make the component accept a `Signal` (or a `StrictSignal`) instead. + +#### Ajax + +Airstream now has a built-in way to perform Ajax requests: + +```scala +AjaxEventStream + .get("/api/kittens") // EventStream[dom.XMLHttpRequest] + .map(req => req.responseText) // EventStream[String] +``` + +Methods for POST, PUT, PATCH, and DELETE are also available. + +The request is made every time the stream is started. If the stream is stopped while the request is pending, the request will not be cancelled, but its result will be discarded. + +The implementation follows that of `org.scalajs.dom.ext.ajax.apply`, but is adjusted slightly to be better behaved in Airstream. + + + +### Websockets + +Airstream has no official websockets integration yet. + +For several users' implementations, search Laminar gitter room, and the issues in this repo. + + + +### DOM Events + +`DomEventStream` previously available in Laminar now lives in Airstream. + +```scala +val element: dom.Element = ??? +DomEventStream(element, "click") // EventStream[dom.MouseEvent] +``` + +This stream, when started, registers a `click` event listener on `element`, and emits all events the listener receives until it is stopped, at which point the listener is removed. + + + #### Custom Observables EventBus is a very generic solution that should suit most needs, even if perhaps not very elegantly sometimes. You can create your own observables that emit events in their own unique way by wrapping or extending EventBus (easier) or extending Observable (more work and knowledge required, but rewarded with better behavior)). +If extending Observable, you will need to make the `topoRank` field public to be able to override it. See [#37](https://github.com/raquo/Airstream/issues/37). + Unfortunately I don't have enough time to describe how to create custom observables in detail right now. You will need to read the rest of the documentation and the source code – you will see how other observables such as MapEventStream or FilterEventStream are implemented. Airstream's source code should be easy to comprehend. It is clean, small (a bit more than 1K LoC with all the operators), and does not use complicated implicits or hardcore functional stuff. From 02aeaac6a4a853f1cf1815a98800fdecc9d4463c Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Sun, 27 Dec 2020 21:27:02 +0530 Subject: [PATCH 09/42] Added Websocket event sources for #49 --- README.md | 33 ++++- .../com/raquo/airstream/web/DomError.scala | 8 ++ .../airstream/web/WebSocketEventStream.scala | 115 ++++++++++++++++++ .../com/raquo/airstream/web/package.scala | 18 +++ 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 src/main/scala/com/raquo/airstream/web/DomError.scala create mode 100644 src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala create mode 100644 src/main/scala/com/raquo/airstream/web/package.scala diff --git a/README.md b/README.md index 330295e7..e36e2e31 100644 --- a/README.md +++ b/README.md @@ -587,11 +587,40 @@ The implementation follows that of `org.scalajs.dom.ext.ajax.apply`, but is adju ### Websockets -Airstream has no official websockets integration yet. +Airstream supports uni-directional and bi-directional websockets. -For several users' implementations, search Laminar gitter room, and the issues in this repo. +```scala +import com.raquo.airstream.eventstream.EventStream +import com.raquo.airstream.web.WebSocketEventStream +import org.scalajs.dom + +import scala.scalajs.js.typedarray.ArrayBuffer + +// absolute URL is required +// use com.raquo.airstream.web.websocketPath to construct an absolute URL from a relative one +val url: String = ??? + +// uni-directional, server -> client +val s1: EventStream[dom.MessageEvent] = WebSocketEventStream(url) +// bi-directional, transmit text from client -> server +val src2: EventStream[String] = ??? +val s2: EventStream[dom.MessageEvent] = WebSocketEventStream(url, src2) + +// bi-directional, transmit binary from client -> server +val src3: EventStream[ArrayBuffer] = ??? +val s3: EventStream[dom.MessageEvent] = WebSocketEventStream(url, src3) + +// bi-directional, transmit blob from client -> server +val src4: EventStream[dom.Blob] = ??? +val s4: EventStream[dom.MessageEvent] = WebSocketEventStream(url, src4) +``` +The behavior of the returned stream is as follows: + - A new websocket connection is established when the stream is started. + - Upstream messages, if any, are transmitted on this connection. + - Server messages and connection errors (including termination) are propagated downstream. + - The connection is closed when this stream is stopped. ### DOM Events diff --git a/src/main/scala/com/raquo/airstream/web/DomError.scala b/src/main/scala/com/raquo/airstream/web/DomError.scala new file mode 100644 index 00000000..ce501293 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/web/DomError.scala @@ -0,0 +1,8 @@ +package com.raquo.airstream.web + +import org.scalajs.dom + +/** + * Wraps a [[dom.Event DOM error event]]. + */ +final case class DomError(event: dom.Event) extends Exception diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala new file mode 100644 index 00000000..e233966c --- /dev/null +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -0,0 +1,115 @@ +package com.raquo.airstream.web + +import com.raquo.airstream.core.Transaction +import com.raquo.airstream.eventstream.EventStream +import com.raquo.airstream.features.{InternalNextErrorObserver, SingleParentObservable} +import com.raquo.airstream.web.WebSocketEventStream.Transmitter +import org.scalajs.dom + +import scala.scalajs.js + +/** + * [[WebSocketEventStream]] emits messages from a [[dom.WebSocket]] connection. + * + * Lifecycle: + * - A new connection is established when this stream is started. + * - Upstream messages, if any, are transmitted on this connection. + * - Server [[dom.MessageEvent messages]] and connection [[DomError errors]] are propagated downstream. + * - The connection is closed when this stream is stopped. + */ +class WebSocketEventStream[A](override val parent: EventStream[A], url: String)(implicit T: Transmitter[A]) + extends EventStream[dom.MessageEvent] + with SingleParentObservable[A, dom.MessageEvent] + with InternalNextErrorObserver[A] { + + protected[airstream] val topoRank: Int = 1 + + private var jsSocket: js.UndefOr[dom.WebSocket] = js.undefined + + protected[airstream] def onError(nextError: Throwable, transaction: Transaction): Unit = { + // noop + } + + protected[airstream] def onNext(nextValue: A, transaction: Transaction): Unit = { + // transmit upstream message, no guard required since transmitter is trusted + jsSocket.foreach(T.transmit(_, nextValue)) + } + + override protected[this] def onStart(): Unit = { + + val socket = new dom.WebSocket(url) + + // initialize new socket + T.initialize(socket) + + // propagate connection termination error + socket.onclose = + (e: dom.CloseEvent) => if (jsSocket.nonEmpty) { + jsSocket = js.undefined + new Transaction(fireError(DomError(e), _)) + } + + // propagate connection error + socket.onerror = + (e: dom.Event) => if (jsSocket.nonEmpty) new Transaction(fireError(DomError(e), _)) + + // propagate message received + socket.onmessage = + (e: dom.MessageEvent) => if (jsSocket.nonEmpty) new Transaction(fireValue(e, _)) + + // update local reference + socket.onopen = + (_: dom.Event) => if (jsSocket.isEmpty) jsSocket = socket + + super.onStart() + } + + override protected[this] def onStop(): Unit = { + // Is "close" async? + // just to be safe, reset local reference before closing to prevent error propagation in "onclose" + val socket = jsSocket + jsSocket = js.undefined + socket.foreach(_.close()) + super.onStop() + } +} + +object WebSocketEventStream { + + /** + * Returns an [[EventStream]] that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket]] connection. + * + * Websocket [[dom.Event errors]], including [[dom.CloseEvent termination]], are propagated as [[DomError]]s. + * + * @param url '''absolute''' URL of the websocket endpoint, + * use [[websocketPath]] to construct an absolute URL from a relative one + * @param transmit stream of messages to be transmitted to the websocket endpoint + */ + def apply[A: Transmitter](url: String, transmit: EventStream[A] = EventStream.empty): EventStream[dom.MessageEvent] = + new WebSocketEventStream(transmit, url) + + sealed abstract class Transmitter[A] { + + def initialize(socket: dom.WebSocket): Unit + def transmit(socket: dom.WebSocket, data: A): Unit + } + + object Transmitter { + + private def binary[A](send: (dom.WebSocket, A) => Unit, binaryType: String): Transmitter[A] = + new Transmitter[A] { + final def initialize(socket: dom.WebSocket): Unit = socket.binaryType = binaryType + final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + } + + private def simple[A](send: (dom.WebSocket, A) => Unit): Transmitter[A] = + new Transmitter[A] { + final def initialize(socket: dom.WebSocket): Unit = () + final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + } + + implicit val binaryTransmitter: Transmitter[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") + implicit val blobTransmitter: Transmitter[dom.Blob] = binary(_ send _, "blob") + implicit val stringTransmitter: Transmitter[String] = simple(_ send _) + } +} diff --git a/src/main/scala/com/raquo/airstream/web/package.scala b/src/main/scala/com/raquo/airstream/web/package.scala new file mode 100644 index 00000000..42194001 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/web/package.scala @@ -0,0 +1,18 @@ +package com.raquo.airstream + +import org.scalajs.dom + +package object web { + + /** + * Constructs and returns an absolute websocket URL from a relative one. + */ + def websocketPath(relative: String): String = { + val prefix = dom.document.location.protocol match { + case "https:" => "wss:" + case _ => "ws:" + } + val suffix = if (relative.startsWith("/")) relative else s"/$relative" + s"$prefix//${dom.document.location.hostname}:${dom.document.location.port}$suffix" + } +} From 5bcde0bb08f93f213b9c541e772a8d68381be136 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Sun, 27 Dec 2020 22:36:52 +0530 Subject: [PATCH 10/42] Rename websocketPath to websocketUrl --- README.md | 2 +- .../scala/com/raquo/airstream/web/WebSocketEventStream.scala | 4 ++-- src/main/scala/com/raquo/airstream/web/package.scala | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e36e2e31..1717a332 100644 --- a/README.md +++ b/README.md @@ -597,7 +597,7 @@ import org.scalajs.dom import scala.scalajs.js.typedarray.ArrayBuffer // absolute URL is required -// use com.raquo.airstream.web.websocketPath to construct an absolute URL from a relative one +// use com.raquo.airstream.web.websocketUrl to construct an absolute URL from a relative one val url: String = ??? // uni-directional, server -> client diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index e233966c..daf57eed 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -81,8 +81,8 @@ object WebSocketEventStream { * * Websocket [[dom.Event errors]], including [[dom.CloseEvent termination]], are propagated as [[DomError]]s. * - * @param url '''absolute''' URL of the websocket endpoint, - * use [[websocketPath]] to construct an absolute URL from a relative one + * @param url '''absolute''' URL of the websocket endpoint, + * use [[websocketUrl]] to construct an absolute URL from a relative one * @param transmit stream of messages to be transmitted to the websocket endpoint */ def apply[A: Transmitter](url: String, transmit: EventStream[A] = EventStream.empty): EventStream[dom.MessageEvent] = diff --git a/src/main/scala/com/raquo/airstream/web/package.scala b/src/main/scala/com/raquo/airstream/web/package.scala index 42194001..8a0d87f4 100644 --- a/src/main/scala/com/raquo/airstream/web/package.scala +++ b/src/main/scala/com/raquo/airstream/web/package.scala @@ -7,7 +7,7 @@ package object web { /** * Constructs and returns an absolute websocket URL from a relative one. */ - def websocketPath(relative: String): String = { + def websocketUrl(relative: String): String = { val prefix = dom.document.location.protocol match { case "https:" => "wss:" case _ => "ws:" From bfa32431be69291508c2951b04fa96e55de0ac7d Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Sun, 27 Dec 2020 22:41:13 +0530 Subject: [PATCH 11/42] Separate constructors for unidirectional and bidirectional usecases --- .../raquo/airstream/web/WebSocketEventStream.scala | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index daf57eed..4f36cd78 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -76,6 +76,17 @@ class WebSocketEventStream[A](override val parent: EventStream[A], url: String)( object WebSocketEventStream { + /** + * Returns an [[EventStream]] that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket]] connection. + * + * Websocket [[dom.Event errors]], including [[dom.CloseEvent termination]], are propagated as [[DomError]]s. + * + * @param url '''absolute''' URL of the websocket endpoint, + * use [[websocketUrl]] to construct an absolute URL from a relative one + */ + def apply(url: String): EventStream[dom.MessageEvent] = + apply[Void](url) + /** * Returns an [[EventStream]] that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket]] connection. * @@ -85,7 +96,7 @@ object WebSocketEventStream { * use [[websocketUrl]] to construct an absolute URL from a relative one * @param transmit stream of messages to be transmitted to the websocket endpoint */ - def apply[A: Transmitter](url: String, transmit: EventStream[A] = EventStream.empty): EventStream[dom.MessageEvent] = + def apply[A: Transmitter](url: String, transmit: EventStream[A]): EventStream[dom.MessageEvent] = new WebSocketEventStream(transmit, url) sealed abstract class Transmitter[A] { @@ -111,5 +122,6 @@ object WebSocketEventStream { implicit val binaryTransmitter: Transmitter[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") implicit val blobTransmitter: Transmitter[dom.Blob] = binary(_ send _, "blob") implicit val stringTransmitter: Transmitter[String] = simple(_ send _) + implicit val voidTransmitter: Transmitter[Void] = simple((_, _) => ()) } } From 5e72c7293096786e83dd4220bdb3feaa70de469d Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Sun, 27 Dec 2020 22:51:41 +0530 Subject: [PATCH 12/42] Fix unidirectional constructor --- .../scala/com/raquo/airstream/web/WebSocketEventStream.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 4f36cd78..eb26646a 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -85,7 +85,7 @@ object WebSocketEventStream { * use [[websocketUrl]] to construct an absolute URL from a relative one */ def apply(url: String): EventStream[dom.MessageEvent] = - apply[Void](url) + apply[Void](url, EventStream.empty) /** * Returns an [[EventStream]] that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket]] connection. From 71b0903315c4f27a91fb08be73b735213e5ae3f6 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Sun, 27 Dec 2020 23:04:31 +0530 Subject: [PATCH 13/42] Defer callback registration --- .../airstream/web/WebSocketEventStream.scala | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index eb26646a..4b56f957 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -42,24 +42,27 @@ class WebSocketEventStream[A](override val parent: EventStream[A], url: String)( // initialize new socket T.initialize(socket) - // propagate connection termination error - socket.onclose = - (e: dom.CloseEvent) => if (jsSocket.nonEmpty) { - jsSocket = js.undefined - new Transaction(fireError(DomError(e), _)) - } + // update local reference + socket.onopen = + (_: dom.Event) => if (jsSocket.isEmpty) { - // propagate connection error - socket.onerror = - (e: dom.Event) => if (jsSocket.nonEmpty) new Transaction(fireError(DomError(e), _)) + // propagate connection termination error + socket.onclose = + (e: dom.CloseEvent) => if (jsSocket.nonEmpty) { + jsSocket = js.undefined + new Transaction(fireError(DomError(e), _)) + } - // propagate message received - socket.onmessage = - (e: dom.MessageEvent) => if (jsSocket.nonEmpty) new Transaction(fireValue(e, _)) + // propagate connection error + socket.onerror = + (e: dom.Event) => if (jsSocket.nonEmpty) new Transaction(fireError(DomError(e), _)) - // update local reference - socket.onopen = - (_: dom.Event) => if (jsSocket.isEmpty) jsSocket = socket + // propagate message received + socket.onmessage = + (e: dom.MessageEvent) => if (jsSocket.nonEmpty) new Transaction(fireValue(e, _)) + + jsSocket = socket + } super.onStart() } From 5cafb9f1d6a2aa4f3aa001fc2550a9f030353914 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Sun, 27 Dec 2020 23:22:24 +0530 Subject: [PATCH 14/42] Revised error handling --- README.md | 5 ++-- .../com/raquo/airstream/web/DomError.scala | 8 ------- .../airstream/web/WebSocketClosedError.scala | 8 +++++++ .../airstream/web/WebSocketEventStream.scala | 23 ++++++++++--------- 4 files changed, 23 insertions(+), 21 deletions(-) delete mode 100644 src/main/scala/com/raquo/airstream/web/DomError.scala create mode 100644 src/main/scala/com/raquo/airstream/web/WebSocketClosedError.scala diff --git a/README.md b/README.md index 1717a332..d5e0f188 100644 --- a/README.md +++ b/README.md @@ -617,9 +617,10 @@ val s4: EventStream[dom.MessageEvent] = WebSocketEventStream(url, src4) ``` The behavior of the returned stream is as follows: - - A new websocket connection is established when the stream is started. + - A new connection is established when this stream is started. - Upstream messages, if any, are transmitted on this connection. - - Server messages and connection errors (including termination) are propagated downstream. + - Server messages are propagated downstream. + - Connection termination, not initiated by this stream, is propagated downstream as an error. - The connection is closed when this stream is stopped. ### DOM Events diff --git a/src/main/scala/com/raquo/airstream/web/DomError.scala b/src/main/scala/com/raquo/airstream/web/DomError.scala deleted file mode 100644 index ce501293..00000000 --- a/src/main/scala/com/raquo/airstream/web/DomError.scala +++ /dev/null @@ -1,8 +0,0 @@ -package com.raquo.airstream.web - -import org.scalajs.dom - -/** - * Wraps a [[dom.Event DOM error event]]. - */ -final case class DomError(event: dom.Event) extends Exception diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketClosedError.scala b/src/main/scala/com/raquo/airstream/web/WebSocketClosedError.scala new file mode 100644 index 00000000..ce6a2f8c --- /dev/null +++ b/src/main/scala/com/raquo/airstream/web/WebSocketClosedError.scala @@ -0,0 +1,8 @@ +package com.raquo.airstream.web + +import org.scalajs.dom + +/** + * Wraps a [[dom.CloseEvent websocket closed event]]. + */ +final case class WebSocketClosedError(event: dom.CloseEvent) extends Exception diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 4b56f957..5b9623e2 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -14,7 +14,8 @@ import scala.scalajs.js * Lifecycle: * - A new connection is established when this stream is started. * - Upstream messages, if any, are transmitted on this connection. - * - Server [[dom.MessageEvent messages]] and connection [[DomError errors]] are propagated downstream. + * - Server messages are propagated downstream. + * - Connection termination, not initiated by this stream, is propagated downstream as an error. * - The connection is closed when this stream is stopped. */ class WebSocketEventStream[A](override val parent: EventStream[A], url: String)(implicit T: Transmitter[A]) @@ -42,21 +43,21 @@ class WebSocketEventStream[A](override val parent: EventStream[A], url: String)( // initialize new socket T.initialize(socket) - // update local reference + // register callbacks and update local reference socket.onopen = (_: dom.Event) => if (jsSocket.isEmpty) { - // propagate connection termination error + // https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications#Creating_a_WebSocket_object + // as per documentation, "onclose" is called right after "onerror" + // so register callback for "onclose" only + + // propagate connection close event as error socket.onclose = (e: dom.CloseEvent) => if (jsSocket.nonEmpty) { jsSocket = js.undefined - new Transaction(fireError(DomError(e), _)) + new Transaction(fireError(WebSocketClosedError(e), _)) } - // propagate connection error - socket.onerror = - (e: dom.Event) => if (jsSocket.nonEmpty) new Transaction(fireError(DomError(e), _)) - // propagate message received socket.onmessage = (e: dom.MessageEvent) => if (jsSocket.nonEmpty) new Transaction(fireValue(e, _)) @@ -69,7 +70,7 @@ class WebSocketEventStream[A](override val parent: EventStream[A], url: String)( override protected[this] def onStop(): Unit = { // Is "close" async? - // just to be safe, reset local reference before closing to prevent error propagation in "onclose" + // just to be safe, reset local reference before closing to prevent error propagation in "onclose" callback val socket = jsSocket jsSocket = js.undefined socket.foreach(_.close()) @@ -82,7 +83,7 @@ object WebSocketEventStream { /** * Returns an [[EventStream]] that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket]] connection. * - * Websocket [[dom.Event errors]], including [[dom.CloseEvent termination]], are propagated as [[DomError]]s. + * Connection termination, not initiated by this stream, is reported as a [[WebSocketClosedError]]. * * @param url '''absolute''' URL of the websocket endpoint, * use [[websocketUrl]] to construct an absolute URL from a relative one @@ -93,7 +94,7 @@ object WebSocketEventStream { /** * Returns an [[EventStream]] that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket]] connection. * - * Websocket [[dom.Event errors]], including [[dom.CloseEvent termination]], are propagated as [[DomError]]s. + * Connection termination, not initiated by this stream, is reported as a [[WebSocketClosedError]]. * * @param url '''absolute''' URL of the websocket endpoint, * use [[websocketUrl]] to construct an absolute URL from a relative one From dae60cd93001f2730350e4328aa2c5b8916adb9f Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Mon, 28 Dec 2020 17:55:17 +0530 Subject: [PATCH 15/42] Added project parameter --- README.md | 72 +++++++--- .../airstream/web/WebSocketEventStream.scala | 135 ++++++++++++------ 2 files changed, 142 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index d5e0f188..8f9194a5 100644 --- a/README.md +++ b/README.md @@ -587,41 +587,69 @@ The implementation follows that of `org.scalajs.dom.ext.ajax.apply`, but is adju ### Websockets -Airstream supports uni-directional and bi-directional websockets. +Airstream supports unidirectional and bidirectional websockets. +#### Absolute URL is required +```scala +import com.raquo.airstream.web.websocketUrl + +val url: String = websocketUrl("relative/url") +``` + +#### Unidirectional websocket stream ```scala import com.raquo.airstream.eventstream.EventStream import com.raquo.airstream.web.WebSocketEventStream import org.scalajs.dom -import scala.scalajs.js.typedarray.ArrayBuffer +// builder for creating unidirectional stream +val builder = WebSocketEventStream("absolute/url") -// absolute URL is required -// use com.raquo.airstream.web.websocketUrl to construct an absolute URL from a relative one -val url: String = ??? +// raw websocket messages +val raw: EventStream[dom.MessageEvent] = builder.raw -// uni-directional, server -> client -val s1: EventStream[dom.MessageEvent] = WebSocketEventStream(url) +// extract and cast dom.MessageEvent.data +val data: EventStream[String] = builder.data[String] -// bi-directional, transmit text from client -> server -val src2: EventStream[String] = ??? -val s2: EventStream[dom.MessageEvent] = WebSocketEventStream(url, src2) +// alias for the common usecase (data[String]) +val text: EventStream[String] = builder.text +``` -// bi-directional, transmit binary from client -> server -val src3: EventStream[ArrayBuffer] = ??? -val s3: EventStream[dom.MessageEvent] = WebSocketEventStream(url, src3) +#### Bidirectional websocket stream +Usage: +```scala +import com.raquo.airstream.eventstream.EventStream +import com.raquo.airstream.web.WebSocketEventStream +import org.scalajs.dom + +// messages to be transmitted +val transmit: EventStream[String] = ??? + +// builder for creating bidirectional stream +val builder = WebSocketEventStream("absolute/url", transmit) + +// raw websocket messages +val raw: EventStream[dom.MessageEvent] = builder.raw -// bi-directional, transmit blob from client -> server -val src4: EventStream[dom.Blob] = ??? -val s4: EventStream[dom.MessageEvent] = WebSocketEventStream(url, src4) +// extract and cast dom.MessageEvent.data +val data: EventStream[String] = builder.data[String] + +// alias for the common usecase (data[String]) +val text: EventStream[String] = builder.text ``` -The behavior of the returned stream is as follows: - - A new connection is established when this stream is started. - - Upstream messages, if any, are transmitted on this connection. - - Server messages are propagated downstream. - - Connection termination, not initiated by this stream, is propagated downstream as an error. - - The connection is closed when this stream is stopped. +Transmission is supported for the following types: + - `js.typedarray.ArrayBuffer` + - `dom.raw.Blob` + - `String` + + +#### Stream lifecycle + - A new websocket connection is established on start. + - Outgoing messages, if any, are sent on this connection. + - Incoming messages are propagated as events. + - Connection termination, not initiated by this stream, is propagated as `WebSocketClosedError` error. + - The connection is closed on stop. ### DOM Events diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 5b9623e2..4ca9fdaf 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -3,25 +3,27 @@ package com.raquo.airstream.web import com.raquo.airstream.core.Transaction import com.raquo.airstream.eventstream.EventStream import com.raquo.airstream.features.{InternalNextErrorObserver, SingleParentObservable} -import com.raquo.airstream.web.WebSocketEventStream.Transmitter +import com.raquo.airstream.web.WebSocketEventStream.Driver import org.scalajs.dom import scala.scalajs.js +import scala.util.{Success, Try} /** - * [[WebSocketEventStream]] emits messages from a [[dom.WebSocket]] connection. + * An event source that emits messages from a [[dom.WebSocket]] connection. * - * Lifecycle: - * - A new connection is established when this stream is started. - * - Upstream messages, if any, are transmitted on this connection. - * - Server messages are propagated downstream. - * - Connection termination, not initiated by this stream, is propagated downstream as an error. - * - The connection is closed when this stream is stopped. + * Stream lifecycle: + * - A new websocket connection is established on start. + * - Outgoing messages, if any, are sent on this connection. + * - Incoming messages are propagated as events. + * - Connection termination, not initiated by this stream, is propagated as [[WebSocketClosedError error]]. + * - The connection is closed on stop. */ -class WebSocketEventStream[A](override val parent: EventStream[A], url: String)(implicit T: Transmitter[A]) - extends EventStream[dom.MessageEvent] - with SingleParentObservable[A, dom.MessageEvent] - with InternalNextErrorObserver[A] { +class WebSocketEventStream[I, O]( + override val parent: EventStream[I], + project: dom.MessageEvent => Try[O], + url: String +)(implicit D: Driver[I]) extends EventStream[O] with SingleParentObservable[I, O] with InternalNextErrorObserver[I] { protected[airstream] val topoRank: Int = 1 @@ -31,9 +33,9 @@ class WebSocketEventStream[A](override val parent: EventStream[A], url: String)( // noop } - protected[airstream] def onNext(nextValue: A, transaction: Transaction): Unit = { + protected[airstream] def onNext(nextValue: I, transaction: Transaction): Unit = { // transmit upstream message, no guard required since transmitter is trusted - jsSocket.foreach(T.transmit(_, nextValue)) + jsSocket.foreach(D.transmit(_, nextValue)) } override protected[this] def onStart(): Unit = { @@ -41,7 +43,7 @@ class WebSocketEventStream[A](override val parent: EventStream[A], url: String)( val socket = new dom.WebSocket(url) // initialize new socket - T.initialize(socket) + D.initialize(socket) // register callbacks and update local reference socket.onopen = @@ -60,7 +62,9 @@ class WebSocketEventStream[A](override val parent: EventStream[A], url: String)( // propagate message received socket.onmessage = - (e: dom.MessageEvent) => if (jsSocket.nonEmpty) new Transaction(fireValue(e, _)) + (e: dom.MessageEvent) => if (jsSocket.nonEmpty) { + project(e).fold(e => new Transaction(fireError(e, _)), o => new Transaction(fireValue(o, _))) + } jsSocket = socket } @@ -81,51 +85,96 @@ class WebSocketEventStream[A](override val parent: EventStream[A], url: String)( object WebSocketEventStream { /** - * Returns an [[EventStream]] that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket]] connection. - * - * Connection termination, not initiated by this stream, is reported as a [[WebSocketClosedError]]. + * Builder for unidirectional websocket stream. * - * @param url '''absolute''' URL of the websocket endpoint, + * @param url absolute URL of a websocket endpoint, * use [[websocketUrl]] to construct an absolute URL from a relative one */ - def apply(url: String): EventStream[dom.MessageEvent] = - apply[Void](url, EventStream.empty) + def apply(url: String): Builder[Void] = + new Builder[Void](EventStream.empty, url) /** - * Returns an [[EventStream]] that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket]] connection. + * Builder for bidirectional websocket stream. * - * Connection termination, not initiated by this stream, is reported as a [[WebSocketClosedError]]. + * Transmission is supported for the following types: + * - [[js.typedarray.ArrayBuffer]] + * - [[dom.raw.Blob]] + * - [[String]] * - * @param url '''absolute''' URL of the websocket endpoint, + * @param url absolute URL of a websocket endpoint, * use [[websocketUrl]] to construct an absolute URL from a relative one - * @param transmit stream of messages to be transmitted to the websocket endpoint + * @param transmit message to be transmitted from client to server */ - def apply[A: Transmitter](url: String, transmit: EventStream[A]): EventStream[dom.MessageEvent] = - new WebSocketEventStream(transmit, url) + def apply[I: Driver](url: String, transmit: EventStream[I]): Builder[I] = + new Builder(transmit, url) + + private def apply[I: Driver, O]( + transmit: EventStream[I], + project: dom.MessageEvent => Try[O], + url: String + ): EventStream[O] = + new WebSocketEventStream(transmit, project, url) - sealed abstract class Transmitter[A] { + sealed abstract class Driver[A] { def initialize(socket: dom.WebSocket): Unit + def transmit(socket: dom.WebSocket, data: A): Unit } - object Transmitter { + final class Builder[I: Driver](transmit: EventStream[I], url: String) { + + /** + * Returns a stream that extracts data from raw [[dom.MessageEvent messages]] and emits them. + * + * @see [[raw]] + */ + def data[O]: EventStream[O] = + WebSocketEventStream(transmit, m => Try(m.data.asInstanceOf[O]), url) + + /** + * Returns a stream that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket websocket]] connection. + * + * Stream lifecycle: + * - A new websocket connection is established on start. + * - Outgoing messages, if any, are sent on this connection. + * - Incoming messages are propagated as events. + * - Connection termination, not initiated by this stream, is propagated as [[WebSocketClosedError error]]. + * - The connection is closed on stop. + */ + def raw: EventStream[dom.MessageEvent] = + WebSocketEventStream(transmit, Success.apply, url) + + /** + * Returns a stream that extracts text data from raw [[dom.MessageEvent messages]] and emits them. + * + * @see [[raw]] + */ + def text: EventStream[String] = + data[String] + } - private def binary[A](send: (dom.WebSocket, A) => Unit, binaryType: String): Transmitter[A] = - new Transmitter[A] { - final def initialize(socket: dom.WebSocket): Unit = socket.binaryType = binaryType - final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) - } + object Driver { - private def simple[A](send: (dom.WebSocket, A) => Unit): Transmitter[A] = - new Transmitter[A] { - final def initialize(socket: dom.WebSocket): Unit = () - final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + implicit val binaryTransmitter: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") + implicit val blobTransmitter: Driver[dom.Blob] = binary(_ send _, "blob") + implicit val stringTransmitter: Driver[String] = simple(_ send _) + implicit val voidTransmitter: Driver[Void] = simple((_, _) => ()) + + private def binary[A](send: (dom.WebSocket, A) => Unit, binaryType: String): Driver[A] = + new Driver[A] { + + final def initialize(socket: dom.WebSocket): Unit = socket.binaryType = binaryType + + final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) } - implicit val binaryTransmitter: Transmitter[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") - implicit val blobTransmitter: Transmitter[dom.Blob] = binary(_ send _, "blob") - implicit val stringTransmitter: Transmitter[String] = simple(_ send _) - implicit val voidTransmitter: Transmitter[Void] = simple((_, _) => ()) + private def simple[A](send: (dom.WebSocket, A) => Unit): Driver[A] = + new Driver[A] { + + final def initialize(socket: dom.WebSocket): Unit = () + + final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + } } } From 7a19cddc2a3e59c2f2f3f70f9e177d4468de1c59 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Mon, 28 Dec 2020 17:56:49 +0530 Subject: [PATCH 16/42] Fix names --- .../com/raquo/airstream/web/WebSocketEventStream.scala | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 4ca9fdaf..98b37f1b 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -34,7 +34,7 @@ class WebSocketEventStream[I, O]( } protected[airstream] def onNext(nextValue: I, transaction: Transaction): Unit = { - // transmit upstream message, no guard required since transmitter is trusted + // transmit upstream message, no guard required since driver is trusted jsSocket.foreach(D.transmit(_, nextValue)) } @@ -156,10 +156,10 @@ object WebSocketEventStream { object Driver { - implicit val binaryTransmitter: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") - implicit val blobTransmitter: Driver[dom.Blob] = binary(_ send _, "blob") - implicit val stringTransmitter: Driver[String] = simple(_ send _) - implicit val voidTransmitter: Driver[Void] = simple((_, _) => ()) + implicit val binaryDriver: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") + implicit val blobDriver: Driver[dom.Blob] = binary(_ send _, "blob") + implicit val stringDriver: Driver[String] = simple(_ send _) + implicit val voidDriver: Driver[Void] = simple((_, _) => ()) private def binary[A](send: (dom.WebSocket, A) => Unit, binaryType: String): Driver[A] = new Driver[A] { From 040352ef9d4adae3f1f6c69132a9d7cf7a08d90b Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Mon, 28 Dec 2020 18:29:55 +0530 Subject: [PATCH 17/42] Redesigned error type --- README.md | 6 +++++- .../com/raquo/airstream/web/WebSocketClosedError.scala | 8 -------- .../scala/com/raquo/airstream/web/WebSocketError.scala | 3 +++ .../com/raquo/airstream/web/WebSocketEventStream.scala | 10 ++++++---- src/main/scala/com/raquo/airstream/web/package.scala | 2 ++ 5 files changed, 16 insertions(+), 13 deletions(-) delete mode 100644 src/main/scala/com/raquo/airstream/web/WebSocketClosedError.scala create mode 100644 src/main/scala/com/raquo/airstream/web/WebSocketError.scala diff --git a/README.md b/README.md index 8f9194a5..938db83b 100644 --- a/README.md +++ b/README.md @@ -643,12 +643,16 @@ Transmission is supported for the following types: - `dom.raw.Blob` - `String` +#### Errors + - A connection termination is propagated as a `WebSocketClosed` error. + - Transmission attempt on a terminated connection is propagated as a `WebSocketError` (with the message to be transmitted). #### Stream lifecycle - A new websocket connection is established on start. - Outgoing messages, if any, are sent on this connection. + - Transmission failures, due to connection termination, are propagated as errors. + - Connection termination, not initiated by this stream, is propagated as an error. - Incoming messages are propagated as events. - - Connection termination, not initiated by this stream, is propagated as `WebSocketClosedError` error. - The connection is closed on stop. ### DOM Events diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketClosedError.scala b/src/main/scala/com/raquo/airstream/web/WebSocketClosedError.scala deleted file mode 100644 index ce6a2f8c..00000000 --- a/src/main/scala/com/raquo/airstream/web/WebSocketClosedError.scala +++ /dev/null @@ -1,8 +0,0 @@ -package com.raquo.airstream.web - -import org.scalajs.dom - -/** - * Wraps a [[dom.CloseEvent websocket closed event]]. - */ -final case class WebSocketClosedError(event: dom.CloseEvent) extends Exception diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketError.scala b/src/main/scala/com/raquo/airstream/web/WebSocketError.scala new file mode 100644 index 00000000..58532182 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/web/WebSocketError.scala @@ -0,0 +1,3 @@ +package com.raquo.airstream.web + +final case class WebSocketError[E](event: E) extends Exception diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 98b37f1b..a3b8fac3 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -15,8 +15,9 @@ import scala.util.{Success, Try} * Stream lifecycle: * - A new websocket connection is established on start. * - Outgoing messages, if any, are sent on this connection. + * - Transmission failures, due to connection termination, are propagated as errors. + * - Connection termination, not initiated by this stream, is propagated as an error. * - Incoming messages are propagated as events. - * - Connection termination, not initiated by this stream, is propagated as [[WebSocketClosedError error]]. * - The connection is closed on stop. */ class WebSocketEventStream[I, O]( @@ -35,7 +36,7 @@ class WebSocketEventStream[I, O]( protected[airstream] def onNext(nextValue: I, transaction: Transaction): Unit = { // transmit upstream message, no guard required since driver is trusted - jsSocket.foreach(D.transmit(_, nextValue)) + jsSocket.fold(fireError(WebSocketError(nextValue), transaction))(D.transmit(_, nextValue)) } override protected[this] def onStart(): Unit = { @@ -57,7 +58,7 @@ class WebSocketEventStream[I, O]( socket.onclose = (e: dom.CloseEvent) => if (jsSocket.nonEmpty) { jsSocket = js.undefined - new Transaction(fireError(WebSocketClosedError(e), _)) + new Transaction(fireError(WebSocketError(e), _)) } // propagate message received @@ -138,8 +139,9 @@ object WebSocketEventStream { * Stream lifecycle: * - A new websocket connection is established on start. * - Outgoing messages, if any, are sent on this connection. + * - Transmission failures, due to connection termination, are propagated as errors. + * - Connection termination, not initiated by this stream, is propagated as an error. * - Incoming messages are propagated as events. - * - Connection termination, not initiated by this stream, is propagated as [[WebSocketClosedError error]]. * - The connection is closed on stop. */ def raw: EventStream[dom.MessageEvent] = diff --git a/src/main/scala/com/raquo/airstream/web/package.scala b/src/main/scala/com/raquo/airstream/web/package.scala index 8a0d87f4..7954572a 100644 --- a/src/main/scala/com/raquo/airstream/web/package.scala +++ b/src/main/scala/com/raquo/airstream/web/package.scala @@ -4,6 +4,8 @@ import org.scalajs.dom package object web { + type WebSocketClosed = WebSocketError[dom.CloseEvent] + /** * Constructs and returns an absolute websocket URL from a relative one. */ From 8883fdba9e6508342b71b2990e36c13b662b406a Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Mon, 28 Dec 2020 18:40:35 +0530 Subject: [PATCH 18/42] Remove unused alias --- README.md | 2 +- src/main/scala/com/raquo/airstream/web/package.scala | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 938db83b..1fb58fb7 100644 --- a/README.md +++ b/README.md @@ -644,7 +644,7 @@ Transmission is supported for the following types: - `String` #### Errors - - A connection termination is propagated as a `WebSocketClosed` error. + - A connection termination is propagated as a `WebSocketError` (with a `dom.CloseEvent`). - Transmission attempt on a terminated connection is propagated as a `WebSocketError` (with the message to be transmitted). #### Stream lifecycle diff --git a/src/main/scala/com/raquo/airstream/web/package.scala b/src/main/scala/com/raquo/airstream/web/package.scala index 7954572a..8a0d87f4 100644 --- a/src/main/scala/com/raquo/airstream/web/package.scala +++ b/src/main/scala/com/raquo/airstream/web/package.scala @@ -4,8 +4,6 @@ import org.scalajs.dom package object web { - type WebSocketClosed = WebSocketError[dom.CloseEvent] - /** * Constructs and returns an absolute websocket URL from a relative one. */ From 181a487a0d185be7f6425dd5728df100b0c13054 Mon Sep 17 00:00:00 2001 From: Binh Nguyen Date: Wed, 30 Dec 2020 08:42:46 +0700 Subject: [PATCH 19/42] Build: Use sbt-tpolcat for scalac warnings; bump sbt and Scala versions (#57) --- .gitignore | 2 ++ .travis.yml | 4 ++-- build.sbt | 18 ++++++++++----- project/build.properties | 2 +- project/plugins.sbt | 2 ++ release.sbt | 4 ---- .../scala-2.12/scala/annotation/unused.scala | 3 +++ .../com/raquo/airstream/core/Observable.scala | 7 +++--- .../com/raquo/airstream/core/Observer.scala | 2 +- .../airstream/eventbus/EventBusStream.scala | 8 +------ .../raquo/airstream/eventbus/WriteBus.scala | 2 +- .../eventstream/ConcurrentEventStream.scala | 3 ++- .../eventstream/DebounceEventStream.scala | 2 +- .../eventstream/DelayEventStream.scala | 6 +++-- .../airstream/eventstream/EventStream.scala | 4 +--- .../eventstream/SwitchEventStream.scala | 4 +++- .../airstream/ownership/DynamicOwner.scala | 8 ------- .../com/raquo/airstream/ownership/Owner.scala | 3 ++- .../com/raquo/airstream/signal/Signal.scala | 2 +- .../com/raquo/airstream/AsyncUnitSpec.scala | 3 +-- .../com/raquo/airstream/core/GlitchSpec.scala | 1 - .../airstream/errors/ObserverErrorSpec.scala | 2 +- .../EventStreamFlattenFutureSpec.scala | 6 ++--- .../eventstream/EventStreamFlattenSpec.scala | 4 ++-- .../EventStreamFromFutureSpec.scala | 14 ++++++------ .../eventstream/PeriodicEventStreamSpec.scala | 12 +++++----- .../eventstream/SwitchEventStreamSpec.scala | 4 ++-- .../airstream/fixtures/Calculation.scala | 15 +++++++++++++ .../com/raquo/airstream/fixtures/Effect.scala | 3 +++ .../raquo/airstream/fixtures/package.scala | 20 ----------------- .../ownership/DynamicOwnerSpec.scala | 2 +- .../signal/SampleCombineSignal2Spec.scala | 1 - .../signal/SignalFlattenFutureSpec.scala | 8 +++---- .../signal/SignalFromFutureSpec.scala | 22 +++++++++---------- .../airstream/signal/SwitchSignalSpec.scala | 2 +- .../com/raquo/airstream/signal/VarSpec.scala | 6 ++--- 36 files changed, 102 insertions(+), 109 deletions(-) create mode 100644 src/main/scala-2.12/scala/annotation/unused.scala create mode 100644 src/test/scala/com/raquo/airstream/fixtures/Calculation.scala create mode 100644 src/test/scala/com/raquo/airstream/fixtures/Effect.scala delete mode 100644 src/test/scala/com/raquo/airstream/fixtures/package.scala diff --git a/.gitignore b/.gitignore index c333d1fc..d6f8ff60 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ target .DS_Store yarn.lock + +/.bsp diff --git a/.travis.yml b/.travis.yml index 5e3c11bf..ae490108 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,8 +9,8 @@ jdk: - oraclejdk11 scala: - - 2.12.10 - - 2.13.1 + - 2.12.12 + - 2.13.4 script: - sbt ++$TRAVIS_SCALA_VERSION test diff --git a/build.sbt b/build.sbt index cae911c2..8b75e7e3 100644 --- a/build.sbt +++ b/build.sbt @@ -7,12 +7,18 @@ libraryDependencies ++= Seq( "org.scalatest" %%% "scalatest" % "3.2.0" % Test ) -scalacOptions ++= Seq( - // "-deprecation", - "-feature", - "-language:higherKinds", - "-language:implicitConversions" -) +val filterScalacOptions = { options: Seq[String] => + options.filterNot(Set( + "-Ywarn-value-discard", + "-Wvalue-discard" + )) +} + +scalaVersion := "2.13.4" + +crossScalaVersions := Seq("2.12.12", "2.13.4") + +scalacOptions ~= filterScalacOptions // @TODO[Build] Why does this need " in (Compile, doc)" while other options don't? scalacOptions in (Compile, doc) ++= Seq( diff --git a/project/build.properties b/project/build.properties index c288de4e..daac5dbf 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.3.4 +sbt.version = 1.4.6 diff --git a/project/plugins.sbt b/project/plugins.sbt index 4c5f81f8..0e5dce8e 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -9,3 +9,5 @@ addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.1") addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.8") addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.8.1") + +addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.16") diff --git a/release.sbt b/release.sbt index a5629b34..92e94ece 100644 --- a/release.sbt +++ b/release.sbt @@ -4,10 +4,6 @@ normalizedName := "airstream" organization := "com.raquo" -scalaVersion := "2.13.3" - -crossScalaVersions := Seq("2.12.11", "2.13.3") - homepage := Some(url("https://github.com/raquo/Airstream")) licenses += ("MIT", url("https://github.com/raquo/Airstream/blob/master/LICENSE.md")) diff --git a/src/main/scala-2.12/scala/annotation/unused.scala b/src/main/scala-2.12/scala/annotation/unused.scala new file mode 100644 index 00000000..8fa03221 --- /dev/null +++ b/src/main/scala-2.12/scala/annotation/unused.scala @@ -0,0 +1,3 @@ +package scala.annotation + +final class unused extends deprecated("unused", "unused") diff --git a/src/main/scala/com/raquo/airstream/core/Observable.scala b/src/main/scala/com/raquo/airstream/core/Observable.scala index fa621045..0018f701 100644 --- a/src/main/scala/com/raquo/airstream/core/Observable.scala +++ b/src/main/scala/com/raquo/airstream/core/Observable.scala @@ -1,12 +1,13 @@ package com.raquo.airstream.core import com.raquo.airstream.eventstream.EventStream -import com.raquo.airstream.features.{FlattenStrategy, Splittable} +import com.raquo.airstream.features.FlattenStrategy import com.raquo.airstream.features.FlattenStrategy.{SwitchSignalStrategy, SwitchStreamStrategy} import com.raquo.airstream.ownership.{Owner, Subscription} import com.raquo.airstream.signal.Signal import org.scalajs.dom +import scala.annotation.unused import scala.scalajs.js import scala.util.Try @@ -90,7 +91,7 @@ trait Observable[+A] { def debugLog(prefix: String = "event", when: A => Boolean = _ => true): Self[A] = { map(value => { if (when(value)) { - println(prefix + ": ", value.asInstanceOf[js.Any]) + println(prefix + ": " + value.asInstanceOf[js.Any]) } value }) @@ -150,7 +151,7 @@ trait Observable[+A] { subscription } - @inline protected def onAddedExternalObserver(observer: Observer[A]): Unit = () + @inline protected def onAddedExternalObserver(@unused observer: Observer[A]): Unit = () /** Child observable should call this method on its parents when it is started. * This observable calls [[onStart]] if this action has given it its first observer (internal or external). diff --git a/src/main/scala/com/raquo/airstream/core/Observer.scala b/src/main/scala/com/raquo/airstream/core/Observer.scala index e4eb0f41..e9e9d198 100644 --- a/src/main/scala/com/raquo/airstream/core/Observer.scala +++ b/src/main/scala/com/raquo/airstream/core/Observer.scala @@ -126,7 +126,7 @@ object Observer { if (onTryParam.isDefinedAt(nextValue)) { onTryParam(nextValue) } else { - nextValue.fold(err => AirstreamError.sendUnhandledError(err), identity) + nextValue.fold(err => AirstreamError.sendUnhandledError(err), _ => ()) } } catch { case err: Throwable => diff --git a/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala b/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala index 706c0617..34237da7 100644 --- a/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala +++ b/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala @@ -3,10 +3,9 @@ package com.raquo.airstream.eventbus import com.raquo.airstream.core.Transaction import com.raquo.airstream.eventstream.EventStream import com.raquo.airstream.features.InternalNextErrorObserver - import scala.scalajs.js -class EventBusStream[A] private[eventbus] (writeBus: WriteBus[A]) extends EventStream[A] with InternalNextErrorObserver[A] { +class EventBusStream[A] private[eventbus] () extends EventStream[A] with InternalNextErrorObserver[A] { private[eventbus] val sourceStreams: js.Array[EventStream[A]] = js.Array() @@ -70,8 +69,3 @@ class EventBusStream[A] private[eventbus] (writeBus: WriteBus[A]) extends EventS sourceStreams.foreach(sourceStream => Transaction.removeInternalObserver(sourceStream, observer = this)) } } - -object EventBusStream { - - -} diff --git a/src/main/scala/com/raquo/airstream/eventbus/WriteBus.scala b/src/main/scala/com/raquo/airstream/eventbus/WriteBus.scala index f48eb713..e69c2bd8 100644 --- a/src/main/scala/com/raquo/airstream/eventbus/WriteBus.scala +++ b/src/main/scala/com/raquo/airstream/eventbus/WriteBus.scala @@ -12,7 +12,7 @@ class WriteBus[A] extends Observer[A] { /** Hidden here because the public interface of WriteBus is all about writing * rather than reading, but exposed in [[EventBus]] */ - private[eventbus] val stream: EventBusStream[A] = new EventBusStream(this) + private[eventbus] val stream: EventBusStream[A] = new EventBusStream() /** Note: this source will be removed when the `owner` you provide says so. * To remove this source manually, call .kill() on the resulting Subscription. diff --git a/src/main/scala/com/raquo/airstream/eventstream/ConcurrentEventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/ConcurrentEventStream.scala index b4b3d315..71830edd 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/ConcurrentEventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/ConcurrentEventStream.scala @@ -22,7 +22,8 @@ class ConcurrentEventStream[A]( private val accumulatedStreams: js.Array[EventStream[A]] = js.Array() private val internalEventObserver: InternalObserver[A] = InternalObserver[A]( - onNext = (nextEvent, _) => new Transaction(fireValue(nextEvent, _)), + onNext = (nextEvent, _) => new Transaction(fireValue(nextEvent, _)) + , onError = (nextError, _) => new Transaction(fireError(nextError, _)) ) diff --git a/src/main/scala/com/raquo/airstream/eventstream/DebounceEventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/DebounceEventStream.scala index cf281f26..d2c24cdb 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/DebounceEventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/DebounceEventStream.scala @@ -32,7 +32,7 @@ class DebounceEventStream[A]( override protected[airstream] def onTry(nextValue: Try[A], transaction: Transaction): Unit = { maybeLastTimeoutHandle.foreach(js.timers.clearTimeout) maybeLastTimeoutHandle = js.defined( - js.timers.setTimeout(delayFromLastEventMillis) { + js.timers.setTimeout(delayFromLastEventMillis.toDouble) { //println(s"> init trx from DebounceEventStream.onTry($nextValue)") new Transaction(fireTry(nextValue, _)) } diff --git a/src/main/scala/com/raquo/airstream/eventstream/DelayEventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/DelayEventStream.scala index f35379f6..c3febb49 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/DelayEventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/DelayEventStream.scala @@ -18,19 +18,21 @@ class DelayEventStream[A]( override protected[airstream] def onNext(nextValue: A, transaction: Transaction): Unit = { var timerHandle: SetTimeoutHandle = null - timerHandle = js.timers.setTimeout(delayMillis) { + timerHandle = js.timers.setTimeout(delayMillis.toDouble) { //println(s"> init trx from DelayEventStream.onNext($nextValue)") timerHandles.splice(timerHandles.indexOf(timerHandle), deleteCount = 1) // Remove handle new Transaction(fireValue(nextValue, _)) + () } timerHandles.push(timerHandle) } override def onError(nextError: Throwable, transaction: Transaction): Unit = { var timerHandle: SetTimeoutHandle = null - timerHandle = js.timers.setTimeout(delayMillis) { + timerHandle = js.timers.setTimeout(delayMillis.toDouble) { timerHandles.splice(timerHandles.indexOf(timerHandle), deleteCount = 1) // Remove handle new Transaction(fireError(nextError, _)) + () } timerHandles.push(timerHandle) } diff --git a/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala index 18c64bcd..873d07b7 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala @@ -2,9 +2,8 @@ package com.raquo.airstream.eventstream import com.raquo.airstream.core.AirstreamError.ObserverError import com.raquo.airstream.core.{AirstreamError, Observable, Transaction} -import com.raquo.airstream.features.{CombineObservable, Splittable} +import com.raquo.airstream.features.CombineObservable import com.raquo.airstream.signal.{FoldLeftSignal, Signal, SignalFromEventStream} - import scala.concurrent.Future import scala.scalajs.js import scala.util.{Failure, Success, Try} @@ -178,7 +177,6 @@ object EventStream { /** @param emitOnce if true, the event will be emitted at most one time. * If false, the event will be emitted every time the stream is started. */ - @deprecated("Use `fromValue` or `empty` (see docs)", "0.4") // @TODO Are we sure we want to deprecate this? def fromSeq[A](events: Seq[A], emitOnce: Boolean): EventStream[A] = { new SeqEventStream[A](events.map(Success(_)), emitOnce) } diff --git a/src/main/scala/com/raquo/airstream/eventstream/SwitchEventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/SwitchEventStream.scala index ce28529d..028b8742 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/SwitchEventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/SwitchEventStream.scala @@ -47,7 +47,9 @@ class SwitchEventStream[I, O]( //println(s"> init trx from SwitchEventStream.onValue(${nextEvent})") new Transaction(fireValue(nextEvent, _)) }, - onError = (nextError, _) => new Transaction(fireError(nextError, _)) + onError = (nextError, _) => { + new Transaction(fireError(nextError, _)) + } ) override protected[airstream] def onNext(nextValue: I, transaction: Transaction): Unit = { diff --git a/src/main/scala/com/raquo/airstream/ownership/DynamicOwner.scala b/src/main/scala/com/raquo/airstream/ownership/DynamicOwner.scala index 408fc1be..ae6b5a53 100644 --- a/src/main/scala/com/raquo/airstream/ownership/DynamicOwner.scala +++ b/src/main/scala/com/raquo/airstream/ownership/DynamicOwner.scala @@ -1,7 +1,5 @@ package com.raquo.airstream.ownership -import com.raquo.airstream.ownership.DynamicOwner.PrivateOwner - import scala.scalajs.js // @Warning[Fragile] @@ -128,9 +126,3 @@ class DynamicOwner(onAccessAfterKilled: () => Unit) { } } } - -object DynamicOwner { - - /** This owner has no special logic, it is managed by the containing DynamicOwner */ - private class PrivateOwner extends Owner {} -} diff --git a/src/main/scala/com/raquo/airstream/ownership/Owner.scala b/src/main/scala/com/raquo/airstream/ownership/Owner.scala index 88686637..24133c99 100644 --- a/src/main/scala/com/raquo/airstream/ownership/Owner.scala +++ b/src/main/scala/com/raquo/airstream/ownership/Owner.scala @@ -1,5 +1,6 @@ package com.raquo.airstream.ownership +import scala.annotation.unused import scala.scalajs.js /** Owner decides when to kill its subscriptions. @@ -32,7 +33,7 @@ trait Owner { * You can override it to add custom behaviour. * Note: You can rely on this base method being empty. */ - protected[this] def onOwned(subscription: Subscription): Unit = () + protected[this] def onOwned(@unused subscription: Subscription): Unit = () private[ownership] def onKilledExternally(subscription: Subscription): Unit = { val index = subscriptions.indexOf(subscription) diff --git a/src/main/scala/com/raquo/airstream/signal/Signal.scala b/src/main/scala/com/raquo/airstream/signal/Signal.scala index 27a3fee6..9d69f436 100644 --- a/src/main/scala/com/raquo/airstream/signal/Signal.scala +++ b/src/main/scala/com/raquo/airstream/signal/Signal.scala @@ -198,7 +198,7 @@ trait Signal[+A] extends Observable[A] { // We want to report unhandled errors on such signals if they have no observers (including internal observers) // because if we don't, the error will not be reported anywhere, and I think we would usually want it. if (isError && !errorReported) { - nextValue.fold(AirstreamError.sendUnhandledError, identity) + nextValue.fold(AirstreamError.sendUnhandledError, _ => ()) } } } diff --git a/src/test/scala/com/raquo/airstream/AsyncUnitSpec.scala b/src/test/scala/com/raquo/airstream/AsyncUnitSpec.scala index 62f953cc..da353245 100644 --- a/src/test/scala/com/raquo/airstream/AsyncUnitSpec.scala +++ b/src/test/scala/com/raquo/airstream/AsyncUnitSpec.scala @@ -2,7 +2,6 @@ package com.raquo.airstream import org.scalatest.funspec.AsyncFunSpec import org.scalatest.matchers.should.Matchers - import scala.concurrent.{ExecutionContext, Future, Promise} import scala.scalajs.js import scala.util.Try @@ -17,7 +16,7 @@ class AsyncUnitSpec extends AsyncFunSpec with Matchers { def delay[V](millis: Int)(value: => V): Future[V] = { val promise = Promise[V]() - js.timers.setTimeout(millis) { + js.timers.setTimeout(millis.toDouble) { promise.complete(Try(value)) } promise.future.map(identity)(executionContext) diff --git a/src/test/scala/com/raquo/airstream/core/GlitchSpec.scala b/src/test/scala/com/raquo/airstream/core/GlitchSpec.scala index d3f48f16..b06e6b62 100644 --- a/src/test/scala/com/raquo/airstream/core/GlitchSpec.scala +++ b/src/test/scala/com/raquo/airstream/core/GlitchSpec.scala @@ -4,7 +4,6 @@ import com.raquo.airstream.UnitSpec import com.raquo.airstream.eventbus.EventBus import com.raquo.airstream.eventstream.EventStream import com.raquo.airstream.fixtures.{Calculation, Effect, TestableOwner} -import com.raquo.airstream.ownership.Owner import com.raquo.airstream.signal.Var import scala.collection.mutable diff --git a/src/test/scala/com/raquo/airstream/errors/ObserverErrorSpec.scala b/src/test/scala/com/raquo/airstream/errors/ObserverErrorSpec.scala index efc677cb..c4adf195 100644 --- a/src/test/scala/com/raquo/airstream/errors/ObserverErrorSpec.scala +++ b/src/test/scala/com/raquo/airstream/errors/ObserverErrorSpec.scala @@ -60,7 +60,7 @@ class ObserverErrorSpec extends UnitSpec with BeforeAndAfter { signal.addObserver(Observer.withRecover( num => if (num % 2 == 0) effects += Effect("sub3", num) else throw err3, - { case err => throw err31 } + { case _ => throw err31 } )) signal.addObserver(Observer.withRecover( diff --git a/src/test/scala/com/raquo/airstream/eventstream/EventStreamFlattenFutureSpec.scala b/src/test/scala/com/raquo/airstream/eventstream/EventStreamFlattenFutureSpec.scala index 25980ced..989dcb24 100644 --- a/src/test/scala/com/raquo/airstream/eventstream/EventStreamFlattenFutureSpec.scala +++ b/src/test/scala/com/raquo/airstream/eventstream/EventStreamFlattenFutureSpec.scala @@ -18,7 +18,7 @@ class EventStreamFlattenFutureSpec extends AsyncUnitSpec { // We should better demonstrate the difference between this strategy and OverflowFutureFlattenStrategy // Basically, this strategy would fail the `promise5` part of overflow strategy's spec (see below) - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner val effects = mutable.Buffer[Effect[Int]]() @@ -90,7 +90,7 @@ class EventStreamFlattenFutureSpec extends AsyncUnitSpec { it("EventStream.flatten(ConcurrentFutureStrategy)") { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner val effects = mutable.Buffer[Effect[Int]]() @@ -165,7 +165,7 @@ class EventStreamFlattenFutureSpec extends AsyncUnitSpec { it("EventStream.flatten(OverwriteFutureStrategy)") { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner val effects = mutable.Buffer[Effect[Int]]() diff --git a/src/test/scala/com/raquo/airstream/eventstream/EventStreamFlattenSpec.scala b/src/test/scala/com/raquo/airstream/eventstream/EventStreamFlattenSpec.scala index 57b67ca0..e92429dc 100644 --- a/src/test/scala/com/raquo/airstream/eventstream/EventStreamFlattenSpec.scala +++ b/src/test/scala/com/raquo/airstream/eventstream/EventStreamFlattenSpec.scala @@ -313,7 +313,7 @@ class EventStreamFlattenSpec extends AsyncUnitSpec { // -- - val sub2 = mergeStream.addObserver(Observer.empty) + mergeStream.addObserver(Observer.empty) bus1.writer.onNext(5) bus2.writer.onNext(30) bus3.writer.onNext(200) @@ -422,7 +422,7 @@ class EventStreamFlattenSpec extends AsyncUnitSpec { // -- - val sub2 = mergeSignal.addObserver(Observer.empty) + mergeSignal.addObserver(Observer.empty) bus1.writer.onNext(5) bus2.writer.onNext(30) bus3.writer.onNext(200) // `stream3` is current value of mergeSignal diff --git a/src/test/scala/com/raquo/airstream/eventstream/EventStreamFromFutureSpec.scala b/src/test/scala/com/raquo/airstream/eventstream/EventStreamFromFutureSpec.scala index 30f9e144..d0ef2fe6 100644 --- a/src/test/scala/com/raquo/airstream/eventstream/EventStreamFromFutureSpec.scala +++ b/src/test/scala/com/raquo/airstream/eventstream/EventStreamFromFutureSpec.scala @@ -10,15 +10,15 @@ import scala.concurrent.Promise class EventStreamFromFutureSpec extends AsyncUnitSpec with BeforeAndAfter { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner - val calculations = mutable.Buffer[Calculation[Int]]() - val effects = mutable.Buffer[Effect[Int]]() + private val calculations = mutable.Buffer[Calculation[Int]]() + private val effects = mutable.Buffer[Effect[Int]]() - val obs1 = Observer[Int](effects += Effect("obs1", _)) - val obs2 = Observer[Int](effects += Effect("obs2", _)) + private val obs1 = Observer[Int](effects += Effect("obs1", _)) + private val obs2 = Observer[Int](effects += Effect("obs2", _)) - def makePromise() = Promise[Int]() + def makePromise(): Promise[Int] = Promise[Int]() def clearLogs(): Assertion = { calculations.clear() @@ -26,7 +26,7 @@ class EventStreamFromFutureSpec extends AsyncUnitSpec with BeforeAndAfter { assert(true) } - def makeStream(promise: Promise[Int]) = EventStream + def makeStream(promise: Promise[Int]): EventStream[Int] = EventStream .fromFuture(promise.future) .map(Calculation.log("stream", calculations)) diff --git a/src/test/scala/com/raquo/airstream/eventstream/PeriodicEventStreamSpec.scala b/src/test/scala/com/raquo/airstream/eventstream/PeriodicEventStreamSpec.scala index 5eff6269..06103b03 100644 --- a/src/test/scala/com/raquo/airstream/eventstream/PeriodicEventStreamSpec.scala +++ b/src/test/scala/com/raquo/airstream/eventstream/PeriodicEventStreamSpec.scala @@ -57,7 +57,7 @@ class PeriodicEventStreamSpec extends AsyncUnitSpec with BeforeAndAfter { _ <- delay(20) { effects shouldEqual mutable.Buffer() } - sub2 = stream.addObserver(obs1) + _ = stream.addObserver(obs1) _ = { effects shouldEqual mutable.Buffer(Effect("obs1", 0)) effects.clear() @@ -104,7 +104,7 @@ class PeriodicEventStreamSpec extends AsyncUnitSpec with BeforeAndAfter { _ <- delay(20) { effects shouldEqual mutable.Buffer() } - sub2 = stream.addObserver(obs1) + _ = stream.addObserver(obs1) _ = { effects shouldEqual mutable.Buffer() } @@ -152,7 +152,7 @@ class PeriodicEventStreamSpec extends AsyncUnitSpec with BeforeAndAfter { _ <- delay(20) { effects shouldEqual mutable.Buffer() } - sub2 = stream.addObserver(obs1) + _ = stream.addObserver(obs1) _ = { effects shouldEqual mutable.Buffer(Effect("obs1", 3)) effects.clear() @@ -200,7 +200,7 @@ class PeriodicEventStreamSpec extends AsyncUnitSpec with BeforeAndAfter { _ <- delay(20) { effects shouldEqual mutable.Buffer() } - sub2 = stream.addObserver(obs1) + _ = stream.addObserver(obs1) _ = { effects shouldEqual mutable.Buffer() } @@ -220,7 +220,7 @@ class PeriodicEventStreamSpec extends AsyncUnitSpec with BeforeAndAfter { if (index < 5) { val nextIndex = index + 1 val nextInterval = if (index <= 1) 15 else 30 - Some(nextIndex, nextInterval) + Some((nextIndex, nextInterval)) } else { None } @@ -260,7 +260,7 @@ class PeriodicEventStreamSpec extends AsyncUnitSpec with BeforeAndAfter { effects shouldEqual mutable.Buffer() } _ = sub1.kill() - sub2 = stream.addObserver(obs1) + _ = stream.addObserver(obs1) _ <- delay(5) { effects shouldEqual mutable.Buffer(Effect("obs1", 0)) effects.clear() diff --git a/src/test/scala/com/raquo/airstream/eventstream/SwitchEventStreamSpec.scala b/src/test/scala/com/raquo/airstream/eventstream/SwitchEventStreamSpec.scala index 16d4a8eb..bd32ecf3 100644 --- a/src/test/scala/com/raquo/airstream/eventstream/SwitchEventStreamSpec.scala +++ b/src/test/scala/com/raquo/airstream/eventstream/SwitchEventStreamSpec.scala @@ -14,7 +14,7 @@ class SwitchEventStreamSpec extends UnitSpec { it("EventStream: mirrors last emitted stream, but only if subscribed") { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner val calculations = mutable.Buffer[Calculation[Int]]() val effects = mutable.Buffer[Effect[Int]]() @@ -141,7 +141,7 @@ class SwitchEventStreamSpec extends UnitSpec { it("Signal: mirrors last emitted stream, but only if subscribed") { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner val calculations = mutable.Buffer[Calculation[Int]]() val effects = mutable.Buffer[Effect[Int]]() diff --git a/src/test/scala/com/raquo/airstream/fixtures/Calculation.scala b/src/test/scala/com/raquo/airstream/fixtures/Calculation.scala new file mode 100644 index 00000000..396aefaf --- /dev/null +++ b/src/test/scala/com/raquo/airstream/fixtures/Calculation.scala @@ -0,0 +1,15 @@ +package com.raquo.airstream.fixtures + +import scala.collection.mutable + +case class Calculation[V](name: String, value: V) + +object Calculation { + + def log[V](name: String, to: mutable.Buffer[Calculation[V]])(value: V): V = { + val calculation = Calculation(name, value) + // println(calculation) + to += calculation + value + } +} diff --git a/src/test/scala/com/raquo/airstream/fixtures/Effect.scala b/src/test/scala/com/raquo/airstream/fixtures/Effect.scala new file mode 100644 index 00000000..554f2979 --- /dev/null +++ b/src/test/scala/com/raquo/airstream/fixtures/Effect.scala @@ -0,0 +1,3 @@ +package com.raquo.airstream.fixtures + +case class Effect[V](name: String, value: V) diff --git a/src/test/scala/com/raquo/airstream/fixtures/package.scala b/src/test/scala/com/raquo/airstream/fixtures/package.scala deleted file mode 100644 index 6833c4b6..00000000 --- a/src/test/scala/com/raquo/airstream/fixtures/package.scala +++ /dev/null @@ -1,20 +0,0 @@ -package com.raquo.airstream - -import scala.collection.mutable - -package object fixtures { - - case class Calculation[V](name: String, value: V) - - object Calculation { - - def log[V](name: String, to: mutable.Buffer[Calculation[V]])(value: V): V = { - val calculation = Calculation(name, value) - // println(calculation) - to += calculation - value - } - } - - case class Effect[V](name: String, value: V) -} diff --git a/src/test/scala/com/raquo/airstream/ownership/DynamicOwnerSpec.scala b/src/test/scala/com/raquo/airstream/ownership/DynamicOwnerSpec.scala index 4a17f494..e677bac1 100644 --- a/src/test/scala/com/raquo/airstream/ownership/DynamicOwnerSpec.scala +++ b/src/test/scala/com/raquo/airstream/ownership/DynamicOwnerSpec.scala @@ -22,7 +22,7 @@ class DynamicOwnerSpec extends UnitSpec { val dynOwner = new DynamicOwner(() => fail("Attempted to use permakilled owner!")) - val dynSub1 = DynamicSubscription(dynOwner, owner => bus1.events.addObserver(obs1)(owner)) + DynamicSubscription(dynOwner, owner => bus1.events.addObserver(obs1)(owner)) bus1.writer.onNext(100) diff --git a/src/test/scala/com/raquo/airstream/signal/SampleCombineSignal2Spec.scala b/src/test/scala/com/raquo/airstream/signal/SampleCombineSignal2Spec.scala index 423666bd..a4585e85 100644 --- a/src/test/scala/com/raquo/airstream/signal/SampleCombineSignal2Spec.scala +++ b/src/test/scala/com/raquo/airstream/signal/SampleCombineSignal2Spec.scala @@ -2,7 +2,6 @@ package com.raquo.airstream.signal import com.raquo.airstream.UnitSpec import com.raquo.airstream.core.Observer -import com.raquo.airstream.eventbus.EventBus import com.raquo.airstream.fixtures.{Calculation, Effect, TestableOwner} import scala.collection.mutable diff --git a/src/test/scala/com/raquo/airstream/signal/SignalFlattenFutureSpec.scala b/src/test/scala/com/raquo/airstream/signal/SignalFlattenFutureSpec.scala index 67da0bd3..fe358209 100644 --- a/src/test/scala/com/raquo/airstream/signal/SignalFlattenFutureSpec.scala +++ b/src/test/scala/com/raquo/airstream/signal/SignalFlattenFutureSpec.scala @@ -3,7 +3,7 @@ package com.raquo.airstream.signal import com.raquo.airstream.AsyncUnitSpec import com.raquo.airstream.core.Observer import com.raquo.airstream.eventbus.EventBus -import com.raquo.airstream.features.FlattenStrategy.{ConcurrentFutureStrategy, OverwriteFutureStrategy, SwitchFutureStrategy} +import com.raquo.airstream.features.FlattenStrategy.SwitchFutureStrategy import com.raquo.airstream.fixtures.{Effect, TestableOwner} import org.scalatest.Assertion @@ -16,7 +16,7 @@ class SignalFlattenFutureSpec extends AsyncUnitSpec { it("initial unresolved future results in an async event") { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner val effects = mutable.Buffer[Effect[Int]]() @@ -63,7 +63,7 @@ class SignalFlattenFutureSpec extends AsyncUnitSpec { it("initial future that is resolved at the same time as stream created and observer added result in an async event") { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner val effects = mutable.Buffer[Effect[Int]]() @@ -109,7 +109,7 @@ class SignalFlattenFutureSpec extends AsyncUnitSpec { it("initial already-resolved future results in an async event if resolved async-before stream creation") { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner val effects = mutable.Buffer[Effect[Int]]() diff --git a/src/test/scala/com/raquo/airstream/signal/SignalFromFutureSpec.scala b/src/test/scala/com/raquo/airstream/signal/SignalFromFutureSpec.scala index 217dd263..db620a6d 100644 --- a/src/test/scala/com/raquo/airstream/signal/SignalFromFutureSpec.scala +++ b/src/test/scala/com/raquo/airstream/signal/SignalFromFutureSpec.scala @@ -10,15 +10,15 @@ import scala.concurrent.Promise class SignalFromFutureSpec extends AsyncUnitSpec with BeforeAndAfter { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner - val calculations = mutable.Buffer[Calculation[Option[Int]]]() - val effects = mutable.Buffer[Effect[Option[Int]]]() + private val calculations = mutable.Buffer[Calculation[Option[Int]]]() + private val effects = mutable.Buffer[Effect[Option[Int]]]() - val obs1 = Observer[Option[Int]](effects += Effect("obs1", _)) - val obs2 = Observer[Option[Int]](effects += Effect("obs2", _)) + private val obs1 = Observer[Option[Int]](effects += Effect("obs1", _)) + private val obs2 = Observer[Option[Int]](effects += Effect("obs2", _)) - def makePromise() = Promise[Int]() + def makePromise(): Promise[Int] = Promise[Int]() def clearLogs(): Assertion = { calculations.clear() @@ -26,7 +26,7 @@ class SignalFromFutureSpec extends AsyncUnitSpec with BeforeAndAfter { assert(true) } - def makeSignal(promise: Promise[Int]) = Signal + def makeSignal(promise: Promise[Int]): Signal[Option[Int]] = Signal .fromFuture(promise.future) .map(Calculation.log("signal", calculations)) @@ -117,15 +117,15 @@ class SignalFromFutureSpec extends AsyncUnitSpec with BeforeAndAfter { val promise = makePromise() val signal = Signal.fromFuture(promise.future) // Don't use `makeSignal` here, we need the _original_, strict signal - assert(signal.now() == None) + assert(signal.now().isEmpty) promise.success(100) // @TODO[API] Well, this here is not very desirable, but I don't see a way around it - assert(signal.now() == None) + assert(signal.now().isEmpty) delay { - assert(signal.now() == Some(100)) + assert(signal.now().contains(100)) } } @@ -135,6 +135,6 @@ class SignalFromFutureSpec extends AsyncUnitSpec with BeforeAndAfter { val signal = Signal.fromFuture(promise.future) // Don't use `makeSignal` here, we need the _original_, strict signal - assert(signal.now() == Some(100)) + assert(signal.now().contains(100)) } } diff --git a/src/test/scala/com/raquo/airstream/signal/SwitchSignalSpec.scala b/src/test/scala/com/raquo/airstream/signal/SwitchSignalSpec.scala index 3b98b401..24337f28 100644 --- a/src/test/scala/com/raquo/airstream/signal/SwitchSignalSpec.scala +++ b/src/test/scala/com/raquo/airstream/signal/SwitchSignalSpec.scala @@ -11,7 +11,7 @@ class SwitchSignalSpec extends UnitSpec { it("mirrors last emitted signal, but only if subscribed") { - implicit val owner = new TestableOwner + implicit val owner: TestableOwner = new TestableOwner val calculations = mutable.Buffer[Calculation[Int]]() val effects = mutable.Buffer[Effect[Int]]() diff --git a/src/test/scala/com/raquo/airstream/signal/VarSpec.scala b/src/test/scala/com/raquo/airstream/signal/VarSpec.scala index fc8e35e9..f40c54cf 100644 --- a/src/test/scala/com/raquo/airstream/signal/VarSpec.scala +++ b/src/test/scala/com/raquo/airstream/signal/VarSpec.scala @@ -115,7 +115,7 @@ class VarSpec extends UnitSpec with BeforeAndAfter { x.writer.onNext(4) - val sub2 = signal.addObserver(obs)(owner) + signal.addObserver(obs)(owner) // Emit a value to the new external observer. Standard Signal behaviour. assert(calculations == mutable.Buffer()) @@ -162,7 +162,7 @@ class VarSpec extends UnitSpec with BeforeAndAfter { // -- - val sub1 = signal.addObserver(obs)(owner) + signal.addObserver(obs)(owner) // Error values are propagated to new observers assert(errorEffects == mutable.Buffer(Effect("signal-err", err1))) @@ -661,8 +661,6 @@ class VarSpec extends UnitSpec with BeforeAndAfter { val err1 = new Exception("err1") - val err2 = new Exception("err2") - val resetErr = new Exception("resetErr") val v = Var(List(1)) From 874a2f9cbe12a3054e7621fde1ca5a9517dde403 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Sun, 27 Dec 2020 05:44:13 +0530 Subject: [PATCH 20/42] New: AjaxEventStream --- .../raquo/airstream/web/AjaxEventStream.scala | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala diff --git a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala new file mode 100644 index 00000000..99905d86 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala @@ -0,0 +1,153 @@ +package com.raquo.airstream.web + +import com.raquo.airstream.core.Transaction +import com.raquo.airstream.eventstream.EventStream +import org.scalajs.dom + +/** + * [[AjaxEventStream]] performs an HTTP request and emits the [[dom.XMLHttpRequest]]/[[dom.ext.AjaxException]] on + * success/failure. + * + * The network request is delayed until start. In other words, the network request is not performed if the stream is + * never started. + * + * On restart, a new request is performed and the subsequent success/failure is propagated downstream. + */ +class AjaxEventStream( + method: String, + url: String, + data: dom.ext.Ajax.InputData, + timeout: Int, + headers: Map[String, String], + withCredentials: Boolean, + responseType: String +) extends EventStream[dom.XMLHttpRequest] { + + protected[airstream] val topoRank: Int = 1 + + override protected[this] def onStart(): Unit = { + // the implementation mirrors dom.ext.Ajax.apply + val req = new dom.XMLHttpRequest + req.onreadystatechange = (_: dom.Event) => { + if (isStarted && req.readyState == 4) { + val status = req.status + if ((status >= 200 && status < 300) || status == 304) + new Transaction(fireValue(req, _)) + else + new Transaction(fireError(dom.ext.AjaxException(req), _)) + } + } + req.open(method, url) + req.responseType = responseType + req.timeout = timeout.toDouble + req.withCredentials = withCredentials + headers.foreach(Function.tupled(req.setRequestHeader)) + if (data == null) req.send() else req.send(data) + } +} + +object AjaxEventStream { + + /** + * Returns an [[EventStream]] that performs an HTTP request and emits the + * [[dom.XMLHttpRequest]]/[[dom.ext.AjaxException]] on success/failure. + * + * The network request is delayed until start. In other words, the network request is not performed if the stream is + * never started. + * + * On restart, a new request is performed and the subsequent success/failure is propagated downstream. + * + * @see [[dom.raw.XMLHttpRequest]] for a description of the parameters + */ + def apply( + method: String, + url: String, + data: dom.ext.Ajax.InputData, + timeout: Int, + headers: Map[String, String], + withCredentials: Boolean, + responseType: String + ): EventStream[dom.XMLHttpRequest] = { + new AjaxEventStream(method, url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `GET` request. + * + * @see [[apply]] + */ + def get( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("GET", url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `POST` request. + * + * @see [[apply]] + */ + def post( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("POST", url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `PUT` request. + * + * @see [[apply]] + */ + def put( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("PUT", url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `PATCH` request. + * + * @see [[apply]] + */ + def patch( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("PATCH", url, data, timeout, headers, withCredentials, responseType) + } + + /** + * Returns an [[EventStream]] that performs an HTTP `DELETE` request. + * + * @see [[apply]] + */ + def delete( + url: String, + data: dom.ext.Ajax.InputData = null, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, + responseType: String = "" + ): EventStream[dom.XMLHttpRequest] = { + apply("DELETE", url, data, timeout, headers, withCredentials, responseType) + } +} From 86a57d0ff552f83d26eb17715a626f323f161991 Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Sat, 26 Dec 2020 18:13:37 -0800 Subject: [PATCH 21/42] API: Clear pending ajax request when stream is stopped Same reasoning as Delay, see previous commit --- .../com/raquo/airstream/web/AjaxEventStream.scala | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala index 99905d86..005fbec2 100644 --- a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala @@ -25,11 +25,15 @@ class AjaxEventStream( protected[airstream] val topoRank: Int = 1 + private var pendingRequest: Option[dom.XMLHttpRequest] = None + override protected[this] def onStart(): Unit = { // the implementation mirrors dom.ext.Ajax.apply val req = new dom.XMLHttpRequest + pendingRequest = Some(req) req.onreadystatechange = (_: dom.Event) => { - if (isStarted && req.readyState == 4) { + if (pendingRequest.contains(req) && req.readyState == 4) { + pendingRequest = None val status = req.status if ((status >= 200 && status < 300) || status == 304) new Transaction(fireValue(req, _)) @@ -44,6 +48,11 @@ class AjaxEventStream( headers.foreach(Function.tupled(req.setRequestHeader)) if (data == null) req.send() else req.send(data) } + + override protected[this] def onStop(): Unit = { + pendingRequest = None + super.onStop() + } } object AjaxEventStream { From d4c9e0b7ce24800e62aaaa8daeffe5d83469b8da Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Sat, 26 Dec 2020 18:26:06 -0800 Subject: [PATCH 22/42] New: DomEventStream --- .../raquo/airstream/web/DomEventStream.scala | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/main/scala/com/raquo/airstream/web/DomEventStream.scala diff --git a/src/main/scala/com/raquo/airstream/web/DomEventStream.scala b/src/main/scala/com/raquo/airstream/web/DomEventStream.scala new file mode 100644 index 00000000..36d53ba4 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/web/DomEventStream.scala @@ -0,0 +1,50 @@ +package com.raquo.airstream.web + +import com.raquo.airstream.core.Transaction +import com.raquo.airstream.eventstream.EventStream +import org.scalajs.dom + +import scala.scalajs.js + +/** + * This stream, when started, registers an event listener on a specific target + * like a DOM element, document, or window, and re-emits all events sent to the listener. + * + * When this stream is stopped, the listener is removed. + * + * @param eventTarget any DOM event target, e.g. element, document, or window + * @param eventKey DOM event name, e.g. "click", "input", "change" + * @param useCapture See section about "useCapture" in https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + * + */ +class DomEventStream[Ev <: dom.Event]( + eventTarget: dom.EventTarget, + eventKey: String, + useCapture: Boolean +) extends EventStream[Ev] { + + override protected[airstream] val topoRank: Int = 1 + + val eventHandler: js.Function1[Ev, Unit] = { ev => + new Transaction(fireValue(ev, _)) + } + + override protected[this] def onStart(): Unit = { + eventTarget.addEventListener(eventKey, eventHandler, useCapture) + } + + override protected[this] def onStop(): Unit = { + eventTarget.removeEventListener(eventKey, eventHandler, useCapture) + } +} + +object DomEventStream { + + def apply[Ev <: dom.Event]( + eventTarget: dom.EventTarget, + eventKey: String, + useCapture: Boolean = false + ): EventStream[Ev] = { + new DomEventStream[Ev](eventTarget, eventKey, useCapture) + } +} From 05ba3a8deb5db2fdd534a4a656276df7bf977a0f Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Sat, 26 Dec 2020 18:47:02 -0800 Subject: [PATCH 23/42] Docs: Add new web streams --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/README.md b/README.md index eed8f17f..6017bff2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ I created Airstream because I found existing solutions were not suitable for bui * [EventBus](#eventbus) * [Var](#var) * [Val](#val) + * [Ajax](#ajax) + * [Websockets](#websockets) + * [DOM Events](#dom-events) * [Custom Observables](#custom-observables) * [FRP Glitches](#frp-glitches) * [Other Libraries](#other-libraries) @@ -563,12 +566,54 @@ Remember that this atomicity guarantee only applies to failures which would have Val is useful when a component wants to accept either a Signal or a constant value as input. You can just wrap your constant in a Val, and make the component accept a `Signal` (or a `StrictSignal`) instead. + +#### Ajax + +Airstream now has a built-in way to perform Ajax requests: + +```scala +AjaxEventStream + .get("/api/kittens") // EventStream[dom.XMLHttpRequest] + .map(req => req.responseText) // EventStream[String] +``` + +Methods for POST, PUT, PATCH, and DELETE are also available. + +The request is made every time the stream is started. If the stream is stopped while the request is pending, the request will not be cancelled, but its result will be discarded. + +The implementation follows that of `org.scalajs.dom.ext.Ajax.apply`, but is adjusted slightly to be better behaved in Airstream. + + + +### Websockets + +Airstream has no official websockets integration yet. + +For several users' implementations, search Laminar gitter room, and the issues in this repo. + + + +### DOM Events + +`DomEventStream` previously available in Laminar now lives in Airstream. + +```scala +val element: dom.Element = ??? +DomEventStream(element, "click") // EventStream[dom.MouseEvent] +``` + +This stream, when started, registers a `click` event listener on `element`, and emits all events the listener receives until it is stopped, at which point the listener is removed. + + + #### Custom Observables EventBus is a very generic solution that should suit most needs, even if perhaps not very elegantly sometimes. You can create your own observables that emit events in their own unique way by wrapping or extending EventBus (easier) or extending Observable (more work and knowledge required, but rewarded with better behavior)). +If extending Observable, you will need to make the `topoRank` field public to be able to override it. See [#37](https://github.com/raquo/Airstream/issues/37). + Unfortunately I don't have enough time to describe how to create custom observables in detail right now. You will need to read the rest of the documentation and the source code – you will see how other observables such as MapEventStream or FilterEventStream are implemented. Airstream's source code should be easy to comprehend. It is clean, small (a bit more than 1K LoC with all the operators), and does not use complicated implicits or hardcore functional stuff. From 26a75b8fdc5ba025b23534e614b9e335b7e6484f Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Sun, 27 Dec 2020 19:10:30 -0800 Subject: [PATCH 24/42] New: EventStream.{withCallback, withObserver} --- README.md | 19 +++++++++++++++- .../airstream/eventstream/EventStream.scala | 22 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6017bff2..48377a8e 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ I created Airstream because I found existing solutions were not suitable for bui * [EventStream.fromSeq](#eventstreamfromseq) * [EventStream.periodic](#eventstreamperiodic) * [EventStream.empty](#eventstreamempty) + * [EventStream.withCallback and withObserver](#eventstreamwithcallback-and-withobserver) * [EventBus](#eventbus) * [Var](#var) * [Val](#val) @@ -439,6 +440,22 @@ The underlying `PeriodicEventStream` class offers more functionality, including A stream that never emits any events. +#### `EventStream.withCallback` and `withObserver` + +`EventStream.withCallback[A]` Creates and returns a stream and an `A => Unit` callback that, when called, passes the input value to that stream. Of course, as streams are lazy, the stream will only emit if it has observers. + +```scala +val (stream, callback) = EventStream.withCallback[Int] +callback(1) // nothing happens because stream has no observers +stream.foreach(println) +callback(2) // `2` will be printed +``` + +`EventStream.withJsCallback[A]` works similarly except it returns a js.Function for easier integration with Javascript libraries. + +`EventStream.withObserver[A]` works similarly but creates an observer, which among other conveniences passes the errors that it receives into the stream. + + #### EventBus `new EventBus[MyEvent]` is the general-purpose way to create a stream on which you can manually trigger events. The resulting EventBus exposes two properties: @@ -1180,7 +1197,7 @@ stream.recoverToTry.collect { case Failure(err) => err } // EventStream[Throwabl ## Limitations * Airstream only runs on Scala.js because its primary intended use case is unidirectional dataflow architecture on the frontend. I have no plans to make it run on the JVM. It would require too much of my time and too much compromise, complicating the API to support a completely different environment and use cases. -* Airstream has no concept of observables "completing". Personally I don't think this is a limitation, but I can see it being viewed as such. See [Issue #23](https://github.com/raquo/Airstream/issues/23). +* Airstream has no concept of observables "completing". Personally I don't think this is much of a limitation, but I can see it being viewed as such. See [Issue #23](https://github.com/raquo/Airstream/issues/23). ## My Related Projects diff --git a/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala index 873d07b7..217c2ac9 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala @@ -1,9 +1,11 @@ package com.raquo.airstream.eventstream import com.raquo.airstream.core.AirstreamError.ObserverError -import com.raquo.airstream.core.{AirstreamError, Observable, Transaction} +import com.raquo.airstream.core.{AirstreamError, Observable, Observer, Transaction} +import com.raquo.airstream.eventbus.EventBus import com.raquo.airstream.features.CombineObservable import com.raquo.airstream.signal.{FoldLeftSignal, Signal, SignalFromEventStream} + import scala.concurrent.Future import scala.scalajs.js import scala.util.{Failure, Success, Try} @@ -201,6 +203,24 @@ object EventStream { fromFuture(promise.toFuture) } + /** Create a stream and a callback that, when fired, makes that stream emit. */ + def withCallback[A]: (EventStream[A], A => Unit) = { + val bus = new EventBus[A] + (bus.events, bus.writer.onNext) + } + + /** Create a stream and a JS callback that, when fired, makes that stream emit. */ + def withJsCallback[A]: (EventStream[A], js.Function1[A, Unit]) = { + val bus = new EventBus[A] + (bus.events, bus.writer.onNext) + } + + /** Create a stream and an observer that, when receiving an event or an error, makes that stream emit. */ + def withObserver[A]: (EventStream[A], Observer[A]) = { + val bus = new EventBus[A] + (bus.events, bus.writer) + } + def periodic( intervalMs: Int, emitInitial: Boolean = true, From c05abf9d7896060dbc5f424a5a41973afe793604 Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Tue, 29 Dec 2020 01:34:12 -0800 Subject: [PATCH 25/42] New: Custom event sources, rework AjaxEventStream, and more - New: Signal.fromValue and Signal.fromTry - New: Observer.toJsFn1 --- README.md | 32 ++- .../com/raquo/airstream/core/Observer.scala | 3 + .../airstream/custom/CustomSignalSource.scala | 41 ++++ .../raquo/airstream/custom/CustomSource.scala | 112 +++++++++ .../airstream/custom/CustomStreamSource.scala | 25 ++ .../airstream/eventbus/EventBusStream.scala | 1 - .../airstream/eventstream/EventStream.scala | 53 +++- .../eventstream/FutureEventStream.scala | 5 +- .../eventstream/SeqEventStream.scala | 32 --- .../airstream/features/FlattenStrategy.scala | 4 +- .../com/raquo/airstream/signal/Signal.scala | 23 ++ .../raquo/airstream/web/AjaxEventStream.scala | 227 +++++++++++++----- .../raquo/airstream/web/DomEventStream.scala | 58 ++--- 13 files changed, 471 insertions(+), 145 deletions(-) create mode 100644 src/main/scala/com/raquo/airstream/custom/CustomSignalSource.scala create mode 100644 src/main/scala/com/raquo/airstream/custom/CustomSource.scala create mode 100644 src/main/scala/com/raquo/airstream/custom/CustomStreamSource.scala delete mode 100644 src/main/scala/com/raquo/airstream/eventstream/SeqEventStream.scala diff --git a/README.md b/README.md index 48377a8e..f5265c37 100644 --- a/README.md +++ b/README.md @@ -596,9 +596,27 @@ AjaxEventStream Methods for POST, PUT, PATCH, and DELETE are also available. -The request is made every time the stream is started. If the stream is stopped while the request is pending, the request will not be cancelled, but its result will be discarded. +The request is made every time the stream is started. If the stream is stopped while the request is pending, the old request will not be cancelled, but its result will be discarded. + +If the request times out, is aborted, returns an HTTP status code that isn't 2xx or 304, or fails in any other way, the stream will emit an `AjaxStreamError`. + +If you want a stream that never fails, a stream that emits an event regardless of all those errors, call `.completeEvents` on your ajax stream. + +You can listen for `progress` or `readyStateChange` events by passing in the corresponding observers to `AjaxEventStream.get` et al, for example: + +```scala +val (progressObserver, $progress) = EventStream.withObserver[(dom.XMLHttpRequest, dom.ProgressEvent)] + +val $request = AjaxEventStream.get( + url = "/api/kittens", + progressObserver = progressObserver +) + +val $bytesLoaded = $progress.map2((xhr, ev) => ev.loaded) +``` + +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. -The implementation follows that of `org.scalajs.dom.ext.Ajax.apply`, but is adjusted slightly to be better behaved in Airstream. @@ -612,14 +630,16 @@ For several users' implementations, search Laminar gitter room, and the issues i ### DOM Events -`DomEventStream` previously available in Laminar now lives in Airstream. - ```scala val element: dom.Element = ??? -DomEventStream(element, "click") // EventStream[dom.MouseEvent] +DomEventStream[dom.MouseEvent](element, "click") // EventStream[dom.MouseEvent] ``` -This stream, when started, registers a `click` event listener on `element`, and emits all events the listener receives until it is stopped, at which point the listener is removed. +This stream, when started, registers a `click` event listener on `element`, and emits all events the listener receives until the stream is stopped, at which point the listener is removed. + +Airstream does not know the names & types of DOM events, so you need to manually specify both. You can get those manually from MDN or programmatically from event props such as `onClick` available in Laminar. + +`DomEventStream` works not just on elements but on any `dom.raw.EventTarget`. However, make sure to check browser compatibility for fancy EventTarget-s such as XMLHttpRequest. diff --git a/src/main/scala/com/raquo/airstream/core/Observer.scala b/src/main/scala/com/raquo/airstream/core/Observer.scala index e9e9d198..c0a3e165 100644 --- a/src/main/scala/com/raquo/airstream/core/Observer.scala +++ b/src/main/scala/com/raquo/airstream/core/Observer.scala @@ -2,10 +2,13 @@ package com.raquo.airstream.core import com.raquo.airstream.core.AirstreamError.{ObserverError, ObserverErrorHandlingError} +import scala.scalajs.js import scala.util.{Failure, Success, Try} trait Observer[-A] { + lazy val toJsFn1: js.Function1[A, Unit] = onNext + /** Note: must not throw! */ def onNext(nextValue: A): Unit diff --git a/src/main/scala/com/raquo/airstream/custom/CustomSignalSource.scala b/src/main/scala/com/raquo/airstream/custom/CustomSignalSource.scala new file mode 100644 index 00000000..2ad98423 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/custom/CustomSignalSource.scala @@ -0,0 +1,41 @@ +package com.raquo.airstream.custom + +import com.raquo.airstream.custom.CustomSource._ +import com.raquo.airstream.signal.Signal + +import scala.util.{Success, Try} + +// @TODO[Test] needs testing + +/** Use this to easily create a custom signal from an external source + * + * See docs on custom sources, and [[CustomSource.Config]] + */ +class CustomSignalSource[A] ( + getInitialValue: => Try[A], + makeConfig: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => CustomSource.Config, +) extends Signal[A] with CustomSource[A] { + + override protected[this] def initialValue: Try[A] = getInitialValue + + override protected[this] val config: Config = makeConfig(_fireTry, tryNow, getStartIndex, getIsStarted) +} + +object CustomSignalSource { + + def apply[A]( + initial: => A + )( + config: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => Config + ): Signal[A] = { + new CustomSignalSource[A](Success(initial), config) + } + + def fromTry[A]( + initial: => Try[A] + )( + config: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => Config + ): Signal[A] = { + new CustomSignalSource[A](initial, config) + } +} diff --git a/src/main/scala/com/raquo/airstream/custom/CustomSource.scala b/src/main/scala/com/raquo/airstream/custom/CustomSource.scala new file mode 100644 index 00000000..4d88e647 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/custom/CustomSource.scala @@ -0,0 +1,112 @@ +package com.raquo.airstream.custom + +import com.raquo.airstream.core.{Observable, Transaction} +import com.raquo.airstream.custom.CustomSource._ + +import scala.util.Try + +// @TODO[Docs] Write docs and link to that. + +/** Base functionality for a custom observable based on start and stop callbacks. + * + * See: + * - [[com.raquo.airstream.custom.CustomStreamSource]] + * - [[com.raquo.airstream.custom.CustomSignalSource]] + */ +trait CustomSource[A] extends Observable[A] { + + protected[this] val config: Config + + // -- + + /** CustomSource is intended for observables that don't synchronously depend on other observables. */ + override protected[airstream] val topoRank: Int = 1 + + protected[this] var startIndex: StartIndex = 0 + + + protected[this] val _fireValue: FireValue[A] = { value => + //println(s"> init trx from CustomSource(${value})") + new Transaction(fireValue(value, _)) + } + + protected[this] val _fireError: FireError = { error => + //println(s"> init error trx from CustomSource(${error})") + new Transaction(fireError(error, _)) + } + + protected[this] val _fireTry: SetCurrentValue[A] = { value => + //println(s"> init try trx from CustomSource(${value})") + new Transaction(fireTry(value, _)) + } + + protected[this] val getStartIndex: GetStartIndex = () => startIndex + + protected[this] val getIsStarted: GetIsStarted = () => isStarted + + override protected[this] def onStart(): Unit = { + startIndex += 1 + Try(config.onStart()).recover { + case err: Throwable => _fireError(err) + } + } + + override protected[this] def onStop(): Unit = { + config.onStop() + } +} + +object CustomSource { + + /** See docs for custom sources */ + final class Config private ( + val onStart: () => Unit, + val onStop: () => Unit + ) { + + /** Create a version of a config that only runs start / stop if the predicate passes. + * - `start` will be run when the CustomSource is about to start + * if `passes` returns true at that time + * - `stop` will be run when the CustomSource is about to stop + * if your `start` code ran the last time CustomSource started + */ + def when(passes: () => Boolean): Config = { + var started = false + new Config( + () => { + if (passes()) { + started = true + onStart() + } + }, + onStop = () => { + if (started) { + onStop() + } + started = false + } + ) + } + } + + object Config { + + def apply(onStart: => Unit, onStop: => Unit): Config = { + new Config(() => onStart, () => onStop) + } + } + + type StartIndex = Int + + type FireValue[A] = A => Unit + + type FireError = Throwable => Unit + + type SetCurrentValue[A] = Try[A] => () + + type GetCurrentValue[A] = () => Try[A] + + type GetStartIndex = () => StartIndex + + type GetIsStarted = () => Boolean +} diff --git a/src/main/scala/com/raquo/airstream/custom/CustomStreamSource.scala b/src/main/scala/com/raquo/airstream/custom/CustomStreamSource.scala new file mode 100644 index 00000000..df1ac703 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/custom/CustomStreamSource.scala @@ -0,0 +1,25 @@ +package com.raquo.airstream.custom + +import com.raquo.airstream.custom.CustomSource._ +import com.raquo.airstream.eventstream.EventStream + +/** Use this to easily create a custom signal from an external source + * + * See docs on custom sources, and [[CustomSource.Config]] + */ +class CustomStreamSource[A] private ( + makeConfig: (FireValue[A], FireError, GetStartIndex, GetIsStarted) => CustomSource.Config, +) extends EventStream[A] with CustomSource[A] { + + override protected[this] val config: Config = makeConfig(_fireValue, _fireError, getStartIndex, getIsStarted) + +} + +object CustomStreamSource { + + def apply[A]( + config: (FireValue[A], FireError, GetStartIndex, GetIsStarted) => Config + ): EventStream[A] = { + new CustomStreamSource[A](config) + } +} diff --git a/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala b/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala index 34237da7..f3950fe6 100644 --- a/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala +++ b/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala @@ -12,7 +12,6 @@ class EventBusStream[A] private[eventbus] () extends EventStream[A] with Interna /** Made more public to allow usage from WriteBus */ override protected[eventbus] def isStarted: Boolean = super.isStarted - // @TODO document why. Basically event bus breaks the "static DAG" requirement for topo ranking override protected[airstream] val topoRank: Int = 1 @inline private[eventbus] def addSource(sourceStream: EventStream[A]): Unit = { diff --git a/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala index 217c2ac9..461aab38 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/EventStream.scala @@ -2,6 +2,8 @@ package com.raquo.airstream.eventstream import com.raquo.airstream.core.AirstreamError.ObserverError import com.raquo.airstream.core.{AirstreamError, Observable, Observer, Transaction} +import com.raquo.airstream.custom.CustomSource._ +import com.raquo.airstream.custom.{CustomSource, CustomStreamSource} import com.raquo.airstream.eventbus.EventBus import com.raquo.airstream.features.CombineObservable import com.raquo.airstream.signal.{FoldLeftSignal, Signal, SignalFromEventStream} @@ -174,35 +176,74 @@ object EventStream { /** Event stream that never emits anything */ val empty: EventStream[Nothing] = { - new SeqEventStream[Nothing](events = Nil, emitOnce = true) + fromCustomSource[Nothing]( + shouldStart = _ => false, + start = (_, _, _, _) => (), + stop = _ => () + ) } /** @param emitOnce if true, the event will be emitted at most one time. * If false, the event will be emitted every time the stream is started. */ def fromSeq[A](events: Seq[A], emitOnce: Boolean): EventStream[A] = { - new SeqEventStream[A](events.map(Success(_)), emitOnce) + fromCustomSource[A]( + shouldStart = startIndex => if (emitOnce) startIndex == 1 else true, + start = (fireEvent, _, _, _) => events.foreach(fireEvent), + stop = _ => () + ) } /** @param emitOnce if true, the event will be emitted at most one time. * If false, the event will be emitted every time the stream is started. */ def fromValue[A](event: A, emitOnce: Boolean): EventStream[A] = { - new SeqEventStream[A](List(Success(event)), emitOnce) + fromCustomSource[A]( + shouldStart = startIndex => if (emitOnce) startIndex == 1 else true, + start = (fireEvent, _, _, _) => fireEvent(event), + stop = _ => () + ) } /** @param emitOnce if true, the event will be emitted at most one time. * If false, the event will be emitted every time the stream is started. */ def fromTry[A](value: Try[A], emitOnce: Boolean): EventStream[A] = { - new SeqEventStream[A](List(value), emitOnce) + fromCustomSource[A]( + shouldStart = startIndex => if (emitOnce) startIndex == 1 else true, + start = (fireEvent, fireError, _, _) => value.fold(fireError, fireEvent), + stop = _ => () + ) } - def fromFuture[A](future: Future[A]): EventStream[A] = { - new FutureEventStream(future, emitIfFutureCompleted = false) + def fromFuture[A](future: Future[A], emitFutureIfCompleted: Boolean = false): EventStream[A] = { + new FutureEventStream[A](future, emitFutureIfCompleted) } @inline def fromJsPromise[A](promise: js.Promise[A]): EventStream[A] = { fromFuture(promise.toFuture) } + /** Easy helper for custom events. See [[CustomStreamSource]] for docs. + * + * @param stop MUST NOT THROW! + */ + def fromCustomSource[A]( + shouldStart: StartIndex => Boolean = _ => true, + start: (FireValue[A], FireError, GetStartIndex, GetIsStarted) => Unit, + stop: StartIndex => Unit + ): EventStream[A] = { + CustomStreamSource[A] { (fireValue, fireError, getStartIndex, getIsStarted) => + CustomSource.Config( + onStart = { + start(fireValue, fireError, getStartIndex, getIsStarted) + }, + onStop = { + stop(getStartIndex()) + } + ).when { + () => shouldStart(getStartIndex()) + } + } + } + /** Create a stream and a callback that, when fired, makes that stream emit. */ def withCallback[A]: (EventStream[A], A => Unit) = { val bus = new EventBus[A] diff --git a/src/main/scala/com/raquo/airstream/eventstream/FutureEventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/FutureEventStream.scala index a0432e38..ad3ea00a 100644 --- a/src/main/scala/com/raquo/airstream/eventstream/FutureEventStream.scala +++ b/src/main/scala/com/raquo/airstream/eventstream/FutureEventStream.scala @@ -22,7 +22,10 @@ class FutureEventStream[A](future: Future[A], emitIfFutureCompleted: Boolean) ex if (!future.isCompleted || emitIfFutureCompleted) { // @TODO[API] Do we need "isStarted" filter on these? Doesn't seem to affect anything for now... future.onComplete(_.fold( - nextError => new Transaction(fireError(nextError, _)), + nextError => { + //println(s"> init trx from FutureEventStream.init($nextError)") + new Transaction(fireError(nextError, _)) + }, nextValue => { //println(s"> init trx from FutureEventStream.init($nextValue)") new Transaction(fireValue(nextValue, _)) diff --git a/src/main/scala/com/raquo/airstream/eventstream/SeqEventStream.scala b/src/main/scala/com/raquo/airstream/eventstream/SeqEventStream.scala deleted file mode 100644 index 2259637b..00000000 --- a/src/main/scala/com/raquo/airstream/eventstream/SeqEventStream.scala +++ /dev/null @@ -1,32 +0,0 @@ -package com.raquo.airstream.eventstream - -import com.raquo.airstream.core.Transaction - -import scala.util.Try - -// @TODO[Airstream] needs testing - -// @TODO[API] This looks like a kludge only relevant to streams-only libraries. Reconsider if we want to include this at all. -// @TODO[API] Is it desirable that we re-emit these events when this stream is re-started, or should that only happen once? -// @TODO[API] This is how XStream does it, I haven't compared with other libs -/** This event stream emits a sequence of events every time it is started */ -class SeqEventStream[A](events: Seq[Try[A]], emitOnce: Boolean) extends EventStream[A] { - - private[this] var hasEmitted = false - - override protected[airstream] val topoRank: Int = 1 - - override protected[this] def onStart(): Unit = { - super.onStart() - if (!emitOnce || !hasEmitted) { - events.foreach { event => - //println(s"> init trx from SeqEventStream(${event})") - new Transaction(fireTry(event, _)) - } - } - if (!hasEmitted) { - hasEmitted = true - } - } - -} diff --git a/src/main/scala/com/raquo/airstream/features/FlattenStrategy.scala b/src/main/scala/com/raquo/airstream/features/FlattenStrategy.scala index f6a132b1..aa63a98f 100644 --- a/src/main/scala/com/raquo/airstream/features/FlattenStrategy.scala +++ b/src/main/scala/com/raquo/airstream/features/FlattenStrategy.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.features import com.raquo.airstream.core.Observable -import com.raquo.airstream.eventstream.{ConcurrentEventStream, ConcurrentFutureStream, EventStream, FutureEventStream, SwitchEventStream} +import com.raquo.airstream.eventstream.{ConcurrentEventStream, ConcurrentFutureStream, EventStream, SwitchEventStream} import com.raquo.airstream.signal.{Signal, SwitchSignal} import scala.concurrent.Future @@ -33,7 +33,7 @@ object FlattenStrategy { override def flatten[A](parent: Observable[Future[A]]): EventStream[A] = { new SwitchEventStream[Future[A], A]( parent = parent, - makeStream = new FutureEventStream(_, emitIfFutureCompleted = true) + makeStream = EventStream.fromFuture(_, emitFutureIfCompleted = true) ) } } diff --git a/src/main/scala/com/raquo/airstream/signal/Signal.scala b/src/main/scala/com/raquo/airstream/signal/Signal.scala index 9d69f436..bb983917 100644 --- a/src/main/scala/com/raquo/airstream/signal/Signal.scala +++ b/src/main/scala/com/raquo/airstream/signal/Signal.scala @@ -1,6 +1,8 @@ package com.raquo.airstream.signal import com.raquo.airstream.core.{AirstreamError, Observable, Observer, Transaction} +import com.raquo.airstream.custom.{CustomSignalSource, CustomSource} +import com.raquo.airstream.custom.CustomSource._ import com.raquo.airstream.eventstream.{EventStream, MapEventStream} import com.raquo.airstream.features.CombineObservable import com.raquo.airstream.ownership.Owner @@ -206,6 +208,10 @@ trait Signal[+A] extends Observable[A] { object Signal { + def fromValue[A](value: A): Val[A] = Val(value) + + def fromTry[A](value: Try[A]): Val[A] = Val.fromTry(value) + @inline def fromFuture[A](future: Future[A]): Signal[Option[A]] = { new FutureSignal(future) } @@ -214,6 +220,23 @@ object Signal { new FutureSignal(promise.toFuture) } + /** Easy helper for custom signals. See [[CustomSignalSource]] for docs. + * + * @param stop MUST NOT THROW! + */ + def fromCustomSource[A]( + initial: => A, + start: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => Unit, + stop: StartIndex => Unit + ): Signal[A] = { + CustomSignalSource[A](initial)( (setValue, getValue, getStartIndex, getIsStarted) => { + CustomSource.Config( + onStart = start(setValue, getValue, getStartIndex, getIsStarted), + onStop = stop(getStartIndex()) + ) + }) + } + implicit def toTuple2Signal[A, B](signal: Signal[(A, B)]): Tuple2Signal[A, B] = { new Tuple2Signal(signal) } diff --git a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala index 005fbec2..5ac01e4a 100644 --- a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala @@ -1,17 +1,33 @@ package com.raquo.airstream.web -import com.raquo.airstream.core.Transaction +import com.raquo.airstream.core.{Observer, Transaction} import com.raquo.airstream.eventstream.EventStream +import com.raquo.airstream.web.AjaxEventStream.{AjaxAbort, AjaxError, AjaxStreamException, AjaxTimeout} import org.scalajs.dom +import scala.scalajs.js + +// @TODO[Test] Needs testing + /** - * [[AjaxEventStream]] performs an HTTP request and emits the [[dom.XMLHttpRequest]]/[[dom.ext.AjaxException]] on - * success/failure. + * [[AjaxEventStream]] performs an HTTP request and emits an [[dom.XMLHttpRequest]] on success, + * or an [[AjaxStreamException]] error (AjaxError | AjaxTimeout | AjaxAbort) on failure. + * + * Acceptable HTTP response status codes are 2xx and 304, others result in AjaxError. + * + * The network request is only performed when the stream is started. * - * The network request is delayed until start. In other words, the network request is not performed if the stream is - * never started. + * When stream is restarted, a new request is performed, and the subsequent response is emitted. + * The previous request is not aborted, but its response will be ignored. * - * On restart, a new request is performed and the subsequent success/failure is propagated downstream. + * 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. + * + * @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. */ class AjaxEventStream( method: String, @@ -20,7 +36,9 @@ class AjaxEventStream( timeout: Int, headers: Map[String, String], withCredentials: Boolean, - responseType: String + responseType: String, + progressObserver: Observer[(dom.XMLHttpRequest, dom.ProgressEvent)] = Observer.empty, + readyStateChangeObserver: Observer[dom.XMLHttpRequest] = Observer.empty ) extends EventStream[dom.XMLHttpRequest] { protected[airstream] val topoRank: Int = 1 @@ -28,62 +46,101 @@ class AjaxEventStream( private var pendingRequest: Option[dom.XMLHttpRequest] = None override protected[this] def onStart(): Unit = { - // the implementation mirrors dom.ext.Ajax.apply - val req = new dom.XMLHttpRequest - pendingRequest = Some(req) - req.onreadystatechange = (_: dom.Event) => { - if (pendingRequest.contains(req) && req.readyState == 4) { + val request = AjaxEventStream.openRequest(method, url, timeout, headers, withCredentials, responseType) + + pendingRequest = Some(request) + + // As far as I can tell, only one of onload / onerror / onabort / ontimeout events can fire for every request, + // so the callbacks below are both mutually exhaustive and encompass all possible outcomes. + // If this is not the case, we have a problem. + + // Note: XMLHttpRequest is a valid target for native JS addEventListener, which is a better API, + // but that pattern isn't supported on XMLHttpRequest by all browsers (e.g. IE11 doesn't work). + + request.onload = (_: dom.Event) => { + if (pendingRequest.contains(request)) { pendingRequest = None - val status = req.status + val status = request.status if ((status >= 200 && status < 300) || status == 304) - new Transaction(fireValue(req, _)) + new Transaction(fireValue(request, _)) else - new Transaction(fireError(dom.ext.AjaxException(req), _)) + new Transaction(fireError(AjaxError(request, s"Bad HTTP response status code: $status"), _)) + } + } + + request.onerror = (ev: dom.ErrorEvent) => { + if (pendingRequest.contains(request)) { + pendingRequest = None + new Transaction(fireError(AjaxError(request, ev.message), _)) + } + } + + request.onabort = (_: js.Any) => { + if (pendingRequest.contains(request)) { + pendingRequest = None + new Transaction(fireError(AjaxAbort(request), _)) + } + } + + request.ontimeout = (_: dom.Event) => { + if (pendingRequest.contains(request)) { + pendingRequest = None + new Transaction(fireError(AjaxTimeout(request), _)) + } + } + + // The following observers are optional. + + if (progressObserver != Observer.empty) { + request.onprogress = ev => { + if (pendingRequest.contains(request)) { + progressObserver.onNext((request, ev)) + } + } + } + + if (readyStateChangeObserver != Observer.empty) { + request.onreadystatechange = (_: dom.Event) => { + if (pendingRequest.contains(request)) { + readyStateChangeObserver.onNext(request) + } } } - req.open(method, url) - req.responseType = responseType - req.timeout = timeout.toDouble - req.withCredentials = withCredentials - headers.foreach(Function.tupled(req.setRequestHeader)) - if (data == null) req.send() else req.send(data) + + AjaxEventStream.sendRequest(request, data) + } + + /** This stream will emit at most one event per request regardless of the outcome. + * + * You need to introspect the result to determine whether the request + * succeeded, failed, timed out, or was aborted. + */ + lazy val completeEvents: EventStream[dom.XMLHttpRequest] = { + this.recover { + case err: AjaxStreamException => Some(err.xhr) + } } override protected[this] def onStop(): Unit = { pendingRequest = None - super.onStop() } } object AjaxEventStream { - /** - * Returns an [[EventStream]] that performs an HTTP request and emits the - * [[dom.XMLHttpRequest]]/[[dom.ext.AjaxException]] on success/failure. - * - * The network request is delayed until start. In other words, the network request is not performed if the stream is - * never started. - * - * On restart, a new request is performed and the subsequent success/failure is propagated downstream. - * - * @see [[dom.raw.XMLHttpRequest]] for a description of the parameters - */ - def apply( - method: String, - url: String, - data: dom.ext.Ajax.InputData, - timeout: Int, - headers: Map[String, String], - withCredentials: Boolean, - responseType: String - ): EventStream[dom.XMLHttpRequest] = { - new AjaxEventStream(method, url, data, timeout, headers, withCredentials, responseType) - } + /** A more detailed version of [[dom.ext.AjaxException]] (no relation) */ + sealed abstract class AjaxStreamException(val xhr: dom.XMLHttpRequest) extends Exception + + final case class AjaxError(override val xhr: dom.XMLHttpRequest, message: String) extends AjaxStreamException(xhr) + + final case class AjaxTimeout(override val xhr: dom.XMLHttpRequest) extends AjaxStreamException(xhr) + + final case class AjaxAbort(override val xhr: dom.XMLHttpRequest) extends AjaxStreamException(xhr) /** * Returns an [[EventStream]] that performs an HTTP `GET` request. * - * @see [[apply]] + * @see [[AjaxEventStream]] */ def get( url: String, @@ -91,15 +148,17 @@ object AjaxEventStream { timeout: Int = 0, headers: Map[String, String] = Map.empty, withCredentials: Boolean = false, - responseType: String = "" - ): EventStream[dom.XMLHttpRequest] = { - apply("GET", url, data, timeout, headers, withCredentials, responseType) + responseType: String = "", + 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) } /** * Returns an [[EventStream]] that performs an HTTP `POST` request. * - * @see [[apply]] + * @see [[AjaxEventStream]] */ def post( url: String, @@ -107,15 +166,17 @@ object AjaxEventStream { timeout: Int = 0, headers: Map[String, String] = Map.empty, withCredentials: Boolean = false, - responseType: String = "" - ): EventStream[dom.XMLHttpRequest] = { - apply("POST", url, data, timeout, headers, withCredentials, responseType) + responseType: String = "", + 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) } /** * Returns an [[EventStream]] that performs an HTTP `PUT` request. * - * @see [[apply]] + * @see [[AjaxEventStream]] */ def put( url: String, @@ -123,15 +184,17 @@ object AjaxEventStream { timeout: Int = 0, headers: Map[String, String] = Map.empty, withCredentials: Boolean = false, - responseType: String = "" - ): EventStream[dom.XMLHttpRequest] = { - apply("PUT", url, data, timeout, headers, withCredentials, responseType) + responseType: String = "", + 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) } /** * Returns an [[EventStream]] that performs an HTTP `PATCH` request. * - * @see [[apply]] + * @see [[AjaxEventStream]] */ def patch( url: String, @@ -139,15 +202,17 @@ object AjaxEventStream { timeout: Int = 0, headers: Map[String, String] = Map.empty, withCredentials: Boolean = false, - responseType: String = "" - ): EventStream[dom.XMLHttpRequest] = { - apply("PATCH", url, data, timeout, headers, withCredentials, responseType) + responseType: String = "", + 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) } /** * Returns an [[EventStream]] that performs an HTTP `DELETE` request. * - * @see [[apply]] + * @see [[AjaxEventStream]] */ def delete( url: String, @@ -155,8 +220,44 @@ object AjaxEventStream { timeout: Int = 0, headers: Map[String, String] = Map.empty, withCredentials: Boolean = false, + responseType: String = "", + 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) + } + + /** Initializes and configures the XmlHttpRequest. This does not cause any network activity. + * + * Note: `data` is added later, when actually sending the request. + * + * AjaxEventStream already does this internally. This is provided as a building block for custom logic. + */ + def openRequest( + method: String, + url: String, + timeout: Int = 0, + headers: Map[String, String] = Map.empty, + withCredentials: Boolean = false, responseType: String = "" - ): EventStream[dom.XMLHttpRequest] = { - apply("DELETE", url, data, timeout, headers, withCredentials, responseType) + ): 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. + * + * AjaxEventStream already does this internally. This is provided as a building block for custom logic. + */ + def sendRequest( + request: dom.XMLHttpRequest, + data: dom.ext.Ajax.InputData = null + ): Unit = { + if (data == null) request.send() else request.send(data) } } diff --git a/src/main/scala/com/raquo/airstream/web/DomEventStream.scala b/src/main/scala/com/raquo/airstream/web/DomEventStream.scala index 36d53ba4..28779806 100644 --- a/src/main/scala/com/raquo/airstream/web/DomEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/DomEventStream.scala @@ -1,50 +1,40 @@ package com.raquo.airstream.web -import com.raquo.airstream.core.Transaction +import com.raquo.airstream.custom.{CustomSource, CustomStreamSource} import com.raquo.airstream.eventstream.EventStream import org.scalajs.dom import scala.scalajs.js -/** - * This stream, when started, registers an event listener on a specific target - * like a DOM element, document, or window, and re-emits all events sent to the listener. - * - * When this stream is stopped, the listener is removed. - * - * @param eventTarget any DOM event target, e.g. element, document, or window - * @param eventKey DOM event name, e.g. "click", "input", "change" - * @param useCapture See section about "useCapture" in https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener - * - */ -class DomEventStream[Ev <: dom.Event]( - eventTarget: dom.EventTarget, - eventKey: String, - useCapture: Boolean -) extends EventStream[Ev] { - - override protected[airstream] val topoRank: Int = 1 - - val eventHandler: js.Function1[Ev, Unit] = { ev => - new Transaction(fireValue(ev, _)) - } - - override protected[this] def onStart(): Unit = { - eventTarget.addEventListener(eventKey, eventHandler, useCapture) - } - - override protected[this] def onStop(): Unit = { - eventTarget.removeEventListener(eventKey, eventHandler, useCapture) - } -} - object DomEventStream { + /** + * This stream, when started, registers an event listener on a specific target + * like a DOM element, document, or window, and re-emits all events sent to the listener. + * + * When this stream is stopped, the listener is removed. + * + * @tparam Ev - You need to specify what event type you're expecting. + * The event type depends on the event, i.e. eventKey. Look it up on MDN. + * + * @param eventTarget any DOM event target, e.g. element, document, or window + * @param eventKey DOM event name, e.g. "click", "input", "change" + * @param useCapture See section about "useCapture" in https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + * + */ def apply[Ev <: dom.Event]( eventTarget: dom.EventTarget, eventKey: String, useCapture: Boolean = false ): EventStream[Ev] = { - new DomEventStream[Ev](eventTarget, eventKey, useCapture) + CustomStreamSource[Ev]( (fireValue, _, _, _) => { + + val eventHandler: js.Function1[Ev, Unit] = fireValue + + CustomSource.Config( + onStart = eventTarget.addEventListener(eventKey, eventHandler, useCapture), + onStop = eventTarget.removeEventListener(eventKey, eventHandler, useCapture) + ) + }) } } From bb1730ac9241a9030e7b86fcabd6489c330128df Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Tue, 29 Dec 2020 02:10:48 -0800 Subject: [PATCH 26/42] 2.12: Fix SetCurrentValue signature --- src/main/scala/com/raquo/airstream/custom/CustomSource.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/com/raquo/airstream/custom/CustomSource.scala b/src/main/scala/com/raquo/airstream/custom/CustomSource.scala index 4d88e647..09f9f350 100644 --- a/src/main/scala/com/raquo/airstream/custom/CustomSource.scala +++ b/src/main/scala/com/raquo/airstream/custom/CustomSource.scala @@ -102,7 +102,7 @@ object CustomSource { type FireError = Throwable => Unit - type SetCurrentValue[A] = Try[A] => () + type SetCurrentValue[A] = Try[A] => Unit type GetCurrentValue[A] = () => Try[A] From 9a1378abe004bd813ad8311a39852081cfcd8d63 Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Tue, 29 Dec 2020 22:15:26 -0800 Subject: [PATCH 27/42] New: Observer.contramapSome, Var.someWriter --- src/main/scala/com/raquo/airstream/core/Observer.scala | 6 +++++- src/main/scala/com/raquo/airstream/signal/Var.scala | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/scala/com/raquo/airstream/core/Observer.scala b/src/main/scala/com/raquo/airstream/core/Observer.scala index c0a3e165..99f12052 100644 --- a/src/main/scala/com/raquo/airstream/core/Observer.scala +++ b/src/main/scala/com/raquo/airstream/core/Observer.scala @@ -34,6 +34,11 @@ trait Observer[-A] { ) } + /** Available only on Observers of Option, this is a shortcut for contramap[B](Some(_)) */ + def contramapSome[V](implicit evidence: Option[V] <:< A): Observer[V] = { + contramap[V](value => evidence(Some(value))) + } + /** Like `contramap` but with `collect` semantics: not calling the original observer when `pf` is not defined */ def contracollect[B](pf: PartialFunction[B, A]): Observer[B] = { Observer.withRecover( @@ -56,7 +61,6 @@ trait Observer[-A] { object Observer { - /** An observer that does nothing. Use it to ensure that an Observable is started * * Used by SignalView and EventStreamView diff --git a/src/main/scala/com/raquo/airstream/signal/Var.scala b/src/main/scala/com/raquo/airstream/signal/Var.scala index bc40365e..77d97224 100644 --- a/src/main/scala/com/raquo/airstream/signal/Var.scala +++ b/src/main/scala/com/raquo/airstream/signal/Var.scala @@ -24,6 +24,11 @@ class Var[A] private(private[this] var currentValue: Try[A]) { new Transaction(setCurrentValue(nextTry, _)) } + /** Write values into a Var of Option[V] without manually wrapping in Some() */ + def someWriter[V](implicit evidence: Option[V] <:< A): Observer[V] = { + writer.contramapSome + } + /** An observer much like writer, but can compose input events with the current value of the var, for example: * * val v = Var(List(1, 2, 3)) From f018b41dce71596b777f1304c37c118184ef006d Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Tue, 29 Dec 2020 23:58:52 -0800 Subject: [PATCH 28/42] New: requestObserver for Ajax requests; Also some fixes: - Fix: Ajax error message is not actually available - Fix: delay xhr.open() so that readyStateChange fires with readyState = 1 --- README.md | 2 + .../raquo/airstream/web/AjaxEventStream.scala | 123 ++++++++++++++---- 2 files changed, 103 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f5265c37..63e2c688 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala index 5ac01e4a..24034907 100644 --- a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala @@ -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, @@ -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] { @@ -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) @@ -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 = (_: 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."), _)) } } @@ -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. @@ -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. @@ -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 + ) } /** @@ -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 + ) } /** @@ -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 + ) } /** @@ -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 + ) } /** @@ -221,28 +288,37 @@ 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 @@ -250,14 +326,17 @@ object AjaxEventStream { 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) } } From 764da325264a72d495844c42b9e6f639d4950748 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Wed, 30 Dec 2020 15:27:07 +0530 Subject: [PATCH 29/42] Define error ADT --- README.md | 4 ++-- .../scala/com/raquo/airstream/web/WebSocketError.scala | 3 --- .../com/raquo/airstream/web/WebSocketEventStream.scala | 9 +++++++-- 3 files changed, 9 insertions(+), 7 deletions(-) delete mode 100644 src/main/scala/com/raquo/airstream/web/WebSocketError.scala diff --git a/README.md b/README.md index 1fb58fb7..4190a4a9 100644 --- a/README.md +++ b/README.md @@ -644,8 +644,8 @@ Transmission is supported for the following types: - `String` #### Errors - - A connection termination is propagated as a `WebSocketError` (with a `dom.CloseEvent`). - - Transmission attempt on a terminated connection is propagated as a `WebSocketError` (with the message to be transmitted). + - `WebSocketClosed`: connection termination error + - `WebSocketError`: transmission error (due to a terminated connection) #### Stream lifecycle - A new websocket connection is established on start. diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketError.scala b/src/main/scala/com/raquo/airstream/web/WebSocketError.scala deleted file mode 100644 index 58532182..00000000 --- a/src/main/scala/com/raquo/airstream/web/WebSocketError.scala +++ /dev/null @@ -1,3 +0,0 @@ -package com.raquo.airstream.web - -final case class WebSocketError[E](event: E) extends Exception diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index a3b8fac3..c8032721 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -3,7 +3,7 @@ package com.raquo.airstream.web import com.raquo.airstream.core.Transaction import com.raquo.airstream.eventstream.EventStream import com.raquo.airstream.features.{InternalNextErrorObserver, SingleParentObservable} -import com.raquo.airstream.web.WebSocketEventStream.Driver +import com.raquo.airstream.web.WebSocketEventStream.{Driver, WebSocketClosed, WebSocketError} import org.scalajs.dom import scala.scalajs.js @@ -58,7 +58,7 @@ class WebSocketEventStream[I, O]( socket.onclose = (e: dom.CloseEvent) => if (jsSocket.nonEmpty) { jsSocket = js.undefined - new Transaction(fireError(WebSocketError(e), _)) + new Transaction(fireError(WebSocketClosed(e), _)) } // propagate message received @@ -116,6 +116,11 @@ object WebSocketEventStream { ): EventStream[O] = new WebSocketEventStream(transmit, project, url) + sealed abstract class WebSocketStreamException extends Exception + + final case class WebSocketClosed(event: dom.Event) extends WebSocketStreamException + final case class WebSocketError[I](input: I) extends WebSocketStreamException + sealed abstract class Driver[A] { def initialize(socket: dom.WebSocket): Unit From 167c44b41cd7eb4bb73dba08723786c5bab61294 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Wed, 30 Dec 2020 16:53:42 +0530 Subject: [PATCH 30/42] Revise builders --- README.md | 24 +--- .../airstream/web/WebSocketEventStream.scala | 120 ++++++++---------- 2 files changed, 57 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 4190a4a9..51016031 100644 --- a/README.md +++ b/README.md @@ -602,17 +602,11 @@ import com.raquo.airstream.eventstream.EventStream import com.raquo.airstream.web.WebSocketEventStream import org.scalajs.dom -// builder for creating unidirectional stream -val builder = WebSocketEventStream("absolute/url") - // raw websocket messages -val raw: EventStream[dom.MessageEvent] = builder.raw - -// extract and cast dom.MessageEvent.data -val data: EventStream[String] = builder.data[String] +val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw("absolute/url") -// alias for the common usecase (data[String]) -val text: EventStream[String] = builder.text +// extract and emit text data from raw websocket messages +val text: EventStream[String] = WebSocketEventStream.text("absolute/url") ``` #### Bidirectional websocket stream @@ -625,17 +619,11 @@ import org.scalajs.dom // messages to be transmitted val transmit: EventStream[String] = ??? -// builder for creating bidirectional stream -val builder = WebSocketEventStream("absolute/url", transmit) - // raw websocket messages -val raw: EventStream[dom.MessageEvent] = builder.raw - -// extract and cast dom.MessageEvent.data -val data: EventStream[String] = builder.data[String] +val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw("absolute/url", transmit) -// alias for the common usecase (data[String]) -val text: EventStream[String] = builder.text +// extract and emit text data from raw websocket messages +val text: EventStream[String] = WebSocketEventStream.text("absolute/url", transmit) ``` Transmission is supported for the following types: diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index c8032721..353ef455 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -20,7 +20,7 @@ import scala.util.{Success, Try} * - Incoming messages are propagated as events. * - The connection is closed on stop. */ -class WebSocketEventStream[I, O]( +class WebSocketEventStream[I, O] private( override val parent: EventStream[I], project: dom.MessageEvent => Try[O], url: String @@ -85,39 +85,7 @@ class WebSocketEventStream[I, O]( object WebSocketEventStream { - /** - * Builder for unidirectional websocket stream. - * - * @param url absolute URL of a websocket endpoint, - * use [[websocketUrl]] to construct an absolute URL from a relative one - */ - def apply(url: String): Builder[Void] = - new Builder[Void](EventStream.empty, url) - - /** - * Builder for bidirectional websocket stream. - * - * Transmission is supported for the following types: - * - [[js.typedarray.ArrayBuffer]] - * - [[dom.raw.Blob]] - * - [[String]] - * - * @param url absolute URL of a websocket endpoint, - * use [[websocketUrl]] to construct an absolute URL from a relative one - * @param transmit message to be transmitted from client to server - */ - def apply[I: Driver](url: String, transmit: EventStream[I]): Builder[I] = - new Builder(transmit, url) - - private def apply[I: Driver, O]( - transmit: EventStream[I], - project: dom.MessageEvent => Try[O], - url: String - ): EventStream[O] = - new WebSocketEventStream(transmit, project, url) - sealed abstract class WebSocketStreamException extends Exception - final case class WebSocketClosed(event: dom.Event) extends WebSocketStreamException final case class WebSocketError[I](input: I) extends WebSocketStreamException @@ -128,18 +96,53 @@ object WebSocketEventStream { def transmit(socket: dom.WebSocket, data: A): Unit } - final class Builder[I: Driver](transmit: EventStream[I], url: String) { + final object Driver { + + implicit val arrayBufferDriver: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") + implicit val blobDriver: Driver[dom.Blob] = binary(_ send _, "blob") + implicit val stringDriver: Driver[String] = simple(_ send _) + implicit val voidDriver: Driver[Void] = simple((_, _) => ()) + + private def binary[A](send: (dom.WebSocket, A) => Unit, binaryType: String): Driver[A] = + new Driver[A] { + + final def initialize(socket: dom.WebSocket): Unit = socket.binaryType = binaryType + + final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + } + + private def simple[A](send: (dom.WebSocket, A) => Unit): Driver[A] = + new Driver[A] { + + final def initialize(socket: dom.WebSocket): Unit = () + + final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + } + } + + private sealed abstract class extract[O](project: dom.MessageEvent => Try[O]) { /** - * Returns a stream that extracts data from raw [[dom.MessageEvent messages]] and emits them. + * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. * - * @see [[raw]] + * Stream lifecycle: + * - A new websocket connection is established on start. + * - Connection termination, not initiated by this stream, is propagated as an error. + * - Incoming messages are propagated as events. + * - The connection is closed on stop. + * + * @param url absolute URL of websocket endpoint */ - def data[O]: EventStream[O] = - WebSocketEventStream(transmit, m => Try(m.data.asInstanceOf[O]), url) + def apply(url: String): EventStream[O] = + apply[Void](url, EventStream.empty) /** - * Returns a stream that emits [[dom.MessageEvent messages]] from a [[dom.WebSocket websocket]] connection. + * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. + * + * Transmission is supported for the following types: + * - [[js.typedarray.ArrayBuffer]] + * - [[dom.raw.Blob]] + * - [[String]] * * Stream lifecycle: * - A new websocket connection is established on start. @@ -148,40 +151,19 @@ object WebSocketEventStream { * - Connection termination, not initiated by this stream, is propagated as an error. * - Incoming messages are propagated as events. * - The connection is closed on stop. - */ - def raw: EventStream[dom.MessageEvent] = - WebSocketEventStream(transmit, Success.apply, url) - - /** - * Returns a stream that extracts text data from raw [[dom.MessageEvent messages]] and emits them. * - * @see [[raw]] + * @param url absolute URL of websocket endpoint + * @param transmit messages to send to the websocket endpoint */ - def text: EventStream[String] = - data[String] + def apply[I: Driver](url: String, transmit: EventStream[I]): EventStream[O] = + new WebSocketEventStream(transmit, project, url) } - object Driver { - - implicit val binaryDriver: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") - implicit val blobDriver: Driver[dom.Blob] = binary(_ send _, "blob") - implicit val stringDriver: Driver[String] = simple(_ send _) - implicit val voidDriver: Driver[Void] = simple((_, _) => ()) - - private def binary[A](send: (dom.WebSocket, A) => Unit, binaryType: String): Driver[A] = - new Driver[A] { + private sealed abstract class data[O] extends extract(e => Try(e.data.asInstanceOf[String])) - final def initialize(socket: dom.WebSocket): Unit = socket.binaryType = binaryType + /** Builder for streams that emit [[dom.MessageEvent messages]] from a websocket connection */ + final case object raw extends extract(Success(_)) - final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) - } - - private def simple[A](send: (dom.WebSocket, A) => Unit): Driver[A] = - new Driver[A] { - - final def initialize(socket: dom.WebSocket): Unit = () - - final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) - } - } + /** Builder for streams that extract and emit text [[dom.MessageEvent#data data]] from a websocket connection */ + final case object text extends data[String] } From 37d7600349c06026b3bf288d8ee03008da85a422 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Wed, 30 Dec 2020 17:46:11 +0530 Subject: [PATCH 31/42] Fix build errors --- README.md | 4 ++-- .../com/raquo/airstream/web/WebSocketEventStream.scala | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 51016031..93d9488a 100644 --- a/README.md +++ b/README.md @@ -605,7 +605,7 @@ import org.scalajs.dom // raw websocket messages val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw("absolute/url") -// extract and emit text data from raw websocket messages +// extract text data from raw websocket messages val text: EventStream[String] = WebSocketEventStream.text("absolute/url") ``` @@ -622,7 +622,7 @@ val transmit: EventStream[String] = ??? // raw websocket messages val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw("absolute/url", transmit) -// extract and emit text data from raw websocket messages +// extract text data from raw websocket messages val text: EventStream[String] = WebSocketEventStream.text("absolute/url", transmit) ``` diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 353ef455..1deb6a55 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -120,7 +120,7 @@ object WebSocketEventStream { } } - private sealed abstract class extract[O](project: dom.MessageEvent => Try[O]) { + sealed abstract class extract[O](project: dom.MessageEvent => Try[O]) { /** * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. @@ -159,11 +159,11 @@ object WebSocketEventStream { new WebSocketEventStream(transmit, project, url) } - private sealed abstract class data[O] extends extract(e => Try(e.data.asInstanceOf[String])) + sealed abstract class data[O] extends extract(e => Try(e.data.asInstanceOf[String])) /** Builder for streams that emit [[dom.MessageEvent messages]] from a websocket connection */ final case object raw extends extract(Success(_)) - /** Builder for streams that extract and emit text [[dom.MessageEvent#data data]] from a websocket connection */ + /** Builder for streams that extract text [[dom.raw.MessageEvent.data data]] from a websocket connection */ final case object text extends data[String] } From 0f98b008660b1d4644612bed786f996105a6290d Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Thu, 31 Dec 2020 02:34:33 +0530 Subject: [PATCH 32/42] Added optional observers --- README.md | 6 +- .../airstream/web/WebSocketEventStream.scala | 158 ++++++++++-------- 2 files changed, 95 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 93d9488a..36bc3ce4 100644 --- a/README.md +++ b/README.md @@ -617,13 +617,13 @@ import com.raquo.airstream.web.WebSocketEventStream import org.scalajs.dom // messages to be transmitted -val transmit: EventStream[String] = ??? +val out: EventStream[String] = ??? // raw websocket messages -val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw("absolute/url", transmit) +val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw("absolute/url", out) // extract text data from raw websocket messages -val text: EventStream[String] = WebSocketEventStream.text("absolute/url", transmit) +val text: EventStream[String] = WebSocketEventStream.text("absolute/url", out) ``` Transmission is supported for the following types: diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 1deb6a55..7cf7bd18 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -1,6 +1,6 @@ package com.raquo.airstream.web -import com.raquo.airstream.core.Transaction +import com.raquo.airstream.core.{Observer, Transaction} import com.raquo.airstream.eventstream.EventStream import com.raquo.airstream.features.{InternalNextErrorObserver, SingleParentObservable} import com.raquo.airstream.web.WebSocketEventStream.{Driver, WebSocketClosed, WebSocketError} @@ -19,11 +19,23 @@ import scala.util.{Success, Try} * - Connection termination, not initiated by this stream, is propagated as an error. * - Incoming messages are propagated as events. * - The connection is closed on stop. + * + * '''Warning''': [[dom.WebSocket]] is an ugly, imperative JS construct. We set event callbacks for + * onclose, onmessage, and if requested, also for onopen. + * Make sure you don't override Airstream's listeners, or this stream will not work properly. + * + * @param parent stream of outgoing messages + * @param project mapping for incoming messages + * @param url absolute URL of websocket endpoint + * @param socketObserver called when a websocket connection is created + * @param socketOpenObserver called when a websocket connection is open */ class WebSocketEventStream[I, O] private( override val parent: EventStream[I], project: dom.MessageEvent => Try[O], - url: String + url: String, + socketObserver: Observer[dom.WebSocket], + socketOpenObserver: Observer[dom.WebSocket] )(implicit D: Driver[I]) extends EventStream[O] with SingleParentObservable[I, O] with InternalNextErrorObserver[I] { protected[airstream] val topoRank: Int = 1 @@ -43,32 +55,39 @@ class WebSocketEventStream[I, O] private( val socket = new dom.WebSocket(url) + // update local reference + jsSocket = socket + // initialize new socket D.initialize(socket) - // register callbacks and update local reference - socket.onopen = - (_: dom.Event) => if (jsSocket.isEmpty) { + // https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications#Creating_a_WebSocket_object + // "onclose" is called right after "onerror", so register callback for "onclose" only + // propagate connection close event as error + socket.onclose = + (e: dom.CloseEvent) => if (jsSocket.contains(socket)) { + jsSocket = js.undefined + new Transaction(fireError(WebSocketClosed(e), _)) + } - // https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications#Creating_a_WebSocket_object - // as per documentation, "onclose" is called right after "onerror" - // so register callback for "onclose" only + // propagate message received + socket.onmessage = + (e: dom.MessageEvent) => if (jsSocket.contains(socket)) { + project(e).fold(e => new Transaction(fireError(e, _)), o => new Transaction(fireValue(o, _))) + } - // propagate connection close event as error - socket.onclose = - (e: dom.CloseEvent) => if (jsSocket.nonEmpty) { - jsSocket = js.undefined - new Transaction(fireError(WebSocketClosed(e), _)) - } + // call/register optional observers - // propagate message received - socket.onmessage = - (e: dom.MessageEvent) => if (jsSocket.nonEmpty) { - project(e).fold(e => new Transaction(fireError(e, _)), o => new Transaction(fireValue(o, _))) - } + if (socketOpenObserver ne Observer.empty) { + socket.onopen = + (_: dom.Event) => if (jsSocket.contains(socket)) { + socketOpenObserver.onNext(socket) + } + } - jsSocket = socket - } + if (socketObserver ne Observer.empty) { + socketObserver.onNext(socket) + } super.onStart() } @@ -86,8 +105,6 @@ class WebSocketEventStream[I, O] private( object WebSocketEventStream { sealed abstract class WebSocketStreamException extends Exception - final case class WebSocketClosed(event: dom.Event) extends WebSocketStreamException - final case class WebSocketError[I](input: I) extends WebSocketStreamException sealed abstract class Driver[A] { @@ -96,45 +113,27 @@ object WebSocketEventStream { def transmit(socket: dom.WebSocket, data: A): Unit } - final object Driver { - - implicit val arrayBufferDriver: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") - implicit val blobDriver: Driver[dom.Blob] = binary(_ send _, "blob") - implicit val stringDriver: Driver[String] = simple(_ send _) - implicit val voidDriver: Driver[Void] = simple((_, _) => ()) - - private def binary[A](send: (dom.WebSocket, A) => Unit, binaryType: String): Driver[A] = - new Driver[A] { - - final def initialize(socket: dom.WebSocket): Unit = socket.binaryType = binaryType - - final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) - } - - private def simple[A](send: (dom.WebSocket, A) => Unit): Driver[A] = - new Driver[A] { - - final def initialize(socket: dom.WebSocket): Unit = () - - final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) - } - } - sealed abstract class extract[O](project: dom.MessageEvent => Try[O]) { /** * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. * - * Stream lifecycle: - * - A new websocket connection is established on start. - * - Connection termination, not initiated by this stream, is propagated as an error. - * - Incoming messages are propagated as events. - * - The connection is closed on stop. - * * @param url absolute URL of websocket endpoint */ def apply(url: String): EventStream[O] = - apply[Void](url, EventStream.empty) + apply(url, Observer.empty, Observer.empty) + + /** + * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. + * + * @param url absolute URL of websocket endpoint + * @param socketObserver called when a websocket connection is created + * @param socketOpenObserver called when a websocket connection is open + */ + def apply(url: String, + socketObserver: Observer[dom.WebSocket], + socketOpenObserver: Observer[dom.WebSocket]): EventStream[O] = + apply[Void](url, EventStream.empty, socketObserver, socketOpenObserver) /** * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. @@ -144,26 +143,53 @@ object WebSocketEventStream { * - [[dom.raw.Blob]] * - [[String]] * - * Stream lifecycle: - * - A new websocket connection is established on start. - * - Outgoing messages, if any, are sent on this connection. - * - Transmission failures, due to connection termination, are propagated as errors. - * - Connection termination, not initiated by this stream, is propagated as an error. - * - Incoming messages are propagated as events. - * - The connection is closed on stop. - * - * @param url absolute URL of websocket endpoint - * @param transmit messages to send to the websocket endpoint + * @param url absolute URL of websocket endpoint + * @param stream stream of outgoing messages + * @param socketObserver called when a websocket connection is created + * @param socketOpenObserver called when a websocket connection is open */ - def apply[I: Driver](url: String, transmit: EventStream[I]): EventStream[O] = - new WebSocketEventStream(transmit, project, url) + def apply[I: Driver]( + url: String, + stream: EventStream[I], + socketObserver: Observer[dom.WebSocket] = Observer.empty, + socketOpenObserver: Observer[dom.WebSocket] = Observer.empty): EventStream[O] = + new WebSocketEventStream(stream, project, url, socketObserver, socketOpenObserver) } sealed abstract class data[O] extends extract(e => Try(e.data.asInstanceOf[String])) + final case class WebSocketClosed(event: dom.Event) extends WebSocketStreamException + + final case class WebSocketError[I](input: I) extends WebSocketStreamException + + final object Driver { + + implicit val arrayBufferDriver: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") + implicit val blobDriver: Driver[dom.Blob] = binary(_ send _, "blob") + implicit val stringDriver: Driver[String] = simple(_ send _) + implicit val voidDriver: Driver[Void] = simple((_, _) => ()) + + private def binary[A](send: (dom.WebSocket, A) => Unit, binaryType: String): Driver[A] = + new Driver[A] { + + final def initialize(socket: dom.WebSocket): Unit = socket.binaryType = binaryType + + final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + } + + private def simple[A](send: (dom.WebSocket, A) => Unit): Driver[A] = + new Driver[A] { + + final def initialize(socket: dom.WebSocket): Unit = () + + final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + } + } + /** Builder for streams that emit [[dom.MessageEvent messages]] from a websocket connection */ final case object raw extends extract(Success(_)) /** Builder for streams that extract text [[dom.raw.MessageEvent.data data]] from a websocket connection */ final case object text extends data[String] + } From 2271c450c61f73911124d8ecf69a3c9ad1a173ac Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Thu, 31 Dec 2020 04:39:45 +0530 Subject: [PATCH 33/42] Added binary data extractors --- README.md | 15 ++++++++++--- .../airstream/web/WebSocketEventStream.scala | 22 +++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 36bc3ce4..44dc9cb0 100644 --- a/README.md +++ b/README.md @@ -602,10 +602,14 @@ import com.raquo.airstream.eventstream.EventStream import com.raquo.airstream.web.WebSocketEventStream import org.scalajs.dom +import scala.scalajs.js + // raw websocket messages val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw("absolute/url") -// extract text data from raw websocket messages +// or use one of the extractors to access just the data +val binary: EventStream[js.typedarray.ArrayBuffer] = WebSocketEventStream.binary("absolute/url") +val blob: EventStream[dom.Blob] = WebSocketEventStream.blob("absolute/url") val text: EventStream[String] = WebSocketEventStream.text("absolute/url") ``` @@ -616,17 +620,22 @@ import com.raquo.airstream.eventstream.EventStream import com.raquo.airstream.web.WebSocketEventStream import org.scalajs.dom +import scala.scalajs.js + // messages to be transmitted val out: EventStream[String] = ??? // raw websocket messages val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw("absolute/url", out) -// extract text data from raw websocket messages +// or use one of the extractors to access just the data +val binary: EventStream[js.typedarray.ArrayBuffer] = WebSocketEventStream.binary("absolute/url", out) +val blob: EventStream[dom.Blob] = WebSocketEventStream.blob("absolute/url", out) val text: EventStream[String] = WebSocketEventStream.text("absolute/url", out) ``` -Transmission is supported for the following types: +#### Supported types +The following types are supported for transmission/reception: - `js.typedarray.ArrayBuffer` - `dom.raw.Blob` - `String` diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 7cf7bd18..f76ed3f2 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -21,7 +21,7 @@ import scala.util.{Success, Try} * - The connection is closed on stop. * * '''Warning''': [[dom.WebSocket]] is an ugly, imperative JS construct. We set event callbacks for - * onclose, onmessage, and if requested, also for onopen. + * `onclose`, `onmessage`, and if requested, also for `onopen`. * Make sure you don't override Airstream's listeners, or this stream will not work properly. * * @param parent stream of outgoing messages @@ -113,7 +113,8 @@ object WebSocketEventStream { def transmit(socket: dom.WebSocket, data: A): Unit } - sealed abstract class extract[O](project: dom.MessageEvent => Try[O]) { + /** Transforms websocket [[dom.MessageEvent messages]] */ + sealed abstract class transform[O](project: dom.MessageEvent => Try[O]) { /** * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. @@ -156,7 +157,8 @@ object WebSocketEventStream { new WebSocketEventStream(stream, project, url, socketObserver, socketOpenObserver) } - sealed abstract class data[O] extends extract(e => Try(e.data.asInstanceOf[String])) + /** Extracts the data from a [[dom.MessageEvent message]] */ + sealed abstract class data[O] extends transform(e => Try(e.data.asInstanceOf[O])) final case class WebSocketClosed(event: dom.Event) extends WebSocketStreamException @@ -164,8 +166,8 @@ object WebSocketEventStream { final object Driver { - implicit val arrayBufferDriver: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") implicit val blobDriver: Driver[dom.Blob] = binary(_ send _, "blob") + implicit val binaryDriver: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") implicit val stringDriver: Driver[String] = simple(_ send _) implicit val voidDriver: Driver[Void] = simple((_, _) => ()) @@ -186,10 +188,16 @@ object WebSocketEventStream { } } - /** Builder for streams that emit [[dom.MessageEvent messages]] from a websocket connection */ - final case object raw extends extract(Success(_)) + /** Extracts [[js.typedarray.ArrayBuffer binary]] data from a [[dom.MessageEvent message]] */ + final case object binary extends data[js.typedarray.ArrayBuffer] - /** Builder for streams that extract text [[dom.raw.MessageEvent.data data]] from a websocket connection */ + /** Extracts [[dom.Blob blob]] data from a [[dom.MessageEvent message]] */ + final case object blob extends data[dom.Blob] + + /** Returns [[dom.MessageEvent messages]] as is */ + final case object raw extends transform(Success(_)) + + /** Extracts [[String text]] data from a [[dom.MessageEvent message]] */ final case object text extends data[String] } From 578d62ca3485d9e3fe0bb7e2704e54510bbdbc77 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Thu, 31 Dec 2020 12:58:10 +0530 Subject: [PATCH 34/42] Better names --- .../raquo/airstream/web/WebSocketEventStream.scala | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index f76ed3f2..7cb497b0 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -113,8 +113,8 @@ object WebSocketEventStream { def transmit(socket: dom.WebSocket, data: A): Unit } - /** Transforms websocket [[dom.MessageEvent messages]] */ - sealed abstract class transform[O](project: dom.MessageEvent => Try[O]) { + /** Reads websocket [[dom.MessageEvent messages]] */ + sealed abstract class reader[O](project: dom.MessageEvent => Try[O]) { /** * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. @@ -145,20 +145,20 @@ object WebSocketEventStream { * - [[String]] * * @param url absolute URL of websocket endpoint - * @param stream stream of outgoing messages + * @param writer stream of outgoing messages * @param socketObserver called when a websocket connection is created * @param socketOpenObserver called when a websocket connection is open */ def apply[I: Driver]( url: String, - stream: EventStream[I], + writer: EventStream[I], socketObserver: Observer[dom.WebSocket] = Observer.empty, socketOpenObserver: Observer[dom.WebSocket] = Observer.empty): EventStream[O] = - new WebSocketEventStream(stream, project, url, socketObserver, socketOpenObserver) + new WebSocketEventStream(writer, project, url, socketObserver, socketOpenObserver) } /** Extracts the data from a [[dom.MessageEvent message]] */ - sealed abstract class data[O] extends transform(e => Try(e.data.asInstanceOf[O])) + sealed abstract class data[O] extends reader(e => Try(e.data.asInstanceOf[O])) final case class WebSocketClosed(event: dom.Event) extends WebSocketStreamException @@ -195,7 +195,7 @@ object WebSocketEventStream { final case object blob extends data[dom.Blob] /** Returns [[dom.MessageEvent messages]] as is */ - final case object raw extends transform(Success(_)) + final case object raw extends reader(Success(_)) /** Extracts [[String text]] data from a [[dom.MessageEvent message]] */ final case object text extends data[String] From b1bef32cf410c5922ac9269c714a17bd2bb14a0e Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Thu, 31 Dec 2020 13:00:43 +0530 Subject: [PATCH 35/42] Add/remove final modifier --- .../airstream/web/WebSocketEventStream.scala | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 7cb497b0..ee972bd9 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -121,7 +121,7 @@ object WebSocketEventStream { * * @param url absolute URL of websocket endpoint */ - def apply(url: String): EventStream[O] = + final def apply(url: String): EventStream[O] = apply(url, Observer.empty, Observer.empty) /** @@ -131,7 +131,7 @@ object WebSocketEventStream { * @param socketObserver called when a websocket connection is created * @param socketOpenObserver called when a websocket connection is open */ - def apply(url: String, + final def apply(url: String, socketObserver: Observer[dom.WebSocket], socketOpenObserver: Observer[dom.WebSocket]): EventStream[O] = apply[Void](url, EventStream.empty, socketObserver, socketOpenObserver) @@ -149,7 +149,7 @@ object WebSocketEventStream { * @param socketObserver called when a websocket connection is created * @param socketOpenObserver called when a websocket connection is open */ - def apply[I: Driver]( + final def apply[I: Driver]( url: String, writer: EventStream[I], socketObserver: Observer[dom.WebSocket] = Observer.empty, @@ -164,7 +164,7 @@ object WebSocketEventStream { final case class WebSocketError[I](input: I) extends WebSocketStreamException - final object Driver { + object Driver { implicit val blobDriver: Driver[dom.Blob] = binary(_ send _, "blob") implicit val binaryDriver: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") @@ -189,15 +189,15 @@ object WebSocketEventStream { } /** Extracts [[js.typedarray.ArrayBuffer binary]] data from a [[dom.MessageEvent message]] */ - final case object binary extends data[js.typedarray.ArrayBuffer] + object binary extends data[js.typedarray.ArrayBuffer] /** Extracts [[dom.Blob blob]] data from a [[dom.MessageEvent message]] */ - final case object blob extends data[dom.Blob] + object blob extends data[dom.Blob] /** Returns [[dom.MessageEvent messages]] as is */ - final case object raw extends reader(Success(_)) + object raw extends reader(Success(_)) /** Extracts [[String text]] data from a [[dom.MessageEvent message]] */ - final case object text extends data[String] + object text extends data[String] } From 0fd099aa1fa0c376e61ed232a828e29e8c194dbf Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Fri, 1 Jan 2021 14:22:41 +0530 Subject: [PATCH 36/42] Updated docs --- README.md | 41 +++++++++++++++++-- .../airstream/web/WebSocketEventStream.scala | 12 +++--- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 63e2c688..b38ea875 100644 --- a/README.md +++ b/README.md @@ -620,15 +620,50 @@ In a similar manner, you can pass a `requestObserver` that will be called with t 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. +### Websockets +Airstream has support for streaming messages from websockets. -### Websockets +Usage: +```scala +import com.raquo.airstream.web.websocketUrl +import com.raquo.airstream.eventstream.EventStream +import com.raquo.airstream.web.WebSocketEventStream +import org.scalajs.dom + +import scala.scalajs.js + +// absolute URL is required +val url: String = websocketUrl("relative/url") -Airstream has no official websockets integration yet. +// messages to be transmitted (bidirectional usecase) +// supported types: js.typedarray.ArrayBuffer, dom.Blob, String +val writer: EventStream[String] = ??? + +// for unidirectional usecase, skip the "writer" parameter + +// raw websocket messages +val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw(url, writer) + +// or use one of the data extractors +val binary: EventStream[js.typedarray.ArrayBuffer] = WebSocketEventStream.binary(url, writer) +val blob: EventStream[dom.Blob] = WebSocketEventStream.blob(url, writer) +val text: EventStream[String] = WebSocketEventStream.text(url, writer) +``` -For several users' implementations, search Laminar gitter room, and the issues in this repo. +All constructors accept optional observers: + - `socketObserver`: notified when a websocket is created + - `socketOpenObserver`: notified when a websocket is open +Stream lifecycle: + - A new websocket connection is established on start. + - Outgoing messages, if any, are sent on this connection. + - Transmission failures, due to connection termination, are propagated as errors. + - Connection termination, not initiated by this stream, is propagated as an error. + - Incoming messages are propagated as events. + - The connection is closed on stop. +Warning: `dom.WebSocket` is an ugly, imperative JS construct. We set event callbacks for `onclose`, `onmessage`, and if requested, also for `onopen`. Make sure you don't override Airstream's listeners, or this stream will not work properly. ### DOM Events diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index ee972bd9..a9ed28ae 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -113,7 +113,7 @@ object WebSocketEventStream { def transmit(socket: dom.WebSocket, data: A): Unit } - /** Reads websocket [[dom.MessageEvent messages]] */ + /** streams websocket messages */ sealed abstract class reader[O](project: dom.MessageEvent => Try[O]) { /** @@ -157,7 +157,7 @@ object WebSocketEventStream { new WebSocketEventStream(writer, project, url, socketObserver, socketOpenObserver) } - /** Extracts the data from a [[dom.MessageEvent message]] */ + /** streams the data from a [[dom.MessageEvent message]] */ sealed abstract class data[O] extends reader(e => Try(e.data.asInstanceOf[O])) final case class WebSocketClosed(event: dom.Event) extends WebSocketStreamException @@ -188,16 +188,16 @@ object WebSocketEventStream { } } - /** Extracts [[js.typedarray.ArrayBuffer binary]] data from a [[dom.MessageEvent message]] */ + /** streams [[js.typedarray.ArrayBuffer binary]] data */ object binary extends data[js.typedarray.ArrayBuffer] - /** Extracts [[dom.Blob blob]] data from a [[dom.MessageEvent message]] */ + /** streams [[dom.Blob blob]] data */ object blob extends data[dom.Blob] - /** Returns [[dom.MessageEvent messages]] as is */ + /** streams websocket [[dom.MessageEvent messages]] */ object raw extends reader(Success(_)) - /** Extracts [[String text]] data from a [[dom.MessageEvent message]] */ + /** streams [[String text]] data */ object text extends data[String] } From fb7d8edd694189f56540cbeb5baec41c82963451 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Tue, 5 Jan 2021 01:11:37 +0530 Subject: [PATCH 37/42] Added protocol parameter --- .../airstream/web/WebSocketEventStream.scala | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index a9ed28ae..f22ab343 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -27,6 +27,7 @@ import scala.util.{Success, Try} * @param parent stream of outgoing messages * @param project mapping for incoming messages * @param url absolute URL of websocket endpoint + * @param protocol name of the (optional) sub-protocol the server selected * @param socketObserver called when a websocket connection is created * @param socketOpenObserver called when a websocket connection is open */ @@ -34,6 +35,7 @@ class WebSocketEventStream[I, O] private( override val parent: EventStream[I], project: dom.MessageEvent => Try[O], url: String, + protocol: String, socketObserver: Observer[dom.WebSocket], socketOpenObserver: Observer[dom.WebSocket] )(implicit D: Driver[I]) extends EventStream[O] with SingleParentObservable[I, O] with InternalNextErrorObserver[I] { @@ -53,7 +55,7 @@ class WebSocketEventStream[I, O] private( override protected[this] def onStart(): Unit = { - val socket = new dom.WebSocket(url) + val socket = new dom.WebSocket(url, protocol) // update local reference jsSocket = socket @@ -122,19 +124,21 @@ object WebSocketEventStream { * @param url absolute URL of websocket endpoint */ final def apply(url: String): EventStream[O] = - apply(url, Observer.empty, Observer.empty) + apply(url, "", Observer.empty, Observer.empty) /** * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. * * @param url absolute URL of websocket endpoint + * @param protocol name of the (optional) sub-protocol the server selected * @param socketObserver called when a websocket connection is created * @param socketOpenObserver called when a websocket connection is open */ final def apply(url: String, - socketObserver: Observer[dom.WebSocket], - socketOpenObserver: Observer[dom.WebSocket]): EventStream[O] = - apply[Void](url, EventStream.empty, socketObserver, socketOpenObserver) + protocol: String, + socketObserver: Observer[dom.WebSocket], + socketOpenObserver: Observer[dom.WebSocket]): EventStream[O] = + apply[Void](url, EventStream.empty, protocol, socketObserver, socketOpenObserver) /** * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. @@ -146,15 +150,17 @@ object WebSocketEventStream { * * @param url absolute URL of websocket endpoint * @param writer stream of outgoing messages + * @param protocol name of the (optional) sub-protocol the server selected * @param socketObserver called when a websocket connection is created * @param socketOpenObserver called when a websocket connection is open */ final def apply[I: Driver]( url: String, writer: EventStream[I], + protocol: String = "", socketObserver: Observer[dom.WebSocket] = Observer.empty, socketOpenObserver: Observer[dom.WebSocket] = Observer.empty): EventStream[O] = - new WebSocketEventStream(writer, project, url, socketObserver, socketOpenObserver) + new WebSocketEventStream(writer, project, url, protocol, socketObserver, socketOpenObserver) } /** streams the data from a [[dom.MessageEvent message]] */ From 270e05a9fb7fd51e66b968b16b4b7185a543c0a4 Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Wed, 6 Jan 2021 14:46:42 +0530 Subject: [PATCH 38/42] Redesign --- README.md | 43 +-- .../airstream/web/WebSocketEventStream.scala | 339 ++++++++++-------- 2 files changed, 197 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index b38ea875..9a73c617 100644 --- a/README.md +++ b/README.md @@ -622,48 +622,7 @@ Warning: dom.XmlHttpRequest is an ugly, imperative JS construct. We set event ca ### Websockets -Airstream has support for streaming messages from websockets. - -Usage: -```scala -import com.raquo.airstream.web.websocketUrl -import com.raquo.airstream.eventstream.EventStream -import com.raquo.airstream.web.WebSocketEventStream -import org.scalajs.dom - -import scala.scalajs.js - -// absolute URL is required -val url: String = websocketUrl("relative/url") - -// messages to be transmitted (bidirectional usecase) -// supported types: js.typedarray.ArrayBuffer, dom.Blob, String -val writer: EventStream[String] = ??? - -// for unidirectional usecase, skip the "writer" parameter - -// raw websocket messages -val raw: EventStream[dom.MessageEvent] = WebSocketEventStream.raw(url, writer) - -// or use one of the data extractors -val binary: EventStream[js.typedarray.ArrayBuffer] = WebSocketEventStream.binary(url, writer) -val blob: EventStream[dom.Blob] = WebSocketEventStream.blob(url, writer) -val text: EventStream[String] = WebSocketEventStream.text(url, writer) -``` - -All constructors accept optional observers: - - `socketObserver`: notified when a websocket is created - - `socketOpenObserver`: notified when a websocket is open - -Stream lifecycle: - - A new websocket connection is established on start. - - Outgoing messages, if any, are sent on this connection. - - Transmission failures, due to connection termination, are propagated as errors. - - Connection termination, not initiated by this stream, is propagated as an error. - - Incoming messages are propagated as events. - - The connection is closed on stop. - -Warning: `dom.WebSocket` is an ugly, imperative JS construct. We set event callbacks for `onclose`, `onmessage`, and if requested, also for `onopen`. Make sure you don't override Airstream's listeners, or this stream will not work properly. +TODO. ### DOM Events diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index f22ab343..591e14d1 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -2,208 +2,261 @@ package com.raquo.airstream.web import com.raquo.airstream.core.{Observer, Transaction} import com.raquo.airstream.eventstream.EventStream -import com.raquo.airstream.features.{InternalNextErrorObserver, SingleParentObservable} -import com.raquo.airstream.web.WebSocketEventStream.{Driver, WebSocketClosed, WebSocketError} import org.scalajs.dom import scala.scalajs.js import scala.util.{Success, Try} /** - * An event source that emits messages from a [[dom.WebSocket]] connection. - * - * Stream lifecycle: - * - A new websocket connection is established on start. - * - Outgoing messages, if any, are sent on this connection. - * - Transmission failures, due to connection termination, are propagated as errors. - * - Connection termination, not initiated by this stream, is propagated as an error. - * - Incoming messages are propagated as events. - * - The connection is closed on stop. + * An event source that emits/transmits messages from/on a [[dom.WebSocket]] connection. * * '''Warning''': [[dom.WebSocket]] is an ugly, imperative JS construct. We set event callbacks for - * `onclose`, `onmessage`, and if requested, also for `onopen`. + * `onclose`, `onmessage`, and if requested, also for `onerror`, `onopen`. * Make sure you don't override Airstream's listeners, or this stream will not work properly. * - * @param parent stream of outgoing messages - * @param project mapping for incoming messages - * @param url absolute URL of websocket endpoint - * @param protocol name of the (optional) sub-protocol the server selected - * @param socketObserver called when a websocket connection is created - * @param socketOpenObserver called when a websocket connection is open + * @param url absolute URL of websocket endpoint + * @param protocol name of the sub-protocol the server selected + * @param closeObserver called when a websocket connection is closed + * @param errorObserver called when a websocket connection error occurs + * @param openObserver called when a websocket connection is open + * @param startObserver called when a websocket connection is started + * @param unsentObserver called when a message cannot be sent + * + * @see [[https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API MDN]] */ -class WebSocketEventStream[I, O] private( - override val parent: EventStream[I], - project: dom.MessageEvent => Try[O], +class WebSocketEventStream[I, O] private ( url: String, - protocol: String, - socketObserver: Observer[dom.WebSocket], - socketOpenObserver: Observer[dom.WebSocket] -)(implicit D: Driver[I]) extends EventStream[O] with SingleParentObservable[I, O] with InternalNextErrorObserver[I] { + project: dom.MessageEvent => Try[I], + closeObserver: Observer[dom.CloseEvent], + errorObserver: Observer[dom.Event], + openObserver: Observer[dom.Event], + startObserver: Observer[dom.WebSocket], + unsentObserver: Observer[O], + protocol: String +)(implicit W: WebSocketEventStream.Writer[O]) + extends EventStream[I] { protected[airstream] val topoRank: Int = 1 - private var jsSocket: js.UndefOr[dom.WebSocket] = js.undefined + private var websocket: js.UndefOr[dom.WebSocket] = js.undefined + + def close(): Unit = + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close + websocket.foreach { socket => + socket.onclose = null + socket.onerror = null + socket.onmessage = null + socket.onopen = null + socket.close() + websocket = js.undefined + } - protected[airstream] def onError(nextError: Throwable, transaction: Transaction): Unit = { - // noop + def open(): Unit = { + close() + connect() } - protected[airstream] def onNext(nextValue: I, transaction: Transaction): Unit = { - // transmit upstream message, no guard required since driver is trusted - jsSocket.fold(fireError(WebSocketError(nextValue), transaction))(D.transmit(_, nextValue)) - } + def send(out: O): Unit = + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send + // The WebSocket.send() method enqueues the specified data to be transmitted to the server over the WebSocket + // connection, increasing the value of bufferedAmount by the number of bytes needed to contain the data. If the + // data can't be sent (for example, because it needs to be buffered but the buffer is full), the socket is closed + // automatically. + websocket.fold(unsentObserver.onNext(out))(W.write(_, out)) override protected[this] def onStart(): Unit = { + websocket.fold(connect())(bind) + super.onStart() + } + + override protected[this] def onStop(): Unit = { + close() + super.onStop() + } + + private def bind(socket: dom.WebSocket): Unit = + // bind message listener + socket.onmessage = (e: dom.MessageEvent) => { + val _ = project(e).fold(e => new Transaction(fireError(e, _)), o => new Transaction(fireValue(o, _))) + } + private def connect(): Unit = { val socket = new dom.WebSocket(url, protocol) // update local reference - jsSocket = socket + websocket = socket // initialize new socket - D.initialize(socket) - - // https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications#Creating_a_WebSocket_object - // "onclose" is called right after "onerror", so register callback for "onclose" only - // propagate connection close event as error - socket.onclose = - (e: dom.CloseEvent) => if (jsSocket.contains(socket)) { - jsSocket = js.undefined - new Transaction(fireError(WebSocketClosed(e), _)) - } - - // propagate message received - socket.onmessage = - (e: dom.MessageEvent) => if (jsSocket.contains(socket)) { - project(e).fold(e => new Transaction(fireError(e, _)), o => new Transaction(fireValue(o, _))) - } + W.initialize(socket) - // call/register optional observers + // bind message listener + if (isStarted) bind(socket) - if (socketOpenObserver ne Observer.empty) { - socket.onopen = - (_: dom.Event) => if (jsSocket.contains(socket)) { - socketOpenObserver.onNext(socket) - } + // register required listeners + socket.onclose = (e: dom.CloseEvent) => { + websocket = js.undefined + if (closeObserver ne Observer.empty) closeObserver.onNext(e) } - if (socketObserver ne Observer.empty) { - socketObserver.onNext(socket) - } - - super.onStart() - } + // register optional listeners + if (errorObserver ne Observer.empty) socket.onerror = errorObserver.onNext + if (openObserver ne Observer.empty) socket.onopen = openObserver.onNext - override protected[this] def onStop(): Unit = { - // Is "close" async? - // just to be safe, reset local reference before closing to prevent error propagation in "onclose" callback - val socket = jsSocket - jsSocket = js.undefined - socket.foreach(_.close()) - super.onStop() + // call optional observer + if (startObserver ne Observer.empty) startObserver.onNext(socket) } } object WebSocketEventStream { - sealed abstract class WebSocketStreamException extends Exception + /** + * A unidirectional (server to client) websocket connection defined as 2 values, + * 1. controller: observer that controls when a connection is opened/closed + * 1. receiver: stream of incoming messages of type `I` + */ + type Simplex[+I] = (Observer[Boolean], EventStream[I]) - sealed abstract class Driver[A] { + /** + * A bidirectional websocket connection defined as 3 values, + * 1. controller: observer that controls when a connection is opened/closed + * 1. receiver: stream of incoming messages of type `I` + * 1. transmitter: observer that transmits outgoing messages of type `O` + */ + type Duplex[+I, -O] = (Observer[Boolean], EventStream[I], Observer[O]) - def initialize(socket: dom.WebSocket): Unit - - def transmit(socket: dom.WebSocket, data: A): Unit - } - - /** streams websocket messages */ - sealed abstract class reader[O](project: dom.MessageEvent => Try[O]) { + sealed abstract class Reader[I](project: dom.MessageEvent => Try[I]) { /** - * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. + * Returns a websocket [[Duplex connection]] as 3 values, + * 1. `controller`: observer that controls when a connection is opened/closed + * 1. `receiver`: stream of incoming messages of type `I` + * 1. `transmitter`: observer that transmits outgoing messages of type `O` * - * @param url absolute URL of websocket endpoint - */ - final def apply(url: String): EventStream[O] = - apply(url, "", Observer.empty, Observer.empty) - - /** - * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. + * Connection lifecycle: + * - A new websocket connection is established when either + * - the `receiver` is started. + * - the `controller` is called with a value of true. + * - A connection is closed when either + * - the `receiver` is stopped. + * - the `controller` is called with a value of false. + * - The `closeObserver` is called when the connection is terminated abruptly. + * - This can be used in conjunction with the `controller` to implement a retry policy. + * + * Transmission: + * - The `transmitter` sends outgoing messages on the underlying websocket connection, if any. + * - If no connection exists, the message is passed on to the `unsentObserver`. + * - Note that a connection is automatically closed when + * [[https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send data can't be sent]]. * - * @param url absolute URL of websocket endpoint - * @param protocol name of the (optional) sub-protocol the server selected - * @param socketObserver called when a websocket connection is created - * @param socketOpenObserver called when a websocket connection is open + * @param url absolute URL of websocket endpoint + * @param closeObserver called when a websocket connection is closed + * @param errorObserver called when a websocket connection error occurs + * @param openObserver called when a websocket connection is opened + * @param startObserver called when a websocket connection is started + * @param unsentObserver called when transmission is attempted on a non existing connection + * @param protocol websocket server sub-protocol */ - final def apply(url: String, - protocol: String, - socketObserver: Observer[dom.WebSocket], - socketOpenObserver: Observer[dom.WebSocket]): EventStream[O] = - apply[Void](url, EventStream.empty, protocol, socketObserver, socketOpenObserver) + final def apply[O: Writer]( + url: String, + closeObserver: Observer[dom.CloseEvent] = Observer.empty, + errorObserver: Observer[dom.Event] = Observer.empty, + openObserver: Observer[dom.Event] = Observer.empty, + startObserver: Observer[dom.WebSocket] = Observer.empty, + unsentObserver: Observer[O] = Observer.empty, + protocol: String = "" + ): Duplex[I, O] = { + val ws = new WebSocketEventStream( + url, + project, + closeObserver, + errorObserver, + openObserver, + startObserver, + unsentObserver, + protocol + ) + (Observer(if (_) ws.open() else ws.close()), ws, Observer(ws.send)) + } /** - * Returns a stream that emits messages of type `O` from a [[dom.WebSocket websocket]] connection. + * Returns a websocket [[Simplex connection]] as 2 values, + * 1. `controller`: observer that controls when a connection is opened/closed + * 1. `receiver`: stream of incoming messages of type `I` * - * Transmission is supported for the following types: - * - [[js.typedarray.ArrayBuffer]] - * - [[dom.raw.Blob]] - * - [[String]] + * Connection lifecycle: + * - A new websocket connection is established when either + * - the `receiver` is started. + * - the `controller` is called with a value of true. + * - A connection is closed when either + * - the `receiver` is stopped. + * - the `controller` is called with a value of false. + * - The `closeObserver` is called when the connection is terminated abruptly. + * - This can be used in conjunction with the `controller` to implement a retry policy. * - * @param url absolute URL of websocket endpoint - * @param writer stream of outgoing messages - * @param protocol name of the (optional) sub-protocol the server selected - * @param socketObserver called when a websocket connection is created - * @param socketOpenObserver called when a websocket connection is open + * @param url absolute URL of websocket endpoint + * @param closeObserver called when a websocket connection is closed + * @param errorObserver called when a websocket connection error occurs + * @param openObserver called when a websocket connection is opened + * @param startObserver called when a websocket connection is started + * @param protocol websocket server sub-protocol + * @return */ - final def apply[I: Driver]( + final def read( url: String, - writer: EventStream[I], - protocol: String = "", - socketObserver: Observer[dom.WebSocket] = Observer.empty, - socketOpenObserver: Observer[dom.WebSocket] = Observer.empty): EventStream[O] = - new WebSocketEventStream(writer, project, url, protocol, socketObserver, socketOpenObserver) + closeObserver: Observer[dom.CloseEvent] = Observer.empty, + errorObserver: Observer[dom.Event] = Observer.empty, + openObserver: Observer[dom.Event] = Observer.empty, + startObserver: Observer[dom.WebSocket] = Observer.empty, + protocol: String = "" + ): Simplex[I] = { + val (control, receiver, _) = + apply[Void](url, closeObserver, errorObserver, openObserver, startObserver, Observer.empty, protocol) + (control, receiver) + } } - /** streams the data from a [[dom.MessageEvent message]] */ - sealed abstract class data[O] extends reader(e => Try(e.data.asInstanceOf[O])) - - final case class WebSocketClosed(event: dom.Event) extends WebSocketStreamException + sealed abstract class Driver[A: Writer] extends Reader(e => Try(e.data.asInstanceOf[A])) { - final case class WebSocketError[I](input: I) extends WebSocketStreamException - - object Driver { + /** @see [[apply]] */ + final def open( + url: String, + closeObserver: Observer[dom.CloseEvent] = Observer.empty, + errorObserver: Observer[dom.Event] = Observer.empty, + openObserver: Observer[dom.Event] = Observer.empty, + startObserver: Observer[dom.WebSocket] = Observer.empty, + unsentObserver: Observer[A] = Observer.empty, + protocol: String = "" + ): Duplex[A, A] = + apply(url, closeObserver, errorObserver, openObserver, startObserver, unsentObserver, protocol) + } - implicit val blobDriver: Driver[dom.Blob] = binary(_ send _, "blob") - implicit val binaryDriver: Driver[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") - implicit val stringDriver: Driver[String] = simple(_ send _) - implicit val voidDriver: Driver[Void] = simple((_, _) => ()) + sealed abstract class Writer[O] { + def initialize(socket: dom.WebSocket): Unit + def write(socket: dom.WebSocket, data: O): Unit + } - private def binary[A](send: (dom.WebSocket, A) => Unit, binaryType: String): Driver[A] = - new Driver[A] { + object Writer { - final def initialize(socket: dom.WebSocket): Unit = socket.binaryType = binaryType + implicit val arraybuffer: Writer[js.typedarray.ArrayBuffer] = binary(_ send _, "arraybuffer") + implicit val blob: Writer[dom.Blob] = binary(_ send _, "blob") + implicit val string: Writer[String] = simple(_ send _) + implicit val noop: Writer[Void] = simple((_, _) => ()) - final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + private def binary[A](f: (dom.WebSocket, A) => Unit, binaryType: String): Writer[A] = + new Writer[A] { + final def initialize(socket: dom.WebSocket): Unit = socket.binaryType = binaryType + final def write(socket: dom.WebSocket, data: A): Unit = f(socket, data) } - private def simple[A](send: (dom.WebSocket, A) => Unit): Driver[A] = - new Driver[A] { - - final def initialize(socket: dom.WebSocket): Unit = () - - final def transmit(socket: dom.WebSocket, data: A): Unit = send(socket, data) + private def simple[A](f: (dom.WebSocket, A) => Unit): Writer[A] = + new Writer[A] { + final def initialize(socket: dom.WebSocket): Unit = () + final def write(socket: dom.WebSocket, data: A): Unit = f(socket, data) } } - /** streams [[js.typedarray.ArrayBuffer binary]] data */ - object binary extends data[js.typedarray.ArrayBuffer] - - /** streams [[dom.Blob blob]] data */ - object blob extends data[dom.Blob] - - /** streams websocket [[dom.MessageEvent messages]] */ - object raw extends reader(Success(_)) - - /** streams [[String text]] data */ - object text extends data[String] - + object arraybuffer extends Driver[js.typedarray.ArrayBuffer] + object blob extends Driver[dom.Blob] + object raw extends Reader(Success(_)) + object text extends Driver[String] } From 1d7e9bbe35dd2e6518b4ee5b05e5a631583018ab Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Wed, 6 Jan 2021 14:57:25 +0530 Subject: [PATCH 39/42] Connect if subscribed --- .../airstream/web/WebSocketEventStream.scala | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 591e14d1..83090983 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -80,31 +80,32 @@ class WebSocketEventStream[I, O] private ( val _ = project(e).fold(e => new Transaction(fireError(e, _)), o => new Transaction(fireValue(o, _))) } - private def connect(): Unit = { - val socket = new dom.WebSocket(url, protocol) + private def connect(): Unit = + if (isStarted) { + val socket = new dom.WebSocket(url, protocol) - // update local reference - websocket = socket + // update local reference + websocket = socket - // initialize new socket - W.initialize(socket) + // initialize new socket + W.initialize(socket) - // bind message listener - if (isStarted) bind(socket) + // bind message listener + if (isStarted) bind(socket) - // register required listeners - socket.onclose = (e: dom.CloseEvent) => { - websocket = js.undefined - if (closeObserver ne Observer.empty) closeObserver.onNext(e) - } + // register required listeners + socket.onclose = (e: dom.CloseEvent) => { + websocket = js.undefined + if (closeObserver ne Observer.empty) closeObserver.onNext(e) + } - // register optional listeners - if (errorObserver ne Observer.empty) socket.onerror = errorObserver.onNext - if (openObserver ne Observer.empty) socket.onopen = openObserver.onNext + // register optional listeners + if (errorObserver ne Observer.empty) socket.onerror = errorObserver.onNext + if (openObserver ne Observer.empty) socket.onopen = openObserver.onNext - // call optional observer - if (startObserver ne Observer.empty) startObserver.onNext(socket) - } + // call optional observer + if (startObserver ne Observer.empty) startObserver.onNext(socket) + } } object WebSocketEventStream { @@ -135,7 +136,7 @@ object WebSocketEventStream { * Connection lifecycle: * - A new websocket connection is established when either * - the `receiver` is started. - * - the `controller` is called with a value of true. + * - the `controller` is called with a value of true (and the receiver is subscribed to). * - A connection is closed when either * - the `receiver` is stopped. * - the `controller` is called with a value of false. @@ -186,7 +187,7 @@ object WebSocketEventStream { * Connection lifecycle: * - A new websocket connection is established when either * - the `receiver` is started. - * - the `controller` is called with a value of true. + * - the `controller` is called with a value of true (and the receiver is subscribed to). * - A connection is closed when either * - the `receiver` is stopped. * - the `controller` is called with a value of false. From 97b1067ddb38e04ea02bf818ef3774d3842206fd Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Wed, 6 Jan 2021 16:17:34 +0530 Subject: [PATCH 40/42] Format --- .../com/raquo/airstream/web/WebSocketEventStream.scala | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index 83090983..c88b4d02 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -57,11 +57,7 @@ class WebSocketEventStream[I, O] private ( } def send(out: O): Unit = - // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send - // The WebSocket.send() method enqueues the specified data to be transmitted to the server over the WebSocket - // connection, increasing the value of bufferedAmount by the number of bytes needed to contain the data. If the - // data can't be sent (for example, because it needs to be buffered but the buffer is full), the socket is closed - // automatically. + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send websocket.fold(unsentObserver.onNext(out))(W.write(_, out)) override protected[this] def onStart(): Unit = { @@ -75,7 +71,7 @@ class WebSocketEventStream[I, O] private ( } private def bind(socket: dom.WebSocket): Unit = - // bind message listener + // bind message listener socket.onmessage = (e: dom.MessageEvent) => { val _ = project(e).fold(e => new Transaction(fireError(e, _)), o => new Transaction(fireValue(o, _))) } From 54edb62b826781e1b4d4ccccd9ab11cdc0a7382f Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Mon, 11 Jan 2021 15:22:39 +0530 Subject: [PATCH 41/42] Make protocol parameter optional --- .../com/raquo/airstream/web/WebSocketEventStream.scala | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index c88b4d02..bc95a6f3 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -32,7 +32,7 @@ class WebSocketEventStream[I, O] private ( openObserver: Observer[dom.Event], startObserver: Observer[dom.WebSocket], unsentObserver: Observer[O], - protocol: String + protocol: js.UndefOr[String] )(implicit W: WebSocketEventStream.Writer[O]) extends EventStream[I] { @@ -78,7 +78,7 @@ class WebSocketEventStream[I, O] private ( private def connect(): Unit = if (isStarted) { - val socket = new dom.WebSocket(url, protocol) + val socket = protocol.fold(new dom.WebSocket(url))(new dom.WebSocket(url, _)) // update local reference websocket = socket @@ -160,7 +160,7 @@ object WebSocketEventStream { openObserver: Observer[dom.Event] = Observer.empty, startObserver: Observer[dom.WebSocket] = Observer.empty, unsentObserver: Observer[O] = Observer.empty, - protocol: String = "" + protocol: js.UndefOr[String] = js.undefined ): Duplex[I, O] = { val ws = new WebSocketEventStream( url, @@ -204,7 +204,7 @@ object WebSocketEventStream { errorObserver: Observer[dom.Event] = Observer.empty, openObserver: Observer[dom.Event] = Observer.empty, startObserver: Observer[dom.WebSocket] = Observer.empty, - protocol: String = "" + protocol: js.UndefOr[String] = js.undefined ): Simplex[I] = { val (control, receiver, _) = apply[Void](url, closeObserver, errorObserver, openObserver, startObserver, Observer.empty, protocol) @@ -222,7 +222,7 @@ object WebSocketEventStream { openObserver: Observer[dom.Event] = Observer.empty, startObserver: Observer[dom.WebSocket] = Observer.empty, unsentObserver: Observer[A] = Observer.empty, - protocol: String = "" + protocol: js.UndefOr[String] = js.undefined ): Duplex[A, A] = apply(url, closeObserver, errorObserver, openObserver, startObserver, unsentObserver, protocol) } From 219d1d9d93ef7cae9cbe7dfc49814cdabae40aca Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Mon, 11 Jan 2021 15:28:20 +0530 Subject: [PATCH 42/42] Update local socket reference in callbacks --- .../airstream/web/WebSocketEventStream.scala | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala index bc95a6f3..4e2b769c 100644 --- a/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/WebSocketEventStream.scala @@ -11,7 +11,7 @@ import scala.util.{Success, Try} * An event source that emits/transmits messages from/on a [[dom.WebSocket]] connection. * * '''Warning''': [[dom.WebSocket]] is an ugly, imperative JS construct. We set event callbacks for - * `onclose`, `onmessage`, and if requested, also for `onerror`, `onopen`. + * `onclose`, `onmessage`, `onopen`, and if requested, also for `onerror`. * Make sure you don't override Airstream's listeners, or this stream will not work properly. * * @param url absolute URL of websocket endpoint @@ -41,15 +41,8 @@ class WebSocketEventStream[I, O] private ( private var websocket: js.UndefOr[dom.WebSocket] = js.undefined def close(): Unit = - // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close - websocket.foreach { socket => - socket.onclose = null - socket.onerror = null - socket.onmessage = null - socket.onopen = null - socket.close() - websocket = js.undefined - } + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close + websocket.foreach(_.close()) def open(): Unit = { close() @@ -80,24 +73,26 @@ class WebSocketEventStream[I, O] private ( if (isStarted) { val socket = protocol.fold(new dom.WebSocket(url))(new dom.WebSocket(url, _)) - // update local reference - websocket = socket - // initialize new socket W.initialize(socket) // bind message listener if (isStarted) bind(socket) - // register required listeners + // register listeners socket.onclose = (e: dom.CloseEvent) => { + // reset local reference websocket = js.undefined if (closeObserver ne Observer.empty) closeObserver.onNext(e) } + socket.onopen = (e: dom.Event) => { + // update local reference + websocket = socket + if (openObserver ne Observer.empty) openObserver.onNext(e) + } // register optional listeners if (errorObserver ne Observer.empty) socket.onerror = errorObserver.onNext - if (openObserver ne Observer.empty) socket.onopen = openObserver.onNext // call optional observer if (startObserver ne Observer.empty) startObserver.onNext(socket)