All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.airepublic.http.sse.impl.SseService Maven / Gradle / Ivy

package com.airepublic.http.sse.impl;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Serializable;
import java.io.StringReader;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.StandardSocketOptions;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.Future;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.inject.Named;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLException;

import com.airepublic.http.common.AsyncHttpReader;
import com.airepublic.http.common.BufferUtil;
import com.airepublic.http.common.Headers;
import com.airepublic.http.common.HttpRequest;
import com.airepublic.http.common.HttpResponse;
import com.airepublic.http.common.HttpStatus;
import com.airepublic.http.common.SslSupport;
import com.airepublic.http.sse.api.ISseRegistry;
import com.airepublic.http.sse.api.ISseService;
import com.airepublic.http.sse.api.ProducerEntry;
import com.airepublic.http.sse.api.SseEvent;
import com.airepublic.http.sse.api.SseProducer;

/**
 * A service to send and received SSE.
 * 
 * @author Torsten Oltmanns
 *
 */
@Named
public class SseService implements Serializable, ISseService {
    private static final Logger logger = Logger.getGlobal();
    private static final long serialVersionUID = 1L;
    private static SSLContext clientSSLContext;

    /**
     * The task to execute a sending of an SSE.
     * 
     * @author Torsten Oltmanns
     *
     */
    private class SendTask extends ForkJoinTask {
        private static final long serialVersionUID = 1L;
        private final SseEvent event;
        private final SocketChannel channel;
        private final SSLEngine sslEngine;


        /**
         * Constructor.
         * 
         * @param event the {@link SseEvent} to send
         * @param channel the {@link SocketChannel} to the receiver
         * @param sslEngine the {@link SSLEngine} to encrypt the {@link SseEvent} or null if not an
         *        SSL connections
         */
        public SendTask(final SseEvent event, final SocketChannel channel, final SSLEngine sslEngine) {
            this.event = event;
            this.channel = channel;
            this.sslEngine = sslEngine;
        }


        @Override
        protected boolean exec() {
            try {
                ByteBuffer[] buffers = { encode(event) };

                if (sslEngine != null) {
                    buffers = SslSupport.wrap(sslEngine, channel, buffers);
                }

                channel.write(buffers);
            } catch (final Exception e) {
                completeExceptionally(e);
                return false;
            }

            return true;
        }


        @Override
        public SseEvent getRawResult() {
            return event;
        }


        @Override
        protected void setRawResult(final SseEvent value) {
        }
    }

    /**
     * The task to execute a broadcast of an SSE.
     * 
     * @author Torsten Oltmanns
     *
     */
    private class BroadcastTask extends ForkJoinTask {
        private static final long serialVersionUID = 1L;
        private final SseEvent event;
        private final Map channels;


        /**
         * Constructor.
         * 
         * @param event the {@link SseEvent} to send
         * @param channels the {@link SocketChannel} to the receiver mapped to the associated
         *        {@link SSLEngine} (or null if not an SSL connection)
         */
        public BroadcastTask(final SseEvent event, final Map channels) {
            this.event = event;
            this.channels = channels;
        }


        @Override
        protected boolean exec() {
            Throwable t = null;

            for (final Entry entry : channels.entrySet()) {
                final SocketChannel channel = entry.getKey();

                if (channel.isOpen()) {
                    try {
                        ByteBuffer[] buffers = { encode(event) };
                        final SSLEngine sslEngine = entry.getValue();

                        if (sslEngine != null) {
                            buffers = SslSupport.wrap(sslEngine, channel, buffers);
                        }

                        channel.write(buffers);
                    } catch (final Exception e) {
                        t = e;
                    }
                }
            }

            if (t != null) {
                completeExceptionally(t);
                return false;
            }

            return true;
        }


        @Override
        public SseEvent getRawResult() {
            return event;
        }


        @Override
        protected void setRawResult(final SseEvent value) {
        }
    }

    /**
     * The task to execute receiving of SSE.
     * 
     * @author Torsten Oltmanns
     *
     */
    private class ReceiveTask extends ForkJoinTask {
        private static final long serialVersionUID = 1L;
        private final Consumer consumer;
        private final URI uri;
        private final boolean isSecure;
        private SSLEngine sslEngine;
        private SocketChannel channel = null;
        private Selector selector = null;
        private boolean closed = false;


        /**
         * Constructor.
         * 
         * @param uri the {@link URI} to the event source
         * @param consumer the {@link Consumer} accepting the incoming {@link SseEvent}s
         * @throws IOException if opening a connection fails
         */
        public ReceiveTask(final URI uri, final Consumer consumer) throws IOException {
            this.consumer = consumer;
            this.uri = uri;
            isSecure = uri.getScheme().equals("https");
            selector = Selector.open();
        }


