Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ jobs:
matrix:
os: [ubuntu-22.04]
scala: [3.8.3]
java: [temurin@17]
java: [temurin@25, temurin@17]
project: [rootJS, rootJVM]
exclude:
- project: rootJS
java: temurin@17
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
Expand All @@ -42,6 +45,19 @@ jobs:
- name: Setup sbt
uses: sbt/setup-sbt@v1

- name: Setup Java (temurin@25)
id: setup-java-temurin-25
if: matrix.java == 'temurin@25'
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: sbt

- name: sbt update
if: matrix.java == 'temurin@25' && steps.setup-java-temurin-25.outputs.cache-hit == 'false'
run: sbt +update

- name: Setup Java (temurin@17)
id: setup-java-temurin-17
if: matrix.java == 'temurin@17'
Expand All @@ -59,11 +75,11 @@ jobs:
run: sbt githubWorkflowCheck

- name: Check headers and formatting
if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04'
if: matrix.java == 'temurin@25' && matrix.os == 'ubuntu-22.04'
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck lucumaScalafmtCheck lucumaScalafixCheck

- name: Check scalafix lints
if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04'
if: matrix.java == 'temurin@25' && matrix.os == 'ubuntu-22.04'
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' 'scalafixAll --check'

- name: scalaJSLink
Expand All @@ -74,11 +90,11 @@ jobs:
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test

- name: Check binary compatibility
if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04'
if: matrix.java == 'temurin@25' && matrix.os == 'ubuntu-22.04'
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' mimaReportBinaryIssues

- name: Generate API documentation
if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04'
if: matrix.java == 'temurin@25' && matrix.os == 'ubuntu-22.04'
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' doc

- name: Aggregate coverage reports
Expand All @@ -94,7 +110,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-22.04]
java: [temurin@17]
java: [temurin@25]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout current branch (full)
Expand All @@ -105,6 +121,19 @@ jobs:
- name: Setup sbt
uses: sbt/setup-sbt@v1

- name: Setup Java (temurin@25)
id: setup-java-temurin-25
if: matrix.java == 'temurin@25'
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: sbt

- name: sbt update
if: matrix.java == 'temurin@25' && steps.setup-java-temurin-25.outputs.cache-hit == 'false'
run: sbt +update

- name: Setup Java (temurin@17)
id: setup-java-temurin-17
if: matrix.java == 'temurin@17'
Expand Down
3 changes: 2 additions & 1 deletion .mergify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ pull_request_rules:
- body~=labels:.*early-semver-patch
- body~=labels:.*early-semver-minor
- 'title=flake.lock: Update'
- status-success=Test (ubuntu-22.04, 3.8.3, temurin@17, rootJS)
- status-success=Test (ubuntu-22.04, 3.8.3, temurin@25, rootJS)
- status-success=Test (ubuntu-22.04, 3.8.3, temurin@25, rootJVM)
- status-success=Test (ubuntu-22.04, 3.8.3, temurin@17, rootJVM)
actions:
merge: {}
Expand Down
1 change: 1 addition & 0 deletions .scalafix.conf
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
include ".scalafix-common.conf"

OrganizeImports.removeUnused = false # scala 3
OrganizeImports.targetDialect = Scala3
8 changes: 5 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
lazy val V = _root_.scalafix.sbt.BuildInfo

ThisBuild / tlBaseVersion := "0.52"
ThisBuild / tlBaseVersion := "0.53"
ThisBuild / tlJdkRelease := Some(17)
ThisBuild / githubWorkflowJavaVersions := Seq("17").map(JavaSpec.temurin(_))
ThisBuild / githubWorkflowJavaVersions := Seq("25", "17").map(JavaSpec.temurin(_))
ThisBuild / scalaVersion := "3.8.3"
ThisBuild / crossScalaVersions := Seq("3.8.3")
ThisBuild / githubWorkflowScalaVersions := Seq("3.8.3")
Expand Down Expand Up @@ -88,7 +88,9 @@ lazy val http4s =
libraryDependencies ++=
Settings.Libraries.Http4sCirce.value ++
Settings.Libraries.Http4sClient.value ++
Settings.Libraries.Http4sOtel4sMiddleware.value
Settings.Libraries.Http4sOtel4sMiddleware.value ++
Settings.Libraries.MUnitCatsEffect.value ++
Settings.Libraries.MUnit.value
)
.dependsOn(core)

