This is an automated email from the ASF dual-hosted git repository.
pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-http.git
The following commit(s) were added to refs/heads/main by this push:
new 35174606d Add selective WebSocket message compression (#1172)
35174606d is described below
commit 35174606d8618071d5e0002c0cfe708c7baded3b
Author: Matthias Kurz <[email protected]>
AuthorDate: Fri Jul 24 00:04:44 2026 +0200
Add selective WebSocket message compression (#1172)
* Add selective WebSocket message compression #61
* Simplify selective WebSocket compression examples #61
* Order WebSocket server spec imports #61
---
.../handleWebSocketMessages.md | 3 +
.../handleWebSocketMessagesForOptionalProtocol.md | 3 +
.../handleWebSocketMessagesForProtocol.md | 3 +
.../main/paradox/server-side/websocket-support.md | 14 +
.../http/javadsl/server/WebSocketCoreExample.java | 12 +
.../scaladsl/server/WebSocketExampleSpec.scala | 14 +
.../pekko/http/impl/engine/ws/Handshake.scala | 57 +++-
.../impl/engine/ws/MessageToFrameRenderer.scala | 31 +-
.../http/impl/engine/ws/PerMessageDeflate.scala | 60 +++-
.../engine/ws/UpgradeToWebSocketLowLevel.scala | 12 +
.../pekko/http/impl/engine/ws/WebSocket.scala | 34 ++-
.../http/javadsl/model/ws/WebSocketUpgrade.scala | 67 +++++
.../http/scaladsl/model/ws/WebSocketUpgrade.scala | 107 +++++++
.../http/impl/engine/ws/WebSocketServerSpec.scala | 314 +++++++++++++++++++++
.../directives/WebSocketDirectivesSpec.scala | 83 +++++-
.../server/directives/WebSocketDirectives.scala | 44 ++-
.../server/directives/WebSocketDirectives.scala | 43 +++
17 files changed, 870 insertions(+), 31 deletions(-)
diff --git
a/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessages.md
b/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessages.md
index 0d30e5a1c..fb675362f 100644
---
a/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessages.md
+++
b/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessages.md
@@ -13,6 +13,9 @@
The directive first checks if the request was a valid WebSocket handshake
request and if yes, it completes the request
with the passed handler. Otherwise, the request is rejected with an
@apidoc[ExpectedWebSocketRequestRejection$].
+The overload that accepts a `shouldCompress` filter can select compression
separately for each outbound message after
+`permessage-deflate` is negotiated.
+
WebSocket subprotocols offered in the `Sec-WebSocket-Protocol` header of the
request are ignored. If you want to
support several protocols use the
@ref[handleWebSocketMessagesForProtocol](handleWebSocketMessagesForProtocol.md)
directive, instead.
diff --git
a/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessagesForOptionalProtocol.md
b/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessagesForOptionalProtocol.md
index 9c978800c..40f4222ed 100644
---
a/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessagesForOptionalProtocol.md
+++
b/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessagesForOptionalProtocol.md
@@ -18,6 +18,9 @@ If the `subprotocol` parameter is
@scala[None]@java[@javadoc:[empty](java.util.O
announced in the WebSocket request) @scala[contains `protocol`]@java[matches
the contained subprotocol]. If the client did not offer the protocol in question
the request is rejected with an
@apidoc[UnsupportedWebSocketSubprotocolRejection].
+The overload that accepts a `shouldCompress` filter can select compression
separately for each outbound message after
+`permessage-deflate` is negotiated.
+
To support several subprotocols you may chain several
`handleWebSocketMessagesForOptionalProtocol` routes.
The `handleWebSocketMessagesForOptionalProtocol` directive is used as a
building block for @ref[WebSocket Directives](index.md) to handle websocket
messages.
diff --git
a/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessagesForProtocol.md
b/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessagesForProtocol.md
index 9a3184a54..0664a1fba 100644
---
a/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessagesForProtocol.md
+++
b/docs/src/main/paradox/routing-dsl/directives/websocket-directives/handleWebSocketMessagesForProtocol.md
@@ -18,6 +18,9 @@ The directive first checks if the request was a valid
WebSocket handshake reques
subprotocol name. If yes, the directive completes the request with the passed
handler. Otherwise, the request is
either rejected with an @apidoc[ExpectedWebSocketRequestRejection$] or an
@apidoc[UnsupportedWebSocketSubprotocolRejection].
+The overload that accepts a `shouldCompress` filter can select compression
separately for each outbound message after
+`permessage-deflate` is negotiated.
+
To support several subprotocols, for example at the same path, several
instances of `handleWebSocketMessagesForProtocol` can
be chained using `~` as you can see in the below example.
diff --git a/docs/src/main/paradox/server-side/websocket-support.md
b/docs/src/main/paradox/server-side/websocket-support.md
index f0f8326a6..6a2256a68 100644
--- a/docs/src/main/paradox/server-side/websocket-support.md
+++ b/docs/src/main/paradox/server-side/websocket-support.md
@@ -171,6 +171,20 @@ The server exposes additional settings for the negotiated
extension under
If compression is enabled globally, a route can still decline compression for
a single accepted WebSocket by using the
`handleMessages` or `handleMessagesWith` overload with `compressionEnabled =
false`.
+After `permessage-deflate` is negotiated, an application can also select which
server-to-client messages are compressed.
+The compression filter is evaluated once for each outbound text or binary
message. Returning `true` compresses that
+message; returning `false` sends it uncompressed. The same decision applies to
every fragment of a streamed message.
+
+Scala
+: @@snip
[WebSocketExampleSpec.scala](/docs/src/test/scala/docs/http/scaladsl/server/WebSocketExampleSpec.scala)
{ #websocket-selective-compression }
+
+Java
+: @@snip
[WebSocketCoreExample.java](/docs/src/test/java/docs/http/javadsl/server/WebSocketCoreExample.java)
{ #websocket-selective-compression }
+
+The filter only controls outbound messages. It does not affect extension
negotiation, and the client can still send both
+compressed and uncompressed messages. The filter is not evaluated if
`permessage-deflate` was not negotiated. To disable
+compression in both directions for a security-sensitive endpoint, use the
`compressionEnabled = false` overload instead.
+
@@@ note
The `server_no_context_takeover` and `client_no_context_takeover` extension
parameters affect whether compression
dictionaries are retained across messages. Retaining context generally
improves compression ratio, while disabling
diff --git
a/docs/src/test/java/docs/http/javadsl/server/WebSocketCoreExample.java
b/docs/src/test/java/docs/http/javadsl/server/WebSocketCoreExample.java
index e28988938..b09f2ad79 100644
--- a/docs/src/test/java/docs/http/javadsl/server/WebSocketCoreExample.java
+++ b/docs/src/test/java/docs/http/javadsl/server/WebSocketCoreExample.java
@@ -21,6 +21,7 @@ import java.util.Optional;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Predicate;
import org.apache.pekko.NotUsed;
import org.apache.pekko.actor.ActorSystem;
import org.apache.pekko.http.javadsl.ConnectionContext;
@@ -32,6 +33,7 @@ import org.apache.pekko.http.javadsl.model.StatusCodes;
import org.apache.pekko.http.javadsl.model.ws.Message;
import org.apache.pekko.http.javadsl.model.ws.TextMessage;
import org.apache.pekko.http.javadsl.model.ws.WebSocketRequest;
+import org.apache.pekko.http.javadsl.model.ws.WebSocketUpgrade;
import org.apache.pekko.http.javadsl.settings.ClientConnectionSettings;
import org.apache.pekko.http.javadsl.settings.ServerSettings;
import org.apache.pekko.http.javadsl.settings.WebSocketSettings;
@@ -123,6 +125,16 @@ public class WebSocketCoreExample {
// #websocket-handler
+ static HttpResponse selectiveCompression(
+ WebSocketUpgrade upgrade, Flow<Message, Message, NotUsed> handler) {
+ // #websocket-selective-compression
+ Predicate<Message> shouldCompress = Message::isText;
+
+ HttpResponse response = upgrade.handleMessagesWith(handler,
shouldCompress);
+ // #websocket-selective-compression
+ return response;
+ }
+
{
ActorSystem system = null;
Flow<HttpRequest, HttpResponse, NotUsed> handler = null;
diff --git
a/docs/src/test/scala/docs/http/scaladsl/server/WebSocketExampleSpec.scala
b/docs/src/test/scala/docs/http/scaladsl/server/WebSocketExampleSpec.scala
index a446a7522..9cb7b6390 100644
--- a/docs/src/test/scala/docs/http/scaladsl/server/WebSocketExampleSpec.scala
+++ b/docs/src/test/scala/docs/http/scaladsl/server/WebSocketExampleSpec.scala
@@ -125,6 +125,20 @@ class WebSocketExampleSpec extends AnyWordSpec with
Matchers with CompileOnlySpe
.onComplete(_ => system.terminate()) // and shutdown when done
}
+ "selective-compression-example" in compileOnlySpec {
+ import pekko.http.scaladsl.model.ws.{ Message, WebSocketUpgrade }
+ import pekko.stream.scaladsl.Flow
+
+ val upgrade: WebSocketUpgrade = null
+ val handler: Flow[Message, Message, Any] = null
+
+ // #websocket-selective-compression
+ val shouldCompress: Message => Boolean = _.isText
+
+ val response = upgrade.handleMessages(handler, shouldCompress)
+ // #websocket-selective-compression
+ }
+
"ping-server-example" in compileOnlySpec {
implicit val system: ActorSystem = null
val route = null
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/Handshake.scala
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/Handshake.scala
index b9b293c9e..77a2096fe 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/Handshake.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/Handshake.scala
@@ -42,6 +42,9 @@ private[http] object Handshake {
object Server {
+ private val CompressEveryMessage: Message => Boolean = _ => true
+ private val CompressEveryFrame: FrameStart => Boolean = _ => true
+
/**
* Validates a client WebSocket handshake. Returns either
`OptionVal.Some(UpgradeToWebSocketLowLevel)` or
* `OptionVal.None`
@@ -140,12 +143,22 @@ private[http] object Handshake {
def handle(
handler: Either[Graph[FlowShape[FrameEvent, FrameEvent], Any],
Graph[FlowShape[Message, Message], Any]],
subprotocol: Option[String],
- compressionEnabled: Boolean): HttpResponse = {
+ compressionEnabled: Boolean,
+ shouldCompressMessage: Message => Boolean =
CompressEveryMessage,
+ shouldCompressFrame: FrameStart => Boolean =
CompressEveryFrame): HttpResponse = {
require(
subprotocol.forall(chosen =>
clientSupportedSubprotocols.contains(chosen)),
s"Tried to choose invalid subprotocol '$subprotocol' which
wasn't offered by the client: [${requestedProtocols.mkString(", ")}]")
val acceptedPerMessageDeflate = if (compressionEnabled)
perMessageDeflate else None
- buildResponse(key.get, handler, subprotocol,
acceptedPerMessageDeflate, settings, log)
+ buildResponse(
+ key.get,
+ handler,
+ subprotocol,
+ acceptedPerMessageDeflate,
+ settings,
+ log,
+ shouldCompressMessage,
+ shouldCompressFrame)
}
def handleFrames(
@@ -158,6 +171,13 @@ private[http] object Handshake {
compressionEnabled: Boolean): HttpResponse =
handle(Left(handlerFlow), subprotocol, compressionEnabled)
+ override private[http] def handleFrames(
+ handlerFlow: Graph[FlowShape[FrameEvent, FrameEvent], Any],
+ subprotocol: Option[String],
+ compressionEnabled: Boolean,
+ shouldCompress: FrameStart => Boolean): HttpResponse =
+ handle(Left(handlerFlow), subprotocol, compressionEnabled,
shouldCompressFrame = shouldCompress)
+
override def handleMessages(handlerFlow: Graph[FlowShape[Message,
Message], Any],
subprotocol: Option[String] = None): HttpResponse =
handle(Right(handlerFlow), subprotocol, compressionEnabled =
true)
@@ -167,6 +187,12 @@ private[http] object Handshake {
subprotocol: Option[String],
compressionEnabled: Boolean): HttpResponse =
handle(Right(handlerFlow), subprotocol, compressionEnabled)
+
+ override def handleMessages(
+ handlerFlow: Graph[FlowShape[Message, Message], Any],
+ subprotocol: Option[String],
+ shouldCompress: Message => Boolean): HttpResponse =
+ handle(Right(handlerFlow), subprotocol, compressionEnabled =
true, shouldCompressMessage = shouldCompress)
}
OptionVal.Some(header)
} else OptionVal.None
@@ -197,12 +223,33 @@ private[http] object Handshake {
subprotocol: Option[String],
perMessageDeflate: Option[PerMessageDeflate.Negotiated],
settings: WebSocketSettings,
- log: LoggingAdapter): HttpResponse = {
+ log: LoggingAdapter): HttpResponse =
+ buildResponse(
+ key,
+ handler,
+ subprotocol,
+ perMessageDeflate,
+ settings,
+ log,
+ CompressEveryMessage,
+ CompressEveryFrame)
+
+ private def buildResponse(
+ key: `Sec-WebSocket-Key`,
+ handler: Either[Graph[FlowShape[FrameEvent, FrameEvent], Any],
Graph[FlowShape[Message, Message], Any]],
+ subprotocol: Option[String],
+ perMessageDeflate: Option[PerMessageDeflate.Negotiated],
+ settings: WebSocketSettings,
+ log: LoggingAdapter,
+ shouldCompressMessage: Message => Boolean,
+ shouldCompressFrame: FrameStart => Boolean): HttpResponse = {
val frameHandler = handler match {
case Left(frameHandler) =>
-
perMessageDeflate.map(_.frameEventBidiFlow(settings.randomFactory).join(frameHandler)).getOrElse(frameHandler)
+ perMessageDeflate
+ .map(_.frameEventBidiFlow(settings.randomFactory,
shouldCompressFrame).join(frameHandler))
+ .getOrElse(frameHandler)
case Right(messageHandler) =>
- WebSocket.stack(serverSide = true, settings, perMessageDeflate =
perMessageDeflate, log = log)
+ WebSocket.stack(true, settings, perMessageDeflate, log,
shouldCompressMessage)
.join(messageHandler)
}
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/MessageToFrameRenderer.scala
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/MessageToFrameRenderer.scala
index 1c5df2dce..0ae9389ce 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/MessageToFrameRenderer.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/MessageToFrameRenderer.scala
@@ -30,23 +30,34 @@ import pekko.http.scaladsl.model.ws._
*/
@InternalApi
private[http] object MessageToFrameRenderer {
- def create(serverSide: Boolean): Flow[Message, FrameStart, NotUsed] = {
- def strictFrames(opcode: Opcode, data: ByteString): Source[FrameStart, ?] =
+ def create(serverSide: Boolean): Flow[Message, FrameStart, NotUsed] =
+ create(serverSide, None)
+
+ def create(serverSide: Boolean, shouldCompress: Message => Boolean):
Flow[Message, FrameStart, NotUsed] =
+ create(serverSide, Some(shouldCompress))
+
+ private def create(
+ serverSide: Boolean,
+ shouldCompress: Option[Message => Boolean]): Flow[Message, FrameStart,
NotUsed] = {
+ def strictFrames(opcode: Opcode, data: ByteString, compress: Boolean):
Source[FrameStart, ?] =
// FIXME: fragment?
- Source.single(FrameEvent.fullFrame(opcode, None, data, fin = true))
+ Source.single(FrameEvent.fullFrame(opcode, None, data, fin = true, rsv1
= compress))
- def streamedFrames[M](opcode: Opcode, data: Source[ByteString, M]):
Source[FrameStart, Any] =
+ def streamedFrames[M](opcode: Opcode, data: Source[ByteString, M],
compress: Boolean): Source[FrameStart, Any] =
data.statefulMap(() => true)((isFirst, data) => {
val frameOpcode = if (isFirst) opcode else Opcode.Continuation
- (false, FrameEvent.fullFrame(frameOpcode, None, data, fin = false))
+ (false, FrameEvent.fullFrame(frameOpcode, None, data, fin = false,
rsv1 = isFirst && compress))
}, _ => None) ++ Source.single(FrameEvent.emptyLastContinuationFrame)
Flow[Message]
- .flatMapConcat {
- case BinaryMessage.Strict(data) => strictFrames(Opcode.Binary, data)
- case bm: BinaryMessage => streamedFrames(Opcode.Binary,
bm.dataStream)
- case TextMessage.Strict(text) => strictFrames(Opcode.Text,
ByteString(text, StandardCharsets.UTF_8))
- case tm: TextMessage => streamedFrames(Opcode.Text,
tm.textStream.via(Utf8Encoder))
+ .flatMapConcat { message =>
+ val compress = shouldCompress.exists(_(message))
+ message match {
+ case BinaryMessage.Strict(data) => strictFrames(Opcode.Binary, data,
compress)
+ case bm: BinaryMessage => streamedFrames(Opcode.Binary,
bm.dataStream, compress)
+ case TextMessage.Strict(text) => strictFrames(Opcode.Text,
ByteString(text, StandardCharsets.UTF_8), compress)
+ case tm: TextMessage => streamedFrames(Opcode.Text,
tm.textStream.via(Utf8Encoder), compress)
+ }
}
}
}
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
index 7bf669aa9..771da9027 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
@@ -70,8 +70,16 @@ private[http] object PerMessageDeflate {
def bidiFlow: BidiFlow[FrameEventOrError, FrameEventOrError, FrameEvent,
FrameEvent, NotUsed] =
BidiFlow.fromFlows(inflaterFlow, deflaterFlow)
+ def messageBidiFlow: BidiFlow[FrameEventOrError, FrameEventOrError,
FrameEvent, FrameEvent, NotUsed] =
+ BidiFlow.fromFlows(inflaterFlow, selectiveDeflaterFlow(_.header.rsv1,
rsv1IndicatesCompression = true))
+
def frameEventBidiFlow(
maskRandom: () => Random): BidiFlow[FrameEvent, FrameEvent,
FrameEvent, FrameEvent, NotUsed] =
+ frameEventBidiFlow(maskRandom, _ => true)
+
+ def frameEventBidiFlow(
+ maskRandom: () => Random,
+ shouldCompress: FrameStart => Boolean): BidiFlow[FrameEvent,
FrameEvent, FrameEvent, FrameEvent, NotUsed] =
BidiFlow.fromFlows(
Flow[FrameEvent]
.via(Masking.unmaskIf(condition = true))
@@ -81,13 +89,26 @@ private[http] object PerMessageDeflate {
case FrameError(ex) => throw ex
}
.via(Masking.maskIf(condition = true, maskRandom)),
- deflaterFlow)
+ selectiveDeflaterFlow(shouldCompress, rsv1IndicatesCompression =
false))
private def inflaterFlow: Flow[FrameEventOrError, FrameEventOrError,
NotUsed] =
createInflaterFlow(clientNoContextTakeover, settings,
DefaultCompressionFactory)
private def deflaterFlow: Flow[FrameEvent, FrameEvent, NotUsed] =
createDeflaterFlow(serverNoContextTakeover, settings,
DefaultCompressionFactory)
+
+ private def selectiveDeflaterFlow(
+ shouldCompress: FrameStart => Boolean,
+ rsv1IndicatesCompression: Boolean): Flow[FrameEvent, FrameEvent,
NotUsed] =
+ Flow.fromGraph(new LifecycleMapConcatStage(
+ "PerMessageDeflate.deflater",
+ () =>
+ new DeflaterFlow(
+ serverNoContextTakeover,
+ settings,
+ DefaultCompressionFactory,
+ shouldCompress,
+ rsv1IndicatesCompression)))
}
private[ws] def createInflaterFlow(
@@ -104,7 +125,13 @@ private[http] object PerMessageDeflate {
compressionFactory: CompressionFactory): Flow[FrameEvent, FrameEvent,
NotUsed] =
Flow.fromGraph(new LifecycleMapConcatStage(
"PerMessageDeflate.deflater",
- () => new DeflaterFlow(noContextTakeover, settings, compressionFactory)))
+ () =>
+ new DeflaterFlow(
+ noContextTakeover,
+ settings,
+ compressionFactory,
+ _ => true,
+ rsv1IndicatesCompression = false)))
def negotiate(
requested: immutable.Seq[WebSocketExtension],
@@ -242,11 +269,14 @@ private[http] object PerMessageDeflate {
private final class DeflaterFlow(
noContextTakeover: Boolean,
settings: WebSocketCompressionSettingsImpl,
- compressionFactory: CompressionFactory)
+ compressionFactory: CompressionFactory,
+ shouldCompress: FrameStart => Boolean,
+ rsv1IndicatesCompression: Boolean)
extends LifecycleMapConcat[FrameEvent, FrameEvent] {
private var deflater =
compressionFactory.newDeflater(settings.compressionLevel)
private var frame: Option[UncompressedFrame] = None
private var messageInProgress = false
+ private var compressFragmentedMessage = false
private var bypassFrameInProgress = false
private val buffer = new Array[Byte](8192)
@@ -254,7 +284,7 @@ private[http] object PerMessageDeflate {
case FrameStart(header, _)
if (header.opcode == Protocol.Opcode.Text ||
header.opcode == Protocol.Opcode.Binary) &&
- (header.rsv1 || header.rsv2 || header.rsv3) =>
+ (header.rsv2 || header.rsv3 || (header.rsv1 &&
!rsv1IndicatesCompression)) =>
throw new ProtocolException("Unexpected reserved bit for outbound
WebSocket message")
case FrameStart(header, _)
if header.opcode == Protocol.Opcode.Continuation &&
@@ -265,18 +295,32 @@ private[http] object PerMessageDeflate {
header.opcode == Protocol.Opcode.Binary =>
if (messageInProgress || frame.isDefined)
throw new ProtocolException("Unexpected data frame while fragmented
message is open")
+ val compress = if (rsv1IndicatesCompression) header.rsv1 else
shouldCompress(start)
messageInProgress = !header.fin
- frame = Some(UncompressedFrame(header.copy(length = 0, rsv1 = true),
data, removeTail = header.fin))
- if (start.lastPart) finishFrame() else Nil
+ compressFragmentedMessage = compress && messageInProgress
+ if (compress) {
+ frame = Some(UncompressedFrame(header.copy(length = 0, rsv1 = true),
data, removeTail = header.fin))
+ if (start.lastPart) finishFrame() else Nil
+ } else {
+ bypassFrameInProgress = !start.lastPart
+ start :: Nil
+ }
case start @ FrameStart(header, _) if bypassFrameInProgress =>
throw new ProtocolException(s"Unexpected frame ${header.opcode} while
frame data is open")
case start @ FrameStart(header, _) if (frame.isDefined ||
messageInProgress) && header.opcode.isControl =>
bypassFrameInProgress = !start.lastPart
start :: Nil
case start @ FrameStart(header, data) if messageInProgress &&
header.opcode == Protocol.Opcode.Continuation =>
+ val compress = compressFragmentedMessage
messageInProgress = !header.fin
- frame = Some(UncompressedFrame(header.copy(length = 0), data,
removeTail = header.fin))
- if (start.lastPart) finishFrame() else Nil
+ if (!messageInProgress) compressFragmentedMessage = false
+ if (compress) {
+ frame = Some(UncompressedFrame(header.copy(length = 0), data,
removeTail = header.fin))
+ if (start.lastPart) finishFrame() else Nil
+ } else {
+ bypassFrameInProgress = !start.lastPart
+ start :: Nil
+ }
case start @ FrameStart(header, _) if frame.isDefined ||
messageInProgress =>
throw new ProtocolException(s"Unexpected frame ${header.opcode} while
fragmented message is open")
case data: FrameData if bypassFrameInProgress =>
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/UpgradeToWebSocketLowLevel.scala
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/UpgradeToWebSocketLowLevel.scala
index ca5fbd165..d2274f4d2 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/UpgradeToWebSocketLowLevel.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/UpgradeToWebSocketLowLevel.scala
@@ -49,4 +49,16 @@ private[http] abstract class UpgradeToWebSocketLowLevel
extends InternalCustomHe
subprotocol: Option[String],
compressionEnabled: Boolean): HttpResponse =
handleFrames(handlerFlow, subprotocol)
+
+ /**
+ * The `shouldCompress` function is evaluated once for every outbound text
or binary message when
+ * `permessage-deflate` was negotiated. The decision for the initial frame
is retained for every continuation frame.
+ */
+ @InternalApi
+ private[http] def handleFrames(
+ handlerFlow: Graph[FlowShape[FrameEvent, FrameEvent], Any],
+ subprotocol: Option[String],
+ compressionEnabled: Boolean,
+ shouldCompress: FrameStart => Boolean): HttpResponse =
+ handleFrames(handlerFlow, subprotocol, compressionEnabled)
}
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/WebSocket.scala
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/WebSocket.scala
index 5d1d37656..3e6fdcb7a 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/WebSocket.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/WebSocket.scala
@@ -50,12 +50,29 @@ private[http] object WebSocket {
perMessageDeflate: Option[PerMessageDeflate.Negotiated] = None,
closeTimeout: FiniteDuration = 3.seconds, // TODO put close timeout into
the settings?
log: LoggingAdapter): BidiFlow[FrameEvent, Message, Message, FrameEvent,
NotUsed] =
+ stack(serverSide, websocketSettings, perMessageDeflate, closeTimeout, log,
_ => true)
+
+ def stack(
+ serverSide: Boolean,
+ websocketSettings: WebSocketSettings,
+ perMessageDeflate: Option[PerMessageDeflate.Negotiated],
+ log: LoggingAdapter,
+ shouldCompress: Message => Boolean): BidiFlow[FrameEvent, Message,
Message, FrameEvent, NotUsed] =
+ stack(serverSide, websocketSettings, perMessageDeflate, 3.seconds, log,
shouldCompress)
+
+ def stack(
+ serverSide: Boolean,
+ websocketSettings: WebSocketSettings,
+ perMessageDeflate: Option[PerMessageDeflate.Negotiated],
+ closeTimeout: FiniteDuration,
+ log: LoggingAdapter,
+ shouldCompress: Message => Boolean): BidiFlow[FrameEvent, Message,
Message, FrameEvent, NotUsed] =
masking(serverSide, websocketSettings.randomFactory).atop(
FrameLogger.logFramesIfEnabled(websocketSettings.logFrames)).atop(
- perMessageDeflate.map(_.bidiFlow).getOrElse(BidiFlow.identity)).atop(
+
perMessageDeflate.map(_.messageBidiFlow).getOrElse(BidiFlow.identity)).atop(
frameHandling(serverSide, closeTimeout, log)).atop(
periodicKeepAlive(websocketSettings)).atop(
- messageAPI(serverSide, closeTimeout))
+ messageAPI(serverSide, closeTimeout, perMessageDeflate.map(_ =>
shouldCompress)))
/** The lowest layer that implements the binary protocol */
def framing: BidiFlow[ByteString, FrameEvent, FrameEvent, ByteString,
NotUsed] =
@@ -154,7 +171,14 @@ private[http] object WebSocket {
*/
def messageAPI(
serverSide: Boolean,
- closeTimeout: FiniteDuration): BidiFlow[FrameHandler.Output, Message,
Message, FrameOutHandler.Input, NotUsed] = {
+ closeTimeout: FiniteDuration): BidiFlow[FrameHandler.Output, Message,
Message, FrameOutHandler.Input, NotUsed] =
+ messageAPI(serverSide, closeTimeout, None)
+
+ private def messageAPI(
+ serverSide: Boolean,
+ closeTimeout: FiniteDuration,
+ shouldCompress: Option[Message => Boolean])
+ : BidiFlow[FrameHandler.Output, Message, Message, FrameOutHandler.Input,
NotUsed] = {
/* Collects user-level API messages from MessageDataParts */
val collectMessage: Flow[MessageDataPart, Message, NotUsed] =
Flow[MessageDataPart]
@@ -188,7 +212,9 @@ private[http] object WebSocket {
.named("ws-prepare-messages")
def renderMessages: Flow[Message, FrameStart, NotUsed] =
- MessageToFrameRenderer.create(serverSide)
+ shouldCompress
+ .map(MessageToFrameRenderer.create(serverSide, _))
+ .getOrElse(MessageToFrameRenderer.create(serverSide))
.named("ws-render-messages")
BidiFlow.fromGraph(GraphDSL.create() { implicit b =>
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/javadsl/model/ws/WebSocketUpgrade.scala
b/http-core/src/main/scala/org/apache/pekko/http/javadsl/model/ws/WebSocketUpgrade.scala
index ceb70296a..04694d8df 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/javadsl/model/ws/WebSocketUpgrade.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/javadsl/model/ws/WebSocketUpgrade.scala
@@ -14,6 +14,7 @@
package org.apache.pekko.http.javadsl.model.ws
import java.lang.{ Iterable => JIterable }
+import java.util.function.Predicate
import org.apache.pekko
import pekko.http.javadsl.model.HttpResponse
@@ -52,6 +53,20 @@ trait WebSocketUpgrade {
def handleMessagesWith(
handlerFlow: Graph[FlowShape[Message, Message], ? <: Any],
compressionEnabled: Boolean): HttpResponse
+ /**
+ * Returns a response that can be used to answer a WebSocket handshake
request. The connection will afterwards
+ * use the given handlerFlow to handle WebSocket messages from the client.
+ *
+ * If {@code permessage-deflate} is negotiated, {@code shouldCompress} is
evaluated once for each outbound text or
+ * binary message. Returning {@code true} compresses that message and
returning {@code false} sends it uncompressed.
+ * The filter does not affect inbound messages or whether compression is
negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleMessagesWith(
+ handlerFlow: Graph[FlowShape[Message, Message], ? <: Any],
shouldCompress: Predicate[Message]): HttpResponse =
+ handleMessagesWith(handlerFlow)
+
/**
* Returns a response that can be used to answer a WebSocket handshake
request. The connection will afterwards
* use the given handlerFlow to handle WebSocket messages from the client.
The given subprotocol must be one
@@ -73,6 +88,23 @@ trait WebSocketUpgrade {
subprotocol: String,
compressionEnabled: Boolean): HttpResponse
+ /**
+ * Returns a response that can be used to answer a WebSocket handshake
request. The connection will afterwards
+ * use the given handlerFlow to handle WebSocket messages from the client.
The given subprotocol must be one
+ * of the ones offered by the client.
+ *
+ * If {@code permessage-deflate} is negotiated, {@code shouldCompress} is
evaluated once for each outbound text or
+ * binary message. Returning {@code true} compresses that message and
returning {@code false} sends it uncompressed.
+ * The filter does not affect inbound messages or whether compression is
negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleMessagesWith(
+ handlerFlow: Graph[FlowShape[Message, Message], ? <: Any],
+ subprotocol: String,
+ shouldCompress: Predicate[Message]): HttpResponse =
+ handleMessagesWith(handlerFlow, subprotocol)
+
/**
* Returns a response that can be used to answer a WebSocket handshake
request. The connection will afterwards
* use the given inSink to handle WebSocket messages from the client and the
given outSource to send messages to the client.
@@ -91,6 +123,23 @@ trait WebSocketUpgrade {
def handleMessagesWith(inSink: Graph[SinkShape[Message], ? <: Any],
outSource: Graph[SourceShape[Message], ? <: Any],
compressionEnabled: Boolean): HttpResponse
+ /**
+ * Returns a response that can be used to answer a WebSocket handshake
request. The connection will afterwards
+ * use the given inSink to handle WebSocket messages from the client and the
given outSource to send messages to the
+ * client.
+ *
+ * If {@code permessage-deflate} is negotiated, {@code shouldCompress} is
evaluated once for each outbound text or
+ * binary message. Returning {@code true} compresses that message and
returning {@code false} sends it uncompressed.
+ * The filter does not affect inbound messages or whether compression is
negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleMessagesWith(
+ inSink: Graph[SinkShape[Message], ? <: Any],
+ outSource: Graph[SourceShape[Message], ? <: Any],
+ shouldCompress: Predicate[Message]): HttpResponse =
+ handleMessagesWith(inSink, outSource)
+
/**
* Returns a response that can be used to answer a WebSocket handshake
request. The connection will afterwards
* use the given inSink to handle WebSocket messages from the client and the
given outSource to send messages to the client.
@@ -111,4 +160,22 @@ trait WebSocketUpgrade {
*/
def handleMessagesWith(inSink: Graph[SinkShape[Message], ? <: Any],
outSource: Graph[SourceShape[Message], ? <: Any],
subprotocol: String, compressionEnabled: Boolean): HttpResponse
+
+ /**
+ * Returns a response that can be used to answer a WebSocket handshake
request. The connection will afterwards
+ * use the given inSink to handle WebSocket messages from the client and the
given outSource to send messages to the
+ * client. The given subprotocol must be one of the ones offered by the
client.
+ *
+ * If {@code permessage-deflate} is negotiated, {@code shouldCompress} is
evaluated once for each outbound text or
+ * binary message. Returning {@code true} compresses that message and
returning {@code false} sends it uncompressed.
+ * The filter does not affect inbound messages or whether compression is
negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleMessagesWith(
+ inSink: Graph[SinkShape[Message], ? <: Any],
+ outSource: Graph[SourceShape[Message], ? <: Any],
+ subprotocol: String,
+ shouldCompress: Predicate[Message]): HttpResponse =
+ handleMessagesWith(inSink, outSource, subprotocol)
}
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/ws/WebSocketUpgrade.scala
b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/ws/WebSocketUpgrade.scala
index 34de701c2..2f60e6c9f 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/ws/WebSocketUpgrade.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/ws/WebSocketUpgrade.scala
@@ -14,6 +14,7 @@
package org.apache.pekko.http.scaladsl.model.ws
import java.lang.Iterable
+import java.util.function.{ Predicate => JPredicate }
import scala.collection.immutable
import org.apache.pekko
import pekko.NotUsed
@@ -71,6 +72,36 @@ trait WebSocketUpgrade extends jm.ws.WebSocketUpgrade {
compressionEnabled: Boolean): HttpResponse =
handleMessages(handlerFlow, subprotocol)
+ /**
+ * The high-level interface to create a WebSocket server based on "messages".
+ *
+ * If `permessage-deflate` is negotiated, `shouldCompress` is evaluated once
for each outbound text or binary
+ * message. Returning `true` compresses that message and returning `false`
sends it uncompressed. The filter does not
+ * affect inbound messages or whether compression is negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleMessages(
+ handlerFlow: Graph[FlowShape[Message, Message], Any],
+ shouldCompress: Message => Boolean): HttpResponse =
+ handleMessages(handlerFlow, None, shouldCompress)
+
+ /**
+ * The high-level interface to create a WebSocket server based on "messages".
+ *
+ * Optionally, a subprotocol out of the ones requested by the client can be
chosen. If `permessage-deflate` is
+ * negotiated, `shouldCompress` is evaluated once for each outbound text or
binary message. Returning `true`
+ * compresses that message and returning `false` sends it uncompressed. The
filter does not affect inbound messages
+ * or whether compression is negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleMessages(
+ handlerFlow: Graph[FlowShape[Message, Message], Any],
+ subprotocol: Option[String],
+ shouldCompress: Message => Boolean): HttpResponse =
+ handleMessages(handlerFlow, subprotocol)
+
/**
* The high-level interface to create a WebSocket server based on "messages".
*
@@ -108,6 +139,38 @@ trait WebSocketUpgrade extends jm.ws.WebSocketUpgrade {
compressionEnabled: Boolean): HttpResponse =
handleMessages(scaladsl.Flow.fromSinkAndSource(inSink, outSource),
subprotocol, compressionEnabled)
+ /**
+ * The high-level interface to create a WebSocket server based on "messages".
+ *
+ * If `permessage-deflate` is negotiated, `shouldCompress` is evaluated once
for each outbound text or binary
+ * message. Returning `true` compresses that message and returning `false`
sends it uncompressed. The filter does not
+ * affect inbound messages or whether compression is negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleMessagesWithSinkSource(
+ inSink: Graph[SinkShape[Message], Any],
+ outSource: Graph[SourceShape[Message], Any],
+ shouldCompress: Message => Boolean): HttpResponse =
+ handleMessages(scaladsl.Flow.fromSinkAndSource(inSink, outSource), None,
shouldCompress)
+
+ /**
+ * The high-level interface to create a WebSocket server based on "messages".
+ *
+ * Optionally, a subprotocol out of the ones requested by the client can be
chosen. If `permessage-deflate` is
+ * negotiated, `shouldCompress` is evaluated once for each outbound text or
binary message. Returning `true`
+ * compresses that message and returning `false` sends it uncompressed. The
filter does not affect inbound messages
+ * or whether compression is negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleMessagesWithSinkSource(
+ inSink: Graph[SinkShape[Message], Any],
+ outSource: Graph[SourceShape[Message], Any],
+ subprotocol: Option[String],
+ shouldCompress: Message => Boolean): HttpResponse =
+ handleMessages(scaladsl.Flow.fromSinkAndSource(inSink, outSource),
subprotocol, shouldCompress)
+
import scala.jdk.CollectionConverters._
/**
@@ -131,6 +194,16 @@ trait WebSocketUpgrade extends jm.ws.WebSocketUpgrade {
compressionEnabled: Boolean): HttpResponse =
handleMessages(JavaMapping.toScala(handlerFlow), None, compressionEnabled)
+ /**
+ * Java API
+ *
+ * @since 2.0.0
+ */
+ override def handleMessagesWith(
+ handlerFlow: Graph[FlowShape[jm.ws.Message, jm.ws.Message], ? <: Any],
+ shouldCompress: JPredicate[jm.ws.Message]): HttpResponse =
+ handleMessages(JavaMapping.toScala(handlerFlow), message =>
shouldCompress.test(message))
+
/**
* Java API
*/
@@ -149,6 +222,17 @@ trait WebSocketUpgrade extends jm.ws.WebSocketUpgrade {
compressionEnabled: Boolean): HttpResponse =
handleMessages(JavaMapping.toScala(handlerFlow), subprotocol =
Some(subprotocol), compressionEnabled)
+ /**
+ * Java API
+ *
+ * @since 2.0.0
+ */
+ override def handleMessagesWith(
+ handlerFlow: Graph[FlowShape[jm.ws.Message, jm.ws.Message], ? <: Any],
+ subprotocol: String,
+ shouldCompress: JPredicate[jm.ws.Message]): HttpResponse =
+ handleMessages(JavaMapping.toScala(handlerFlow), Some(subprotocol),
message => shouldCompress.test(message))
+
/**
* Java API
*/
@@ -167,6 +251,17 @@ trait WebSocketUpgrade extends jm.ws.WebSocketUpgrade {
compressionEnabled: Boolean): HttpResponse =
handleMessages(createScalaFlow(inSink, outSource), None,
compressionEnabled)
+ /**
+ * Java API
+ *
+ * @since 2.0.0
+ */
+ override def handleMessagesWith(
+ inSink: Graph[SinkShape[jm.ws.Message], ? <: Any],
+ outSource: Graph[SourceShape[jm.ws.Message], ? <: Any],
+ shouldCompress: JPredicate[jm.ws.Message]): HttpResponse =
+ handleMessages(createScalaFlow(inSink, outSource), message =>
shouldCompress.test(message))
+
/**
* Java API
*
@@ -190,6 +285,18 @@ trait WebSocketUpgrade extends jm.ws.WebSocketUpgrade {
compressionEnabled: Boolean): HttpResponse =
handleMessages(createScalaFlow(inSink, outSource), subprotocol =
Some(subprotocol), compressionEnabled)
+ /**
+ * Java API
+ *
+ * @since 2.0.0
+ */
+ override def handleMessagesWith(
+ inSink: Graph[SinkShape[jm.ws.Message], ? <: Any],
+ outSource: Graph[SourceShape[jm.ws.Message], ? <: Any],
+ subprotocol: String,
+ shouldCompress: JPredicate[jm.ws.Message]): HttpResponse =
+ handleMessages(createScalaFlow(inSink, outSource), Some(subprotocol),
message => shouldCompress.test(message))
+
private def createScalaFlow(inSink: Graph[SinkShape[jm.ws.Message], ? <:
Any],
outSource: Graph[SourceShape[jm.ws.Message], ? <: Any]):
Graph[FlowShape[Message, Message], NotUsed] =
JavaMapping.toScala(scaladsl.Flow.fromSinkAndSourceMat(inSink,
outSource)(scaladsl.Keep.none): Graph[FlowShape[
diff --git
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/ws/WebSocketServerSpec.scala
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/ws/WebSocketServerSpec.scala
index bffc62f9a..ec29e5c06 100644
---
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/ws/WebSocketServerSpec.scala
+++
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/ws/WebSocketServerSpec.scala
@@ -17,11 +17,13 @@ import java.io.ByteArrayOutputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
+import java.util.function.Predicate
import java.util.zip.Deflater
import java.util.zip.Inflater
import org.apache.pekko
import pekko.actor.ActorSystem
+import pekko.http.javadsl.model.ws.{ Message => JavaMessage, TextMessage =>
JavaTextMessage }
import pekko.http.scaladsl.model.ws._
import pekko.http.scaladsl.model.AttributeKeys.webSocketUpgrade
import pekko.stream.Materializer
@@ -536,6 +538,131 @@ class WebSocketServerSpec extends
PekkoSpecWithMaterializer("pekko.http.server.w
}
}
+ "select compression separately for each outbound message" in
Utils.assertAllStagesStopped {
+ new TestSetup {
+ sendWebSocketRequest("Sec-WebSocket-Extensions:
permessage-deflate\r\n")
+
+ val request = expectRequest()
+ val upgrade = request.attribute(webSocketUpgrade)
+ val evaluated = new AtomicInteger()
+ val firstMessage = "compressed first server message"
+ val secondMessage = "plain"
+ val thirdMessage = "compressed third server message"
+ val response = upgrade.get.handleMessages(
+ Flow.fromSinkAndSource(
+ Sink.ignore,
+ Source(List(
+ TextMessage.Strict(firstMessage),
+ TextMessage.Strict(secondMessage),
+ TextMessage.Strict(thirdMessage)))),
+ None,
+ message => {
+ evaluated.incrementAndGet()
+
message.asInstanceOf[TextMessage.Strict].text.startsWith("compressed")
+ })
+ responses.sendNext(response)
+
+ expectResponseWithWipedDate(
+ """HTTP/1.1 101 Switching Protocols
+ |Upgrade: websocket
+ |Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
+ |Sec-WebSocket-Extensions: permessage-deflate
+ |Server: pekko-http/test
+ |Date: XXXX
+ |Connection: upgrade
+ |
+ |""")
+
+ val firstPayload = expectCompressedFrame(Protocol.Opcode.Text, fin =
true, rsv1 = true)
+ expectWSFrame(Protocol.Opcode.Text, ByteString(secondMessage), fin =
true)
+ val thirdPayload = expectCompressedFrame(Protocol.Opcode.Text, fin =
true, rsv1 = true)
+ inflatePerMessagesWithContext(firstPayload,
thirdPayload).map(_.utf8String) shouldEqual
+ Seq(firstMessage, thirdMessage)
+ evaluated.get() shouldEqual 3
+ expectWSCloseFrame(Protocol.CloseCodes.Regular)
+
+ sendWSCloseFrame(Protocol.CloseCodes.Regular, mask = true)
+ closeNetworkInput()
+ expectNetworkClose()
+ }
+ }
+
+ "select compression separately through the Java API" in
Utils.assertAllStagesStopped {
+ new TestSetup {
+ sendWebSocketRequest("Sec-WebSocket-Extensions:
permessage-deflate\r\n")
+
+ val request = expectRequest()
+ val upgrade = request.attribute(webSocketUpgrade)
+ val response = upgrade.get.handleMessagesWith(
+ Flow.fromSinkAndSource(
+ Sink.foreach[JavaMessage](_ => ()),
+ Source(List[JavaMessage](
+ JavaTextMessage.create("plain"),
+ JavaTextMessage.create("compressed Java server message")))),
+ new Predicate[JavaMessage] {
+ override def test(message: JavaMessage): Boolean =
+ message.asTextMessage.getStrictText.startsWith("compressed")
+ })
+ responses.sendNext(response)
+
+ expectResponseWithWipedDate(
+ """HTTP/1.1 101 Switching Protocols
+ |Upgrade: websocket
+ |Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
+ |Sec-WebSocket-Extensions: permessage-deflate
+ |Server: pekko-http/test
+ |Date: XXXX
+ |Connection: upgrade
+ |
+ |""")
+
+ expectWSFrame(Protocol.Opcode.Text, ByteString("plain"), fin = true)
+ expectCompressedTextFrame("compressed Java server message")
+ expectWSCloseFrame(Protocol.CloseCodes.Regular)
+
+ sendWSCloseFrame(Protocol.CloseCodes.Regular, mask = true)
+ closeNetworkInput()
+ expectNetworkClose()
+ }
+ }
+
+ "not evaluate the outbound compression filter when permessage-deflate is
not negotiated" in
+ Utils.assertAllStagesStopped {
+ new TestSetup {
+ sendWebSocketRequest("")
+
+ val request = expectRequest()
+ val upgrade = request.attribute(webSocketUpgrade)
+ val evaluated = new AtomicInteger()
+ val response = upgrade.get.handleMessages(
+ Flow.fromSinkAndSource(Sink.ignore,
Source.single(TextMessage.Strict("plain server message"))),
+ None,
+ _ => {
+ evaluated.incrementAndGet()
+ true
+ })
+ responses.sendNext(response)
+
+ expectResponseWithWipedDate(
+ """HTTP/1.1 101 Switching Protocols
+ |Upgrade: websocket
+ |Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
+ |Server: pekko-http/test
+ |Date: XXXX
+ |Connection: upgrade
+ |
+ |""")
+
+ expectWSFrame(Protocol.Opcode.Text, ByteString("plain server
message"), fin = true)
+ evaluated.get() shouldEqual 0
+ expectWSCloseFrame(Protocol.CloseCodes.Regular)
+
+ sendWSCloseFrame(Protocol.CloseCodes.Regular, mask = true)
+ closeNetworkInput()
+ expectNetworkClose()
+ }
+ }
+
"deflate empty outbound messages sent by the application" in
Utils.assertAllStagesStopped {
new TestSetup {
sendWebSocketRequest("Sec-WebSocket-Extensions:
permessage-deflate\r\n")
@@ -718,6 +845,48 @@ class WebSocketServerSpec extends
PekkoSpecWithMaterializer("pekko.http.server.w
tracking.awaitAllEnded()
}
+ "keep one uncompressed selection across a streamed outbound message" in
Utils.assertAllStagesStopped {
+ new TestSetup {
+ sendWebSocketRequest("Sec-WebSocket-Extensions:
permessage-deflate\r\n")
+
+ val request = expectRequest()
+ val upgrade = request.attribute(webSocketUpgrade)
+ val evaluated = new AtomicInteger()
+ val response = upgrade.get.handleMessages(
+ Flow.fromSinkAndSource(
+ Sink.ignore,
+ Source.single(TextMessage(Source(List("streamed ", "plain ",
"message"))))),
+ None,
+ _ => {
+ evaluated.incrementAndGet()
+ false
+ })
+ responses.sendNext(response)
+
+ expectResponseWithWipedDate(
+ """HTTP/1.1 101 Switching Protocols
+ |Upgrade: websocket
+ |Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
+ |Sec-WebSocket-Extensions: permessage-deflate
+ |Server: pekko-http/test
+ |Date: XXXX
+ |Connection: upgrade
+ |
+ |""")
+
+ expectWSFrame(Protocol.Opcode.Text, ByteString("streamed "), fin =
false)
+ expectWSFrame(Protocol.Opcode.Continuation, ByteString("plain "),
fin = false)
+ expectWSFrame(Protocol.Opcode.Continuation, ByteString("message"),
fin = false)
+ expectWSFrame(Protocol.Opcode.Continuation, ByteString.empty, fin =
true)
+ evaluated.get() shouldEqual 1
+ expectWSCloseFrame(Protocol.CloseCodes.Regular)
+
+ sendWSCloseFrame(Protocol.CloseCodes.Regular, mask = true)
+ closeNetworkInput()
+ expectNetworkClose()
+ }
+ }
+
"fail invalid compressed messages with a protocol error" in
Utils.assertAllStagesStopped {
new TestSetup {
sendWebSocketRequest("Sec-WebSocket-Extensions:
permessage-deflate\r\n")
@@ -1290,6 +1459,143 @@ class WebSocketServerSpec extends
PekkoSpecWithMaterializer("pekko.http.server.w
}
}
+ "select compression separately for low-level outbound messages" in
Utils.assertAllStagesStopped {
+ new TestSetup {
+ sendWebSocketRequest("Sec-WebSocket-Extensions:
permessage-deflate\r\n")
+
+ val request = expectRequest()
+ val upgrade = request.attribute(webSocketUpgrade)
+ val evaluated = new AtomicInteger()
+ val firstMessage = ByteString("compressed first low-level message")
+ val secondMessage = ByteString("plain")
+ val thirdMessage = ByteString("compressed third low-level message")
+ val response =
upgrade.get.asInstanceOf[UpgradeToWebSocketLowLevel].handleFrames(
+ Flow.fromSinkAndSource(
+ Sink.ignore,
+ Source(List(
+ FrameEvent.fullFrame(Protocol.Opcode.Text, None, firstMessage,
fin = true),
+ FrameEvent.fullFrame(Protocol.Opcode.Text, None,
secondMessage, fin = true),
+ FrameEvent.fullFrame(Protocol.Opcode.Text, None, thirdMessage,
fin = true)))),
+ None,
+ compressionEnabled = true,
+ shouldCompress = start => {
+ evaluated.incrementAndGet()
+ start.data.utf8String.startsWith("compressed")
+ })
+ responses.sendNext(response)
+
+ expectResponseWithWipedDate(
+ """HTTP/1.1 101 Switching Protocols
+ |Upgrade: websocket
+ |Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
+ |Sec-WebSocket-Extensions: permessage-deflate
+ |Server: pekko-http/test
+ |Date: XXXX
+ |Connection: upgrade
+ |
+ |""")
+
+ val firstPayload = expectCompressedFrame(Protocol.Opcode.Text, fin =
true, rsv1 = true)
+ expectWSFrame(Protocol.Opcode.Text, secondMessage, fin = true)
+ val thirdPayload = expectCompressedFrame(Protocol.Opcode.Text, fin =
true, rsv1 = true)
+ inflatePerMessagesWithContext(firstPayload, thirdPayload)
shouldEqual Seq(firstMessage, thirdMessage)
+ evaluated.get() shouldEqual 3
+ netOut.expectComplete()
+
+ sendWSCloseFrame(Protocol.CloseCodes.Regular, mask = true)
+ closeNetworkInput()
+ }
+ }
+
+ "retain a low-level compression decision across continuation and control
frames" in
+ Utils.assertAllStagesStopped {
+ new TestSetup {
+ sendWebSocketRequest("Sec-WebSocket-Extensions:
permessage-deflate\r\n")
+
+ val request = expectRequest()
+ val upgrade = request.attribute(webSocketUpgrade)
+ val evaluated = new AtomicInteger()
+ val response =
upgrade.get.asInstanceOf[UpgradeToWebSocketLowLevel].handleFrames(
+ Flow.fromSinkAndSource(
+ Sink.ignore,
+ Source(List(
+ FrameEvent.fullFrame(Protocol.Opcode.Text, None,
ByteString("plain "), fin = false),
+ FrameEvent.fullFrame(Protocol.Opcode.Ping, None,
ByteString("ping"), fin = true),
+ FrameEvent.fullFrame(Protocol.Opcode.Continuation, None,
ByteString("message"), fin = true)))),
+ None,
+ compressionEnabled = true,
+ shouldCompress = _ => {
+ evaluated.incrementAndGet()
+ false
+ })
+ responses.sendNext(response)
+
+ expectResponseWithWipedDate(
+ """HTTP/1.1 101 Switching Protocols
+ |Upgrade: websocket
+ |Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
+ |Sec-WebSocket-Extensions: permessage-deflate
+ |Server: pekko-http/test
+ |Date: XXXX
+ |Connection: upgrade
+ |
+ |""")
+
+ expectWSFrame(Protocol.Opcode.Text, ByteString("plain "), fin =
false)
+ expectWSFrame(Protocol.Opcode.Ping, ByteString("ping"), fin = true)
+ expectWSFrame(Protocol.Opcode.Continuation, ByteString("message"),
fin = true)
+ evaluated.get() shouldEqual 1
+ netOut.expectComplete()
+
+ sendWSCloseFrame(Protocol.CloseCodes.Regular, mask = true)
+ closeNetworkInput()
+ }
+ }
+
+ "pass every frame-data event through for an uncompressed low-level
message" in
+ Utils.assertAllStagesStopped {
+ new TestSetup {
+ sendWebSocketRequest("Sec-WebSocket-Extensions:
permessage-deflate\r\n")
+
+ val request = expectRequest()
+ val upgrade = request.attribute(webSocketUpgrade)
+ val evaluated = new AtomicInteger()
+ val message = ByteString("plain split frame data")
+ val (firstPart, secondPart) = message.splitAt(8)
+ val response =
upgrade.get.asInstanceOf[UpgradeToWebSocketLowLevel].handleFrames(
+ Flow.fromSinkAndSource(
+ Sink.ignore,
+ Source(List(
+ FrameStart(FrameHeader(Protocol.Opcode.Text, None,
message.length, fin = true), firstPart),
+ FrameData(secondPart, lastPart = true)))),
+ None,
+ compressionEnabled = true,
+ shouldCompress = _ => {
+ evaluated.incrementAndGet()
+ false
+ })
+ responses.sendNext(response)
+
+ expectResponseWithWipedDate(
+ """HTTP/1.1 101 Switching Protocols
+ |Upgrade: websocket
+ |Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
+ |Sec-WebSocket-Extensions: permessage-deflate
+ |Server: pekko-http/test
+ |Date: XXXX
+ |Connection: upgrade
+ |
+ |""")
+
+ expectWSFrame(Protocol.Opcode.Text, message, fin = true)
+ evaluated.get() shouldEqual 1
+ netOut.expectComplete()
+
+ sendWSCloseFrame(Protocol.CloseCodes.Regular, mask = true)
+ closeNetworkInput()
+ }
+ }
+
"apply compression to low-level frame handlers" in
Utils.assertAllStagesStopped {
new TestSetup {
sendWebSocketRequest("Sec-WebSocket-Extensions:
permessage-deflate\r\n")
@@ -1493,6 +1799,14 @@ class WebSocketServerSpec extends
PekkoSpecWithMaterializer("pekko.http.server.w
}
}
+ private def inflatePerMessagesWithContext(messages: ByteString*):
Seq[ByteString] = {
+ val inflater = new Inflater(true)
+ try messages.map(message => inflateFrame(inflater, message ++
ByteString(0x00, 0x00, 0xFF.toByte, 0xFF.toByte)))
+ finally {
+ inflater.end()
+ }
+ }
+
private def inflateFrame(inflater: Inflater, data: ByteString): ByteString =
{
inflater.setInput(data.toArray)
val output = new ByteArrayOutputStream()
diff --git
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/WebSocketDirectivesSpec.scala
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/WebSocketDirectivesSpec.scala
index ad993b9ba..58f6c4da9 100644
---
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/WebSocketDirectivesSpec.scala
+++
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/WebSocketDirectivesSpec.scala
@@ -13,9 +13,15 @@
package org.apache.pekko.http.scaladsl.server.directives
+import java.util.function.Predicate
+
+import scala.collection.immutable
+
import org.apache.pekko
-import pekko.http.scaladsl.model.StatusCodes
-import pekko.http.scaladsl.model.headers.`Sec-WebSocket-Protocol`
+import pekko.http.impl.engine.server.InternalCustomHeader
+import pekko.http.scaladsl.model.AttributeKeys.webSocketUpgrade
+import pekko.http.scaladsl.model.{ HttpRequest, HttpResponse, StatusCodes }
+import pekko.http.scaladsl.model.headers.{ `Sec-WebSocket-Protocol`, Upgrade,
UpgradeProtocol }
import pekko.http.scaladsl.model.ws._
import pekko.http.scaladsl.server.{
ExpectedWebSocketRequestRejection,
@@ -24,7 +30,7 @@ import pekko.http.scaladsl.server.{
UnsupportedWebSocketSubprotocolRejection
}
import pekko.http.scaladsl.testkit.WSProbe
-import pekko.stream.OverflowStrategy
+import pekko.stream.{ FlowShape, Graph, OverflowStrategy }
import pekko.stream.scaladsl.{ Flow, Sink, Source }
import pekko.util.ByteString
@@ -49,6 +55,48 @@ class WebSocketDirectivesSpec extends RoutingSpec {
wsClient.expectCompletion()
}
}
+ "pass the outbound compression selector to the WebSocket upgrade" in {
+ var selectedSubprotocol = Option.empty[Option[String]]
+ var compressionDecision = Option.empty[Boolean]
+ val request = recordingWebSocketRequest() { (subprotocol,
shouldCompress) =>
+ selectedSubprotocol = Some(subprotocol)
+ compressionDecision = Some(shouldCompress(TextMessage("compress")))
+ }
+
+ request ~> handleWebSocketMessages(Flow[Message],
+ {
+ case TextMessage.Strict("compress") => true
+ case _ => false
+ }) ~> check {
+ isWebSocketUpgrade shouldEqual true
+ }
+
+ selectedSubprotocol shouldEqual Some(None)
+ compressionDecision shouldEqual Some(true)
+ }
+ "pass the Java outbound compression predicate and selected subprotocol to
the WebSocket upgrade" in {
+ var selectedSubprotocol = Option.empty[Option[String]]
+ var compressionDecision = Option.empty[Boolean]
+ val request = recordingWebSocketRequest("echo") { (subprotocol,
shouldCompress) =>
+ selectedSubprotocol = Some(subprotocol)
+ compressionDecision = Some(shouldCompress(TextMessage("compress")))
+ }
+ val shouldCompress = new Predicate[pekko.http.javadsl.model.ws.Message] {
+ override def test(message: pekko.http.javadsl.model.ws.Message):
Boolean = message.isText
+ }
+ val javaRoute =
pekko.http.javadsl.server.Directives.handleWebSocketMessagesForProtocol(
+ Flow[pekko.http.javadsl.model.ws.Message].asJava,
+ "echo",
+ shouldCompress)
+
+ request ~> javaRoute.asScala ~> check {
+ isWebSocketUpgrade shouldEqual true
+ header[`Sec-WebSocket-Protocol`].get.protocols shouldEqual
immutable.Seq("echo")
+ }
+
+ selectedSubprotocol shouldEqual Some(Some("echo"))
+ compressionDecision shouldEqual Some(true)
+ }
"choose subprotocol from offered ones" in {
val wsClient = WSProbe()
@@ -113,4 +161,33 @@ class WebSocketDirectivesSpec extends RoutingSpec {
def echo: Flow[Message, Message, Any] =
Flow[Message]
.buffer(1, OverflowStrategy.backpressure) // needed because a noop flow
hasn't any buffer that would start processing
+
+ private def recordingWebSocketRequest(offeredProtocols: String*)(
+ onHandle: (Option[String], Message => Boolean) => Unit): HttpRequest = {
+ val upgrade =
+ new InternalCustomHeader("UpgradeToWebSocketTestHeader") with
WebSocketUpgrade {
+ override def requestedProtocols: immutable.Seq[String] =
offeredProtocols.toList
+
+ override def handleMessages(
+ handlerFlow: Graph[FlowShape[Message, Message], Any],
+ subprotocol: Option[String]): HttpResponse =
+ throw new AssertionError("The selective-compression overload was not
called")
+
+ override def handleMessages(
+ handlerFlow: Graph[FlowShape[Message, Message], Any],
+ subprotocol: Option[String],
+ shouldCompress: Message => Boolean): HttpResponse = {
+ onHandle(subprotocol, shouldCompress)
+ HttpResponse(
+ StatusCodes.SwitchingProtocols,
+ headers =
+ Upgrade(UpgradeProtocol("websocket") :: Nil) ::
+ subprotocol.map(protocol => `Sec-WebSocket-Protocol`(protocol ::
Nil)).toList)
+ }
+ }
+
+ HttpRequest()
+ .addAttribute(webSocketUpgrade, upgrade)
+ .addHeader(upgrade)
+ }
}
diff --git
a/http/src/main/scala/org/apache/pekko/http/javadsl/server/directives/WebSocketDirectives.scala
b/http/src/main/scala/org/apache/pekko/http/javadsl/server/directives/WebSocketDirectives.scala
index 71a0f8ecd..d4a1c9097 100644
---
a/http/src/main/scala/org/apache/pekko/http/javadsl/server/directives/WebSocketDirectives.scala
+++
b/http/src/main/scala/org/apache/pekko/http/javadsl/server/directives/WebSocketDirectives.scala
@@ -16,7 +16,7 @@ package directives
import java.util.{ List => JList }
import java.util.Optional
-import java.util.function.{ Function => JFunction }
+import java.util.function.{ Function => JFunction, Predicate }
import org.apache.pekko
import pekko.NotUsed
@@ -59,6 +59,17 @@ abstract class WebSocketDirectives extends
SecurityDirectives {
D.handleWebSocketMessages(adapt(handler))
}
+ /**
+ * Handles WebSocket requests with the given handler and selectively
compresses outbound messages for which
+ * {@code shouldCompress} returns {@code true} when {@code
permessage-deflate} was negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleWebSocketMessages[T](handler: Flow[Message, Message, T],
shouldCompress: Predicate[Message]): Route =
+ RouteAdapter {
+ D.handleWebSocketMessages(adapt(handler), message =>
shouldCompress.test(message))
+ }
+
/**
* Handles WebSocket requests with the given handler if the given
subprotocol is offered in the request and
* rejects other requests with an [[ExpectedWebSocketRequestRejection]] or
an [[UnsupportedWebSocketSubprotocolRejection]].
@@ -68,6 +79,21 @@ abstract class WebSocketDirectives extends
SecurityDirectives {
D.handleWebSocketMessagesForProtocol(adapt(handler), subprotocol)
}
+ /**
+ * Handles WebSocket requests with the given handler if the given
subprotocol is offered and selectively compresses
+ * outbound messages for which {@code shouldCompress} returns {@code true}
when {@code permessage-deflate} was
+ * negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleWebSocketMessagesForProtocol[T](
+ handler: Flow[Message, Message, T],
+ subprotocol: String,
+ shouldCompress: Predicate[Message]): Route =
+ RouteAdapter {
+ D.handleWebSocketMessagesForProtocol(adapt(handler), subprotocol,
message => shouldCompress.test(message))
+ }
+
/**
* Handles WebSocket requests with the given handler and rejects other
requests with an
* [[ExpectedWebSocketRequestRejection]].
@@ -84,6 +110,22 @@ abstract class WebSocketDirectives extends
SecurityDirectives {
D.handleWebSocketMessagesForOptionalProtocol(adapt(handler),
subprotocol.asScala)
}
+ /**
+ * Handles WebSocket requests with the given handler and selectively
compresses outbound messages for which
+ * {@code shouldCompress} returns {@code true} when {@code
permessage-deflate} was negotiated.
+ *
+ * @since 2.0.0
+ */
+ def handleWebSocketMessagesForOptionalProtocol[T](
+ handler: Flow[Message, Message, T],
+ subprotocol: Optional[String],
+ shouldCompress: Predicate[Message]): Route = RouteAdapter {
+ D.handleWebSocketMessagesForOptionalProtocol(
+ adapt(handler),
+ subprotocol.asScala,
+ message => shouldCompress.test(message))
+ }
+
private def adapt[T](handler: Flow[Message, Message, T]):
scaladsl.Flow[s.Message, s.Message, NotUsed] = {
scaladsl.Flow[s.Message].map(_.asJava).via(handler).map(_.asScala)
}
diff --git
a/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/WebSocketDirectives.scala
b/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/WebSocketDirectives.scala
index faff9f10e..5730ab568 100644
---
a/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/WebSocketDirectives.scala
+++
b/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/WebSocketDirectives.scala
@@ -60,6 +60,18 @@ trait WebSocketDirectives {
def handleWebSocketMessages(handler: Flow[Message, Message, Any]): Route =
handleWebSocketMessagesForOptionalProtocol(handler, None)
+ /**
+ * Handles WebSocket requests with the given handler and selectively
compresses outbound messages for which
+ * `shouldCompress` returns `true` when `permessage-deflate` was negotiated.
+ *
+ * @group websocket
+ * @since 2.0.0
+ */
+ def handleWebSocketMessages(
+ handler: Flow[Message, Message, Any],
+ shouldCompress: Message => Boolean): Route =
+ handleWebSocketMessagesForOptionalProtocol(handler, None, shouldCompress)
+
/**
* Handles WebSocket requests with the given handler if the given
subprotocol is offered in the request and
* rejects other requests with an [[ExpectedWebSocketRequestRejection]] or
an [[UnsupportedWebSocketSubprotocolRejection]].
@@ -69,6 +81,19 @@ trait WebSocketDirectives {
def handleWebSocketMessagesForProtocol(handler: Flow[Message, Message, Any],
subprotocol: String): Route =
handleWebSocketMessagesForOptionalProtocol(handler, Some(subprotocol))
+ /**
+ * Handles WebSocket requests with the given handler if the given
subprotocol is offered and selectively compresses
+ * outbound messages for which `shouldCompress` returns `true` when
`permessage-deflate` was negotiated.
+ *
+ * @group websocket
+ * @since 2.0.0
+ */
+ def handleWebSocketMessagesForProtocol(
+ handler: Flow[Message, Message, Any],
+ subprotocol: String,
+ shouldCompress: Message => Boolean): Route =
+ handleWebSocketMessagesForOptionalProtocol(handler, Some(subprotocol),
shouldCompress)
+
/**
* Handles WebSocket requests with the given handler and rejects other
requests with an
* [[ExpectedWebSocketRequestRejection]].
@@ -90,4 +115,22 @@ trait WebSocketDirectives {
else
reject(UnsupportedWebSocketSubprotocolRejection(subprotocol.get)) //
None.forall == true
}
+
+ /**
+ * Handles WebSocket requests with the given handler and selectively
compresses outbound messages for which
+ * `shouldCompress` returns `true` when `permessage-deflate` was negotiated.
+ *
+ * @group websocket
+ * @since 2.0.0
+ */
+ def handleWebSocketMessagesForOptionalProtocol(
+ handler: Flow[Message, Message, Any],
+ subprotocol: Option[String],
+ shouldCompress: Message => Boolean): Route =
+ extractWebSocketUpgrade { upgrade =>
+ if (subprotocol.forall(sub =>
upgrade.requestedProtocols.exists(_.equalsIgnoreCase(sub))))
+ complete(upgrade.handleMessages(handler, subprotocol, shouldCompress))
+ else
+ reject(UnsupportedWebSocketSubprotocolRejection(subprotocol.get)) //
None.forall == true
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]