001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2017-2018 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.webconsole.base; 020 021import java.lang.ref.ReferenceQueue; 022import java.lang.ref.WeakReference; 023import java.time.Duration; 024import java.time.Instant; 025import java.util.Collections; 026import java.util.HashSet; 027import java.util.Locale; 028import java.util.Map; 029import java.util.Optional; 030import java.util.Set; 031import java.util.concurrent.ConcurrentHashMap; 032import java.util.function.Supplier; 033import org.jgrapes.core.Channel; 034import org.jgrapes.core.Components; 035import org.jgrapes.core.Components.Timer; 036import org.jgrapes.core.EventPipeline; 037import org.jgrapes.core.Subchannel; 038import org.jgrapes.http.Session; 039import org.jgrapes.io.IOSubchannel; 040import org.jgrapes.io.IOSubchannel.DefaultIOSubchannel; 041import org.jgrapes.io.events.Closed; 042import org.jgrapes.io.util.LinkedIOSubchannel; 043 044/** 045 * The server side representation of a window in the browser 046 * that displays a console page (a console connection). An instance 047 * is created when a new console window opens the WebSocket 048 * connection to the server for the first time. If the 049 * connection between the browser and the server is lost 050 * (e.g. due to temporary network failure), the console code in 051 * the browser tries to establish a new WebSocket connection 052 * to the same, already existing {@link ConsoleConnection}. 053 * The {@link ConsoleConnection} object is thus independent 054 * of the WebSocket connection that handles the actual transfer 055 * of notifications. 056 * 057 * ![WebConsole Connection](ConsoleConnection.svg) 058 * 059 * To allow reconnection and because there is no reliable way 060 * to be notified when a window in a browser closes, {@link Closed} 061 * events from the network are ignored and {@link ConsoleConnection}s 062 * remain in an open state as long as they are in use. Only if no 063 * data is received (i.e. {@link #refresh()} isn't called) for the 064 * time span configured with 065 * {@link ConsoleWeblet#setConnectionNetworkTimeout} a timer closes 066 * the connection by calling the {@link #close()} method. (Method 067 * {@link #isConnected()} can be used to check the connection state.) 068 * In order to keep the console connection in an open state while 069 * the connection is inactive, i.e. no data is sent due to user activity, 070 * the SPA automatically generates refresh messages as configured 071 * with {@link ConsoleWeblet#setConnectionRefreshInterval(Duration)}. 072 * 073 * {@link ConsoleConnection} implements the {@link IOSubchannel} 074 * interface. This allows the instances to be used as channels 075 * for exchanging console connection scoped events with the 076 * {@link WebConsole} component. The upstream channel 077 * (see {@link #upstreamChannel()}) is the channel of the 078 * WebSocket. It may be unavailable if the WebSocket connection has 079 * been interrupted and not (yet) re-established. 080 * The {@link IOSubchannel}'s response {@link EventPipeline} 081 * must be used to send events (responses) to the console connection 082 * in the browser. No other event pipeline may be used for 083 * this purpose, else messages will interleave. 084 * 085 * To avoid having too many open WebSockets with inactive console 086 * connections, a maximum inactivity time can be configured with 087 * {@link ConsoleWeblet#setConnectionInactivityTimeout(Duration)}. 088 * The SPA always checks if the time since the last user activity 089 * has reached or exceeded the configured limit before sending the 090 * next refresh message. In case it has, the SPA stops sending 091 * refresh messages and displays a "suspended" dialog to the user. 092 * 093 * When the user chooses to resume, a new WebSocket is opened by the 094 * SPA. If the {@link Session} used before the idle timeout is 095 * still available (hasn't reached its idle timeout or absolute timeout) 096 * and refers to a {@link ConsoleConnection} not yet closed, then this 097 * {@link ConsoleConnection} is reused, else the SPA is reloaded. 098 * 099 * As a convenience, the {@link ConsoleConnection} provides 100 * direct access to the browser session, which can 101 * usually only be obtained from the HTTP event or WebSocket 102 * channel by looking for an association of type {@link Session}. 103 * 104 * @startuml ConsoleConnection.svg 105 * class ConsoleConnection { 106 * -{static}Map<String,ConsoleConnection> consoleConnections 107 * +{static}findOrCreate(String consoleConnectionId, Manager component): ConsoleConnection 108 * +setTimeout(timeout: long): ConsoleConnection 109 * +refresh(): void 110 * +setUpstreamChannel(IOSubchannel upstreamChannel): ConsoleConnection 111 * +setSessionSupplier(Session sessionSupplier): ConsoleConnection 112 * +upstreamChannel(): Optional<IOSubchannel> 113 * +consoleConnectionId(): String 114 * +session(): Optional<Session> 115 * +locale(): Locale 116 * +setLocale(Locale locale): void 117 * } 118 * 119 * interface IOSubchannel { 120 * } 121 * 122 * IOSubchannel <|.. ConsoleConnection 123 * 124 * ConsoleConnection "1" *-- "*" ConsoleConnection : maintains 125 * 126 * package org.jgrapes.http { 127 * class Session 128 * } 129 * 130 * ConsoleConnection "*" -up-> "1" Session: browser session 131 * @enduml 132 */ 133@SuppressWarnings("PMD.LinguisticNaming") 134public final class ConsoleConnection extends DefaultIOSubchannel { 135 136 private static Map<String, WeakReference<ConsoleConnection>> connections 137 = new ConcurrentHashMap<>(); 138 private static ReferenceQueue<ConsoleConnection> unusedConnections 139 = new ReferenceQueue<>(); 140 141 private String connectionId; 142 private final WebConsole console; 143 private final Set<Locale> supportedLocales; 144 private Locale locale; 145 private long timeout; 146 private final Timer timeoutTimer; 147 private boolean open = true; 148 private boolean connected = true; 149 private Supplier<Optional<Session>> sessionSupplier; 150 private IOSubchannel upstreamChannel; 151 152 /** 153 * Weak reference to session. 154 */ 155 private static class ConnectionReference 156 extends WeakReference<ConsoleConnection> { 157 158 private final String id; 159 160 /** 161 * Instantiates a new session reference. 162 * 163 * @param referent the referent 164 */ 165 public ConnectionReference(ConsoleConnection referent) { 166 super(referent, unusedConnections); 167 id = referent.consoleConnectionId(); 168 } 169 } 170 171 private static void cleanUnused() { 172 while (true) { 173 ConnectionReference unused 174 = (ConnectionReference) unusedConnections.poll(); 175 if (unused == null) { 176 break; 177 } 178 connections.remove(unused.id); 179 } 180 } 181 182 /** 183 * Lookup the console connection (the channel) 184 * for the given console connection id. 185 * 186 * @param connectionId the console connection id 187 * @return the channel 188 */ 189 /* default */ static Optional<ConsoleConnection> 190 lookup(String connectionId) { 191 cleanUnused(); 192 return Optional.ofNullable(connections.get(connectionId)) 193 .flatMap(ref -> Optional.ofNullable(ref.get())); 194 } 195 196 /** 197 * Return all connections that belong to the given console as a new 198 * unmodifiable set. 199 * 200 * @param console the console 201 * @return the sets the 202 */ 203 public static Set<ConsoleConnection> byConsole(WebConsole console) { 204 cleanUnused(); 205 Set<ConsoleConnection> result = new HashSet<>(); 206 for (WeakReference<ConsoleConnection> psr : connections.values()) { 207 ConsoleConnection psess = psr.get(); 208 if (psess != null && psess.console != null 209 && psess.console.equals(console)) { 210 result.add(psess); 211 } 212 } 213 return Collections.unmodifiableSet(result); 214 } 215 216 /** 217 * Lookup (and create if not found) the console connection 218 * for the given console connection id. 219 * 220 * @param connectionId the connection id 221 * @param console the console that this connection belongs to 222 * class' constructor if a new channel is created, usually 223 * the console 224 * @param supportedLocales the locales supported by the console 225 * @param timeout the console connection timeout in milli seconds 226 * @return the channel 227 */ 228 /* default */ static ConsoleConnection lookupOrCreate( 229 String connectionId, WebConsole console, 230 Set<Locale> supportedLocales, long timeout) { 231 cleanUnused(); 232 return connections.computeIfAbsent(connectionId, 233 psi -> new ConnectionReference(new ConsoleConnection( 234 console, supportedLocales, connectionId, timeout))) 235 .get(); 236 } 237 238 /** 239 * Replace the id of the console connection with the new id. 240 * 241 * @param newConnectionId the new console connection id 242 * @return the console connection 243 */ 244 /* default */ ConsoleConnection replaceId(String newConnectionId) { 245 connections.remove(connectionId); 246 connectionId = newConnectionId; 247 connections.put(connectionId, new ConnectionReference(this)); 248 connected = true; 249 return this; 250 } 251 252 private ConsoleConnection(WebConsole console, Set<Locale> supportedLocales, 253 String connectionId, long timeout) { 254 super(console.channel(), console.newEventPipeline()); 255 this.console = console; 256 this.supportedLocales = supportedLocales; 257 this.connectionId = connectionId; 258 this.timeout = timeout; 259 timeoutTimer = Components.schedule( 260 tmr -> close(), Duration.ofMillis(timeout)); 261 } 262 263 /** 264 * Changes the timeout for this {@link ConsoleConnection} to the 265 * given value. 266 * 267 * @param timeout the timeout in milli seconds 268 * @return the console connection for easy chaining 269 */ 270 public ConsoleConnection setTimeout(long timeout) { 271 this.timeout = timeout; 272 timeoutTimer.reschedule(Duration.ofMillis(timeout)); 273 return this; 274 } 275 276 /** 277 * Returns the time when this connection will expire. 278 * 279 * @return the instant 280 */ 281 public Instant expiresAt() { 282 return timeoutTimer.scheduledFor(); 283 } 284 285 /** 286 * Resets the {@link ConsoleConnection}'s timeout. 287 */ 288 public void refresh() { 289 timeoutTimer.reschedule(Duration.ofMillis(timeout)); 290 } 291 292 /** 293 * Close this connection. The connection is removed from the 294 * set of open connections and a {@link Closed} event is fired 295 * on the connection. 296 */ 297 public void close() { 298 // Not necessarily invoked by timer. 299 timeoutTimer.cancel(); 300 if (connections.remove(connectionId) != null) { 301 connected = false; 302 open = false; 303 console.newEventPipeline().fire(new Closed<Void>(), this); 304 } 305 } 306 307 /** 308 * Checks if the console connection is open. 309 * 310 * @return true, if is open 311 */ 312 public boolean isOpen() { 313 return open; 314 } 315 316 /* default */ void disconnected() { 317 connected = false; 318 } 319 320 /** 321 * Checks if a network connection with the browser exists. 322 * 323 * @return true, if is connected 324 */ 325 public boolean isConnected() { 326 return connected; 327 } 328 329 /** 330 * Provides access to the weblet's channel. 331 * 332 * @return the channel 333 */ 334 public Channel webletChannel() { 335 return console.webletChannel(); 336 } 337 338 /** 339 * Sets or updates the upstream channel. This method should only 340 * be invoked by the creator of the {@link ConsoleConnection}, by default 341 * the {@link ConsoleWeblet}. 342 * 343 * @param upstreamChannel the upstream channel (WebSocket connection) 344 * @return the console connection for easy chaining 345 */ 346 public ConsoleConnection setUpstreamChannel(IOSubchannel upstreamChannel) { 347 if (upstreamChannel == null) { 348 throw new IllegalArgumentException(); 349 } 350 this.upstreamChannel = upstreamChannel; 351 return this; 352 } 353 354 /** 355 * Sets or updates associated browser session. This method should only 356 * be invoked by the creator of the {@link ConsoleConnection}, by default 357 * the {@link ConsoleWeblet}. 358 * 359 * @param sessionSupplier the browser session supplier 360 * @return the console connection for easy chaining 361 */ 362 public ConsoleConnection 363 setSessionSupplier(Supplier<Optional<Session>> sessionSupplier) { 364 this.sessionSupplier = sessionSupplier; 365 if (locale == null) { 366 locale = session().locale(); 367 } 368 return this; 369 } 370 371 /** 372 * @return the upstream channel 373 */ 374 public IOSubchannel upstreamChannel() { 375 return upstreamChannel; 376 } 377 378 /** 379 * The console connection id is used in the communication between the 380 * browser and the server. It is not guaranteed to remain the same 381 * over time, even if the console connection is maintained. To prevent 382 * wrong usage, its visibility is therefore set to package. 383 * 384 * @return the connectionId 385 */ 386 /* default */ String consoleConnectionId() { 387 return connectionId; 388 } 389 390 /** 391 * @return the browser session 392 */ 393 public Session session() { 394 return sessionSupplier.get().get(); 395 } 396 397 /** 398 * Returns the supported locales. 399 * 400 * @return the set of locales supported by the console 401 */ 402 public Set<Locale> supportedLocales() { 403 return supportedLocales; 404 } 405 406 /** 407 * Return the console connection's locale. The locale is initialized 408 * from the browser session's locale. 409 * 410 * @return the locale 411 */ 412 public Locale locale() { 413 return locale == null ? Locale.getDefault() : locale; 414 } 415 416 /** 417 * Sets the locale for this console connection. 418 * 419 * @param locale the locale 420 * @return the console connection 421 */ 422 public ConsoleConnection setLocale(Locale locale) { 423 this.locale = locale; 424 return this; 425 } 426 427 /* 428 * (non-Javadoc) 429 * 430 * @see java.lang.Object#toString() 431 */ 432 @Override 433 public String toString() { 434 StringBuilder builder = new StringBuilder(); 435 builder.append(Subchannel.toString(this)); 436 Optional.ofNullable(upstreamChannel).ifPresent(upstr -> builder.append( 437 LinkedIOSubchannel.upstreamToString(upstr))); 438 return builder.toString(); 439 } 440 441}