https://forum.xwiki.org/t/proposal-websocket-integration/8992

 

https://github.com/xwiki/xwiki-commons/tree/feature-websocket
https://github.com/xwiki/xwiki-platform/tree/feature-websocket

Description

The following is a proposal on how to integrate / use the WebSocket protocol in XWiki.

Use Cases

  • UC1: Be able to implement a WebSocket server end-point as an XWiki component.
  • UC2: Deploy component-based WebSocket end-points automatically when the XWiki web application starts.
  • UC3: Allow XWiki extensions to provide WebSocket end-points that are deployed / undeployed automatically at runtime when the extension is installed / uninstalled.
  • UC4: Be able to handle all WebSocket lifecycle events from the end-point component: connection open, close, error and message received
  • UC5: Be able to send messages back (reply) from an end-point component
  • UC6: Be able to install a WebSocket end-point in a sub-wiki (even if it requires programming rights)
  • UC7: Be able to access the current user (authentication) from an end-point component
  • UC8: Be able to access the current wiki from an end-point component
  • UC9: Be able to access and modify XWiki documents from an end-point component

WebSocket Protocol Implementations

We basically have two options:

  • rely on the implementation provided by the servlet container running XWiki; all servlet containers we support (Jetty and Tomcat) provide an implementation for the WebSocket protocol
  • rely on a library (embedded WebSocket server); this is the path taken by the WebSocket Integration extension from XWiki Contrib (xwiki-contrib-websocket) which uses Netty.

