001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.bus.websocket;
018
019import static java.nio.charset.StandardCharsets.*;
020
021import java.util.concurrent.*;
022import java.util.concurrent.atomic.*;
023import java.util.logging.*;
024
025import org.apache.juneau.console.bus.*;
026
027import jakarta.websocket.*;
028
029/**
030 * The per-connection endpoint of the bus's WebSocket transport.  Not for direct use: it is public only because the
031 * container instantiates it through {@link BusWebSocketConfigurator}.
032 *
033 * <p>
034 * It closes a refused handshake at once ({@code 4401} / {@code 4403}).  Otherwise it adapts the
035 * {@link Session} to a {@link FrameSink}, connects it to the {@link ServerBus} (which sends the resync), forwards
036 * inbound text frames to {@link ServerBus#receive} (which refuses anything but a {@code pub} frame with a JSON object
037 * payload), and closes with {@code 1009} ({@code bus:frame-too-large}) on a frame over
038 * {@code BusPolicy.maxFrameBytes}.  Nothing it logs contains the session id.
039 *
040 * <p>
041 * It reads the per-connection user properties of its {@link EndpointConfig}.  The container gives each handshake its
042 * own copy of that map (WebSocket 2.1 and later; Jetty and Tomcat do), which is what keeps concurrent connections apart.
043 *
044 * <h5 class='section'>Example:</h5>
045 * <p class='bjava'>
046 *   <jc>// Do not construct this class; register the endpoint instead:</jc>
047 *   BusWebSockets.<jsm>register</jsm>(<jv>servletContext</jv>, JobsRest.<jsf>BUS</jsf>);
048 * </p>
049 *
050 * @since 10.0.0
051 */
052public final class BusWebSocketEndpoint extends Endpoint {
053
054   private static final Logger LOGGER = Logger.getLogger(BusWebSocketEndpoint.class.getName());
055
056   private final ServerBus bus;
057   private final Executor closer;
058   private final AtomicReference<BusSession> busSession = new AtomicReference<>();
059   private final AtomicReference<WsFrameSink> sink = new AtomicReference<>();
060
061   BusWebSocketEndpoint(ServerBus bus) {
062      this(bus, null);
063   }
064
065   /** @param closer Runs the sink's socket closes; {@code null} for the default (a daemon pool). */
066   BusWebSocketEndpoint(ServerBus bus, Executor closer) {
067      this.bus = bus;
068      this.closer = closer;
069   }
070
071   @Override /* Endpoint */
072   public void onOpen(Session session, EndpointConfig config) {
073      var props = config.getUserProperties();
074      if (props.get(BusWebSocketConfigurator.REFUSAL_PROPERTY) instanceof BusWebSocketConfigurator.Refusal r) {
075         WsFrameSink.closeQuietly(session, r.code(), r.reason());
076         return;
077      }
078      if (! (props.get(BusWebSocketConfigurator.SESSION_PROPERTY) instanceof BusSession s)) {
079         var r = BusWebSocketConfigurator.UNKNOWN_SESSION;
080         WsFrameSink.closeQuietly(session, r.code(), r.reason());
081         return;
082      }
083      var policy = bus.policy();
084      var maxBytes = policy.maxFrameBytes();
085      session.setMaxTextMessageBufferSize(maxBytes);
086      var k = closer == null ? new WsFrameSink(session, policy.maxQueuedFrames()) : new WsFrameSink(session, policy.maxQueuedFrames(), closer);
087      busSession.set(s);
088      sink.set(k);
089      try {
090         bus.connect(s, k);
091      } catch (RuntimeException e) {
092         LOGGER.log(Level.WARNING, "bus websocket connect failed: {0}", e.getClass().getName());
093         k.markClosed();
094         try {
095            bus.disconnect(s, k);
096         } catch (RuntimeException e2) {
097            // The same fault that failed the connect; the sink is already marked closed.
098         }
099         WsFrameSink.closeQuietly(session, 1011, "bus:connect-failed");
100         return;
101      }
102      session.addMessageHandler(String.class, text -> onText(session, s, text, maxBytes));
103   }
104
105   private void onText(Session session, BusSession s, String text, int maxBytes) {
106      // A char is at least one byte, so a long enough string needs no encoding to be refused.
107      if (text.length() > maxBytes || text.getBytes(UTF_8).length > maxBytes) {
108         WsFrameSink.closeQuietly(session, CloseReason.CloseCodes.TOO_BIG.getCode(), "bus:frame-too-large");
109         return;
110      }
111      bus.receive(s, text);
112   }
113
114   @Override /* Endpoint */
115   public void onClose(Session session, CloseReason closeReason) {
116      var k = sink.get();
117      if (k != null) {
118         k.markClosed();
119         bus.disconnect(busSession.get(), k);
120      }
121   }
122
123   @Override /* Endpoint */
124   public void onError(Session session, Throwable thr) {
125      // Only the class: a message or stack can carry the request path, which holds the session id.
126      LOGGER.log(Level.FINE, "bus websocket transport error: {0}", thr == null ? null : thr.getClass().getName());
127   }
128}