        @Override
        protected boolean exec() {
            boolean repeat = true;
            long retry = -1L;

            // repeat reconnecting forever unless the ForkJointask is cancelled
            while (repeat) {
                try {
                    // open channel to URI destination
                    channel = openChannel();
                    channel.register(selector, SelectionKey.OP_READ);
                    closed = false;

                    // handshake with the remote server
                    final HttpResponse response = outboundHandshake(uri, selector, channel, sslEngine, consumer);

                    // check if content will be chunked
                    final String transferEncoding = response.getHeaders().getFirst(Headers.TRANSFER_ENCODING);
                    final boolean isChunked = transferEncoding != null && transferEncoding.equals("chunked");

                    // read following SSE events
                    while (!closed && selector.isOpen()) {
                        final int len = selector.select();

                        if (len > 0) {
                            final Iterator it = selector.selectedKeys().iterator();

                            while (it.hasNext()) {
                                final SelectionKey key = it.next();
                                it.remove();

                                if (key.isValid() && key.isReadable()) {
                                    ByteBuffer buffer = ByteBuffer.allocate(1024 * 16);
                                    final int read = ((SocketChannel) key.channel()).read(buffer);

                                    if (read == -1) {
                                        // channel has closed
                                        selector.close();
                                    } else if (read > 0) {
                                        buffer.flip();

                                        if (isSecure) {
                                            buffer = SslSupport.unwrap(sslEngine, channel, buffer);
                                        }

                                        final Long retryChk = processEventBuffer(buffer, isChunked, consumer);

                                        if (retryChk != null) {
                                            retry = retryChk;
                                        }
                                    }
                                }
                            }
                        }
                    }
                } catch (final CancellationException e) {
                    completeExceptionally(e);
                    repeat = false;
                } catch (final Exception e) {
                    logger.log(Level.WARNING, "Receiving SSE encountered an error: " + e.getLocalizedMessage());
                } finally {
                    // close channel, remove it from selector and free resources
                    close();
                }

                // possibly wait before re-connecting
                if (retry != SseEvent.RETRY_NOT_SET && retry > 0) {
                    try {
                        Thread.currentThread().wait(retry);
                    } catch (final InterruptedException e) {
                    }
                }
            }

            close();
            // close the selector after cancellation
            if (selector.isOpen()) {
                try {
                    selector.close();
                } catch (final IOException e) {
                }
            }

            return true;
        }


        /**
         * Closes the current connection.
         */
        protected void close() {
            closed = true;

            // close channel
            if (channel != null) {
                channel.keyFor(selector).cancel();

                try {
                    channel.close();
                } catch (final IOException e) {
                }
            }

            // close SSLEngine
            if (sslEngine != null) {
                try {
                    sslEngine.closeInbound();
                } catch (final SSLException e) {
                }

                sslEngine = null;
            }

            logger.info("Closed channel receiving SSE from: " + uri);
        }


        /**
         * Opens a {@link SocketChannel} to the {@link URI} of the event source.
         * 
         * @return the {@link SocketChannel}
         */
        protected SocketChannel openChannel() {
            int port = uri.getPort();

            if (port <= 0) {
                port = isSecure ? 443 : 80;
            }

            logger.info("Opening channel to receive SSE from: " + uri);

            SocketChannel channel;

            try {
                final SocketAddress remote = new InetSocketAddress(uri.getHost(), port);
                channel = SocketChannel.open();
                channel.setOption(StandardSocketOptions.SO_KEEPALIVE, true);
                channel.configureBlocking(false);
                channel.connect(remote);

                while (!channel.finishConnect()) {
                }

                if (isSecure) {
                    // perform SSL handshake
                    sslEngine = SslSupport.clientSSLHandshake(clientSSLContext, channel, uri);
                }
            } catch (final IOException e) {
                throw new RuntimeException("Failed to create socket channel!", e);
            }

            return channel;
        }


        @Override
        public Void getRawResult() {
            return null;
        }


        @Override
        protected void setRawResult(final Void value) {
        }
    }

