|
1 |
| -# typescript-server-events |
2 |
| -Showcase of different TypeScript Apps utilizing Server Events |
| 1 | +# TypeScript Service Client and Server Events Apps |
| 2 | + |
| 3 | +This project contains a number of self-contained TypeScript and JavaScript projects showcasing the different JavaScript runtime environments that can leverage |
| 4 | +[servicestack-client](https://github.com/ServiceStack/servicestack-client) |
| 5 | +to enable typed end-to-end API calls using the generic `JsonServiceClient` and the generated |
| 6 | +[TypeScript Add ServiceStack Reference](http://docs.servicestack.net/typescript-add-servicestack-reference) DTOs as well as easily handling real-time notifications using |
| 7 | +[TypeScript ServerEventsClient](http://docs.servicestack.net/typescript-server-events-client) |
| 8 | +with minimal effort. |
| 9 | + |
| 10 | +The [servicestack-client](https://github.com/ServiceStack/servicestack-client) npm |
| 11 | +package contains an isomorphic library that can be used in either JavaScript or TypeScript Single Page Web Apps, node.js server projects as well as React Native Mobile Apps. It closely follows the design of the |
| 12 | +[C#/.NET JsonServiceClient](http://docs.servicestack.net/csharp-client) and C# |
| 13 | +[ServerEventsClient](http://docs.servicestack.net/csharp-server-events-client) |
| 14 | +in idiomatic TypeScript to maximize **knowledge sharing** and minimize native **porting efforts** between the different languages [Add ServiceStack Reference supports](http://docs.servicestack.net/add-servicestack-reference#supported-languages). |
| 15 | + |
| 16 | +The examples in this project below explore the simplicity, type benefits and value provided by the |
| 17 | +`JsonServiceClient` and `ServerEventsClient` which enables 100% code sharing of client logic across |
| 18 | +JavaScript's most popular environments. |
| 19 | + |
| 20 | +## Web App |
| 21 | + |
| 22 | +The [Web Example App](https://github.com/ServiceStackApps/typescript-server-events/tree/master/web) |
| 23 | +was built with in [>100 lines of application code](https://github.com/ServiceStackApps/typescript-server-events/blob/master/web/src/app.ts) |
| 24 | +and uses no external runtime library dependencies other than |
| 25 | +[servicestack-client](https://github.com/ServiceStack/servicestack-client) for its functional Web App |
| 26 | +that can connect to any ServerEvents-enabled ServiceStack instance (with CORS) to keep a real-time log of all |
| 27 | +commands sent to the subscribed channel with a synchronized Live list of other Users that are also currently subscribed to the channel. |
| 28 | + |
| 29 | +The Web App is spread across the 4 files below with all functionality maintained in **app.ts**: |
| 30 | + |
| 31 | + - [app.ts](https://github.com/ServiceStackApps/typescript-server-events/blob/master/web/src/app.ts) - Entire App Logic |
| 32 | + - [dtos.ts](https://github.com/ServiceStackApps/typescript-server-events/blob/master/web/src/dtos.ts) - Server generated DTOs from [chat.servicestack.net/types/typescript](http://chat.servicestack.net/types/typescript) |
| 33 | + - [index.html](https://github.com/ServiceStackApps/typescript-server-events/blob/master/web/index.html) - Static HTML page |
| 34 | + - [default.css](https://github.com/ServiceStackApps/typescript-server-events/blob/master/web/default.css) - Static default.css styles |
| 35 | + |
| 36 | + |
| 37 | + |
| 38 | +### Web Server Events Configuration |
| 39 | + |
| 40 | +The heart of the App that's driving all its functionality is the Server Events subscription contained |
| 41 | +in these few lines below: |
| 42 | + |
| 43 | +```ts |
| 44 | +const startListening = () => { |
| 45 | + BASEURL = $("#baseUrl").value; |
| 46 | + CHANNEL = $("#channel").value; |
| 47 | + if (client != null) |
| 48 | + client.stop(); |
| 49 | + |
| 50 | + console.log(`Connecting to ${BASEURL} on channel ${CHANNEL}`); |
| 51 | + client = new ServerEventsClient(BASEURL, [CHANNEL], { |
| 52 | + handlers: { |
| 53 | + onConnect: (e:ServerEventConnect) => { |
| 54 | + refresh(sub = e); |
| 55 | + }, |
| 56 | + onJoin: refresh, |
| 57 | + onLeave: refresh, |
| 58 | + onUpdate: refresh, |
| 59 | + onMessage: (e:ServerEventMessage) => { |
| 60 | + addMessage(e); |
| 61 | + refreshMessages(); |
| 62 | + } |
| 63 | + }, |
| 64 | + onException: e => { |
| 65 | + addMessageHtml(`<div class="error">${e.message || e}</div>`); |
| 66 | + } |
| 67 | + }).start(); |
| 68 | +} |
| 69 | +``` |
| 70 | + |
| 71 | +### Handler implementations |
| 72 | + |
| 73 | +Essentially declarative configuration hooking up different Server Events to the `refresh` handlers |
| 74 | +below which adds the command message to the channels `MESSAGES` list, updates the UI then refreshes the |
| 75 | +`users` list by calling the built-in `client.getChannelSubscribers()`: |
| 76 | + |
| 77 | +```ts |
| 78 | +const $ = sel => document.querySelector(sel); |
| 79 | +const $msgs = $("#messages > div") as HTMLDivElement; |
| 80 | +const $users = $("#users > div") as HTMLDivElement; |
| 81 | + |
| 82 | +const refresh = (e:ServerEventMessage) => { |
| 83 | + addMessage(e); |
| 84 | + refreshMessages(); |
| 85 | + refreshUsers(); |
| 86 | +}; |
| 87 | + |
| 88 | +const refreshUsers = async () => { |
| 89 | + var users = await client.getChannelSubscribers(); |
| 90 | + users.sort((x,y) => y.userId.localeCompare(x.userId)); |
| 91 | + var usersMap = {}; |
| 92 | + var userIds = Object.keys(usersMap); |
| 93 | + var html = users.map(x => |
| 94 | + `<div class="${x.userId == sub.userId ? 'me' : ''}"> |
| 95 | + <img src="${x.profileUrl}" /><b>@${x.displayName}</b><i>#${x.userId}</i><br/> |
| 96 | + </div>`); |
| 97 | + $users.innerHTML = html.join(''); |
| 98 | +}; |
| 99 | + |
| 100 | +const addMessage = (x:ServerEventMessage) => addMessageHtml( |
| 101 | + `<div><b>${x.selector}</b> |
| 102 | + <span class="json" title=${x.json}>${x.json}</span> |
| 103 | + </div>`); |
| 104 | +const addMessageHtml = (html) => (MESSAGES[CHANNEL] || (MESSAGES[CHANNEL]=[])).push(html); |
| 105 | +const refreshMessages = () => $msgs.innerHTML= (MESSAGES[CHANNEL]||[]).reverse().join(''); |
| 106 | +``` |
| 107 | + |
| 108 | +### Changing Server Subscription |
| 109 | + |
| 110 | +To change the server and channel we want to connect to we just need to `startListening()` again |
| 111 | +when the **change** button is clicked: |
| 112 | + |
| 113 | +```csharp |
| 114 | +$("#btnChange").onclick = startListening; |
| 115 | +``` |
| 116 | + |
| 117 | +Which will close the previous subscription and start a new one at the new server and channel. |
| 118 | +You can test connecting to another server by connecting to the .NET Core version of Chat at [chat.netcore.io](http://chat.netcore.io). |
| 119 | + |
| 120 | +### Calling Typed Web Services |
| 121 | + |
| 122 | +The Web App also sends messages |
| 123 | + |
| 124 | +Download the TypeScript DTOs from the [chat.servicestack.net](http://chat.servicestack.net) at |
| 125 | +[/types/typescript](http://docs.servicestack.net/add-servicestack-reference#language-paths) |
| 126 | + |
| 127 | + curl http://chat.servicestack.net/types/typescript > dtos.ts |
| 128 | + |
| 129 | +Then once their downloaded we can reference the Request DTO's of the Services we want to call with: |
| 130 | + |
| 131 | +```ts |
| 132 | +import { PostChatToChannel, PostRawToChannel } from "./dtos"; |
| 133 | +``` |
| 134 | + |
| 135 | +Which just like all |
| 136 | +[ServiceStack Reference languages](http://docs.servicestack.net/add-servicestack-reference#supported-languages) |
| 137 | +we can populate and send with a generic `JsonServiceClient`, an instance of which is also pre-configured with the same `{baseUrl}` available at `client.serviceClient`, e.g: |
| 138 | + |
| 139 | +```ts |
| 140 | +const sendChat = () => { |
| 141 | + let request = new PostChatToChannel(); |
| 142 | + request.from = sub.id; |
| 143 | + request.channel = CHANNEL; |
| 144 | + request.selector = "cmd.chat"; |
| 145 | + request.message = $("#txtChat").value; |
| 146 | + client.serviceClient.post(request); |
| 147 | +}; |
| 148 | +``` |
| 149 | + |
| 150 | +All that's left is sending the chat message which we can do by pressing the **chat** button or hitting enter: |
| 151 | + |
| 152 | +```ts |
| 153 | +$("#btnSendChat").onclick = sendChat; |
| 154 | +$("#txtChat").onkeydown = e => e.keyCode == 13 ? sendChat() : null; |
| 155 | +``` |
| 156 | + |
| 157 | +### Running Web App |
| 158 | + |
| 159 | +To see it in action we just need to launch a static Web Server in the `/web` directory, e.g: |
| 160 | + |
| 161 | + cd web |
| 162 | + http-server |
| 163 | + |
| 164 | +Which will launch a HTTP Server at `http://localhost:8080/` which you can play with in your browser. |
| 165 | + |
| 166 | +### Making changes to Web App |
| 167 | + |
| 168 | +The vibrant ecosystem surrounding npm makes it the best place to develop Single Page Apps with world class |
| 169 | +tools like [Babel](https://babeljs.io/) which you can run in a command-line with: |
| 170 | + |
| 171 | + npm run watch |
| 172 | + |
| 173 | +That will launch a background watcher to monitor your source files for changes and **on save** automatically |
| 174 | +pipe them through the TypeScript compiler and bundle your app in `/dist/bundle.js` which |
| 175 | +is the only .js source file our app needs to reference and reload with **F5** to see any changes. |
| 176 | + |
| 177 | +## node.js Server App |
| 178 | + |
| 179 | +The [/node](https://github.com/ServiceStackApps/typescript-server-events/tree/master/node) server.js app |
| 180 | +has exactly the same functionality as the Web App except instead of using **servicestack-client** to connect |
| 181 | +to [chat.servicestack.net](http://chat.servicestack.net) Server Events stream on the client, all |
| 182 | +connections are made in node.js and only the server state is sent to the client to render its UI. |
| 183 | + |
| 184 | +As the functionality of the app remains the same we're able to reuse the existing DTOs, .html and .css: |
| 185 | + |
| 186 | + - [dtos.ts](https://github.com/ServiceStackApps/typescript-server-events/blob/master/node/src/dtos.ts) |
| 187 | + - [index.html](https://github.com/ServiceStackApps/typescript-server-events/blob/master/node/index.html) |
| 188 | + - [default.css](https://github.com/ServiceStackApps/typescript-server-events/blob/master/node/default.css) |
| 189 | + |
| 190 | + |
| 191 | + |
| 192 | +The difference is in the App's logic which is now split into 2 with the node.js `server.ts` now containing |
| 193 | +most of the App's functionality whilst the `app.ts` relegated to periodically updating the UI with the |
| 194 | +node.js server state: |
| 195 | + |
| 196 | + - [server.ts](https://github.com/ServiceStackApps/typescript-server-events/blob/master/node/server.ts) - maintain all client and server events connection to [chat.servicestack.net](http://chat.servicestack.net) |
| 197 | + - [app.ts](https://github.com/ServiceStackApps/typescript-server-events/blob/master/node/src/app.ts) - periodically render node.js state to HTML UI |
| 198 | + |
| 199 | +As our goal is to maintain the minimal dependencies in each App, the implementation of `server.ts` is |
| 200 | +written against a bare-bones node `http.createServer()` without utilizing a server web framework which |
| 201 | +makes the implementation more verbose but also easier to understand as we're not relying on any hidden |
| 202 | +functionality enabled in a server web framework. |
| 203 | + |
| 204 | +### Enable Server Events |
| 205 | + |
| 206 | +A change we need to make given our App is now running in node.js instead of in a browser is to import the |
| 207 | +[eventsource](https://www.npmjs.com/package/eventsource) pure JavaScript polyfill to provide an `EventSource` implementation in node.js which we can make available in the ``global`` scope in TypeScript with: |
| 208 | + |
| 209 | +```ts |
| 210 | +declare var global:any; |
| 211 | +global.EventSource = require('eventsource'); |
| 212 | +``` |
| 213 | + |
| 214 | +### Node.js Server Events Configuration |
| 215 | + |
| 216 | +Whilst the environment is different the Server Events configuration remains largely the same, but instead of |
| 217 | +retrieving the connection info from Text boxes in a Web Page, it's instead retrieved from the queryString |
| 218 | +passed when the client App calls our `/listen` handler, e.g: |
| 219 | + |
| 220 | +```ts |
| 221 | +"/listen": (req,res) => { |
| 222 | + const qs = url.parse(req.url, true).query; |
| 223 | + if (client) { |
| 224 | + client.stop(); |
| 225 | + client = null; |
| 226 | + } |
| 227 | + BASEURL = qs["baseUrl"]; |
| 228 | + CHANNEL = qs["channel"]; |
| 229 | + console.log(`Connecting to ${BASEURL} #${CHANNEL}...`); |
| 230 | + client = new ServerEventsClient(BASEURL, [CHANNEL], { |
| 231 | + handlers: { |
| 232 | + onConnect: (e:ServerEventConnect) => { |
| 233 | + refresh(sub = e); |
| 234 | + }, |
| 235 | + onJoin: refresh, |
| 236 | + onLeave: refresh, |
| 237 | + onUpdate: refresh, |
| 238 | + onMessage: (e:ServerEventMessage) => { |
| 239 | + addMessage(e); |
| 240 | + } |
| 241 | + }, |
| 242 | + onException: e => { |
| 243 | + addMessageHtml(`<div class="error">${e.message || e}</div>`); |
| 244 | + } |
| 245 | + }).start(); |
| 246 | + res.end(); |
| 247 | +}, |
| 248 | +``` |
| 249 | + |
| 250 | +### Node.js Handler implementation |
| 251 | + |
| 252 | +The handler implementations are more or less the same as the Web App albeit a bit simpler as it just needs |
| 253 | +to capture the Server Event messages and not concern itself with updating the UI: |
| 254 | + |
| 255 | +```ts |
| 256 | +var MESSAGES = []; |
| 257 | +var USERS = []; |
| 258 | + |
| 259 | +const refresh = (e:ServerEventMessage) => { |
| 260 | + addMessage(e); |
| 261 | + refreshUsers(); |
| 262 | +}; |
| 263 | +const refreshUsers = async () => { |
| 264 | + var users = await client.getChannelSubscribers(); |
| 265 | + users.sort((x,y) => y.userId.localeCompare(x.userId)); |
| 266 | + |
| 267 | + var usersMap = {}; |
| 268 | + var userIds = Object.keys(usersMap); |
| 269 | + USERS = users.map(x => ({ |
| 270 | + profileUrl: x.profileUrl, |
| 271 | + displayName: x.displayName, |
| 272 | + userId: x.userId |
| 273 | + })); |
| 274 | +}; |
| 275 | +const addMessage = (x:ServerEventMessage) => |
| 276 | + addMessageHtml(`<div><b>${x.selector}</b> <span class="json" title=${x.json}>${x.json}</span></div>`); |
| 277 | +const addMessageHtml = (html:string) => |
| 278 | + (MESSAGES[CHANNEL] || (MESSAGES[CHANNEL] = [])).push(html); |
| 279 | +``` |
| 280 | + |
| 281 | +### Syncing the UI with Server state in node.js |
| 282 | + |
| 283 | +Syncing and rendering the UI is now the primary job of our clients `app.ts` which just polls the servers |
| 284 | +`/state` route every 100ms and injects it into the HTML UI: |
| 285 | + |
| 286 | +```ts |
| 287 | +const syncState = () => { |
| 288 | + client.get<any>("/state").then(state => { |
| 289 | + var html = state.users.map(x => |
| 290 | + `<div class="${x.userId == state.sub.userId ? 'me' : ''}"> |
| 291 | + <img src="${x.profileUrl}" /><b>@${x.displayName}</b><i>#${x.userId}</i><br/> |
| 292 | + </div>`); |
| 293 | + $users.innerHTML = html.join(''); |
| 294 | + $msgs.innerHTML = state.messages.reverse().join(''); |
| 295 | + }); |
| 296 | +}; |
| 297 | + |
| 298 | +setInterval(syncState, 100); |
| 299 | +``` |
| 300 | + |
| 301 | +The `/state` handler being just dumping the state and collections to JSON: |
| 302 | + |
| 303 | +```ts |
| 304 | +"/state": (req, res) => { |
| 305 | + var state = { |
| 306 | + baseUrl: BASEURL, |
| 307 | + channel: CHANNEL, |
| 308 | + sub, |
| 309 | + messages: (MESSAGES[CHANNEL] || ['<div class="error">NOT CONNECTED</div>']), |
| 310 | + users: USERS |
| 311 | + }; |
| 312 | + res.writeHead(200, { "Content-Type": "application/json" }); |
| 313 | + res.end(JSON.stringify(state)); |
| 314 | +}, |
| 315 | +``` |
| 316 | + |
| 317 | +### Calling Typed Web Services in node.js |
| 318 | + |
| 319 | +As we can expect making Typed API calls in node.js is the same as in a browser except the user data |
| 320 | +comes from a queryString instead of a HTML UI: |
| 321 | + |
| 322 | +```ts |
| 323 | +"/chat": (req,res) => { |
| 324 | + const qs = url.parse(req.url, true).query; |
| 325 | + let request = new PostChatToChannel(); |
| 326 | + request.from = sub.id; |
| 327 | + request.channel = CHANNEL; |
| 328 | + request.selector = "cmd.chat"; |
| 329 | + request.message = qs["message"]; |
| 330 | + client.serviceClient.post(request); |
| 331 | + res.end(); |
| 332 | +}, |
| 333 | +``` |
| 334 | + |
| 335 | +Back in client `app.ts` land our event handlers are exactly the same, the difference is in `sendChat()` |
| 336 | +where instead of making the API call itself it tells the node `server.ts` to do it by calling the `/chat` |
| 337 | +handler: |
| 338 | + |
| 339 | +```ts |
| 340 | +const sendChat = () => client.get("/chat", { message: $("#txtChat").value }); |
| 341 | + |
| 342 | +$("#btnSendChat").onclick = sendChat; |
| 343 | +$("#txtChat").onkeydown = e => e.keyCode == 13 ? sendChat() : null; |
| 344 | +``` |
| 345 | + |
| 346 | +### Running node server.ts |
| 347 | + |
| 348 | +To run our node app we need to launch the compiled `server.js` App with: |
| 349 | + |
| 350 | + cd node |
| 351 | + node server.js |
| 352 | + |
| 353 | +Which also launches our HTTP Server at `http://localhost:8080/`. |
| 354 | + |
| 355 | +### Making changes to Web App |
| 356 | + |
| 357 | +Since there's now a client and server component, we still need to run **Babel** to monitor our source files |
| 358 | +for changes and regenerate client `/dist/bundle.js`: |
| 359 | + |
| 360 | + npm run watch |
| 361 | + |
| 362 | +But if we've a change to `server.ts` we need to compile it by running: |
| 363 | + |
| 364 | + tsc |
| 365 | + |
| 366 | +Then we can re-run our server to see our changes: |
| 367 | + |
| 368 | + node server.js |
| 369 | + |
| 370 | +## React Native App |
| 371 | + |
| 372 | + |
| 373 | + |
| 374 | + |
0 commit comments