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}