    /**
     * The task to execute a process the whole lifecycle of an {@link SseProducer}.
     * 
    *
  • accepting the incoming request
  • *
  • sending the handshake response
  • *
  • sending {@link SseEvent}s by calling the associated {@link SseProducer} method
  • *
  • respecting delay and maximum times configured in the {@link SseProducer} annotation
  • *
* * @author Torsten Oltmanns * */ private class ProcessTask extends ForkJoinTask { private static final long serialVersionUID = 1L; private final SocketChannel channel; private final SSLContext sslContext; private final ISseRegistry sseRegistry; public ProcessTask(final SocketChannel channel, final SSLContext sslContext, final ISseRegistry sseRegistry) { this.channel = channel; this.sslContext = sslContext; this.sseRegistry = sseRegistry; } @Override protected boolean exec() { SSLEngine sslEngine = null; try { // if a SSLContext is set assume the server to be in secure mode if (sslContext != null) { sslEngine = SslSupport.serverSSLHandshake(sslContext, channel); } // accept the handshake request and send a response final HttpRequest request = handshake(channel, sslEngine); // find the SSE producer method mapped to the URI path final ProducerEntry producerEntry = sseRegistry.getSseProducer(request.getPath()); if (producerEntry != null) { final SseProducer annotation = producerEntry.getMethod().getAnnotation(SseProducer.class); final long delayInMs = annotation.unit().toMillis(annotation.delay()); final long maxTimes = annotation.maxTimes(); long times = 0; try { while (maxTimes == -1 || times < maxTimes) { // call the producer method final Object result = producerEntry.getMethod().invoke(producerEntry.getObject(), new Object[0]); if (result instanceof SseEvent) { // if we got an event, send it final SseEvent event = (SseEvent) result; send(event, channel, sslEngine); } times++; try { Thread.sleep(delayInMs); } catch (final Exception e) { } } } catch (final Exception e) { logger.log(Level.SEVERE, "Could not invoke SSE outbound producer method: " + producerEntry.getObject().getClass().getSimpleName() + "#" + producerEntry.getMethod().getName(), e); } } } catch (final Exception e) { logger.log(Level.WARNING, "SSE producer encountered error: " + e.getLocalizedMessage()); } try { channel.close(); return true; } catch (final IOException e) { } return false; } @Override public Void getRawResult() { return null; } @Override protected void setRawResult(final Void value) { } } /** * Create a new instance. */ public SseService() { try { clientSSLContext = SslSupport.createClientSSLContext(); } catch (final IOException e) { throw new IllegalStateException("SSL context could not be created!", e); } } /** * Processes the whole lifecycle of an {@link SseProducer}. *
    *
  • accepting the incoming request
  • *
  • sending the handshake response
  • *
  • sending {@link SseEvent}s by calling the associated {@link SseProducer} method
  • *
  • respecting delay and maximum times configured in the {@link SseProducer} annotation
  • *
* * @param channel the freshly accepted {@link SocketChannel} * @param sslContext the {@link SSLContext} * @param sseRegistry the {@link SseRegistry} where the producer is registered */ @Override public void processRequest(final SocketChannel channel, final SSLContext sslContext, final ISseRegistry sseRegistry) { ForkJoinPool.commonPool().submit(new ProcessTask(channel, sslContext, sseRegistry)); } /** * Performs an initial handshake for incoming requests. * * @param channel the {@link SocketChannel} * @param sslEngine the {@link SSLEngine} or null * @throws IOException if handshake fails */ @Override public HttpRequest handshake(final SocketChannel channel, final SSLEngine sslEngine) throws IOException { // read initiating request final ByteBuffer buffer = ByteBuffer.allocate(4096); final int len = channel.read(buffer); if (len == -1) { try { channel.close(); } catch (final Exception e) { } throw new IOException("Channel is already closed!"); } else if (len == 0) { return null; } buffer.flip(); final AsyncHttpReader httpReader = new AsyncHttpReader(); httpReader.receiveBuffer(buffer); final HttpRequest request = httpReader.getHttpRequest(); // send handshake response sendHandshakeResponse(channel, sslEngine); return request; } @Override public HttpResponse getHandshakeResponse() { final Headers headers = new Headers(); headers.add(Headers.CONTENT_TYPE, "text/event-stream"); headers.add(Headers.CONNECTION, "keep-alive"); headers.add(Headers.CACHE_CONTROL, "no-cache"); headers.add(Headers.PRAGMA, "no-cache"); return new HttpResponse(HttpStatus.SUCCESS, headers); } /** * Sends the handshake response to the client. * * @param channel the {@link SocketChannel} to the client * @param sslEngine the {@link SSLEngine} * @throws IOException if sending fails */ @Override public void sendHandshakeResponse(final SocketChannel channel, final SSLEngine sslEngine) throws IOException { ByteBuffer[] buffers = { getHandshakeResponse().getHeaderBuffer() }; if (sslEngine != null) { buffers = SslSupport.wrap(sslEngine, channel, buffers); } channel.write(buffers); } /** * Performs the client handshake with the server found under the specified URI. * * @param uri the URI to the SSE resource * @param selector the selector for the {@link SocketChannel} * @param channel the {@link SocketChannel} * @param sslEngine the {@link SSLEngine} (optional) * @param consumer the {@link SseEvent} {@link Consumer} * @return the {@link HttpResponse} from the server * @throws IOException if the communication fails */ @Override public HttpResponse outboundHandshake(final URI uri, final Selector selector, final SocketChannel channel, final SSLEngine sslEngine, final Consumer consumer) throws IOException { // create request to URI final Headers headers = new Headers(); headers.add(Headers.HOST, uri.getHost()); headers.add(Headers.ACCEPT, "text/event-stream"); headers.add(Headers.CONNECTION, "keep-alive"); headers.add(Headers.CACHE_CONTROL, "no-cache"); headers.add(Headers.PRAGMA, "no-cache"); headers.add(Headers.TE, "Trailers"); headers.add(Headers.ACCEPT_ENCODING, "gzip, deflate, br"); headers.add("DNT", "1"); headers.add(Headers.REFERER, uri.getHost()); headers.add("Origin", uri.getHost()); final HttpRequest request = new HttpRequest(uri, headers); request.withMethod("GET").withVersion("HTTP/1.1"); logger.fine("SSE client handshake request: " + request.getRequestLine()); ByteBuffer[] buffers = { request.getHeaderBuffer() }; if (sslEngine != null) { buffers = SslSupport.wrap(sslEngine, channel, buffers); } channel.write(buffers); // read the response HttpResponse response = null; final AsyncHttpReader httpReader = new AsyncHttpReader(); if (selector.isOpen()) { final int len = selector.select(); if (len > 0) { final Iterator it = selector.selectedKeys().iterator(); while (it.hasNext()) { final SelectionKey key = it.next(); it.remove(); if (key.isValid() && key.isReadable()) { ByteBuffer buffer = ByteBuffer.allocate(1024 * 16); buffer.position(0); final int read = ((SocketChannel) key.channel()).read(buffer); buffer.flip(); if (sslEngine != null) { buffer = SslSupport.unwrap(sslEngine, channel, buffer); // close outbound channel sslEngine.closeOutbound(); } if (read == -1) { // channel has closed break; } else if (read > 0) { if (httpReader.receiveBuffer(buffer)) { response = httpReader.getHttpResponse(); logger.fine("SSE client handshake response: " + response.getStatus()); final String transferEncoding = response.getHeaders().getFirst(Headers.TRANSFER_ENCODING); final boolean isChunked = transferEncoding != null && transferEncoding.equals("chunked"); // if the response contains a body its already an event if (response.getStatus() == HttpStatus.SUCCESS) { if (response.getBody() != null) { processEventBuffer(response.getBody(), isChunked, consumer); } } } } } } } } return response; } /** * Processes the received {@link ByteBuffer} and notifies the consumer. * * @param buffer the {@link ByteBuffer} * @param isChunked flag whether the received {@link ByteBuffer} was received by a chunked * connection * @param consumer the consumer to notify * @return the last {@link SseEvent#getRetry()} value or null if no event was processed * @throws IOException if parsing the buffer fails */ protected Long processEventBuffer(final ByteBuffer buffer, final boolean isChunked, final Consumer consumer) throws IOException { if (buffer == null || !buffer.hasRemaining()) { return null; } final List events = decode(buffer, isChunked); Long retry = null; if (events != null && !events.isEmpty()) { for (final SseEvent event : events) { consumer.accept(event); retry = event.getRetry(); } } return retry; } /** * Sends the {@link SseEvent} asynchronously to the channel. * * @param event the {@link SseEvent} * @param channel the {@link SocketChannel} * @param sslEngine the {@link SSLEngine} or null * @return a {@link Future} containing the original event * @throws IOException if sending fails */ @Override public Future send(final SseEvent event, final SocketChannel channel, final SSLEngine sslEngine) throws IOException { if (channel.isOpen()) { return ForkJoinPool.commonPool().submit(new SendTask(event, channel, sslEngine)); } else { throw new IOException("Cannot send SseEvent due to closed channel!"); } } /** * Broadcasts the {@link SseEvent} asynchronously to the channels. * * @param event the {@link SseEvent} * @param channels the {@link SocketChannel}s mapped to their {@link SSLEngine}s * @return a {@link Future} containing the original event * @throws IOException if sending fails */ @Override public Future broadcast(final SseEvent event, final Map channels) throws IOException { return ForkJoinPool.commonPool().submit(new BroadcastTask(event, channels)); } /** * Receives {@link SseEvent}s asynchronously from the specified URI and notifies the * {@link Consumer} when an event has been read. * * @param uri the {@link URI} to the event source * @param consumer the {@link Consumer} accepting the received {@link SseEvent}s * @return a {@link Future} * @throws IOException if sending fails */ @Override public Future receive(final URI uri, final Consumer consumer) throws IOException { return ForkJoinPool.commonPool().submit(new ReceiveTask(uri, consumer)); } /** * Reads {@link SseEvent}s from the {@link ByteBuffer}. *

* NOTE: This method expects complete events to be contained in the buffer. *

* * @param buffer the {@link ByteBuffer} * @return the {@link SseEvent} * @throws IOException if reading the event fails */ @Override public List decode(final ByteBuffer buffer, final boolean isChunked) throws IOException { final List events = new ArrayList<>(); SseEvent currentEvent = new SseEvent(); String currentKey = ""; String currentValue = ""; String line = ""; boolean isReadKey = true; // read all preceding blank lines while (line != null && line.isBlank()) { buffer.mark(); line = BufferUtil.readLine(buffer, Charset.forName("UTF-8")); } buffer.reset(); if (line == null) { return Collections.emptyList(); } int contentLength = 0; if (isChunked) { // first line contains the content-length in hexadecimal line = BufferUtil.readLine(buffer, Charset.forName("UTF-8")); contentLength = Integer.parseInt(line.strip(), 16); } if (!isChunked || contentLength > 0) { final byte[] bytes = new byte[buffer.remaining()]; buffer.get(bytes); final String eventMessage = new String(bytes, Charset.forName("UTF-8")); final BufferedReader reader = new BufferedReader(new StringReader(eventMessage)); line = reader.readLine(); while (line != null) { if (line.isBlank() && currentEvent.getData() != null && currentEvent.getData().length() > 0 && !events.contains(currentEvent)) { events.add(currentEvent); currentEvent = new SseEvent(); } int pos = 0; if (isReadKey) { int idx = line.indexOf(':'); // check if only a part of a key has been received if (idx == -1) { // add whatever is left to the current key currentKey = currentKey + line; pos = line.length(); } else { // otherwise add the line until the : to the key currentKey = currentKey + line.substring(0, idx); pos = ++idx; isReadKey = false; } } if (!isReadKey) { currentValue = currentValue + line.substring(pos); pos = line.length(); if (currentKey.equalsIgnoreCase("event")) { currentEvent.setName(currentValue); } else if (currentKey.equalsIgnoreCase("id")) { currentEvent.setId(currentValue); } else if (currentKey.equalsIgnoreCase("retry")) { try { final Long retry = Long.valueOf(currentValue); currentEvent.setRetry(retry); } catch (final NumberFormatException e) { logger.warning("SSE retry value is not a long value:" + currentValue); } } else if (currentKey.equalsIgnoreCase("data")) { currentEvent.setData((currentEvent.getData() != null ? currentEvent.getData() : "") + currentValue); } else if (currentKey.isBlank()) { currentEvent.setComment(currentValue); } } line = reader.readLine(); // check if a value has been fully read by checking if the previous line ended with // \n (then there must be a new line) if (currentValue.length() > 0 && line != null) { // clear the key and value currentKey = ""; currentValue = ""; isReadKey = true; } } // check if the buffer has only one event without separator (\n\n) if (currentEvent.getData() != null && currentEvent.getData().length() > 0 && !events.contains(currentEvent)) { events.add(currentEvent); currentEvent = new SseEvent(); } } return events; } /** * Encodes the {@link SseEvent}s into a {@link ByteBuffer}. * * @param events the {@link SseEvent}s * @return the {@link ByteBuffer} containing the event * @throws IOException if something fails */ @Override public ByteBuffer encode(final SseEvent... events) throws IOException { final StringBuffer buffer = new StringBuffer(); for (final SseEvent event : events) { if (event.getName() != null) { buffer.append("event:").append(event.getName()).append("\n"); } if (event.getId() != null) { buffer.append("id:").append(event.getId()).append("\n"); } if (event.getComment() != null) { final BufferedReader reader = new BufferedReader(new StringReader(event.getComment())); String line = reader.readLine(); while (line != null) { buffer.append(":").append(line).append("\n"); line = reader.readLine(); } } if (event.getRetry() != SseEvent.RETRY_NOT_SET) { buffer.append("retry:").append(event.getRetry()).append("\n"); } final BufferedReader reader = new BufferedReader(new StringReader(event.getData())); String line = reader.readLine(); while (line != null) { buffer.append("data:").append(line).append("\n"); line = reader.readLine(); } buffer.append("\n"); } return ByteBuffer.wrap(buffer.toString().getBytes()); } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy