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}