For this proposal I chose the first option because it allows us to use a standard API, the Java API for WebSocket (JSR356, with it's two versions 1.0 and 1.1). Note that there is another standard API, the Jakarta WebSocket 2.0 that servlet containers implement in their latest versions, but we cannot use it ATM because we still need to support some older versions of these servlet containers. Netty doesn't implement the standard API so we would have to implement a bridge by ourselves.

End-point Components

All XWiki WebSocket end-point components will have to implement the org.xwiki.websocket.EndpointComponent role which is just a marker interface (no methods). In order to write the end-point component you will have to use the standard Java API for WebSocket. Based on how the end-points are deployed / registered we can split them in two categories:

  • statically registered: these are implemented using the annotated WebSocket API (@ServerEndpoint); they are deployed only when the XWiki web application is started so they must be available at that time
  • dynamically registered: these are implemented by extending javax.websocket.Endpoint from the standard API; they can be deployed at runtime, e.g. when an XWiki extension is installed

Static End-points

Static end-points have to specify the path they are mapped to. The path can be an URL template so it can accept path parameters. The final URL that will be used to access such an end-point will look like this: 

ws://<host>/<webAppContextPath>/websocket/<endPointPath>
ws://localhost:8080/xwiki/websocket/echo

Here's a simple end-point implementation that simply echoes the messages it receives:

@Component
@Named("org.xwiki.websocket.internal.StaticEchoEndpoint")
@ServerEndpoint("/echo")
@Singleton
public class StaticEchoEndpoint implements EndpointComponent
{
   @OnOpen
   public void onOpen(Session session) throws IOException
   {
        session.getBasicRemote().sendText("Hi!");
   }

   @OnMessage
   public String onMessage(Session session, String message)
   {
       return message;
   }
}

Dynamic End-points

Dynamic end-points can't specify the path they are mapped to. The path is determined automatically based on the role hint. The final URL that will be used to access such an end-point will look like this:

ws://<host>/<webAppContextPath>/websocket/<wiki>/<endPointRoleHint>
ws://localhost:8080/xwiki/websocket/dev/echo

The wiki is needed in the URL in order to look for the end-point component in the right namespace. Here's a simple end-point implementation that simply echoes the messages it receives:

@Component
@Named("echo")
@Singleton
public class DynamicEchoEndpoint extends Endpoint implements EndpointComponent
{
   @Override
   public void onOpen(Session session, EndpointConfig config)
   {
        session.addMessageHandler(new MessageHandler.Whole<String>()
       {
           @Override
           public void onMessage(String message)
           {
                DynamicEchoEndpoint.this.onMessage(session, message);
           }
       });
       try {
            session.getBasicRemote().sendText("Hi!");
       } catch (IOException e) {
       }
   }

   public void onMessage(Session session, String message)
   {
       try {
            session.getBasicRemote().sendText(message);
       } catch (IOException e) {
       }
   }
}

Note that dynamic end-points must register message handlers explicitly in order to be able to handle received message.

WebSocket Context

If your end-point depends on the XWiki context (current wiki, current user, etc.) then you can inject the WebSocketContext component and use it to run your code with the XWiki context properly initialized.

@OnMessage
public String onMessage(Session session, String message) throws Exception
{
   return this.context.call(session, () -> {
        String currentWiki = this.modelContext.getCurrentEntityReference().extractReference(EntityType.WIKI).getName();
       return String.format("[%s] %s -> %s", currentWiki, this.bridge.getCurrentUserReference(), message);
   });
}

AbstractXWikiEndpoint

For dynamic end-points (those extending javax.websocket.Endpoint) we propose to have an abstract base class to hold utility methods:

@Component
@Named("echo")
@Singleton
public class DynamicEchoEndpoint extends AbstractXWikiEndpoint
{
   @Inject
   private DocumentAccessBridge bridge;

   @Inject
   private ModelContext modelContext;

   @Override
   public void onOpen(Session session, EndpointConfig config)
   {
       this.context.run(session, () -> {
           if (this.bridge.getCurrentUserReference() == null) {
                close(session, CloseReason.CloseCodes.CANNOT_ACCEPT,
                   "We don't accept connections from guest users. Please login first.");
           } else {
                session.addMessageHandler(new MessageHandler.Whole<String>()
               {
                   @Override
                   public void onMessage(String message)
                   {
                        handleMessage(session, message);
                   }
               });
           }
       });
   }

   public String onMessage(String message)
   {
        String currentWiki = this.modelContext.getCurrentEntityReference().extractReference(EntityType.WIKI).getName();
       return String.format("[%s] %s -> %s", currentWiki, this.bridge.getCurrentUserReference(), message);
   }
}

The base class will provide two methods for a start:

  • close(Session, CloseCode, String) to close the given session with the specified reason, handling errors
  • handleMessage(Session, T) to handle a received message by calling the onMessage method and sending back the value returned by it (similar to the way the @OnMessage annotation behaves)

WebSocket Script Service

We propose to start with a single $services.websocket.url(String) method to obtain the URL needed to connect to and communicate with the WebSocket end-point. The string parameter will be used to identify the WebSocket end-point this way:

  • if it starts with a slash then it represents a path so it will target the end-point mapped to that path:
    $services.websocket.url('/echo')
    ## ws://localhost:8080/xwiki/websocket/echo
  • otherwise it represents a role hint so it will target the specified end-point component:
    $services.websocket.url('echo')
    ## ws://localhost:8080/xwiki/websocket/dev/echo

    The token before the end-point role hint is the wiki where to look for the component (the namespace).

Example

Here's a JavaScript snippet that can be used to test the echo end-point:

require([], function() {
 var ws = new WebSocket($jsontool.serialize($services.websocket.url('echo')));

  ws.onopen = function() {
    console.log("WebSocket opened.");
    ws.send("Hello World!");
  };

  ws.onclose = function(event) {
    console.log(`WebSocket closed: ${event.code} ${event.reason}`);
  };

  ws.onerror = function(event) {
    console.log(`WebSocket error: ${event.code} ${event.reason}`);
  };

 var counter = 0;
  ws.onmessage = function(message) {
    console.log(message.data);
    setTimeout(function() {
      ws.send("Counter: " + counter++);
    }, 5000);
  };
});

 


Tags:
    

Get Connected