Expand Down
77 changes: 48 additions & 29 deletions http4s/src/main/scala/clue/http4s/Http4sWebSocketBackend.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,43 +30,62 @@ final class Http4sWebSocketBackend[F[_]: Concurrent](client: WSClient[F])
client
.connectHighLevel:
WSRequest(uri).withHeaders(Headers("Sec-WebSocket-Protocol" -> "graphql-transport-ws"))
.allocated // TODO replace with allocatedCase
.allocated
.flatMap: (connection, release) =>
connection.receiveStream
.evalTap:
case WSFrame.Text(data, _) => handler.onMessage(connectionId, data)
case WSFrame.Binary(_, _) => Concurrent[F].unit
.onFinalizeCase:
case ExitCase.Succeeded =>
connection.closeFrame.tryGet.flatMap: closeFrame =>
val event = closeFrame
.map(close => CloseParams(close.statusCode, close.reason))
.toRight:
new GraphQLException(
s"Unexpected clean close for WS without close frame. URI: [$uri]"
)
handler.onClose(connectionId, event) >> release // TODO
case ExitCase.Errored(t) => handler.onClose(connectionId, t.asLeft) >> release // TODO
case ExitCase.Canceled =>
handler.onClose(
connectionId,
new GraphQLException(s"WS listener canceled. URI: [$uri]").asLeft
) >> release // TODO
.compile
.drain
.start
.as(new Http4sWSConnection(connection))
// Refs to ensure release/close are only executed once
val closeFunctionsF = for
releaseState <- Ref[F].of(false)
closeState <- Ref[F].of(false)
releaseNow = releaseState
.modify(released => true -> !released)
.flatMap(isNotReleased => release.whenA(isNotReleased))
notifyClose =
(event: CloseEvent) =>
closeState
.modify(closed => true -> !closed)
.flatMap(isNotClosed => handler.onClose(connectionId, event).whenA(isNotClosed))
canceledClose =
notifyClose(new GraphQLException(s"WS listener canceled. URI: [$uri]").asLeft)
yield (releaseNow, notifyClose, canceledClose)

closeFunctionsF.flatMap: (releaseNow, notifyClose, canceledClose) =>
connection.receiveStream
.evalTap:
case WSFrame.Text(data, _) => handler.onMessage(connectionId, data)
case WSFrame.Binary(_, _) => Concurrent[F].unit
.onFinalizeCase:
case ExitCase.Succeeded =>
connection.closeFrame.tryGet.flatMap: closeFrame =>
val event = closeFrame
.map(close => CloseParams(close.statusCode, close.reason))
.toRight:
new GraphQLException(
s"Unexpected clean close for WS without close frame. URI: [$uri]"
)
notifyClose(event) >> releaseNow
case ExitCase.Errored(t) => notifyClose(t.asLeft) >> releaseNow
case ExitCase.Canceled => canceledClose >> releaseNow
.compile
.drain
.start
.map(new Http4sWSConnection(connection, _, canceledClose, releaseNow))
.guaranteeCase:
case Outcome.Succeeded(_) => Concurrent[F].unit
case Outcome.Errored(t) => notifyClose(t.asLeft) >> releaseNow
case Outcome.Canceled() => canceledClose >> releaseNow

object Http4sWebSocketBackend:
def apply[F[_]: Concurrent](client: WSClient[F]): Http4sWebSocketBackend[F] =
new Http4sWebSocketBackend(client)

