diff --git a/bom/pom.xml b/bom/pom.xml
index c4dac5986f0..0d19c246a03 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -1,7 +1,7 @@
+
+
+ 4.0.0
+
+
+ io.helidon.nima.testing.junit5
+ helidon-nima-testing-junit5-project
+ 4.0.0-SNAPSHOT
+ ../pom.xml
+
+
+ helidon-nima-testing-junit5-websocket
+ Helidon Níma Testing JUnit5 WebSocket
+
+
+
+ io.helidon.nima.testing.junit5
+ helidon-nima-testing-junit5-webserver
+
+
+ io.helidon.nima.websocket
+ helidon-nima-websocket-client
+
+
+ io.helidon.nima.websocket
+ helidon-nima-websocket-webserver
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+
+
+ org.hamcrest
+ hamcrest-all
+ provided
+
+
+
diff --git a/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsClient.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsClient.java
new file mode 100644
index 00000000000..d0ead51cf99
--- /dev/null
+++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsClient.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.testing.junit5.websocket;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+
+import io.helidon.common.http.Http;
+import io.helidon.common.http.HttpPrologue;
+import io.helidon.nima.websocket.WsListener;
+import io.helidon.nima.websocket.client.WsClient;
+import io.helidon.nima.websocket.webserver.WsRoute;
+import io.helidon.nima.websocket.webserver.WsRouting;
+
+/**
+ * A client for WebSocket, that directly invokes routing (and bypasses network).
+ */
+public class DirectWsClient implements WsClient {
+ private final List connections = new ArrayList<>();
+ private final WsRouting routing;
+
+ private DirectWsClient(WsRouting routing) {
+ this.routing = routing;
+
+ routing.beforeStart();
+ }
+
+ /**
+ * Create a new client based on the provided routing.
+ *
+ * @param routing used to discover route to handle a new connection
+ * @return a new instance for the provided routing
+ */
+ public static DirectWsClient create(WsRouting routing) {
+ return new DirectWsClient(routing);
+ }
+
+ @Override
+ public void connect(URI uri, WsListener clientListener) {
+ HttpPrologue prologue = HttpPrologue.create("ws", "ws", "13", Http.Method.GET, uri.getRawPath(), false);
+ WsRoute route = routing.findRoute(prologue);
+ DirectWsConnection directWsConnection = DirectWsConnection.create(prologue, clientListener, route);
+ directWsConnection.start();
+ connections.add(directWsConnection);
+ }
+
+ @Override
+ public void connect(String path, WsListener listener) {
+ try {
+ connect(new URI("ws", null, "helidon-unit", 65000, path, null, null), listener);
+ } catch (URISyntaxException e) {
+ throw new IllegalArgumentException("Cannot create URI from provided path", e);
+ }
+ }
+
+ void close() {
+ connections.forEach(DirectWsConnection::stop);
+ this.routing.afterStop();
+ }
+}
diff --git a/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsConnection.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsConnection.java
new file mode 100644
index 00000000000..2d1dc189783
--- /dev/null
+++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsConnection.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.testing.junit5.websocket;
+
+import java.net.InetSocketAddress;
+import java.util.Optional;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import io.helidon.common.buffers.BufferData;
+import io.helidon.common.buffers.DataReader;
+import io.helidon.common.buffers.DataWriter;
+import io.helidon.common.context.Context;
+import io.helidon.common.http.HttpPrologue;
+import io.helidon.common.http.WritableHeaders;
+import io.helidon.common.socket.HelidonSocket;
+import io.helidon.nima.http.encoding.ContentEncodingContext;
+import io.helidon.nima.http.media.MediaContext;
+import io.helidon.nima.testing.junit5.webserver.DirectPeerInfo;
+import io.helidon.nima.testing.junit5.webserver.DirectSocket;
+import io.helidon.nima.webserver.ConnectionContext;
+import io.helidon.nima.webserver.Router;
+import io.helidon.nima.webserver.ServerContext;
+import io.helidon.nima.webserver.http.DirectHandlers;
+import io.helidon.nima.websocket.WsListener;
+import io.helidon.nima.websocket.client.ClientWsConnection;
+import io.helidon.nima.websocket.webserver.WsConnection;
+import io.helidon.nima.websocket.webserver.WsRoute;
+
+class DirectWsConnection {
+ private final AtomicBoolean serverStarted = new AtomicBoolean();
+
+ private final HttpPrologue prologue;
+ private final WsListener clientListener;
+ private final WsRoute serverRoute;
+ private final DataReader clientReader;
+ private final DataWriter clientWriter;
+ private final DataReader serverReader;
+ private final DataWriter serverWriter;
+ private final HelidonSocket socket;
+ private final ConnectionContext ctx;
+ private final ExecutorService executorService;
+ private volatile Future> serverFuture;
+ private volatile Future> clientFuture;
+
+ DirectWsConnection(HttpPrologue prologue, WsListener clientListener, WsRoute serverRoute) {
+ this.prologue = prologue;
+ this.clientListener = clientListener;
+ this.serverRoute = serverRoute;
+ this.executorService = Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("direct-test-ws", 1)
+ .factory());
+
+ ArrayBlockingQueue serverToClient = new ArrayBlockingQueue<>(1024);
+ ArrayBlockingQueue clientToServer = new ArrayBlockingQueue<>(1024);
+ this.clientReader = reader(serverToClient);
+ this.clientWriter = writer(clientToServer);
+ this.serverReader = reader(clientToServer);
+ this.serverWriter = writer(serverToClient);
+ DirectPeerInfo info = new DirectPeerInfo(InetSocketAddress.createUnresolved("localhost", 64000),
+ "localhost",
+ 64000,
+ Optional.empty(),
+ Optional.empty());
+ this.socket = DirectSocket.create(info, info, false);
+ this.ctx = ConnectionContext.create(
+ ServerContext.create(Context.create(),
+ MediaContext.create(),
+ ContentEncodingContext.create()),
+ executorService,
+ serverWriter,
+ serverReader,
+ Router.builder().build(),
+ "unit-server",
+ "unit-channel",
+ DirectHandlers.builder().build(),
+ socket,
+ -1);
+ }
+
+ static DirectWsConnection create(HttpPrologue prologue, WsListener clientListener, WsRoute serverRoute) {
+ return new DirectWsConnection(prologue, clientListener, serverRoute);
+ }
+
+ void start() {
+ if (serverStarted.compareAndSet(false, true)) {
+ WsConnection serverConnection = WsConnection.create(ctx, prologue, WritableHeaders.create(), "", serverRoute);
+ ClientWsConnection clientConnection = ClientWsConnection.create(clientListener,
+ socket,
+ clientReader,
+ clientWriter,
+ Optional.empty());
+ serverFuture = executorService.submit(serverConnection::handle);
+ clientFuture = executorService.submit(clientConnection);
+ }
+ }
+
+ void stop() {
+ Future> s = serverFuture;
+ Future> c = clientFuture;
+ if (s != null) {
+ s.cancel(true);
+ }
+ if (c != null) {
+ c.cancel(true);
+ }
+ }
+
+ private static DataReader reader(ArrayBlockingQueue queue) {
+ return new DataReader(() -> {
+ byte[] data;
+ try {
+ data = queue.take();
+ } catch (InterruptedException e) {
+ throw new IllegalArgumentException("Thread interrupted", e);
+ }
+ if (data.length == 0) {
+ return null;
+ }
+ return data;
+ });
+ }
+
+ private DataWriter writer(ArrayBlockingQueue queue) {
+ return new DataWriter() {
+ @Override
+ public void write(BufferData... buffers) {
+ writeNow(buffers);
+ }
+
+ @Override
+ public void write(BufferData buffer) {
+ writeNow(buffer);
+ }
+
+ @Override
+ public void writeNow(BufferData... buffers) {
+ for (BufferData buffer : buffers) {
+ writeNow(buffer);
+ }
+ }
+
+ @Override
+ public void writeNow(BufferData buffer) {
+ byte[] bytes = new byte[buffer.available()];
+ buffer.read(bytes);
+ try {
+ queue.put(bytes);
+ } catch (InterruptedException e) {
+ throw new IllegalStateException("Thread interrupted", e);
+ }
+ }
+ };
+ }
+}
diff --git a/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsDirectExtension.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsDirectExtension.java
new file mode 100644
index 00000000000..eff8f3691a5
--- /dev/null
+++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsDirectExtension.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.testing.junit5.websocket;
+
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import io.helidon.nima.testing.junit5.webserver.Junit5Util;
+import io.helidon.nima.testing.junit5.webserver.spi.DirectJunitExtension;
+import io.helidon.nima.webserver.WebServer;
+import io.helidon.nima.websocket.client.WsClient;
+import io.helidon.nima.websocket.webserver.WsRouting;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+
+/**
+ * A {@link java.util.ServiceLoader} provider implementation that adds support for injection of WebSocket related
+ * artifacts, such as {@link io.helidon.nima.testing.junit5.websocket.DirectWsClient} in Helidon Níma unit tests.
+ */
+public class WsDirectExtension implements DirectJunitExtension {
+ private final Map clients = new HashMap<>();
+
+ @Override
+ public void afterAll(ExtensionContext context) {
+ clients.values().forEach(DirectWsClient::close);
+ }
+
+ @Override
+ public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
+ throws ParameterResolutionException {
+
+ Class> paramType = parameterContext.getParameter().getType();
+ if (DirectWsClient.class.equals(paramType) || WsClient.class.equals(paramType)) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Class> parameterType) {
+ Class> paramType = parameterContext.getParameter().getType();
+ if (DirectWsClient.class.equals(paramType) || WsClient.class.equals(paramType)) {
+ String socketName = Junit5Util.socketName(parameterContext.getParameter());
+
+ DirectWsClient directClient = clients.get(socketName);
+ if (directClient == null) {
+ if (WebServer.DEFAULT_SOCKET_NAME.equals(socketName)) {
+ throw new IllegalStateException("There is no WebSocket routing specified. Please add static method "
+ + "annotated with @SetUpRoute that accepts WebSocketRouting.Builder");
+ } else {
+ throw new IllegalStateException("There is no default routing specified for socket \"" + socketName + "\"."
+ + "annotated with @SetUpRoute that accepts WebSocketRouting.Builder"
+ + " and add @Socket(\"" + socketName + "\") "
+ + "annotation to the parameter");
+ }
+ }
+ return directClient;
+ }
+
+ throw new ParameterResolutionException("Parameter not supported by this extension: " + parameterType);
+ }
+
+ @Override
+ public Optional> setUpRouteParamHandler(Class> type) {
+ if (WsRouting.Builder.class.equals(type)) {
+ return Optional.of(new RoutingParamHandler(clients));
+ }
+ return Optional.empty();
+ }
+
+ private static final class RoutingParamHandler implements DirectJunitExtension.ParamHandler {
+ private final Map clients;
+
+ private RoutingParamHandler(Map clients) {
+ this.clients = clients;
+ }
+
+ @Override
+ public WsRouting.Builder get(String socketName) {
+ return WsRouting.builder();
+ }
+
+ @Override
+ public void handle(Method method, String socketName, WsRouting.Builder value) {
+ if (clients.putIfAbsent(socketName, DirectWsClient.create(value.build())) != null) {
+ throw new IllegalStateException("Method "
+ + method
+ + " defines WebSocket routing for socket \""
+ + socketName
+ + "\""
+ + " that is already defined for class \""
+ + method.getDeclaringClass().getName()
+ + "\".");
+ }
+ }
+ }
+
+}
diff --git a/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsServerExtension.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsServerExtension.java
new file mode 100644
index 00000000000..48714c49a92
--- /dev/null
+++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsServerExtension.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.testing.junit5.websocket;
+
+import java.util.Optional;
+
+import io.helidon.nima.testing.junit5.webserver.Junit5Util;
+import io.helidon.nima.testing.junit5.webserver.spi.ServerJunitExtension;
+import io.helidon.nima.webserver.ListenerConfiguration;
+import io.helidon.nima.webserver.Router;
+import io.helidon.nima.webserver.WebServer;
+import io.helidon.nima.websocket.client.WsClient;
+import io.helidon.nima.websocket.webserver.WsRouting;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+
+/**
+ * A {@link java.util.ServiceLoader} provider implementation that adds support for injection of WebSocket related
+ * artifacts, such as {@link io.helidon.nima.websocket.client.WsClient} in Helidon Níma integration tests.
+ */
+public class WsServerExtension implements ServerJunitExtension {
+ @Override
+ public Optional> setUpRouteParamHandler(Class> type) {
+ if (WsRouting.Builder.class.equals(type)) {
+ return Optional.of(new RoutingParamHandler());
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
+ throws ParameterResolutionException {
+ return WsClient.class.equals(parameterContext.getParameter().getType());
+ }
+
+ @Override
+ public Object resolveParameter(ParameterContext parameterContext,
+ ExtensionContext extensionContext,
+ Class> parameterType,
+ WebServer server) {
+ String socketName = Junit5Util.socketName(parameterContext.getParameter());
+
+ if (WsClient.class.equals(parameterType)) {
+ return WsClient.builder()
+ .baseUri("ws://localhost:" + server.port(socketName))
+ .build();
+ }
+ throw new ParameterResolutionException("WebSocket extension only supports WsClient parameter type");
+ }
+
+ private static final class RoutingParamHandler implements ParamHandler {
+ @Override
+ public WsRouting.Builder get(String socketName,
+ WebServer.Builder serverBuilder,
+ ListenerConfiguration.Builder listenerBuilder,
+ Router.RouterBuilder> routerBuilder) {
+ return WsRouting.builder();
+ }
+
+ @Override
+ public void handle(String socketName,
+ WebServer.Builder serverBuilder,
+ ListenerConfiguration.Builder listenerBuilder,
+ Router.RouterBuilder> routerBuilder,
+ WsRouting.Builder value) {
+ routerBuilder.addRouting(value.build());
+ }
+ }
+}
diff --git a/nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/Frame.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/package-info.java
similarity index 64%
rename from nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/Frame.java
rename to nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/package-info.java
index ac79b692247..a5c7d57ffa1 100644
--- a/nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/Frame.java
+++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/package-info.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,20 +14,7 @@
* limitations under the License.
*/
-package io.helidon.nima.websocket.webserver;
-
-import io.helidon.common.buffers.BufferData;
-
-interface Frame {
- boolean fin();
-
- WsOpCode opCode();
-
- boolean masked();
-
- long payloadLength();
-
- int[] maskingKey();
-
- BufferData payloadData();
-}
+/**
+ * Unit and integration testing support for Níma WebSocket and JUnit 5.
+ */
+package io.helidon.nima.testing.junit5.websocket;
diff --git a/nima/testing/junit5/websocket/src/main/java/module-info.java b/nima/testing/junit5/websocket/src/main/java/module-info.java
new file mode 100644
index 00000000000..9bb326371e1
--- /dev/null
+++ b/nima/testing/junit5/websocket/src/main/java/module-info.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import io.helidon.nima.testing.junit5.websocket.WsDirectExtension;
+import io.helidon.nima.testing.junit5.websocket.WsServerExtension;
+
+/**
+ * Unit and integration testing support for Níma WebSocket and JUnit 5.
+ */
+module io.helidon.nima.testing.junit5.websocket {
+ requires transitive io.helidon.nima.testing.junit5.webserver;
+ requires io.helidon.nima.websocket.webserver;
+ requires io.helidon.nima.websocket.client;
+
+ exports io.helidon.nima.testing.junit5.websocket;
+
+ provides io.helidon.nima.testing.junit5.webserver.spi.ServerJunitExtension
+ with WsServerExtension;
+
+ provides io.helidon.nima.testing.junit5.webserver.spi.DirectJunitExtension
+ with WsDirectExtension;
+}
\ No newline at end of file
diff --git a/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsDirectTestingTest.java b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsDirectTestingTest.java
new file mode 100644
index 00000000000..86c3809d37a
--- /dev/null
+++ b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsDirectTestingTest.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.testing.junit5.websocket;
+
+import io.helidon.nima.testing.junit5.webserver.DirectClient;
+import io.helidon.nima.testing.junit5.webserver.RoutingTest;
+import io.helidon.nima.webclient.http1.Http1Client;
+import io.helidon.nima.websocket.client.WsClient;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+
+@RoutingTest
+class WsDirectTestingTest extends WsSocketAbstractTestingTest {
+ WsDirectTestingTest(Http1Client httpClient, WsClient wsClient) {
+ super(httpClient, wsClient);
+
+ assertThat(httpClient, instanceOf(DirectClient.class));
+ assertThat(wsClient, instanceOf(DirectWsClient.class));
+ }
+}
diff --git a/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketAbstractTestingTest.java b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketAbstractTestingTest.java
new file mode 100644
index 00000000000..958c9081ac7
--- /dev/null
+++ b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketAbstractTestingTest.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.testing.junit5.websocket;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import io.helidon.nima.testing.junit5.webserver.SetUpRoute;
+import io.helidon.nima.testing.junit5.webserver.Socket;
+import io.helidon.nima.webclient.http1.Http1Client;
+import io.helidon.nima.webserver.http.HttpRouting;
+import io.helidon.nima.websocket.WsCloseCodes;
+import io.helidon.nima.websocket.WsListener;
+import io.helidon.nima.websocket.WsSession;
+import io.helidon.nima.websocket.client.WsClient;
+import io.helidon.nima.websocket.webserver.WsRouting;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.fail;
+
+abstract class WsSocketAbstractTestingTest {
+ private static final ServerSideListener WS_LISTENER = new ServerSideListener();
+
+ private final Http1Client httpClient;
+ private final WsClient wsClient;
+
+ protected WsSocketAbstractTestingTest(Http1Client httpClient, WsClient wsClient) {
+ this.httpClient = httpClient;
+ this.wsClient = wsClient;
+ }
+
+ @SetUpRoute
+ static void routing(HttpRouting.Builder http, WsRouting.Builder ws) {
+ http.get("/test", (req, res) -> res.send("http"));
+ ws.endpoint("/testWs", WS_LISTENER);
+ }
+
+ @SetUpRoute("custom")
+ static void customRouting(WsRouting.Builder ws) {
+ ws.endpoint("/customWs", WS_LISTENER);
+ }
+
+ @Test
+ void testHttpEndpoint() {
+ String message = httpClient.get("/test").request(String.class);
+ assertThat(message, is("http"));
+ }
+
+ @Test
+ void testWsEndpoint() {
+ WS_LISTENER.reset();
+
+ ClientSideListener clientListener = new ClientSideListener();
+ wsClient.connect("/testWs", clientListener);
+
+ assertThat("We should have received a response", clientListener.await());
+ assertThat(clientListener.message, is("ws"));
+
+ assertThat(WS_LISTENER.opened, is(true));
+ assertThat(WS_LISTENER.closed, is(true));
+ assertThat(WS_LISTENER.message, is("hello"));
+ }
+
+ @Test
+ void testWsEndpointCustomSocket(@Socket("custom") WsClient wsClient) {
+ WS_LISTENER.reset();
+
+ ClientSideListener clientListener = new ClientSideListener();
+ wsClient.connect("/customWs", clientListener);
+
+ assertThat("We should have received a response", clientListener.await());
+ assertThat(clientListener.message, is("ws"));
+
+ assertThat(WS_LISTENER.opened, is(true));
+ assertThat(WS_LISTENER.closed, is(true));
+ assertThat(WS_LISTENER.message, is("hello"));
+ }
+
+ private static class ClientSideListener implements WsListener {
+ private final CountDownLatch cdl = new CountDownLatch(1);
+ private String message;
+ private volatile Throwable throwable;
+
+ @Override
+ public void onOpen(WsSession session) {
+ session.send("hello", true);
+ }
+
+ @Override
+ public void receive(WsSession session, String text, boolean last) {
+ this.message = text;
+ session.close(WsCloseCodes.NORMAL_CLOSE, "End");
+ }
+
+ @Override
+ public void onClose(WsSession session, int status, String reason) {
+ cdl.countDown();
+ }
+
+ @Override
+ public void onError(WsSession session, Throwable t) {
+ this.throwable = t;
+ cdl.countDown();
+ }
+
+ boolean await() {
+ try {
+ boolean await = cdl.await(10, TimeUnit.SECONDS);
+ if (throwable != null) {
+ fail(throwable);
+ }
+ return await;
+ } catch (InterruptedException e) {
+ fail(e);
+ return false;
+ }
+ }
+ }
+
+ private static class ServerSideListener implements WsListener {
+ boolean opened;
+ boolean closed;
+ String message;
+
+ @Override
+ public void receive(WsSession session, String text, boolean last) {
+ message = text;
+ session.send("ws", true);
+ }
+
+ @Override
+ public void onClose(WsSession session, int status, String reason) {
+ closed = true;
+ }
+
+ @Override
+ public void onOpen(WsSession session) {
+ opened = true;
+ }
+
+ void reset() {
+ opened = false;
+ closed = false;
+ message = null;
+ }
+ }
+}
+
diff --git a/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketServerTestingTest.java b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketServerTestingTest.java
new file mode 100644
index 00000000000..57c3f4b4679
--- /dev/null
+++ b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketServerTestingTest.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.testing.junit5.websocket;
+
+import io.helidon.nima.testing.junit5.webserver.DirectClient;
+import io.helidon.nima.testing.junit5.webserver.ServerTest;
+import io.helidon.nima.webclient.http1.Http1Client;
+import io.helidon.nima.websocket.client.WsClient;
+
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+
+@ServerTest
+class WsSocketServerTestingTest extends WsSocketAbstractTestingTest {
+ WsSocketServerTestingTest(Http1Client httpClient, WsClient wsClient) {
+ super(httpClient, wsClient);
+
+ assertThat(httpClient, not(instanceOf(DirectClient.class)));
+ assertThat(wsClient, not(instanceOf(DirectWsClient.class)));
+ }
+}
diff --git a/nima/testing/junit5/websocket/src/test/resources/logging.properties b/nima/testing/junit5/websocket/src/test/resources/logging.properties
new file mode 100644
index 00000000000..49ea4eb1733
--- /dev/null
+++ b/nima/testing/junit5/websocket/src/test/resources/logging.properties
@@ -0,0 +1,20 @@
+#
+# Copyright (c) 2023 Oracle and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+handlers=io.helidon.logging.jul.HelidonConsoleHandler
+java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS %4$s %3$s !thread!: %5$s%6$s%n
+
+.level=WARNING
diff --git a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java
index 6bd8d317b8c..0dd09ee1127 100644
--- a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java
+++ b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -25,13 +25,15 @@
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
-import java.util.logging.Logger;;
+import java.util.logging.Logger;
import static io.helidon.nima.tests.integration.websocket.webserver.WsAction.Operation.RCV;
import static io.helidon.nima.tests.integration.websocket.webserver.WsAction.OperationType.BINARY;
import static io.helidon.nima.tests.integration.websocket.webserver.WsAction.OperationType.TEXT;
import static java.nio.charset.StandardCharsets.UTF_8;
+;
+
/**
* A websocket client that is driven by a conversation instance.
*/
@@ -63,15 +65,25 @@ public void run() {
@Override
public void close() {
- socket.sendClose(WebSocket.NORMAL_CLOSURE, "bye");
+ try {
+ socket.sendClose(WebSocket.NORMAL_CLOSURE, "bye")
+ .get(10, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
}
private void sendMessage(WsAction action) {
- switch (action.opType) {
- case TEXT -> socket.sendText(action.message, true);
- case BINARY -> socket.sendBinary(ByteBuffer.wrap(action.message.getBytes(UTF_8)), true);
+ try {
+ switch (action.opType) {
+ case TEXT -> socket.sendText(action.message, true).get(10, TimeUnit.SECONDS);
+ case BINARY -> socket.sendBinary(ByteBuffer.wrap(action.message.getBytes(UTF_8)), true)
+ .get(10, TimeUnit.SECONDS);
+ }
+ LOGGER.log(Level.FINE, () -> "Client: " + action);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
}
- LOGGER.log(Level.FINE, () -> "Client: " + action);
}
private void waitMessage(WsAction action) {
diff --git a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationService.java b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationService.java
index 81f5d480f95..59cdcf19bc8 100644
--- a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationService.java
+++ b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationService.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -113,10 +113,10 @@ private void waitMessage(WsAction action, WsSession session) {
WsAction r = received.poll(WAIT_SECONDS, TimeUnit.SECONDS);
assert r != null;
if (!r.equals(action)) {
- session.abort();
+ session.terminate();
}
} catch (Exception e) {
- session.abort();
+ session.terminate();
}
}
}
diff --git a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketOriginTest.java b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketOriginTest.java
index f29bea9c66c..5882761c62d 100644
--- a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketOriginTest.java
+++ b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketOriginTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -34,10 +34,10 @@
import io.helidon.nima.webserver.Router;
import io.helidon.nima.webserver.WebServer;
import io.helidon.nima.webserver.http1.Http1ConnectionProvider;
-import io.helidon.nima.websocket.CloseCodes;
+import io.helidon.nima.websocket.WsCloseCodes;
import io.helidon.nima.websocket.WsListener;
import io.helidon.nima.websocket.WsSession;
-import io.helidon.nima.websocket.webserver.WebSocketRouting;
+import io.helidon.nima.websocket.webserver.WsRouting;
import io.helidon.nima.websocket.webserver.WsUpgradeProvider;
import org.junit.jupiter.api.Test;
@@ -69,7 +69,7 @@ static void updateServer(WebServer.Builder builder) {
@SetUpRoute
static void routing(Router.RouterBuilder> router) {
- router.addRouting(WebSocketRouting.builder()
+ router.addRouting(WsRouting.builder()
.endpoint("/single", WebSocketOriginTest::single));
}
@@ -93,7 +93,7 @@ public CompletionStage> onText(java.net.http.WebSocket webSocket,
})
.get(5, TimeUnit.SECONDS);
webSocket.sendText("lower", true);
- webSocket.sendClose(CloseCodes.NORMAL_CLOSE, "finished");
+ webSocket.sendClose(WsCloseCodes.NORMAL_CLOSE, "finished");
Boolean wasLast = wsCompleted.get(5, TimeUnit.SECONDS);
assertThat(wasLast, is(true));
assertThat(received, hasItem("LOWER"));
diff --git a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketTest.java b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketTest.java
index d9fe5617fff..ea7b7f5a6be 100644
--- a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketTest.java
+++ b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketTest.java
@@ -31,8 +31,8 @@
import io.helidon.nima.testing.junit5.webserver.SetUpRoute;
import io.helidon.nima.webserver.Router;
import io.helidon.nima.webserver.WebServer;
-import io.helidon.nima.websocket.CloseCodes;
-import io.helidon.nima.websocket.webserver.WebSocketRouting;
+import io.helidon.nima.websocket.WsCloseCodes;
+import io.helidon.nima.websocket.webserver.WsRouting;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -60,7 +60,7 @@ class WebSocketTest {
@SetUpRoute
static void router(Router.RouterBuilder> router) {
service = new EchoService();
- router.addRouting(WebSocketRouting.builder().endpoint("/echo", service));
+ router.addRouting(WsRouting.builder().endpoint("/echo", service));
}
@BeforeEach
@@ -72,7 +72,7 @@ void resetClosed() {
void checkClosed() {
EchoService.CloseInfo closeInfo = service.closeInfo();
assertThat(closeInfo, notNullValue());
- assertThat(closeInfo.status(), is(CloseCodes.NORMAL_CLOSE));
+ assertThat(closeInfo.status(), is(WsCloseCodes.NORMAL_CLOSE));
assertThat(closeInfo.reason(), is("normal"));
}
@@ -89,7 +89,7 @@ void testOnce() throws Exception {
ws.request(10);
ws.sendText("Hello", true).get(5, TimeUnit.SECONDS);
- ws.sendClose(CloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS);
+ ws.sendClose(WsCloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS);
List results = listener.getResults();
assertThat(results, contains("Hello"));
@@ -106,7 +106,7 @@ void testMulti() throws Exception {
ws.sendText("First", true).get(5, TimeUnit.SECONDS);
ws.sendText("Second", true).get(5, TimeUnit.SECONDS);
- ws.sendClose(CloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS);
+ ws.sendClose(WsCloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS);
assertThat(listener.getResults(), contains("First", "Second"));
}
@@ -122,7 +122,7 @@ void testFragmentedAndMulti() throws Exception {
ws.sendText("First", false).get(5, TimeUnit.SECONDS);
ws.sendText("Second", true).get(5, TimeUnit.SECONDS);
ws.sendText("Third", true).get(5, TimeUnit.SECONDS);
- ws.sendClose(CloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS);
+ ws.sendClose(WsCloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS);
assertThat(listener.getResults(), contains("FirstSecond", "Third"));
}
diff --git a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationTest.java b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationTest.java
index 779f706951a..ef866ec43dd 100644
--- a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationTest.java
+++ b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
package io.helidon.nima.tests.integration.websocket.webserver;
import java.net.URI;
@@ -25,7 +26,7 @@
import io.helidon.nima.tests.integration.websocket.webserver.WsConversationClient.WsConversationListener;
import io.helidon.nima.webserver.Router;
import io.helidon.nima.webserver.WebServer;
-import io.helidon.nima.websocket.webserver.WebSocketRouting;
+import io.helidon.nima.websocket.webserver.WsRouting;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
@@ -50,7 +51,7 @@ class WsConversationTest {
@SetUpRoute
static void router(Router.RouterBuilder> router) {
service = new WsConversationService();
- router.addRouting(WebSocketRouting.builder().endpoint("/conversation", service));
+ router.addRouting(WsRouting.builder().endpoint("/conversation", service));
}
@Test
diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java
index 1b811a262ed..deee2187ba0 100644
--- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java
+++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
package io.helidon.nima.webclient.http1;
import java.io.ByteArrayOutputStream;
@@ -273,7 +274,8 @@ private String resolvePathParams(String path) {
}
private Http1ClientResponse readResponse(ClientRequestHeaders usedHeaders, ClientConnection connection, DataReader reader) {
- Http.Status responseStatus = readStatus(reader);
+ // todo configurable max status line length
+ Http.Status responseStatus = Http1StatusParser.readStatus(reader, 256);
ClientResponseHeaders responseHeaders = readHeaders(reader);
return new ClientResponseImpl(responseStatus, usedHeaders, responseHeaders, connection, reader);
@@ -289,63 +291,6 @@ private ClientResponseHeaders readHeaders(DataReader reader) {
return ClientResponseHeaders.create(writable);
}
- private Http.Status readStatus(DataReader reader) {
- // todo configurable max status line length
- int newLine = reader.findNewLine(256);
- if (newLine == 4096) {
- throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: \n"
- + reader.readBuffer(newLine).debugDataHex());
- }
- int slash = reader.findOrNewLine(Bytes.SLASH_BYTE, newLine);
- if (slash == newLine) {
- throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: \n"
- + reader.readBuffer(newLine).debugDataHex());
- }
- String protocol = reader.readAsciiString(slash);
- if (!protocol.equals("HTTP")) {
- throw new IllegalStateException("HTTP response did not contain correct status line. Protocol is not HTTP: \n"
- + BufferData.create(protocol.getBytes(StandardCharsets.US_ASCII))
- .debugDataHex());
- }
- reader.skip(1); // /
- newLine -= slash;
- newLine--;
- int space = reader.findOrNewLine(Bytes.SPACE_BYTE, newLine);
- if (space == newLine) {
- throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: HTTP/\n"
- + reader.readBuffer(newLine).debugDataHex());
- }
- String protocolVersion = reader.readAsciiString(space);
- reader.skip(1); // space
- newLine -= space;
- newLine--;
- if (!protocolVersion.equals("1.1")) {
- throw new IllegalStateException("HTTP response did not contain correct status line. Version is not 1.1: \n"
- + BufferData.create(protocolVersion.getBytes(StandardCharsets.US_ASCII))
- .debugDataHex());
- }
- // HTTP/1.1 200 OK
- space = reader.findOrNewLine(Bytes.SPACE_BYTE, newLine);
- if (space == newLine) {
- throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: HTTP/1.1\n"
- + reader.readBuffer(newLine).debugDataHex());
- }
- String code = reader.readAsciiString(space);
- reader.skip(1); // the new line
- newLine -= space;
- newLine--;
- String phrase = reader.readAsciiString(newLine); // the rest of the line is reason phrase
- reader.skip(2); // skip the last CRLF
-
- try {
- return Http.Status.create(Integer.parseInt(code), phrase);
- } catch (NumberFormatException e) {
- throw new IllegalStateException("HTTP Response did not cntain HTTP status line. Line HTTP/1.1 \n"
- + BufferData.create(code.getBytes(StandardCharsets.US_ASCII)) + "\n"
- + BufferData.create(phrase.getBytes(StandardCharsets.US_ASCII)));
- }
- }
-
private boolean handleKeepAlive(WritableHeaders> headers) {
if (headers.contains(HeaderValues.CONNECTION_CLOSE)) {
return false;
diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1Client.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1Client.java
index e9983dd0415..31df4163172 100644
--- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1Client.java
+++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1Client.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@
* HTTP/1.1 client.
*/
public interface Http1Client extends HttpClient {
-
/**
* A new fluent API builder to customize instances.
*
diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1StatusParser.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1StatusParser.java
new file mode 100644
index 00000000000..2f16de7baea
--- /dev/null
+++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1StatusParser.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.webclient.http1;
+
+import java.nio.charset.StandardCharsets;
+
+import io.helidon.common.buffers.BufferData;
+import io.helidon.common.buffers.Bytes;
+import io.helidon.common.buffers.DataReader;
+import io.helidon.common.http.Http;
+
+/**
+ * Parser of HTTP/1.1 response status.
+ */
+public final class Http1StatusParser {
+ private Http1StatusParser() {
+ }
+
+ /**
+ * Read the status line from HTTP/1.1 response.
+ *
+ * @param reader data reader to obtain bytes from
+ * @param maxLength maximal number of bytes that can be processed before end of line is reached
+ * @return parsed HTTP status
+ * @throws java.lang.IllegalStateException in case of unexpected data
+ * @throws io.helidon.common.buffers.DataReader.InsufficientDataAvailableException when not enough data can be obtained
+ * @throws io.helidon.common.buffers.DataReader.IncorrectNewLineException in case we are missing correct end of line
+ * (CRLF)
+ * @throws java.lang.RuntimeException additional exceptions may be thrown from
+ * the reader, depending on its
+ * implementation
+ */
+ public static Http.Status readStatus(DataReader reader, int maxLength) {
+ int newLine = reader.findNewLine(maxLength);
+ if (newLine == maxLength) {
+ throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: \n"
+ + reader.readBuffer(newLine).debugDataHex());
+ }
+ int slash = reader.findOrNewLine(Bytes.SLASH_BYTE, newLine);
+ if (slash == newLine) {
+ throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: \n"
+ + reader.readBuffer(newLine).debugDataHex());
+ }
+ String protocol = reader.readAsciiString(slash);
+ if (!protocol.equals("HTTP")) {
+ throw new IllegalStateException("HTTP response did not contain correct status line. Protocol is not HTTP: \n"
+ + BufferData.create(protocol.getBytes(StandardCharsets.US_ASCII))
+ .debugDataHex());
+ }
+ reader.skip(1); // /
+ newLine -= slash;
+ newLine--;
+ int space = reader.findOrNewLine(Bytes.SPACE_BYTE, newLine);
+ if (space == newLine) {
+ throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: HTTP/\n"
+ + reader.readBuffer(newLine).debugDataHex());
+ }
+ String protocolVersion = reader.readAsciiString(space);
+ reader.skip(1); // space
+ newLine -= space;
+ newLine--;
+ if (!protocolVersion.equals("1.1")) {
+ throw new IllegalStateException("HTTP response did not contain correct status line. Version is not 1.1: \n"
+ + BufferData.create(protocolVersion.getBytes(StandardCharsets.US_ASCII))
+ .debugDataHex());
+ }
+ // HTTP/1.1 200 OK
+ space = reader.findOrNewLine(Bytes.SPACE_BYTE, newLine);
+ if (space == newLine) {
+ throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: HTTP/1.1\n"
+ + reader.readBuffer(newLine).debugDataHex());
+ }
+ String code = reader.readAsciiString(space);
+ reader.skip(1); // the new line
+ newLine -= space;
+ newLine--;
+ String phrase = reader.readAsciiString(newLine); // the rest of the line is reason phrase
+ reader.skip(2); // skip the last CRLF
+
+ try {
+ return Http.Status.create(Integer.parseInt(code), phrase);
+ } catch (NumberFormatException e) {
+ throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line HTTP/1.1 \n"
+ + BufferData.create(code.getBytes(StandardCharsets.US_ASCII)) + "\n"
+ + BufferData.create(phrase.getBytes(StandardCharsets.US_ASCII)));
+ }
+ }
+}
diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/LoomServer.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/LoomServer.java
index 69b67782432..65e3a02740a 100644
--- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/LoomServer.java
+++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/LoomServer.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -33,6 +33,7 @@
import java.util.function.Consumer;
import io.helidon.common.Version;
+import io.helidon.common.context.Context;
import io.helidon.nima.webserver.http.DirectHandlers;
import io.helidon.nima.webserver.spi.ServerConnectionProvider;
@@ -44,15 +45,17 @@ class LoomServer implements WebServer {
private final AtomicBoolean running = new AtomicBoolean();
private final Lock lifecycleLock = new ReentrantLock();
private final ExecutorService executorService;
+ private final Context context;
private final boolean registerShutdownHook;
- private volatile Thread shutdownHook;
+ private volatile Thread shutdownHook;
private volatile List startFutures;
private volatile boolean alreadyStarted = false;
LoomServer(Builder builder, DirectHandlers simpleHandlers) {
this.registerShutdownHook = builder.shutdownHook();
- ServerContextImpl serverContext = new ServerContextImpl(builder.context(),
+ this.context = builder.context();
+ ServerContextImpl serverContext = new ServerContextImpl(context,
builder.mediaContext(),
builder.contentEncodingContext());
@@ -160,6 +163,11 @@ public boolean hasTls(String socketName) {
return false;
}
+ @Override
+ public Context context() {
+ return context;
+ }
+
private void stopIt() {
parallel("stop", ServerListener::stop);
running.set(false);
diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/WebServer.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/WebServer.java
index b5ce260cbc3..9bb238c0a7b 100644
--- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/WebServer.java
+++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/WebServer.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -114,6 +114,13 @@ default boolean hasTls() {
return hasTls(DEFAULT_SOCKET_NAME);
}
+ /**
+ * Context associated with the {@code WebServer}, used as a parent for request contexts.
+ *
+ * @return a server context
+ */
+ Context context();
+
/**
* Returns {@code true} if TLS is configured for the named socket.
*
diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http1/spi/Http1UpgradeProvider.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http1/spi/Http1UpgradeProvider.java
index 37236259105..040ee25e4c8 100644
--- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http1/spi/Http1UpgradeProvider.java
+++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http1/spi/Http1UpgradeProvider.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -39,7 +39,8 @@ public interface Http1UpgradeProvider {
* @param ctx connection context
* @param prologue http prologue of the upgrade request
* @param headers http headers of the upgrade request
- * @return a new connection to use instead of the original {@link io.helidon.nima.webserver.http1.Http1Connection}
+ * @return a new connection to use instead of the original {@link io.helidon.nima.webserver.http1.Http1Connection},
+ * or {@code null} if the connection cannot be upgraded
*/
ServerConnection upgrade(ConnectionContext ctx, HttpPrologue prologue, WritableHeaders> headers);
}
diff --git a/nima/websocket/client/etc/spotbugs/exclude.xml b/nima/websocket/client/etc/spotbugs/exclude.xml
new file mode 100644
index 00000000000..8e078d9d02a
--- /dev/null
+++ b/nima/websocket/client/etc/spotbugs/exclude.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nima/websocket/client/pom.xml b/nima/websocket/client/pom.xml
new file mode 100644
index 00000000000..485d45f28c3
--- /dev/null
+++ b/nima/websocket/client/pom.xml
@@ -0,0 +1,83 @@
+
+
+
+ 4.0.0
+
+ io.helidon.nima.websocket
+ helidon-nima-websocket-project
+ 4.0.0-SNAPSHOT
+
+
+ helidon-nima-websocket-client
+ Helidon Níma WebSocket Client
+
+
+ etc/spotbugs/exclude.xml
+
+
+
+
+ io.helidon.nima.webclient
+ helidon-nima-webclient
+
+
+ io.helidon.common
+ helidon-common-buffers
+
+
+ io.helidon.nima.websocket
+ helidon-nima-websocket
+
+
+ io.helidon.common.features
+ helidon-common-features-api
+ provided
+ true
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.hamcrest
+ hamcrest-all
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ io.helidon.common.features
+ helidon-common-features-processor
+ ${helidon.version}
+
+
+
+
+
+
+
+
diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/ClientWsConnection.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/ClientWsConnection.java
new file mode 100644
index 00000000000..04461a4fd86
--- /dev/null
+++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/ClientWsConnection.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket.client;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+import io.helidon.common.buffers.BufferData;
+import io.helidon.common.buffers.DataReader;
+import io.helidon.common.buffers.DataWriter;
+import io.helidon.common.socket.HelidonSocket;
+import io.helidon.nima.websocket.ClientWsFrame;
+import io.helidon.nima.websocket.ServerWsFrame;
+import io.helidon.nima.websocket.WsCloseCodes;
+import io.helidon.nima.websocket.WsCloseException;
+import io.helidon.nima.websocket.WsListener;
+import io.helidon.nima.websocket.WsOpCode;
+import io.helidon.nima.websocket.WsSession;
+
+/**
+ * Client WebSocket connection. This connection handles a single WebSocket interaction, using
+ * {@link io.helidon.nima.websocket.WsListener} to handle connection events.
+ */
+public class ClientWsConnection implements WsSession, Runnable {
+ private static final System.Logger LOGGER = System.getLogger(ClientWsConnection.class.getName());
+
+ private final WsListener listener;
+ private final HelidonSocket helidonSocket;
+ private final DataReader reader;
+ private final DataWriter writer;
+ private final Optional subProtocol;
+ private final BufferData sendBuffer = BufferData.growing(1024);
+
+ private ContinuationType recvContinuation = ContinuationType.NONE;
+ private boolean sendContinuation;
+ private boolean closeSent;
+ private boolean terminated;
+
+ ClientWsConnection(WsListener listener,
+ HelidonSocket helidonSocket,
+ DataReader reader,
+ DataWriter writer,
+ Optional subProtocol) {
+ this.listener = listener;
+ this.helidonSocket = helidonSocket;
+ this.reader = reader;
+ this.writer = writer;
+ this.subProtocol = subProtocol;
+ }
+
+ /**
+ * Create a new connection. The connection needs to run on ana executor service (it implements {@link java.lang.Runnable})
+ * so it does not block the current thread.
+ *
+ * @param listener WebSocket listener to handle events on this connection
+ * @param helidonSocket Helidon Socket to obtain information about peer
+ * @param reader used to read data from remote server
+ * @param writer use to write data to remote server
+ * @param subProtocol chosen sub-protocol of this connection (negotiated during upgrade from HTTP/1)
+ * @return a new WebSocket connection
+ */
+ public static ClientWsConnection create(WsListener listener,
+ HelidonSocket helidonSocket,
+ DataReader reader,
+ DataWriter writer,
+ Optional subProtocol) {
+ return new ClientWsConnection(listener, helidonSocket, reader, writer, subProtocol);
+ }
+
+ @Override
+ public void run() {
+ Thread.currentThread().setName(helidonSocket.socketId() + " ws client");
+ try {
+ doRun();
+ } catch (Exception e) {
+ try {
+ listener.onError(this, e);
+ this.close(WsCloseCodes.UNEXPECTED_CONDITION, e.getMessage());
+ } catch (Exception ex) {
+ if (LOGGER.isLoggable(System.Logger.Level.TRACE)) {
+ ex.addSuppressed(e);
+ LOGGER.log(System.Logger.Level.TRACE, "Exception while handling exception.", ex);
+ }
+ }
+ } finally {
+ helidonSocket.close();
+ }
+ }
+
+ @Override
+ public WsSession send(String text, boolean last) {
+ return send(ClientWsFrame.data(text, last));
+ }
+
+ @Override
+ public WsSession send(BufferData bufferData, boolean last) {
+ return send(ClientWsFrame.data(bufferData, last));
+ }
+
+ @Override
+ public WsSession ping(BufferData bufferData) {
+ return send(ClientWsFrame.control(WsOpCode.PING, bufferData));
+ }
+
+ @Override
+ public WsSession pong(BufferData bufferData) {
+ return send(ClientWsFrame.control(WsOpCode.PONG, bufferData));
+ }
+
+ @Override
+ public WsSession close(int code, String reason) {
+ closeSent = true;
+
+ byte[] reasonBytes = reason.getBytes(StandardCharsets.UTF_8);
+ BufferData bufferData = BufferData.create(2 + reasonBytes.length);
+ bufferData.writeInt16(code);
+ bufferData.write(reasonBytes);
+ send(ClientWsFrame.control(WsOpCode.CLOSE, bufferData));
+
+ return this;
+ }
+
+ @Override
+ public WsSession terminate() {
+ terminated = true;
+ close(WsCloseCodes.NORMAL_CLOSE, "Terminate");
+
+ return this;
+ }
+
+ @Override
+ public Optional subProtocol() {
+ return subProtocol;
+ }
+
+ private ClientWsConnection send(ClientWsFrame frame) {
+ WsOpCode opCode = frame.opCode();
+ if (opCode == WsOpCode.TEXT || opCode == WsOpCode.BINARY) {
+ if (sendContinuation) {
+ opCode = WsOpCode.CONTINUATION;
+ }
+ sendContinuation = !frame.fin();
+ }
+ frame.opCode(opCode);
+
+ if (LOGGER.isLoggable(System.Logger.Level.TRACE)) {
+ helidonSocket.log(LOGGER, System.Logger.Level.TRACE, "ws client frame send %s", frame);
+ }
+
+ sendBuffer.clear();
+ int opCodeFull = frame.fin() ? 0b10000000 : 0;
+ opCodeFull |= opCode.code();
+ sendBuffer.write(opCodeFull);
+
+ long payloadLength = frame.payloadLength();
+ if (frame.payloadLength() < 126) {
+ // this is a masked frame (all client frames MUST be masked)
+ payloadLength = payloadLength | 0b10000000;
+ sendBuffer.write((int) payloadLength);
+ // TODO finish other options (payload longer than 126 bytes)
+ }
+
+ // write masking key
+ int[] maskingKey = frame.maskingKey();
+ sendBuffer.write(maskingKey[0]);
+ sendBuffer.write(maskingKey[1]);
+ sendBuffer.write(maskingKey[2]);
+ sendBuffer.write(maskingKey[3]);
+ sendBuffer.write(frame.maskedData());
+ writer.writeNow(sendBuffer);
+ return this;
+ }
+
+ private void doRun() {
+ listener.onOpen(this);
+ while (!terminated) {
+ try {
+ ServerWsFrame frame = readFrame();
+ if (!processFrame(frame)) {
+ return;
+ }
+ } catch (DataReader.InsufficientDataAvailableException e) {
+ return;
+ } catch (WsCloseException e) {
+ if (!closeSent) {
+ try {
+ close(e.closeCode(), e.getMessage());
+ } catch (Exception ex) {
+ // we may receive an exception if the remote site closed the connection already
+ if (LOGGER.isLoggable(System.Logger.Level.DEBUG)) {
+ helidonSocket.log(LOGGER,
+ System.Logger.Level.DEBUG,
+ "Failed to send close, remote probably closed connection",
+ ex);
+ }
+ }
+ }
+ } catch (Exception e) {
+ if (LOGGER.isLoggable(System.Logger.Level.TRACE)) {
+ LOGGER.log(System.Logger.Level.TRACE, "Failed while reading or processing frames", e);
+ }
+ return;
+ }
+ }
+ }
+
+ private boolean processFrame(ServerWsFrame frame) {
+ BufferData payload = frame.payloadData();
+ switch (frame.opCode()) {
+ case CONTINUATION -> {
+ boolean finalFrame = frame.fin();
+ ContinuationType ct = recvContinuation;
+ if (finalFrame) {
+ recvContinuation = ContinuationType.NONE;
+ }
+ switch (ct) {
+ case TEXT -> listener.receive(this, payload.readString(payload.available(), StandardCharsets.UTF_8), finalFrame);
+ case BINARY -> listener.receive(this, payload, finalFrame);
+ default -> {
+ close(WsCloseCodes.PROTOCOL_ERROR, "Unexpected continuation received");
+ throw new WsClientException("Unexpected continuation received");
+ }
+ }
+ }
+ case TEXT -> {
+ recvContinuation = ContinuationType.TEXT;
+ listener.receive(this, payload.readString(payload.available(), StandardCharsets.UTF_8), frame.fin());
+ }
+ case BINARY -> {
+ recvContinuation = ContinuationType.BINARY;
+ listener.receive(this, payload, frame.fin());
+ }
+ case CLOSE -> {
+ int status = payload.readInt16();
+ String reason;
+ if (payload.available() > 0) {
+ reason = payload.readString(payload.available(), StandardCharsets.UTF_8);
+ } else {
+ reason = "normal";
+ }
+ listener.onClose(this, status, reason);
+ throw new WsCloseException("normal", WsCloseCodes.NORMAL_CLOSE);
+ }
+ case PING -> listener.onPing(this, payload);
+ case PONG -> listener.onPong(this, payload);
+ default -> throw new WsCloseException("invalid-op-code", WsCloseCodes.PROTOCOL_ERROR);
+ }
+ return true;
+ }
+
+ private ServerWsFrame readFrame() {
+ try {
+ // TODO check may payload size, danger of oom
+ return ServerWsFrame.read(helidonSocket, reader, Integer.MAX_VALUE);
+ } catch (WsCloseException e) {
+ close(e.closeCode(), e.getMessage());
+ throw e;
+ }
+ }
+
+ private enum ContinuationType {
+ NONE,
+ TEXT,
+ BINARY
+ }
+}
diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClient.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClient.java
new file mode 100644
index 00000000000..7835a1722d9
--- /dev/null
+++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClient.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket.client;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import io.helidon.common.http.Headers;
+import io.helidon.common.http.Http;
+import io.helidon.common.http.WritableHeaders;
+import io.helidon.nima.webclient.WebClient;
+import io.helidon.nima.websocket.WsListener;
+
+/**
+ * WebSocket client.
+ */
+public interface WsClient extends WebClient {
+ /**
+ * A new fluent API builder to create new instances of client.
+ *
+ * @return a new builder
+ */
+ static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Starts a new WebSocket connection and runs it in a new virtual thread.
+ * This method returns when the connection is established and a new {@link io.helidon.nima.websocket.WsSession} is
+ * started.
+ *
+ * @param uri URI to connect to
+ * @param listener listener to handle WebSocket
+ */
+ void connect(URI uri, WsListener listener);
+
+ /**
+ * Starts a new WebSocket connection and runs it in a new virtual thread.
+ * This method returns when the connection is established and a new {@link io.helidon.nima.websocket.WsSession} is
+ * started.
+ *
+ * @param path path to connect to, if client uses a base URI, this is resolved against the base URI
+ * @param listener listener to handle WebSocket
+ */
+ void connect(String path, WsListener listener);
+
+ /**
+ * Fluent API builder for {@link io.helidon.nima.websocket.client.WsClient}.
+ */
+ class Builder extends WebClient.Builder {
+ /**
+ * Supported WebSocket version.
+ */
+ static final String SUPPORTED_VERSION = "13";
+ static final Http.HeaderValue HEADER_UPGRADE_WS = Http.Header.createCached(Http.Header.UPGRADE, "websocket");
+ static final Http.HeaderName HEADER_WS_PROTOCOL = Http.Header.create("Sec-WebSocket-Protocol");
+ private static final Http.HeaderValue HEADER_WS_VERSION = Http.Header.createCached(Http.Header.create(
+ "Sec-WebSocket-Version"), SUPPORTED_VERSION);
+ private final List subprotocols = new ArrayList<>();
+ private final WritableHeaders> headers = WritableHeaders.create();
+
+ private Builder() {
+ }
+
+ @Override
+ public WsClient build() {
+ // these headers cannot be modified by user
+ headers.set(HEADER_UPGRADE_WS);
+ headers.set(HEADER_WS_VERSION);
+ headers.set(Http.HeaderValues.CONTENT_LENGTH_ZERO);
+ if (subprotocols.isEmpty()) {
+ headers.remove(HEADER_WS_PROTOCOL);
+ } else {
+ headers.set(HEADER_WS_PROTOCOL, subprotocols);
+ }
+
+ return new WsClientImpl(this);
+ }
+
+ /**
+ * Add sub-protocol. A list of preferred sub-protocols is sent to server, and it chooses zero or one of them.
+ *
+ * @param preferred preferred sub-protocol to use, first one added is considered to be the most desired one
+ * @return updated builder instance
+ */
+ public Builder addSubProtocol(String preferred) {
+ Objects.requireNonNull(preferred);
+ this.subprotocols.add(preferred);
+ return this;
+ }
+
+ /**
+ * Configure sub-protocols.
+ * A list of preferred sub-protocols is sent to server, and it chooses zero or one of them.
+ *
+ * @param preferred preferred sub-protocols to use
+ * @return updated builder instance
+ */
+ public Builder subProtocols(String... preferred) {
+ Objects.requireNonNull(preferred);
+ subprotocols.clear();
+ Collections.addAll(subprotocols, preferred);
+ return this;
+ }
+
+ /**
+ * Configure a custom header to be sent. Some headers cannot be modified (Upgrade, WebSocket version, Content Length).
+ *
+ * @param header header to add
+ * @return updated builder instance
+ */
+ public Builder header(Http.HeaderValue header) {
+ Objects.requireNonNull(header);
+ headers.set(header);
+ return this;
+ }
+
+ Headers headers() {
+ return headers;
+ }
+ }
+}
diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientException.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientException.java
new file mode 100644
index 00000000000..aa6d1629f91
--- /dev/null
+++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientException.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket.client;
+
+/**
+ * A WebSocket client exception.
+ */
+public class WsClientException extends RuntimeException {
+ /**
+ * Exception without an underlying cause.
+ *
+ * @param message descriptive message of what happened
+ */
+ public WsClientException(String message) {
+ super(message);
+ }
+
+ /**
+ * Exception caused by another exception.
+ *
+ * @param message descriptive message
+ * @param cause underlying cause
+ */
+ public WsClientException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientImpl.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientImpl.java
new file mode 100644
index 00000000000..fee5c75cd6d
--- /dev/null
+++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientImpl.java
@@ -0,0 +1,295 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket.client;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.Base64;
+import java.util.HexFormat;
+import java.util.Optional;
+import java.util.Random;
+
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSocket;
+
+import io.helidon.common.LazyValue;
+import io.helidon.common.buffers.BufferData;
+import io.helidon.common.buffers.Bytes;
+import io.helidon.common.buffers.DataReader;
+import io.helidon.common.buffers.DataWriter;
+import io.helidon.common.http.Headers;
+import io.helidon.common.http.Http;
+import io.helidon.common.http.Http.Header;
+import io.helidon.common.http.Http1HeadersParser;
+import io.helidon.common.http.WritableHeaders;
+import io.helidon.common.socket.HelidonSocket;
+import io.helidon.common.socket.PlainSocket;
+import io.helidon.common.socket.SocketContext;
+import io.helidon.common.socket.SocketWriter;
+import io.helidon.common.socket.TlsSocket;
+import io.helidon.nima.webclient.LoomClient;
+import io.helidon.nima.webclient.http1.Http1StatusParser;
+import io.helidon.nima.webclient.spi.DnsResolver;
+import io.helidon.nima.websocket.WsListener;
+
+import static java.lang.System.Logger.Level.TRACE;
+
+class WsClientImpl extends LoomClient implements WsClient {
+ private static final System.Logger LOGGER = System.getLogger(WsClient.class.getName());
+ private static final Http.HeaderValue HEADER_CONN_UPGRADE = Header.create(Header.CONNECTION, "Upgrade");
+ private static final Http.HeaderName HEADER_WS_ACCEPT = Header.create("Sec-WebSocket-Accept");
+ private static final Http.HeaderName HEADER_WS_KEY = Header.create("Sec-WebSocket-Key");
+ private static final LazyValue RANDOM = LazyValue.create(SecureRandom::new);
+ private static final byte[] KEY_SUFFIX = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.US_ASCII);
+ private static final int KEY_SUFFIX_LENGTH = KEY_SUFFIX.length;
+ private static final Base64.Encoder B64_ENCODER = Base64.getEncoder();
+
+ private final Headers headers;
+
+ protected WsClientImpl(WsClient.Builder builder) {
+ super(builder);
+ this.headers = WritableHeaders.create(builder.headers());
+ }
+
+ @Override
+ public void connect(URI uri, WsListener listener) {
+ // there is no connection pooling, as each connection is upgraded to be a websocket connection
+ Socket socket;
+ SSLSocket sslSocket = tls().enabled() ? null : tls().createSocket("http");
+ socket = sslSocket == null ? new Socket() : sslSocket;
+
+ socketOptions().configureSocket(socket);
+ DnsResolver dnsResolver = dnsResolver();
+ try {
+ if (dnsResolver.useDefaultJavaResolver()) {
+ socket.connect(new InetSocketAddress(uri.getHost(), uri().getPort()),
+ (int) socketOptions().connectTimeout().toMillis());
+ } else {
+ InetAddress address = dnsResolver.resolveAddress(uri.getHost(), dnsAddressLookup());
+ socket.connect(new InetSocketAddress(address, uri.getPort()), (int) socketOptions().connectTimeout().toMillis());
+ }
+ } catch (Exception e) {
+ throw new WsClientException("Failed to connect to remote server on " + uri, e);
+ }
+ String channelId = "0x" + HexFormat.of().toHexDigits(System.identityHashCode(socket));
+ HelidonSocket helidonSocket = sslSocket == null
+ ? PlainSocket.client(socket, channelId)
+ : TlsSocket.client(sslSocket, channelId);
+
+ try {
+ finishConnect(channelId, helidonSocket, sslSocket, uri, listener);
+ } catch (Exception e) {
+ try {
+ helidonSocket.close();
+ } catch (Exception ex) {
+ ex.addSuppressed(e);
+ throw ex;
+ }
+ throw e;
+ }
+ }
+
+ @Override
+ public void connect(String path, WsListener listener) {
+ URI baseUri = super.uri();
+ if (baseUri == null) {
+ connect(URI.create(path), listener);
+ } else {
+ connect(baseUri.resolve(path), listener);
+ }
+ }
+
+ protected String hash(SocketContext ctx, String wsKey) {
+ byte[] wsKeyBytes = wsKey.getBytes(StandardCharsets.US_ASCII);
+ int wsKeyBytesLength = wsKeyBytes.length;
+ byte[] toHash = new byte[wsKeyBytesLength + KEY_SUFFIX_LENGTH];
+ System.arraycopy(wsKeyBytes, 0, toHash, 0, wsKeyBytesLength);
+ System.arraycopy(KEY_SUFFIX, 0, toHash, wsKeyBytesLength, KEY_SUFFIX_LENGTH);
+
+ MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance("SHA-1");
+ return B64_ENCODER.encodeToString(digest.digest(toHash));
+ } catch (NoSuchAlgorithmException e) {
+ ctx.log(LOGGER, System.Logger.Level.ERROR, "SHA-1 must be provided for WebSocket to work", e);
+ throw new IllegalStateException("SHA-1 not provided", e);
+ }
+ }
+
+ private void finishConnect(String channelId, HelidonSocket helidonSocket, SSLSocket sslSocket, URI uri, WsListener listener) {
+ DataWriter writer = SocketWriter.create(executor(), helidonSocket, 0);
+
+ if (sslSocket != null) {
+ try {
+ sslSocket.startHandshake();
+ } catch (IOException e) {
+ throw new WsClientException("Failed to do SSL handshake", e);
+ }
+ if (LOGGER.isLoggable(TRACE)) {
+ debugTls(sslSocket, channelId);
+ }
+ String negotiatedProtocol = sslSocket.getApplicationProtocol();
+ if (negotiatedProtocol != null && !"http".equals(negotiatedProtocol)) {
+ helidonSocket.close();
+ throw new IllegalStateException("Failed to negotiate http protocol. Protocol from socket: " + negotiatedProtocol);
+ }
+ }
+
+ // TLS negotiated, socket connected - now upgrade (GET request to the correct path)
+ /*
+ GET path HTTP/1.1
+ Upgrade: websocket
+ Sec-WebSocket-Extensions: list of extensions
+ Sec-WebSocket-Protocol: subprotocols
+ Sec-WebSocket-Key: computed value
+ Sec-WebSocket-Version: 13
+ */
+
+ /*
+ Prepare prologue
+ */
+ String prologue = "GET " + uri.getPath() + " HTTP/1.1\r\n";
+ /*
+ Prepare headers
+ */
+ WritableHeaders> headers = WritableHeaders.create(this.headers);
+ byte[] nonce = new byte[16];
+ RANDOM.get().nextBytes(nonce);
+ String secWsKey = B64_ENCODER.encodeToString(nonce);
+ headers.set(HEADER_WS_KEY, secWsKey);
+ headers.setIfAbsent(Header.create(Header.HOST, uri.getRawAuthority()));
+
+ BufferData data = BufferData.growing(512);
+ data.writeAscii(prologue);
+ for (Http.HeaderValue header : headers) {
+ header.writeHttp1Header(data);
+ }
+ // end of headers - write CRLF (also end of request)
+ data.write(Bytes.CR_BYTE);
+ data.write(Bytes.LF_BYTE);
+ writer.write(data);
+
+ // we have written a full upgrade request, now let's wait for response and make sure it is a valid WS upgrade
+ // response
+ /*
+ HTTP/1.1 101 Switching Protocols
+ Connection: Upgrade
+ Upgrade: websocket
+ Sec-WebSocket-Accept: ...
+ */
+ DataReader reader = new DataReader(helidonSocket);
+ Http.Status status = Http1StatusParser.readStatus(reader, 256);
+ if (!status.equals(Http.Status.SWITCHING_PROTOCOLS_101)) {
+ throw new WsClientException("Failed to upgrade to WebSocket, expected switching protocols status, but got: "
+ + status);
+ }
+ WritableHeaders> responseHeaders = Http1HeadersParser.readHeaders(reader, 4096, true);
+ if (!responseHeaders.contains(HEADER_CONN_UPGRADE)) {
+ throw new WsClientException("Failed to upgrade to WebSocket, expected Connection: Upgrade header. Headers: "
+ + responseHeaders);
+ }
+ if (!responseHeaders.contains(WsClient.Builder.HEADER_UPGRADE_WS)) {
+ throw new WsClientException("Failed to upgrade to WebSocket, expected Upgrade: websocket header. Headers: "
+ + responseHeaders);
+ }
+ if (!responseHeaders.contains(HEADER_WS_ACCEPT)) {
+ throw new WsClientException("Failed to upgrade to WebSocket, expected Sec-WebSocket-Accept header. Headers: "
+ + responseHeaders);
+ }
+ String secWsAccept = responseHeaders.get(HEADER_WS_ACCEPT).value();
+ if (!hash(helidonSocket, secWsKey).equals(secWsAccept)) {
+ throw new WsClientException("Failed to upgrade to WebSocket, expected valid secWsKey. Headers: "
+ + responseHeaders);
+ }
+
+ // we are upgraded, there is no entity, we can switch to web socket
+ ClientWsConnection session;
+
+ // sub-protocol exists
+ if (headers.contains(WsClient.Builder.HEADER_WS_PROTOCOL)) {
+ session = new ClientWsConnection(listener,
+ helidonSocket,
+ reader,
+ writer,
+ Optional.of(headers.get(WsClient.Builder.HEADER_WS_PROTOCOL).value()));
+ } else {
+ session = new ClientWsConnection(listener, helidonSocket, reader, writer, Optional.empty());
+ }
+ // we have connected, now (as we give control to socket listener), we need to run on a separate thread
+ executor().submit(session);
+ }
+
+ private void debugTls(SSLSocket sslSocket, String channelId) {
+ SSLSession sslSession = sslSocket.getSession();
+ if (sslSession == null) {
+ LOGGER.log(TRACE, "No SSL session");
+ return;
+ }
+
+ String msg = "[client " + channelId + "] TLS negotiated:\n"
+ + "Application protocol: " + sslSocket.getApplicationProtocol() + "\n"
+ + "Handshake application protocol: " + sslSocket.getHandshakeApplicationProtocol() + "\n"
+ + "Protocol: " + sslSession.getProtocol() + "\n"
+ + "Cipher Suite: " + sslSession.getCipherSuite() + "\n"
+ + "Peer host: " + sslSession.getPeerHost() + "\n"
+ + "Peer port: " + sslSession.getPeerPort() + "\n"
+ + "Application buffer size: " + sslSession.getApplicationBufferSize() + "\n"
+ + "Packet buffer size: " + sslSession.getPacketBufferSize() + "\n"
+ + "Local principal: " + sslSession.getLocalPrincipal() + "\n";
+
+ try {
+ msg += "Peer principal: " + sslSession.getPeerPrincipal() + "\n";
+ msg += "Peer certs: " + certsToString(sslSession.getPeerCertificates()) + "\n";
+ } catch (SSLPeerUnverifiedException e) {
+ msg += "Peer not verified";
+ }
+
+ LOGGER.log(TRACE, msg);
+ }
+
+ private String certsToString(Certificate[] peerCertificates) {
+ String[] certs = new String[peerCertificates.length];
+
+ for (int i = 0; i < peerCertificates.length; i++) {
+ Certificate peerCertificate = peerCertificates[i];
+ if (peerCertificate instanceof X509Certificate x509) {
+ certs[i] = "type=" + peerCertificate.getType()
+ + ";key=" + peerCertificate.getPublicKey().getAlgorithm()
+ + "(" + peerCertificate.getPublicKey().getFormat() + ")"
+ + ";x509=V" + x509.getVersion()
+ + ";from=" + x509.getNotBefore()
+ + ";to=" + x509.getNotAfter()
+ + ";serial=" + x509.getSerialNumber().toString(16);
+ } else {
+ certs[i] = "type=" + peerCertificate.getType() + ";key=" + peerCertificate.getPublicKey();
+ }
+
+ }
+
+ return String.join(", ", certs);
+ }
+}
diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/package-info.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/package-info.java
new file mode 100644
index 00000000000..db4b2d8026c
--- /dev/null
+++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * WebSocket client.
+ */
+package io.helidon.nima.websocket.client;
diff --git a/nima/websocket/client/src/main/java/module-info.java b/nima/websocket/client/src/main/java/module-info.java
new file mode 100644
index 00000000000..5fb988a8175
--- /dev/null
+++ b/nima/websocket/client/src/main/java/module-info.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import io.helidon.common.features.api.Feature;
+import io.helidon.common.features.api.HelidonFlavor;
+
+/**
+ * WebSocket client.
+ */
+@Feature(value = "WebSocket Client",
+ description = "WebSocket Client",
+ in = HelidonFlavor.NIMA, invalidIn = HelidonFlavor.SE,
+ path = {"WebSocket", "Client"}
+)
+module io.helidon.nima.websocket.client {
+ requires static io.helidon.common.features.api;
+
+ requires io.helidon.nima.websocket;
+ requires io.helidon.nima.webclient;
+
+ exports io.helidon.nima.websocket.client;
+}
\ No newline at end of file
diff --git a/nima/websocket/pom.xml b/nima/websocket/pom.xml
index c7fe90d03a4..1408aaf1911 100644
--- a/nima/websocket/pom.xml
+++ b/nima/websocket/pom.xml
@@ -1,7 +1,7 @@
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
io.helidon.nima.websocket
@@ -32,6 +32,10 @@
io.helidon.common
helidon-common-buffers
+
+ io.helidon.common
+ helidon-common-socket
+
io.helidon.common
helidon-common-http
diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/AbstractWsFrame.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/AbstractWsFrame.java
new file mode 100644
index 00000000000..83c21529c01
--- /dev/null
+++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/AbstractWsFrame.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket;
+
+import io.helidon.common.LazyValue;
+import io.helidon.common.buffers.BufferData;
+import io.helidon.common.buffers.DataReader;
+
+abstract sealed class AbstractWsFrame implements WsFrame permits ServerWsFrame, ClientWsFrame {
+ private final LazyValue unmaskedData;
+ private final long payloadLength;
+ private final boolean fin;
+ private final boolean isPayload;
+
+ private volatile WsOpCode opCode;
+
+ protected AbstractWsFrame(LazyValue unmaskedData,
+ long payloadLength,
+ boolean fin,
+ boolean isPayload,
+ WsOpCode opCode) {
+ this.unmaskedData = unmaskedData;
+ this.payloadLength = payloadLength;
+ this.fin = fin;
+ this.opCode = opCode;
+ this.isPayload = isPayload;
+ }
+
+ @Override
+ public boolean fin() {
+ return fin;
+ }
+
+ @Override
+ public WsOpCode opCode() {
+ return opCode;
+ }
+
+ @Override
+ public long payloadLength() {
+ return payloadLength;
+ }
+
+ @Override
+ public BufferData payloadData() {
+ return unmaskedData.get();
+ }
+
+ @Override
+ public boolean isPayload() {
+ return isPayload;
+ }
+
+ /**
+ * Configure the operation code of this frame.
+ *
+ * @param opCode code to use
+ */
+ public void opCode(WsOpCode opCode) {
+ this.opCode = opCode;
+ }
+
+ @Override
+ public String toString() {
+ return opCode + (fin ? " (last): \n" : ": \n") + unmaskedData.get().debugDataHex();
+ }
+
+ protected static FrameHeader readFrameHeader(DataReader reader, int maxFrameLength) {
+ int opCodeByte = reader.read();
+ boolean fin = (opCodeByte & 0b10000000) != 0;
+ int extensionFlags = opCodeByte & 0b01110000;
+ if (extensionFlags != 0) {
+ throw new WsCloseException("Extension flags defined where none should be", WsCloseCodes.PROTOCOL_ERROR);
+ }
+ WsOpCode opCode = WsOpCode.get(opCodeByte & 0b00001111);
+
+ // byte 1 (possible to byte 9 if maximal number of bytes used for length)
+ int lenByte = reader.read();
+ boolean masked = (lenByte & 0b10000000) != 0;
+
+ long frameLength;
+ int length = lenByte & 0b01111111;
+ if (length < 126) {
+ frameLength = length;
+ } else if (length == 126) {
+ frameLength = reader.readBuffer(2).readInt16();
+ } else {
+ frameLength = reader.readBuffer(8).readLong();
+ }
+
+ if (frameLength < 0) {
+ throw new WsCloseException("Negative payload length", WsCloseCodes.PROTOCOL_ERROR);
+ }
+ if (frameLength > maxFrameLength) {
+ throw new WsCloseException("Payload too large", WsCloseCodes.TOO_BIG);
+ }
+
+ return new FrameHeader(opCode, fin, masked, length);
+ }
+
+ protected static BufferData readPayload(DataReader reader, FrameHeader header) {
+ return reader.readBuffer(header.length());
+ }
+
+ protected static boolean isPayload(FrameHeader header) {
+ WsOpCode opCode = header.opCode;
+ return opCode == WsOpCode.BINARY || opCode == WsOpCode.TEXT;
+ }
+
+ protected record FrameHeader(WsOpCode opCode,
+ boolean fin,
+ boolean masked,
+ int length) {
+ }
+}
diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ClientWsFrame.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ClientWsFrame.java
new file mode 100644
index 00000000000..2ea93ea9167
--- /dev/null
+++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ClientWsFrame.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Random;
+
+import io.helidon.common.LazyValue;
+import io.helidon.common.buffers.BufferData;
+import io.helidon.common.buffers.DataReader;
+import io.helidon.common.socket.SocketContext;
+
+/**
+ * Frame from a client (always masked).
+ */
+public final class ClientWsFrame extends AbstractWsFrame {
+ private static final System.Logger LOGGER = System.getLogger(ClientWsFrame.class.getName());
+ private static final LazyValue RANDOM = LazyValue.create(SecureRandom::new);
+
+ private final LazyValue masked;
+ private final int[] mask;
+
+ private ClientWsFrame(WsOpCode opCode,
+ long payloadLength,
+ BufferData data,
+ boolean fin,
+ int[] mask,
+ boolean masked,
+ boolean isPayload) {
+ super(unmaskedValue(masked, data, mask), payloadLength, fin, isPayload, opCode);
+
+ this.mask = mask;
+
+ if (masked) {
+ this.masked = LazyValue.create(data);
+ } else {
+ this.masked = LazyValue.create(() -> mask(data, mask));
+ }
+ }
+
+ /**
+ * Create a text data frame. This method does not split the message into smaller frames if the number of bytes
+ * is too high, make sure to use with buffer sizes that are guaranteed to be processed by clients.
+ *
+ * @param text text data content
+ * @param last whether the data is last
+ * @return a new client frame
+ */
+ public static ClientWsFrame data(String text, boolean last) {
+ BufferData bufferData = BufferData.create(text.getBytes(StandardCharsets.UTF_8));
+ return new ClientWsFrame(WsOpCode.TEXT,
+ bufferData.available(),
+ bufferData,
+ last,
+ newMaskingKey(),
+ false,
+ true);
+ }
+
+ /**
+ * Create a binary data frame. This method does not split the message into smaller frames if the number of bytes
+ * is too high, make sure to use with buffer sizes that are guaranteed to be processed by clients.
+ *
+ * @param bufferData binary data content
+ * @param last whether the data is last
+ * @return a new client frame
+ */
+ public static ClientWsFrame data(BufferData bufferData, boolean last) {
+ return new ClientWsFrame(WsOpCode.BINARY,
+ bufferData.available(),
+ bufferData,
+ last,
+ newMaskingKey(),
+ false,
+ true);
+ }
+
+ /**
+ * Create a new control frame.
+ *
+ * @param opCode operation code of this frame
+ * @param bufferData data of the frame, maximally 125 bytes
+ * @return a new client frame
+ * @throws java.lang.IllegalArgumentException in case the length is invalid
+ */
+ public static ClientWsFrame control(WsOpCode opCode, BufferData bufferData) {
+ return new ClientWsFrame(opCode,
+ bufferData.available(),
+ bufferData,
+ true,
+ newMaskingKey(),
+ false,
+ false);
+ }
+
+ /**
+ * Read client frame from request data.
+ *
+ * @param ctx socket context
+ * @param dataReader data reader to get frame bytes from
+ * @param maxFrameLength maximal length of a frame, to protect memory from too big frames
+ * @return a new client frame
+ * @throws io.helidon.nima.websocket.WsCloseException in case of invalid frame
+ * @throws java.lang.RuntimeException depending on implementation of dataReader
+ */
+ public static ClientWsFrame read(SocketContext ctx,
+ DataReader dataReader,
+ int maxFrameLength) {
+
+ FrameHeader header = readFrameHeader(dataReader, maxFrameLength);
+
+ if (!header.masked()) {
+ throw new WsCloseException("Unmasked client frame", WsCloseCodes.PROTOCOL_ERROR);
+ }
+
+ // next 4 bytes - masking key
+ int[] maskingKey = new int[4];
+ maskingKey[0] = dataReader.read();
+ maskingKey[1] = dataReader.read();
+ maskingKey[2] = dataReader.read();
+ maskingKey[3] = dataReader.read();
+
+ // next frameLength bytes - actual payload
+ BufferData payload = readPayload(dataReader, header);
+
+ ClientWsFrame frame = new ClientWsFrame(header.opCode(),
+ header.length(),
+ payload,
+ header.fin(),
+ maskingKey,
+ true,
+ isPayload(header));
+
+ if (LOGGER.isLoggable(System.Logger.Level.TRACE)) {
+ ctx.log(LOGGER, System.Logger.Level.TRACE, "ws client frame recv %s", frame);
+ }
+
+ return frame;
+ }
+
+ @Override
+ public boolean masked() {
+ return true;
+ }
+
+ @Override
+ public int[] maskingKey() {
+ return mask;
+ }
+
+ /**
+ * Masked data of this frame, to be sent over the network.
+ *
+ * @return masked data
+ */
+ public BufferData maskedData() {
+ return masked.get();
+ }
+
+ private static LazyValue unmaskedValue(boolean masked, BufferData data, int[] mask) {
+ if (masked) {
+ return LazyValue.create(() -> unmask(data, mask));
+ } else {
+ return LazyValue.create(data);
+ }
+ }
+
+ private static int[] newMaskingKey() {
+ Random random = RANDOM.get();
+
+ int[] maskingKey = new int[4];
+ for (int i = 0; i < maskingKey.length; i++) {
+ maskingKey[i] = random.nextInt(256);
+
+ }
+
+ return maskingKey;
+ }
+
+ private static BufferData unmask(BufferData data, int[] masks) {
+ int length = data.available();
+ BufferData unmasked = BufferData.create(length);
+
+ /*
+ Octet i of the transformed data ("transformed-octet-i") is the XOR of
+ octet i of the original data ("original-octet-i") with octet at index
+ i modulo 4 of the masking key ("masking-key-octet-j"):
+
+ j = i MOD 4
+ transformed-octet-i = original-octet-i XOR masking-key-octet-j
+ */
+ for (int i = 0; i < length; i++) {
+ int maskIndex = i % 4;
+ int mask = masks[maskIndex];
+ unmasked.write(data.read() ^ mask);
+ }
+
+ return unmasked;
+ }
+
+ private static BufferData mask(BufferData data, int[] masks) {
+ int length = data.available();
+ BufferData unmasked = BufferData.create(length);
+
+ /*
+ Octet i of the transformed data ("transformed-octet-i") is the XOR of
+ octet i of the original data ("original-octet-i") with octet at index
+ i modulo 4 of the masking key ("masking-key-octet-j"):
+
+ j = i MOD 4
+ transformed-octet-i = original-octet-i XOR masking-key-octet-j
+ */
+ for (int i = 0; i < length; i++) {
+ int maskIndex = i % 4;
+ int mask = masks[maskIndex];
+ unmasked.write(data.read() ^ mask);
+ }
+
+ return unmasked;
+ }
+}
diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ServerWsFrame.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ServerWsFrame.java
new file mode 100644
index 00000000000..79663b86d7c
--- /dev/null
+++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ServerWsFrame.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket;
+
+import java.nio.charset.StandardCharsets;
+
+import io.helidon.common.LazyValue;
+import io.helidon.common.buffers.BufferData;
+import io.helidon.common.buffers.DataReader;
+import io.helidon.common.socket.SocketContext;
+
+/**
+ * Frame from a server (never masked).
+ */
+public final class ServerWsFrame extends AbstractWsFrame {
+ private static final System.Logger LOGGER = System.getLogger(ServerWsFrame.class.getName());
+
+ ServerWsFrame(WsOpCode opCode, BufferData data, boolean fin, boolean isPayload) {
+ super(LazyValue.create(data), data.available(), fin, isPayload, opCode);
+ }
+
+ /**
+ * Create a text data frame. This method does not split the message into smaller frames if the number of bytes
+ * is too high, make sure to use with buffer sizes that are guaranteed to be processed by clients.
+ *
+ * @param text text data content
+ * @param last whether the data is last
+ * @return a new server frame
+ */
+ public static ServerWsFrame data(String text, boolean last) {
+ BufferData bufferData = BufferData.create(text.getBytes(StandardCharsets.UTF_8));
+
+ return new ServerWsFrame(WsOpCode.TEXT,
+ bufferData,
+ last,
+ true);
+ }
+
+ /**
+ * Create a binary data frame. This method does not split the message into smaller frames if the number of bytes
+ * is too high, make sure to use with buffer sizes that are guaranteed to be processed by clients.
+ *
+ * @param bufferData binary data content
+ * @param last whether the data is last
+ * @return a new server frame
+ */
+ public static ServerWsFrame data(BufferData bufferData, boolean last) {
+ return new ServerWsFrame(WsOpCode.BINARY,
+ bufferData,
+ last,
+ true);
+ }
+
+ /**
+ * Create a new control frame.
+ *
+ * @param opCode operation code of this frame
+ * @param bufferData data of the frame, maximally 125 bytes
+ * @return a new server frame
+ * @throws java.lang.IllegalArgumentException in case the length is invalid
+ */
+ public static ServerWsFrame control(WsOpCode opCode, BufferData bufferData) {
+ if (bufferData.available() > 125) {
+ throw new IllegalArgumentException("Control frames cannot have more than 125 bytes");
+ }
+ return new ServerWsFrame(opCode, bufferData, true, false);
+ }
+
+ /**
+ * Read server frame from request data.
+ *
+ * @param ctx socket context
+ * @param dataReader data reader to get frame bytes from
+ * @param maxFrameLength maximal length of a frame, to protect memory from too big frames
+ * @return a new server frame
+ * @throws io.helidon.nima.websocket.WsCloseException in case of invalid frame
+ * @throws java.lang.RuntimeException depending on implementation of dataReader
+ */
+ public static ServerWsFrame read(SocketContext ctx, DataReader dataReader, int maxFrameLength) {
+
+ FrameHeader header = readFrameHeader(dataReader, maxFrameLength);
+
+ if (header.masked()) {
+ throw new WsCloseException("Masked server frame", WsCloseCodes.PROTOCOL_ERROR);
+ }
+
+ // next frameLength bytes - actual payload
+ // we can safely cast to int, as we make sure it is smaller or equal to MAX_INT
+ BufferData payload = readPayload(dataReader, header);
+
+ ServerWsFrame frame = new ServerWsFrame(header.opCode(),
+ payload,
+ header.fin(),
+ isPayload(header));
+
+ if (LOGGER.isLoggable(System.Logger.Level.TRACE)) {
+ ctx.log(LOGGER, System.Logger.Level.TRACE, "ws server frame recv %s", frame);
+ }
+
+ return frame;
+ }
+
+ @Override
+ public boolean masked() {
+ return false;
+ }
+
+ @Override
+ public int[] maskingKey() {
+ throw new IllegalStateException("Server WebSocket frames must not have masking key");
+ }
+}
diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/CloseCodes.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseCodes.java
similarity index 96%
rename from nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/CloseCodes.java
rename to nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseCodes.java
index dd2b262dcb2..95904936e61 100644
--- a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/CloseCodes.java
+++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseCodes.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
* Codes to use with {@link io.helidon.nima.websocket.WsSession#close(int, String)} and to receive
* in {@link io.helidon.nima.websocket.WsListener#onClose(WsSession, int, String)}.
*/
-public final class CloseCodes {
+public final class WsCloseCodes {
/**
* Code to use with {@link io.helidon.nima.websocket.WsSession#close(int, String)} to indicate normal close operation.
*/
@@ -118,6 +118,6 @@ public final class CloseCodes {
*/
public static final int APPLICATION_MAX = 4999;
- private CloseCodes() {
+ private WsCloseCodes() {
}
}
diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseException.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseException.java
new file mode 100644
index 00000000000..adac7539988
--- /dev/null
+++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseException.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket;
+
+/**
+ * Exception requesting a close of the WebSocket communication.
+ */
+public class WsCloseException extends RuntimeException {
+ private final int code;
+
+ /**
+ * Create a new exception.
+ *
+ * @param message message to use, will be used as the description of the close code
+ * @param closeCode WebSocket close code, see {@link io.helidon.nima.websocket.WsCloseCodes}
+ */
+ public WsCloseException(String message, int closeCode) {
+ super(message);
+ this.code = closeCode;
+ }
+
+ /**
+ * Close code that should be used to close the connection.
+ *
+ * @return close code
+ */
+ public int closeCode() {
+ return code;
+ }
+}
diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsFrame.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsFrame.java
new file mode 100644
index 00000000000..0d6b09f3901
--- /dev/null
+++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsFrame.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket;
+
+import io.helidon.common.buffers.BufferData;
+
+/**
+ * WebSocket frame. A frame represents single message from WebSocket client or server.
+ */
+public interface WsFrame {
+ /**
+ * Is the end of message (or end of continuation).
+ *
+ * @return {@code true} if this is a full message, or the last message in a continuation
+ */
+ boolean fin();
+
+ /**
+ * Operation code of this frame.
+ *
+ * @return code of this frame
+ */
+ WsOpCode opCode();
+
+ /**
+ * Whether this frame is masked. Server frames must not be masked, client frames must be masked.
+ *
+ * @return {@code true} for masked frames
+ */
+ boolean masked();
+
+ /**
+ * Length of the payload bytes.
+ *
+ * @return payload length
+ */
+ long payloadLength();
+
+ /**
+ * Masking key, if {@link #masked()} returns {@code true}.
+ *
+ * @return masking key if available
+ * @throws java.lang.IllegalStateException if this frame is not masked
+ */
+ int[] maskingKey();
+
+ /**
+ * Always unmasked.
+ *
+ * @return payload bytes
+ */
+ BufferData payloadData();
+
+ /**
+ * Helper method to check whether this is a payload frame (text or binary),
+ * or a control frame (such as ping, pong, close etc.).
+ *
+ * @return {@code true} for text or binary frames, {@code false} for control frames
+ * @see #opCode()
+ */
+ default boolean isPayload() {
+ return opCode() == WsOpCode.TEXT || opCode() == WsOpCode.BINARY;
+ }
+}
diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsOpCode.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsOpCode.java
new file mode 100644
index 00000000000..9b93082de24
--- /dev/null
+++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsOpCode.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.nima.websocket;
+
+/**
+ * WebSocket operation code.
+ * Each frame has an operation code to easily understand what should be done.
+ */
+public enum WsOpCode {
+ /**
+ * Continuation frame.
+ */
+ CONTINUATION(0),
+ /**
+ * Payload frame with text payload.
+ */
+ TEXT(0x1),
+ /**
+ * Payload frame with binary payload.
+ */
+ BINARY(0x2),
+ /**
+ * Close control frame.
+ */
+ CLOSE(0x8),
+ /**
+ * Ping control frame.
+ */
+ PING(0x9),
+ /**
+ * Pong control frame.
+ */
+ PONG(0xA);
+
+ private static final WsOpCode[] OP_CODES = new WsOpCode[16];
+
+ static {
+ for (WsOpCode value : WsOpCode.values()) {
+ OP_CODES[value.code] = value;
+ }
+ }
+
+ private final int code;
+
+ WsOpCode(int code) {
+ this.code = code;
+ }
+
+ /**
+ * Get operation code based on its numeric code.
+ *
+ * @param code code
+ * @return operation code for the numeric code
+ * @throws java.lang.IllegalArgumentException in case the code is not valid
+ */
+ public static WsOpCode get(int code) {
+ WsOpCode opCode = OP_CODES[code];
+ if (opCode == null) {
+ throw new IllegalArgumentException("Requested code " + code + " is invalid, there is no OpCode for it.");
+ }
+ return opCode;
+ }
+
+ /**
+ * Numeric code (used in binary frame representation) of this operation code.
+ *
+ * @return numeric code
+ */
+ public int code() {
+ return code;
+ }
+}
diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsSession.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsSession.java
index 6b6c54090af..7d37d143b24 100644
--- a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsSession.java
+++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsSession.java
@@ -61,18 +61,18 @@ public interface WsSession {
/**
* Close session.
*
- * @param code close code, may be one of {@link io.helidon.nima.websocket.CloseCodes}
+ * @param code close code, may be one of {@link io.helidon.nima.websocket.WsCloseCodes}
* @param reason reason description
* @return this instance
*/
WsSession close(int code, String reason);
/**
- * Abort session.
+ * Terminate session. Sends a close and closes the connection.
*
* @return this instance
*/
- WsSession abort();
+ WsSession terminate();
/**
* The WebSocket sub-protocol negotiated for this session.
diff --git a/nima/websocket/websocket/src/main/java/module-info.java b/nima/websocket/websocket/src/main/java/module-info.java
index e91b600ff85..6480c1531d7 100644
--- a/nima/websocket/websocket/src/main/java/module-info.java
+++ b/nima/websocket/websocket/src/main/java/module-info.java
@@ -29,6 +29,8 @@
module io.helidon.nima.websocket {
requires static io.helidon.common.features.api;
+ requires io.helidon.common;
+ requires transitive io.helidon.common.socket;
requires transitive io.helidon.common.buffers;
requires transitive io.helidon.common.http;
diff --git a/tests/integration/native-image/nima-1/src/main/java/io/helidon/tests/integration/nativeimage/nima1/Nima1Main.java b/tests/integration/native-image/nima-1/src/main/java/io/helidon/tests/integration/nativeimage/nima1/Nima1Main.java
index 70467f95ca0..0e6b67e4659 100644
--- a/tests/integration/native-image/nima-1/src/main/java/io/helidon/tests/integration/nativeimage/nima1/Nima1Main.java
+++ b/tests/integration/native-image/nima-1/src/main/java/io/helidon/tests/integration/nativeimage/nima1/Nima1Main.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2019, 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2019, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
package io.helidon.tests.integration.nativeimage.nima1;
import java.nio.file.Paths;
@@ -28,7 +29,7 @@
import io.helidon.nima.webserver.WebServer;
import io.helidon.nima.webserver.http.HttpRouting;
import io.helidon.nima.webserver.staticcontent.StaticContentSupport;
-import io.helidon.nima.websocket.webserver.WebSocketRouting;
+import io.helidon.nima.websocket.webserver.WsRouting;
import static io.helidon.config.ConfigSources.classpath;
import static io.helidon.config.ConfigSources.file;
@@ -68,7 +69,7 @@ static WebServer startServer() {
WebServer server = WebServer.builder()
.port(7076)
.addRouting(createRouting(config))
- .addRouting(WebSocketRouting.builder()
+ .addRouting(WsRouting.builder()
.endpoint("/ws/messages", WebSocketEndpoint::new)
.build())
.start();