Wiki source code of WebSocket Integration
Last modified by slauriere on 2026/01/28 15:01
Show last authors
| author | version | line-number | content |
|---|---|---|---|
| 1 | The following is a proposal on how to integrate / use the [[WebSocket>>https://en.wikipedia.org/wiki/WebSocket]] protocol in XWiki. | ||
| 2 | |||
| 3 | {{toc depth="3"/}} | ||
| 4 | |||
| 5 | = Use Cases = | ||
| 6 | |||
| 7 | * UC1: Be able to implement a WebSocket server end-point as an [[XWiki component>>extensions:Extension.Component Module]]. | ||
| 8 | * UC2: Deploy component-based WebSocket end-points automatically when the XWiki web application starts. | ||
| 9 | * UC3: Allow XWiki extensions to provide WebSocket end-points that are deployed / undeployed automatically at runtime when the extension is installed / uninstalled. | ||
| 10 | * UC4: Be able to handle all WebSocket lifecycle events from the end-point component: connection open, close, error and message received | ||
| 11 | * UC5: Be able to send messages back (reply) from an end-point component | ||
| 12 | * UC6: Be able to install a WebSocket end-point in a sub-wiki (even if it requires programming rights) | ||
| 13 | * UC7: Be able to access the current user (authentication) from an end-point component | ||
| 14 | * UC8: Be able to access the current wiki from an end-point component | ||
| 15 | * UC9: Be able to access and modify XWiki documents from an end-point component | ||
| 16 | |||
| 17 | = WebSocket Protocol Implementations = | ||
| 18 | |||
| 19 | We basically have two options: | ||
| 20 | |||
| 21 | * rely on the implementation provided by the servlet container running XWiki; all servlet containers [[we support>>dev:Community.SupportStrategy.ServletContainerSupportStrategy.WebHome]] (Jetty and Tomcat) provide an implementation for the WebSocket protocol | ||
| 22 | * rely on a library (embedded WebSocket server); this is the path taken by the [[WebSocket Integration>>extensions:Extension.WebSocket]] extension from XWiki Contrib ([[##xwiki-contrib-websocket##>>https://github.com/xwiki-contrib/xwiki-contrib-websocket]]) which uses Netty. | ||
| 23 | |||
| 24 | For this proposal I chose the first option because it allows us to use a standard API, the [[Java API for WebSocket>>https://www.oracle.com/technical-resources/articles/java/jsr356.html]] (JSR356, with it's two versions 1.0 and 1.1). Note that there is another standard API, the [[Jakarta WebSocket 2.0>>https://jakarta.ee/specifications/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. | ||
| 25 | |||
| 26 | = End-point Components = | ||
| 27 | |||
| 28 | 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: | ||
| 29 | |||
| 30 | * 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 | ||
| 31 | * 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 | ||
| 32 | |||
| 33 | == Static End-points == | ||
| 34 | |||
| 35 | 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: | ||
| 36 | |||
| 37 | {{code language="none"}} | ||
| 38 | ws://<host>/<webAppContextPath>/websocket/<endPointPath> | ||
| 39 | ws://localhost:8080/xwiki/websocket/echo | ||
| 40 | {{/code}} | ||
| 41 | |||
| 42 | Here's a simple end-point implementation that simply echoes the messages it receives: | ||
| 43 | |||
| 44 | {{code language="java"}} | ||
| 45 | @Component | ||
| 46 | @Named("org.xwiki.websocket.internal.StaticEchoEndpoint") | ||
| 47 | @ServerEndpoint("/echo") | ||
| 48 | @Singleton | ||
| 49 | public class StaticEchoEndpoint implements EndpointComponent | ||
| 50 | { | ||
| 51 | @OnOpen | ||
| 52 | public void onOpen(Session session) throws IOException | ||
| 53 | { | ||
| 54 | session.getBasicRemote().sendText("Hi!"); | ||
| 55 | } | ||
| 56 | |||
| 57 | @OnMessage | ||
| 58 | public String onMessage(Session session, String message) | ||
| 59 | { | ||
| 60 | return message; | ||
| 61 | } | ||
| 62 | } | ||
| 63 | {{/code}} | ||
| 64 | |||
| 65 | == Dynamic End-points == | ||
| 66 | |||
| 67 | 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: | ||
| 68 | |||
| 69 | {{code language="none"}} | ||
| 70 | ws://<host>/<webAppContextPath>/websocket/<wiki>/<endPointRoleHint> | ||
| 71 | ws://localhost:8080/xwiki/websocket/dev/echo | ||
| 72 | {{/code}} | ||
| 73 | |||
| 74 | 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: | ||
| 75 | |||
| 76 | {{code language="java"}} | ||
| 77 | @Component | ||
| 78 | @Named("echo") | ||
| 79 | @Singleton | ||
| 80 | public class DynamicEchoEndpoint extends Endpoint implements EndpointComponent | ||
| 81 | { | ||
| 82 | @Override | ||
| 83 | public void onOpen(Session session, EndpointConfig config) | ||
| 84 | { | ||
| 85 | session.addMessageHandler(new MessageHandler.Whole<String>() | ||
| 86 | { | ||
| 87 | @Override | ||
| 88 | public void onMessage(String message) | ||
| 89 | { | ||
| 90 | DynamicEchoEndpoint.this.onMessage(session, message); | ||
| 91 | } | ||
| 92 | }); | ||
| 93 | try { | ||
| 94 | session.getBasicRemote().sendText("Hi!"); | ||
| 95 | } catch (IOException e) { | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | public void onMessage(Session session, String message) | ||
| 100 | { | ||
| 101 | try { | ||
| 102 | session.getBasicRemote().sendText(message); | ||
| 103 | } catch (IOException e) { | ||
| 104 | } | ||
| 105 | } | ||
| 106 | } | ||
| 107 | {{/code}} | ||
| 108 | |||
| 109 | Note that dynamic end-points must register message handlers explicitly in order to be able to handle received message. | ||
| 110 | |||
| 111 | == WebSocket Context == | ||
| 112 | |||
| 113 | 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. | ||
| 114 | |||
| 115 | {{code language="java"}} | ||
| 116 | @OnMessage | ||
| 117 | public String onMessage(Session session, String message) throws Exception | ||
| 118 | { | ||
| 119 | return this.context.call(session, () -> { | ||
| 120 | String currentWiki = this.modelContext.getCurrentEntityReference().extractReference(EntityType.WIKI).getName(); | ||
| 121 | return String.format("[%s] %s -> %s", currentWiki, this.bridge.getCurrentUserReference(), message); | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | {{/code}} | ||
| 125 | |||
| 126 | == AbstractXWikiEndpoint == | ||
| 127 | |||
| 128 | For dynamic end-points (those extending ##javax.websocket.Endpoint##) we propose to have an abstract base class to hold utility methods: | ||
| 129 | |||
| 130 | {{code language="java"}} | ||
| 131 | @Component | ||
| 132 | @Named("echo") | ||
| 133 | @Singleton | ||
| 134 | public class DynamicEchoEndpoint extends AbstractXWikiEndpoint | ||
| 135 | { | ||
| 136 | @Inject | ||
| 137 | private DocumentAccessBridge bridge; | ||
| 138 | |||
| 139 | @Inject | ||
| 140 | private ModelContext modelContext; | ||
| 141 | |||
| 142 | @Override | ||
| 143 | public void onOpen(Session session, EndpointConfig config) | ||
| 144 | { | ||
| 145 | this.context.run(session, () -> { | ||
| 146 | if (this.bridge.getCurrentUserReference() == null) { | ||
| 147 | close(session, CloseReason.CloseCodes.CANNOT_ACCEPT, | ||
| 148 | "We don't accept connections from guest users. Please login first."); | ||
| 149 | } else { | ||
| 150 | session.addMessageHandler(new MessageHandler.Whole<String>() | ||
| 151 | { | ||
| 152 | @Override | ||
| 153 | public void onMessage(String message) | ||
| 154 | { | ||
| 155 | handleMessage(session, message); | ||
| 156 | } | ||
| 157 | }); | ||
| 158 | } | ||
| 159 | }); | ||
| 160 | } | ||
| 161 | |||
| 162 | public String onMessage(String message) | ||
| 163 | { | ||
| 164 | String currentWiki = this.modelContext.getCurrentEntityReference().extractReference(EntityType.WIKI).getName(); | ||
| 165 | return String.format("[%s] %s -> %s", currentWiki, this.bridge.getCurrentUserReference(), message); | ||
| 166 | } | ||
| 167 | } | ||
| 168 | {{/code}} | ||
| 169 | |||
| 170 | The base class will provide two methods for a start: | ||
| 171 | |||
| 172 | * ##close(Session, CloseCode, String)## to close the given session with the specified reason, handling errors | ||
| 173 | * ##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) | ||
| 174 | |||
| 175 | = WebSocket Script Service = | ||
| 176 | |||
| 177 | 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: | ||
| 178 | |||
| 179 | * if it starts with a slash then it represents a **path** so it will target the end-point mapped to that path:((( | ||
| 180 | {{code language="none"}} | ||
| 181 | $services.websocket.url('/echo') | ||
| 182 | ## ws://localhost:8080/xwiki/websocket/echo | ||
| 183 | {{/code}} | ||
| 184 | ))) | ||
| 185 | * otherwise it represents a **role hint** so it will target the specified end-point component:((( | ||
| 186 | {{code language="none"}} | ||
| 187 | $services.websocket.url('echo') | ||
| 188 | ## ws://localhost:8080/xwiki/websocket/dev/echo | ||
| 189 | {{/code}} | ||
| 190 | |||
| 191 | The token before the end-point role hint is the wiki where to look for the component (the namespace). | ||
| 192 | ))) | ||
| 193 | |||
| 194 | = Example = | ||
| 195 | |||
| 196 | Here's a JavaScript snippet that can be used to test the echo end-point: | ||
| 197 | |||
| 198 | {{code language="js"}} | ||
| 199 | require([], function() { | ||
| 200 | var ws = new WebSocket($jsontool.serialize($services.websocket.url('echo'))); | ||
| 201 | |||
| 202 | ws.onopen = function() { | ||
| 203 | console.log("WebSocket opened."); | ||
| 204 | ws.send("Hello World!"); | ||
| 205 | }; | ||
| 206 | |||
| 207 | ws.onclose = function(event) { | ||
| 208 | console.log(`WebSocket closed: ${event.code} ${event.reason}`); | ||
| 209 | }; | ||
| 210 | |||
| 211 | ws.onerror = function(event) { | ||
| 212 | console.log(`WebSocket error: ${event.code} ${event.reason}`); | ||
| 213 | }; | ||
| 214 | |||
| 215 | var counter = 0; | ||
| 216 | ws.onmessage = function(message) { | ||
| 217 | console.log(message.data); | ||
| 218 | setTimeout(function() { | ||
| 219 | ws.send("Counter: " + counter++); | ||
| 220 | }, 5000); | ||
| 221 | }; | ||
| 222 | }); | ||
| 223 | {{/code}} |