Ether Framework
Unified API docs for Ether modules
Loading...
Searching...
No Matches
WebSocketProxyEndpoint.java
Go to the documentation of this file.
1package dev.rafex.ether.websocket.proxy.jetty12;
2
3/*-
4 * #%L
5 * ether-websocket-proxy-jetty12
6 * %%
7 * Copyright (C) 2025 - 2026 Raúl Eduardo González Argote
8 * %%
9 * Permission is hereby granted, free of charge, to any person obtaining a copy
10 * of this software and associated documentation files (the "Software"), to deal
11 * in the Software without restriction, including without limitation the rights
12 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 * copies of the Software, and to permit persons to whom the Software is
14 * furnished to do so, subject to the following conditions:
15 *
16 * The above copyright notice and this permission notice shall be included in
17 * all copies or substantial portions of the Software.
18 *
19 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25 * THE SOFTWARE.
26 * #L%
27 */
28
29import java.net.URI;
30import java.nio.ByteBuffer;
31import java.time.Duration;
32import java.util.Objects;
33import java.util.Set;
34import java.util.concurrent.ConcurrentHashMap;
35import java.util.concurrent.ConcurrentMap;
36import java.util.concurrent.TimeUnit;
37import java.util.logging.Level;
38import java.util.logging.Logger;
39
40import org.eclipse.jetty.websocket.api.Callback;
41import org.eclipse.jetty.websocket.api.Session;
42import org.eclipse.jetty.websocket.client.WebSocketClient;
43
44import dev.rafex.ether.websocket.core.WebSocketCloseStatus;
45import dev.rafex.ether.websocket.core.WebSocketEndpoint;
46import dev.rafex.ether.websocket.core.WebSocketSession;
47
48public final class WebSocketProxyEndpoint implements WebSocketEndpoint {
49
50 private static final Logger LOG = Logger.getLogger(WebSocketProxyEndpoint.class.getName());
51
52 private final BackendResolver backendResolver;
53 private final Duration connectTimeout;
54 private final WebSocketClient wsClient;
55 private final ConcurrentMap<String, Session> backendSessions = new ConcurrentHashMap<>();
56
57 public WebSocketProxyEndpoint(final BackendResolver backendResolver, final WebSocketClient wsClient) {
58 this(backendResolver, wsClient, Duration.ofSeconds(10));
59 }
60
61 public WebSocketProxyEndpoint(final BackendResolver backendResolver, final WebSocketClient wsClient,
62 final Duration connectTimeout) {
63 this.backendResolver = Objects.requireNonNull(backendResolver, "backendResolver");
64 this.wsClient = Objects.requireNonNull(wsClient, "wsClient");
65 this.connectTimeout = Objects.requireNonNull(connectTimeout, "connectTimeout");
66 }
67
68 public int activeConnections() {
69 return backendSessions.size();
70 }
71
72 @Override
73 public void onOpen(final WebSocketSession clientSession) throws Exception {
74 final var clientId = clientSession.id();
75 final var requestId = requestIdOf(clientSession);
76 final var backendUri = backendResolver.resolve(clientSession);
77 if (backendUri == null) {
78 LOG.warning(() -> "[%s] proxy: client=%s backend URI null".formatted(requestId, clientId));
79 clientSession.close(WebSocketCloseStatus.SERVER_ERROR);
80 return;
81 }
82
83 final var start = System.nanoTime();
84 try {
85 final var backendListener = new BackendSessionListener(clientSession, requestId);
86 final var backendSession = wsClient.connect(backendListener, backendUri)
87 .get(connectTimeout.toMillis(), TimeUnit.MILLISECONDS);
88
89 final var previous = backendSessions.put(clientId, backendSession);
90 if (previous != null) {
91 previous.close(WebSocketCloseStatus.GOING_AWAY.code(),
92 WebSocketCloseStatus.GOING_AWAY.reason(), Callback.NOOP);
93 }
94
95 final var elapsed = Duration.ofNanos(System.nanoTime() - start);
96 LOG.info(() -> "[%s] proxy: client=%s → backend=%s connected (%dms, total=%d)"
97 .formatted(requestId, clientId, backendUri, elapsed.toMillis(), activeConnections()));
98 } catch (final Exception e) {
99 final var elapsed = Duration.ofNanos(System.nanoTime() - start);
100 LOG.log(Level.WARNING, e,
101 () -> "[%s] proxy: client=%s → backend=%s failed (%dms, total=%d)"
102 .formatted(requestId, clientId, backendUri, elapsed.toMillis(), activeConnections()));
103 clientSession.close(WebSocketCloseStatus.SERVER_ERROR);
104 }
105 }
106
107 @Override
108 public void onText(final WebSocketSession session, final String message) throws Exception {
109 final var backend = backendSessions.get(session.id());
110 if (backend != null && backend.isOpen()) {
111 LOG.fine(() -> "[%s] proxy: client=%s → backend TEXT(%d)"
112 .formatted(requestIdOf(session), session.id(), message.length()));
113 backend.sendText(message, Callback.NOOP);
114 return;
115 }
116 LOG.warning(() -> "[%s] proxy: client=%s onText but backend unavailable"
117 .formatted(requestIdOf(session), session.id()));
118 }
119
120 @Override
121 public void onBinary(final WebSocketSession session, final ByteBuffer message) throws Exception {
122 final var backend = backendSessions.get(session.id());
123 if (backend != null && backend.isOpen()) {
124 final var size = message == null ? 0 : message.remaining();
125 LOG.fine(() -> "[%s] proxy: client=%s → backend BIN(%d)"
126 .formatted(requestIdOf(session), session.id(), size));
127 backend.sendBinary(message, Callback.NOOP);
128 return;
129 }
130 LOG.warning(() -> "[%s] proxy: client=%s onBinary but backend unavailable"
131 .formatted(requestIdOf(session), session.id()));
132 }
133
134 @Override
135 public void onClose(final WebSocketSession session, final WebSocketCloseStatus closeStatus) throws Exception {
136 final var clientId = session.id();
137 final var requestId = requestIdOf(session);
138 closeBackend(clientId, closeStatus);
139 LOG.info(() -> "[%s] proxy: client=%s closed (%d %s, total=%d)"
140 .formatted(requestId, clientId, closeStatus.code(), closeStatus.reason(), activeConnections()));
141 }
142
143 @Override
144 public void onError(final WebSocketSession session, final Throwable error) {
145 final var clientId = session.id();
146 final var requestId = requestIdOf(session);
147 LOG.log(Level.WARNING, error,
148 () -> "[%s] proxy: client=%s error (total=%d)".formatted(requestId, clientId, activeConnections()));
149 closeBackend(clientId, WebSocketCloseStatus.SERVER_ERROR);
150 }
151
152 @Override
153 public Set<String> subprotocols() {
154 return Set.of();
155 }
156
157 private void closeBackend(final String clientId, final WebSocketCloseStatus status) {
158 final var backend = backendSessions.remove(clientId);
159 if (backend != null && backend.isOpen()) {
160 try {
161 backend.close(status.code(), status.reason(), Callback.NOOP);
162 } catch (final Exception e) {
163 LOG.log(Level.FINE, e, () -> "proxy: client=%s backend close error".formatted(clientId));
164 }
165 }
166 }
167
168 private static String requestIdOf(final WebSocketSession session) {
169 final var attr = session.attribute("requestId");
170 return attr != null ? attr.toString() : session.id();
171 }
172
173 private static final class BackendSessionListener implements Session.Listener.AutoDemanding {
174
175 private final WebSocketSession clientSession;
176 private final String requestId;
177
178 BackendSessionListener(final WebSocketSession clientSession, final String requestId) {
179 this.clientSession = clientSession;
180 this.requestId = requestId;
181 }
182
183 @Override
184 public void onWebSocketOpen(final Session session) {
185 LOG.fine(() -> "[%s] proxy: backend session opened for client=%s"
186 .formatted(requestId, clientSession.id()));
187 }
188
189 @Override
190 public void onWebSocketText(final String message) {
191 if (clientSession.isOpen()) {
192 LOG.fine(() -> "[%s] proxy: backend → client=%s TEXT(%d)"
193 .formatted(requestId, clientSession.id(), message.length()));
194 clientSession.sendText(message);
195 return;
196 }
197 LOG.warning(() -> "[%s] proxy: backend text but client=%s closed"
198 .formatted(requestId, clientSession.id()));
199 }
200
201 @Override
202 public void onWebSocketBinary(final ByteBuffer payload, final Callback callback) {
203 if (clientSession.isOpen()) {
204 final var size = payload == null ? 0 : payload.remaining();
205 LOG.fine(() -> "[%s] proxy: backend → client=%s BIN(%d)"
206 .formatted(requestId, clientSession.id(), size));
207 clientSession.sendBinary(payload);
208 }
209 callback.succeed();
210 }
211
212 @Override
213 public void onWebSocketClose(final int statusCode, final String reason, final Callback callback) {
214 LOG.info(() -> "[%s] proxy: backend closed (%d %s) for client=%s"
215 .formatted(requestId, statusCode, reason, clientSession.id()));
216 if (clientSession.isOpen()) {
217 clientSession.close(WebSocketCloseStatus.of(statusCode, reason));
218 }
219 callback.succeed();
220 }
221
222 @Override
223 public void onWebSocketError(final Throwable cause) {
224 LOG.log(Level.WARNING, cause,
225 () -> "[%s] proxy: backend error for client=%s".formatted(requestId, clientSession.id()));
226 if (clientSession.isOpen()) {
227 clientSession.close(WebSocketCloseStatus.SERVER_ERROR);
228 }
229 }
230 }
231}
void onClose(final WebSocketSession session, final WebSocketCloseStatus closeStatus)
Invocado cuando el cliente solicita cerrar la conexión.
void onOpen(final WebSocketSession clientSession)
Invocado cuando la conexión WebSocket se abre exitosamente.
WebSocketProxyEndpoint(final BackendResolver backendResolver, final WebSocketClient wsClient, final Duration connectTimeout)
void onBinary(final WebSocketSession session, final ByteBuffer message)
Invocado cuando se recibe un mensaje binario del cliente.
WebSocketProxyEndpoint(final BackendResolver backendResolver, final WebSocketClient wsClient)
Set< String > subprotocols()
Devuelve el conjunto de subprotocolos WebSocket que este endpoint acepta.
void onError(final WebSocketSession session, final Throwable error)
Invocado cuando ocurre un error en la conexión WebSocket.
void onText(final WebSocketSession session, final String message)
Invocado cuando se recibe un mensaje de texto del cliente.
Define el contrato de un punto final WebSocket.
Representa una sesión WebSocket activa.
String id()
Devuelve el identificador único de esta sesión.
record WebSocketCloseStatus(int code, String reason)
Representa un estado de cierre de una conexión WebSocket conforme a la especificación RFC 6455.