final class Http4sWSConnection[F[_]: Concurrent](
private val conn: WSConnectionHighLevel[F]
private val conn: WSConnectionHighLevel[F],
private val listener: Fiber[F, Throwable, Unit],
private val onCanceled: F[Unit],
private val releaseNow: F[Unit]
) extends WebSocketConnection[F]:
override def send(msg: StreamingMessage.FromClient): F[Unit] =
conn.send(WSFrame.Text(msg.asJson.toString))
conn.send(WSFrame.Text(msg.asJson.noSpaces))

// In high-level WS, we cannot specify close code.
override def closeInternal(closeParameters: Option[CloseParams]): F[Unit] =
Concurrent[F].unit // TODO
listener.cancel >> onCanceled >> releaseNow
Comment thread
hugo-vrijswijk marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

package clue.http4s

import cats.Foldable
import cats.effect.*
import cats.syntax.all.*
import clue.ConnectionId
import clue.websocket.*
import munit.CatsEffectSuite
import org.http4s.Uri
import org.http4s.client.websocket.*
import org.http4s.syntax.literals.*

class Http4sWebSocketBackendSuite extends CatsEffectSuite:

test("close releases high-level connection and only notifies onClose once") {
for {
releases <- Ref.of[IO, Int](0)
closes <- Ref.of[IO, Int](0)
deferred <- Deferred[IO, WSFrame.Close]
wsConn = new WSConnectionHighLevel[IO]:
override def send(wsf: WSDataFrame): IO[Unit] = IO.unit
override def sendMany[G[_]: Foldable, A <: WSDataFrame](wsfs: G[A]): IO[Unit] =
IO.unit
override def receive: IO[Option[WSDataFrame]] = IO.never
override def subprotocol: Option[String] = none
override def closeFrame: Deferred[IO, WSFrame.Close] = deferred
wsClient = new WSClient[IO]:
override def connect(request: WSRequest): Resource[IO, WSConnection[IO]] =
Resource.eval(IO.raiseError(new RuntimeException("unused")))
override def connectHighLevel(
request: WSRequest
): Resource[IO, WSConnectionHighLevel[IO]] =
Resource.make(IO.pure(wsConn))(_ => releases.update(_ + 1))
handler = new WebSocketHandler[IO]:
override def onMessage(connectionId: ConnectionId, msg: String): IO[Unit] =
IO.unit
override def onClose(connectionId: ConnectionId, event: CloseEvent): IO[Unit] =
closes.update(_ + 1)
connection <- Http4sWebSocketBackend[IO](wsClient).connect(
uri"ws://127.0.0.1/ws",
handler,
ConnectionId.Zero
)
_ <- connection.close()
_ <- connection.close()
_ <- IO.cede
releaseN <- releases.get
closeN <- closes.get
Comment thread
hugo-vrijswijk marked this conversation as resolved.
} yield {
assertEquals(releaseN, 1)
assertEquals(closeN, 1)
}
}
2 changes: 1 addition & 1 deletion scalajs/src/main/scala/clue/js/FetchJsBackend.scala
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ final class FetchJsBackend[F[_]: Async](fetchMethod: FetchMethod)
baseRequest.uri.toString,
new RequestInit {
method = HttpMethod.POST
body = request.asJson.toString
body = request.asJson.noSpaces
headers = _headers
signal = _signal
}
Expand Down
2 changes: 1 addition & 1 deletion scalajs/src/main/scala/clue/js/WebSocketJsBackend.scala
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ object WebSocketJsBackend {
final class WebSocketJsConnection[F[_]: Sync: Logger](private val ws: WebSocket)
extends WebSocketConnection[F] {
override def send(msg: StreamingMessage.FromClient): F[Unit] =
Sync[F].delay(ws.send(msg.asJson.toString))
Sync[F].delay(ws.send(msg.asJson.noSpaces))

override def closeInternal(closeParameters: Option[CloseParams]): F[Unit] =
"Disconnecting WebSocket...".traceF >>
Expand Down
Loading