├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── build-test.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── design.md ├── documentation ├── .gitignore ├── config.js ├── content │ ├── api │ │ └── index.md │ ├── docs │ │ ├── contributing.md │ │ └── index.md │ ├── icon │ │ ├── favicon-160x160.png │ │ ├── favicon-16x16.png │ │ ├── favicon-196x196.png │ │ ├── favicon-32x32.png │ │ └── favicon-96x96.png │ ├── index.pug │ ├── logo.png │ └── logo.svg ├── data │ └── menu.yml └── tpl │ ├── __en__ │ └── __sidebar__ ├── js ├── .eslintrc.js ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── prettier.config.js ├── src │ ├── CompositeClosureHelper │ │ └── index.js │ ├── ProcessLauncher │ │ ├── api.md │ │ └── index.js │ ├── SmartConnect │ │ ├── api.md │ │ ├── index.d.ts │ │ └── index.js │ ├── WebsocketConnection │ │ ├── api.md │ │ ├── chunking.js │ │ ├── chunking.ts │ │ ├── index.d.ts │ │ ├── index.js │ │ └── session.js │ └── index.js ├── test │ └── simple.js ├── webpack-test-simple.config.js └── webpack.config.js ├── python ├── MANIFEST.in ├── README.rst ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── src │ ├── tests │ ├── __init__.py │ ├── test-wslink.conf │ ├── testEmitter.py │ ├── testImport.py │ └── testWSProtocol.py │ └── wslink │ ├── LICENSE │ ├── __init__.py │ ├── backends │ ├── __init__.py │ ├── aiohttp │ │ ├── __init__.py │ │ ├── launcher.py │ │ └── relay.py │ ├── generic │ │ ├── __init__.py │ │ └── core.py │ ├── jupyter │ │ ├── __init__.py │ │ └── core.py │ └── tornado │ │ ├── __init__.py │ │ └── core.py │ ├── chunking.py │ ├── emitter.py │ ├── launcher.py │ ├── protocol.py │ ├── publish.py │ ├── relay.py │ ├── server.py │ ├── ssl_context.py │ ├── uri.py │ └── websocket.py └── tests ├── chat-rpc-pub-sub ├── README.md ├── clients │ ├── cpp │ │ ├── CMakeLists.txt │ │ ├── README.md │ │ ├── cli_test.cc │ │ └── demo.gif │ └── js │ │ ├── .eslintrc.cjs │ │ ├── .gitignore │ │ ├── README.md │ │ ├── index.html │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ ├── App.vue │ │ └── main.js │ │ └── vite.config.js ├── launcher.config ├── server │ ├── api.py │ └── chat.py └── www │ ├── assets │ ├── index-08a953cc.css │ └── index-0ffcde62.js │ └── index.html └── simple ├── README.md ├── launcher.config ├── server ├── kitware.png ├── kitware2.png ├── myProtocol.py └── simple.py └── www ├── .gitignore └── README.md /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.py] 16 | indent_size = 4 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: 'https://www.kitware.com/contact-us/' -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a reproducible bug or regression. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Describe the bug** 13 | 14 | A clear and concise description of what the bug is. Before submitting, please remove unnecessary sections. 15 | 16 | **To Reproduce** 17 | 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. See error 22 | 23 | ***Code*** 24 | 25 | ```python 26 | # code goes here 27 | ``` 28 | 29 | **Expected behavior** 30 | 31 | A clear and concise description of what you expected to happen. 32 | 33 | **Screenshots** 34 | 35 | If applicable, add screenshots to help explain your problem (drag and drop the image). 36 | 37 | **Platform:** 38 | 39 | ***Device:*** 40 | 41 | - [ ] Desktop 42 | - [ ] Mobile 43 | 44 | ***OS:*** 45 | 46 | - [ ] Windows 47 | - [ ] MacOS 48 | - [ ] Linux 49 | - [ ] Android 50 | - [ ] iOS 51 | 52 | ***Browsers Affected:*** 53 | 54 | - [ ] Chrome 55 | - [ ] Firefox 56 | - [ ] Safari 57 | - [ ] IE (Version) 58 | 59 | ***wslink version:*** 60 | vMAJOR.MINOR.PATCH 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Help and Support 4 | url: https://discourse.paraview.org/ 5 | about: Please use the forum if you have questions or need help. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for the project. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Is your feature request related to a problem? Please describe.** 13 | 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe the solution you'd like** 17 | 18 | A clear and concise description of what you want to happen. 19 | 20 | **Describe alternatives you've considered** 21 | 22 | A clear and concise description of any alternative solutions or features you've considered. 23 | 24 | **Additional context** 25 | 26 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | A clear and concise description of what the problem was and how this pull request solves it. 2 | 3 | fix #ISSUE_NUMBER 4 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build-js: 11 | name: "JS build: ${{ matrix.os }} - node ${{ matrix.node-version }}" 12 | runs-on: ${{ matrix.os }} 13 | env: 14 | webclient: ./js 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | node-version: [20, 22] 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Node ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Install dependencies 29 | working-directory: ${{env.webclient}} 30 | run: npm ci 31 | 32 | - name: Build Web client 33 | working-directory: ${{env.webclient}} 34 | run: npm run build:release 35 | 36 | test-python: 37 | name: "Python tests: ${{ matrix.os }} - ${{ matrix.python-version }}" 38 | env: 39 | python_pkg: ./python 40 | runs-on: ${{ matrix.os }} 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | os: [ubuntu-latest] 45 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 46 | 47 | defaults: 48 | run: 49 | shell: bash 50 | 51 | steps: 52 | - name: Checkout 53 | uses: actions/checkout@v4 54 | 55 | - name: Set up Python ${{ matrix.python-version }} 56 | uses: actions/setup-python@v5 57 | with: 58 | python-version: ${{ matrix.python-version }} 59 | 60 | - name: Install and Run Tests 61 | working-directory: ${{env.python_pkg}} 62 | run: | 63 | pip install . 64 | python -m unittest discover src/tests 65 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | publish: 9 | name: Publish 10 | runs-on: ubuntu-latest 11 | env: 12 | webclient: ./js 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | - name: Setup node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | - name: Web build 23 | run: | 24 | npm ci 25 | npm run build:release 26 | working-directory: ${{env.webclient}} 27 | - name: Python 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: 3.9 31 | - name: Install setuptools 32 | run: python -m pip install --upgrade "setuptools<70" wheel twine 33 | - name: Release (JS + Python) 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 38 | run: | 39 | git config --global user.name "Github Actions" 40 | git config --global user.email "sebastien.jourdain@kitware.com" 41 | npm run semantic-release 42 | working-directory: ${{env.webclient}} 43 | - name: Publish docs 44 | if: github.ref == 'refs/heads/master' 45 | env: 46 | GIT_PUBLISH_URL: https://${{ secrets.GH_PUBLISH_CREDS }}@github.com/Kitware/wslink.git 47 | run: npm run doc:publish 48 | working-directory: ${{env.webclient}} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | py-env 2 | .DS_Store 3 | runtime* 4 | build 5 | dist 6 | logs 7 | __pycache__ 8 | *.pyc 9 | *.swp 10 | *.egg-info 11 | .npm-packages 12 | .npmrc 13 | npm-debug.log 14 | node_modules 15 | wslink.js 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Kitware Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 25 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wslink 2 | 3 | Wslink allows easy, bi-directional communication between a python server and a 4 | javascript or C++ client over a [websocket]. The client can make remote procedure 5 | calls (RPC) to the server, and the server can publish messages to topics that 6 | the client can subscribe to. The server can include binary attachments in 7 | these messages, which are communicated as a binary websocket message, avoiding 8 | the overhead of encoding and decoding. 9 | 10 | ## RPC and publish/subscribe 11 | 12 | The initial users of wslink driving its development are [VTK] and [ParaView]. 13 | ParaViewWeb and vtkWeb require: 14 | * RPC - a remote procedure call that can be fired by the client and return 15 | sometime later with a response from the server, possibly an error. 16 | 17 | * Publish/subscribe - client can subscribe to a topic provided by the server, 18 | possibly with a filter on the parts of interest. When the topic has updated 19 | results, the server publishes them to the client, without further action on 20 | the client's part. 21 | 22 | Wslink is replacing a communication layer based on Autobahn WAMP, and so one 23 | of the goals is to be fairly compatible with WAMP, but simplify the interface 24 | to the point-to-point communication we actually use. 25 | 26 | ## Examples 27 | 28 | * Set up a Python (3.3+) [virtualenv] using requirements.txt. Roughly: 29 | - `cd wslink/python` 30 | - `pip install virtualenv` 31 | - `virtualenv runtime` 32 | - `source runtime/Scripts/activate` (on Windows) 33 | - `pip install -e .` (to use current wslink for development) 34 | * Install node.js 10+ for the javascript client 35 | * `cd wslink/js` 36 | * `npm run test` 37 | - or: 38 | * `npm run build:example` 39 | * `cd ../tests/simple` 40 | * `python server/simple.py` 41 | - starts a webserver at [localhost](http://localhost:8080/) with buttons to test RPC and pub/sub methods 42 | 43 | ## Existing API 44 | 45 | Existing ParaViewWeb applications use these code patterns: 46 | * @exportRPC decorator in Python server code to register a method as being remotely callable 47 | * session.call("method.uri", [args]) in the JavaScript client to make an RPC call. Usually wrapped as an object method so it appears to be a normal class method. 48 | * session.subscribe("method.uri", callback) in JS client to initiate a pub/sub relationship. 49 | * server calls self.publish("method.uri", result) to push info back to the client 50 | 51 | We don't support introspection or initial handshake about which methods are 52 | supported - the client and server must be in sync. 53 | 54 | Message format: 55 | ```javascript 56 | { 57 | const request = { 58 | wslink: 1.0, 59 | id: `rpc:${clientId}:${count}`, 60 | method: 'myapp.render.window.image', 61 | args: [], 62 | kwargs: { w: 512, h: 512 } 63 | }; 64 | 65 | const response = { 66 | wslink: 1.0, 67 | id: `rpc:${clientId}:${count}`, 68 | result: {}, // either result or error, not both 69 | error: {} 70 | }; 71 | 72 | // types used as prefix for id. 73 | const types = ['rpc', 'publish', 'system']; 74 | } 75 | ``` 76 | 77 | ```python 78 | # add a binary attachment 79 | def getImage(self): 80 | return { 81 | "size": [512, 512], 82 | "blob": session.addAttachment(memoryview(dataArray)), 83 | "mtime": dataArray.getMTime() 84 | } 85 | ``` 86 | 87 | ### Binary attachments 88 | 89 | session.addAttachment() takes binary data and stores it, returning a string key 90 | that will be associated with the attachment. When a message is sent that uses 91 | the attachment key, a text header message and a binary message is sent 92 | beforehand with each attachment. The client will then substitute the binary 93 | buffer for the string key when it receives the final message. 94 | 95 | ### Subscribe 96 | 97 | The client tracks subscriptions - the server currently blindly sends out 98 | messages for any data it produces which might be subscribed to. This is not 99 | very efficient - if the client notifies the server of a subscription, it can 100 | send the data only when someone is listening. The ParaViewWeb app Visualizer 101 | makes an RPC call after subscribing to tell the server to start publishing. 102 | 103 | ### Handshake 104 | 105 | When the client initially connects, it sends a 'hello' to authenticate with 106 | the server, so the server knows this client can handle the messages it sends, 107 | and the server can provide the client with a unique client ID - which the 108 | client must embed in the rpc "id" field of its messages to the server. 109 | 110 | * The first message the client sends should be hello, with the secret key provided by its launcher. 111 | * Server authenticates the key, and responds with the client ID. 112 | * If the client sends the wrong key or no key, the server responds with an authentication error message. 113 | 114 | ### Design 115 | 116 | More extensive discussion in the [design](design.md) document. 117 | 118 | [ParaView]: https://www.paraview.org/web/ 119 | [virtualenv]: https://virtualenv.pypa.io/ 120 | [VTK]: http://www.vtk.org/ 121 | [websocket]: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket 122 | -------------------------------------------------------------------------------- /design.md: -------------------------------------------------------------------------------- 1 | # wslink Design and Motivation 2 | 3 | wslink grew out of several needs and pressures, and I've collected information 4 | and notes about the background and design here. 5 | 6 | ## ParaViewWeb RPC and publish/subscribe 7 | 8 | ParaViewWeb uses autobahn WAMP protocol for RPC and publish/subscribe messages 9 | as of May 2017. Due to changes to autobahn's WAMP implementation, we can't 10 | upgrade autobahn and continue using WAMP. We require: 11 | * RPC - a remote procedure call that can be fired by the client and return sometime later with a response from the server, possibly an error. 12 | * Publish/subscribe - client can subscribe to a topic provided by the server, possibly with a filter on the parts of interest. When the topic has updated results, the server publishes them to the client, without further action on the client's part. 13 | 14 | We would also like: 15 | * Real binary messages - WebSockets support binary messages, and one of our 16 | major use cases is publishing images (rendered frames). WAMP only supports base64-encoded binary objects. 17 | * Other webservers - WAMP has been implemented elsewhere, but in JavaScript 18 | the only implementations require autobahn's router, crossbar.io. It would 19 | be great to support Tornado and CherryPy as alternative webservers to Twisted/autobahn. 20 | 21 | ## Foundation 22 | 23 | [jsonrpc](http://www.jsonrpc.org/specification) has a well-defined, simple, and easily implemented specification for using JSON to do RPC. It is transport agnostic, and language independent, so we can use it between javascript and python. There are many implementations, but we are free to use them or ignore them. JSON can represent four primitive types (Strings, Numbers, Booleans, and Null) and two structured types (Objects and Arrays). We want to extend our messages to handle binary objects, inserted into JSON dict/object, discussed below. 24 | 25 | Websockets allow bi-directional communication between the server and client. Authentication needs to be addressed. 26 | 27 | Because we wish to support pub/sub, we make the client and server more symmetric than in the jsonrpc spec - the server can publish to the client after a subscription is made, so in jsonrpc terms, the server is making an RPC call to the client. So our server and client must both be able to make and receive jsonrpc calls. 28 | 29 | Small examples have proven websocket and binary message support in twisted, tornado, and cherrypy, so if we can abstract the webserver sufficiently, we may be able to support all of these. 30 | 31 | ## Existing API 32 | 33 | Existing ParaViewWeb applications use these code patterns: 34 | * @exportRPC decorator in Python server code to register a method as being remotely callable 35 | * session.call("method.uri", [args]) in the JavaScript client to make an RPC call. Usually wrapped as an object method so it appears to be a normal class method. 36 | * session.subscribe("method.uri", callback) in JS client to initiate a pub/sub relationship. 37 | * server calls self.publish("method.uri", result) to push info back to the client 38 | 39 | We don't support introspection or initial handshake about which methods are supported - the client and server must be in sync. Could the server supply the client with a list of RPC methods it supplies? Yes, but then the client couldn't operate unless it's connected to a server - it would call undefined methods, rather than calling those methods and getting a 'not connected' error. Maybe not a concern. 40 | 41 | The 'session' object is provided by Autobahn|JS WAMP, so we need to replace it. 42 | 43 | ## Jsonrpc implementations 44 | 45 | An old [wiki page](https://en.wikipedia.org/w/index.php?title=JSON-RPC&oldid=731445841#Implementations) lists implementations - most are transport/server specific. 46 | 47 | For Python, [Tinyrpc](https://tinyrpc.readthedocs.io/en/latest/) has a parser for messages that will do validity checking - seems useful. 48 | 49 | For Javascript, [jrpc](https://github.com/vphantom/js-jrpc) extends jsonrpc to be bi-directional, and includes a handshake to upgrade from standard jsonrpc. [RaptorRPC](https://github.com/LinusU/raptor-rpc) might be interesting. 50 | 51 | Message format: 52 | ```javascript 53 | { 54 | const request = { 55 | wslink: 1.0, 56 | id: `rpc:${clientId}:${count}`, 57 | method: 'render.window.image', 58 | args: [], 59 | kwargs: { w: 512, h: 512 } 60 | }; 61 | 62 | const response = { 63 | wslink: 1.0, 64 | id: `rpc:${clientId}:${count}`, 65 | result: {}, // either result or error, not both 66 | error: {} 67 | }; 68 | 69 | // types used as prefix for id. 70 | const types = ['rpc', 'publish', 'system']; 71 | } 72 | ``` 73 | 74 | ```python 75 | // add a binary attachment 76 | def getImage(self): 77 | return { 78 | "size": [512, 512], 79 | "blob": session.addAttachment(memoryview(dataArray)), 80 | "mtime": dataArray.getMTime() 81 | } 82 | ``` 83 | 84 | ## wslink.js 85 | 86 | We would like to support kwargs, like wamp, which violates pure jsonrpc. 87 | We can extend it by simply adding a 'kwargs' param, as above. Therefore we'll 88 | use 'wslink' as our version string, instead of 'jsonrpc'. We also change from 'params' to 'args'. 89 | 90 | AutobahnJS `session.call()` returns a Promise, except that IE doesn't support 91 | promises, so it uses an alternative if needed. node_modules/autobahn/lib/session.js uses `self._defer()` to retrieve it. connection.js has the factory. 92 | ParaViewWeb uses babel-polyfill, which includes a Promise implementation. 93 | 94 | We can use the same defer() pattern, where we store the resolve, reject 95 | functions so we can call them when the message response is received. 96 | 97 | ### Binary attachments 98 | 99 | session.addAttachment() takes binary data and stores it, returning a string 100 | key that will be associated with the attachment. When a message is sent that 101 | uses the attachment key, a binary message is sent beforehand with the 102 | attachment. The client can then substitute the binary buffer for the string 103 | key when it receives the final message. 104 | 105 | Now sending a text header message for each binary message to associate it with 106 | a key. 107 | 108 | ### Subscribe 109 | 110 | The client needs to know about subscriptions - the server can blindly send out 111 | messages for any data it produces which might be subscribed to. This is not 112 | very efficient - if the client notifies the server of a subscription, it can 113 | send the data only when someone is listening. 114 | 115 | ### Handshake 116 | 117 | When the client initially connects, it can authenticate with the server, so the 118 | server knows this client can handle the messages it sends, and the server can 119 | provide the client with a unique client ID - which the client can embed in the 120 | rpc "id" field of it's messages to the server. 121 | 122 | * The first message client sends should be hello, with the secret key provided by it's launcher. 123 | * Server authenicates the key, responds with the client ID. 124 | * If the client doesn't send a key, the server can choose to serve an un-authenticated client, or respond with an authentication error message. 125 | -------------------------------------------------------------------------------- /documentation/.gitignore: -------------------------------------------------------------------------------- 1 | build-tmp 2 | -------------------------------------------------------------------------------- /documentation/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cname: 'kitware.github.io', 3 | baseUrl: '/wslink', 4 | work: './build-tmp', 5 | // api: ['../js/src'], 6 | examples: [], 7 | config: { 8 | title: 'Wslink', 9 | description: '"JavaScript and Python communication"', 10 | subtitle: '"Enable scientific visualization to the Web."', 11 | author: 'Kitware Inc.', 12 | timezone: 'UTC', 13 | url: 'https://kitware.github.io/wslink', 14 | root: '/wslink/', 15 | github: 'kitware/wslink', 16 | // google_analytics: 'UA-90338862-5', 17 | }, 18 | copy: [], 19 | }; 20 | -------------------------------------------------------------------------------- /documentation/content/api/index.md: -------------------------------------------------------------------------------- 1 | title: API 2 | --- 3 | 4 | This documentation provides more detailed information about the API and will be particularly helpful for people who want to use wslink in their application. 5 | 6 | -------------------------------------------------------------------------------- /documentation/content/docs/contributing.md: -------------------------------------------------------------------------------- 1 | title: Contributing 2 | --- 3 | 4 | We welcome your contributions to the development of wslink. This document will help you with the process. 5 | 6 | ## Before You Start 7 | 8 | Please format the code using `black` for Python and `prettier` for JavaScript. Please find below how to use them: 9 | 10 | ``` 11 | # For JavaScript 12 | cd ./js 13 | npm ci 14 | npm run prettier 15 | ``` 16 | 17 | ``` 18 | # For Python 19 | black ./python 20 | ``` 21 | 22 | ## Workflow 23 | 24 | 1. Fork [kitware/wslink](https://github.com/kitware/wslink). 25 | 2. Clone the repository to your computer and install dependencies. 26 | 27 | ``` 28 | $ git clone https://github.com//wslink.git 29 | $ cd wslink/js 30 | $ npm install 31 | $ cd ../python 32 | $ pip install -r requirements-dev.txt 33 | ``` 34 | 35 | 3. Create a feature branch. 36 | 37 | ``` 38 | $ git checkout -b new_feature 39 | ``` 40 | 41 | 4. Start hacking. 42 | 5. Test. 43 | 5. Use Commitizen for commit message 44 | 45 | It does not matter if your changes only happened outside of the JavaScript client. Commitizen is just well integrated into JavaScript and therefore you can use it for commiting any changes. 46 | 47 | ``` 48 | $ cd ./js 49 | $ npm run commit 50 | ``` 51 | 52 | 6. Push the branch: 53 | 54 | ``` 55 | $ git push origin new_feature 56 | ``` 57 | 58 | 6. Create a pull request and describe the change. 59 | 60 | ## Notice 61 | 62 | Semantic-release [convention](https://gist.github.com/stephenparish/9941e89d80e2bc58a153) is used to write commit messages so we can automate change-log generation along with creating a new release for the JavaScript (npm) and Python (PyPI) based on your commit. No need to edit any version number, it will be automatically generated. 63 | 64 | Unfortunately we did not managed to fully automate the testing. This means the testing will need to happen manually. Please refer to the testing checklist for running them on your system. 65 | 66 | ## Testing checklist 67 | 68 | ### Dev environment setup 69 | 70 | ``` 71 | python3 -m venv py-env 72 | source ./py-env/bin/activate 73 | pip install --upgrade -e ./python 74 | ``` 75 | 76 | ### Build web tests 77 | 78 | ``` 79 | cd ./js 80 | npm ci 81 | npm run build:test 82 | ``` 83 | 84 | And 85 | 86 | ``` 87 | cd ./tests/chat-rpc-pub-sub/clients/js 88 | npm ci 89 | npm run build 90 | ``` 91 | 92 | ### Running tests 93 | 94 | Make sure your venv is activated (`source ./py-env/bin/activate`). 95 | 96 | __chat-rpc-pub-sub__ 97 | ``` 98 | cd ./tests/chat-rpc-pub-sub 99 | python ./server/chat.py --port 1234 --content ./www/ --host 0.0.0.0 100 | 101 | # Open several web clients 102 | # => http://localhost:1234/ 103 | 104 | 1. Make sure each client see the messages from any other clients 105 | 2. When n clients are connected, each message should be seen only 106 | once on each client. 107 | ``` 108 | 109 | __simple__ 110 | ``` 111 | cd ./tests/simple 112 | python ./server/simple.py --port 1234 --content ./www/ --host 0.0.0.0 113 | 114 | # Open 1 web clients 115 | # => http://localhost:1234/ 116 | 117 | Then run the following steps while having the dev console open: 118 | 1. Connect 119 | > WS open 120 | 2. Send Add 121 | > result 15 122 | 3. Send Mult 123 | > result 120 124 | 4. Send Image 125 | > result [object Blob] 126 | The GreenBlue Kitware logo should be visible in the page 127 | 5. Test Nesting 128 | In the debug console you should see `Nesting: { bytesList: [Blob, Blob], # image: { blob: Blob } } 129 | 6. Sub/Unsub 130 | > result [object Object] 131 | You should see the picture toggle between GreenBlue / ~RedPurple 132 | 7. Sub/Unsub 133 | > result [object Object] 134 | The picture update should stop 135 | 8. Mistake 136 | > error: -32601, "Unregistered method called", myprotocol.mistake.TYPO 137 | 9. Test NaN 138 | > result Infinity,NaN,-Infinity 139 | 10. Disconnect 140 | 11. Connect 141 | Redo 2-9 142 | 12. Server Quit 143 | Make sure the console running the server does not have any error 144 | ``` 145 | 146 | ## Updating Documentation 147 | 148 | The wslink documentation is part of the code repository. Feel free to update any guide `./documentation/content/docs/*.md`. 149 | 150 | wslink's webpages are on [Github Pages](https://kitware.github.io/wslink/) and are generated using [kw-doc](https://github.com/Kitware/kw-doc). It should be automatically updated by our CI when a commit is made to the master branch. To test what website will look locally, follow those steps: 151 | 152 | * `cd js/` 153 | * `npm run doc:www` 154 | * test the docs locally 155 | 156 | ## Reporting Issues 157 | 158 | When you encounter some problems when using wslink, you can ask me on [GitHub](https://github.com/kitware/wslink/issues). If you can't find the answer, please report it on GitHub. 159 | -------------------------------------------------------------------------------- /documentation/content/docs/index.md: -------------------------------------------------------------------------------- 1 | title: Documentation 2 | --- 3 | 4 | Wslink allows easy, bi-directional communication between a python server and a 5 | javascript or C++ client over a [websocket]. The client can make remote procedure 6 | calls (RPC) to the server, and the server can publish messages to topics that 7 | the client can subscribe to. The server can include binary attachments in 8 | these messages, which are communicated as a binary websocket message, avoiding 9 | the overhead of encoding and decoding. 10 | 11 | ## RPC and publish/subscribe 12 | 13 | The initial users of wslink driving its development are [VTK] and [ParaView]. 14 | ParaViewWeb and vtkWeb require: 15 | * RPC - a remote procedure call that can be fired by the client and return 16 | sometime later with a response from the server, possibly an error. 17 | 18 | * Publish/subscribe - client can subscribe to a topic provided by the server, 19 | possibly with a filter on the parts of interest. When the topic has updated 20 | results, the server publishes them to the client, without further action on 21 | the client's part. 22 | 23 | Wslink is replacing a communication layer based on Autobahn WAMP, and so one 24 | of the goals is to be fairly compatible with WAMP, but simplify the interface 25 | to the point-to-point communication we actually use. 26 | 27 | ## Examples 28 | 29 | ``` 30 | git clone https://github.com/Kitware/wslink.git 31 | cd wslink 32 | python3 -m venv py-env 33 | source ./py-env/bin/activate 34 | pip install wslink 35 | cd ./tests/chat-rpc-pub-sub/ 36 | python ./server/server.py --content ./www --port 1234 37 | > open http://localhost:1234/ 38 | ``` 39 | 40 | ## Existing API 41 | 42 | Existing ParaViewWeb applications use these code patterns: 43 | * @exportRPC decorator in Python server code to register a method as being remotely callable 44 | * session.call("method.uri", [args]) in the JavaScript client to make an RPC call. Usually wrapped as an object method so it appears to be a normal class method. 45 | * session.subscribe("method.uri", callback) in JS client to initiate a pub/sub relationship. 46 | * server calls self.publish("method.uri", result) to push info back to the client 47 | 48 | We don't support introspection or initial handshake about which methods are 49 | supported - the client and server must be in sync. 50 | 51 | ### Binary attachments 52 | 53 | session.addAttachment() takes binary data and stores it, returning a string key 54 | that will be associated with the attachment. When a message is sent that uses 55 | the attachment key, a text header message and a binary message is sent 56 | beforehand with each attachment. The client will then substitute the binary 57 | buffer for the string key when it receives the final message. 58 | 59 | ### Subscribe 60 | 61 | The client tracks subscriptions - the server currently blindly sends out 62 | messages for any data it produces which might be subscribed to. This is not 63 | very efficient - if the client notifies the server of a subscription, it can 64 | send the data only when someone is listening. The ParaViewWeb app Visualizer 65 | makes an RPC call after subscribing to tell the server to start publishing. 66 | 67 | ### Handshake 68 | 69 | When the client initially connects, it sends a 'hello' to authenticate with 70 | the server, so the server knows this client can handle the messages it sends, 71 | and the server can provide the client with a unique client ID - which the 72 | client must embed in the rpc "id" field of its messages to the server. 73 | 74 | * The first message the client sends should be hello, with the secret key provided by its launcher. 75 | * Server authenticates the key, and responds with the client ID. 76 | * If the client sends the wrong key or no key, the server responds with an authentication error message. 77 | 78 | [ParaView]: https://www.paraview.org/ 79 | [virtualenv]: https://virtualenv.pypa.io/ 80 | [VTK]: http://www.vtk.org/ 81 | [websocket]: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket 82 | -------------------------------------------------------------------------------- /documentation/content/icon/favicon-160x160.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/wslink/02841cc92a533ff495adde410b9f32b0eb6d377f/documentation/content/icon/favicon-160x160.png -------------------------------------------------------------------------------- /documentation/content/icon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/wslink/02841cc92a533ff495adde410b9f32b0eb6d377f/documentation/content/icon/favicon-16x16.png -------------------------------------------------------------------------------- /documentation/content/icon/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/wslink/02841cc92a533ff495adde410b9f32b0eb6d377f/documentation/content/icon/favicon-196x196.png -------------------------------------------------------------------------------- /documentation/content/icon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/wslink/02841cc92a533ff495adde410b9f32b0eb6d377f/documentation/content/icon/favicon-32x32.png -------------------------------------------------------------------------------- /documentation/content/icon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/wslink/02841cc92a533ff495adde410b9f32b0eb6d377f/documentation/content/icon/favicon-96x96.png -------------------------------------------------------------------------------- /documentation/content/index.pug: -------------------------------------------------------------------------------- 1 | layout: index 2 | description: Wslink - rpc and pub/sub over websockets 3 | subtitle: Control your Python in JavaScript over WebSockets 4 | cmd: npm install wslink; pip install wslink 5 | comments: false 6 | --- 7 | 8 | ul#intro-feature-list 9 | li.intro-feature-wrap 10 | .intro-feature 11 | .intro-feature-icon 12 | i.fa.fa-cloud-download 13 | h3.intro-feature-title 14 | a(href="https://www.npmjs.com/package/wslink").link Releases 15 | img(style="padding-left: 25px",src="https://badge.fury.io/js/wslink.svg") 16 | p.intro-feature-desc Wslink client is available via #[a(href="https://www.npmjs.com/package/wslink") npm] and should be used via Webpack or Browserify within your project using standard ES6 import syntax for embedded usage. Wslink server is available via #[a(href="https://pypi.python.org/pypi/wslink") pypi] and can be installed using pip. 17 | 18 | li.intro-feature-wrap 19 | .intro-feature 20 | .intro-feature-icon 21 | i.fa.fa-life-ring 22 | h3.intro-feature-title 23 | a(href="http://www.kitware.com/products/support.html").link Support and Services 24 | p.intro-feature-desc Kitware offers advanced software R&D solutions and services. Find out how we can help with your next project. 25 | -------------------------------------------------------------------------------- /documentation/content/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/wslink/02841cc92a533ff495adde410b9f32b0eb6d377f/documentation/content/logo.png -------------------------------------------------------------------------------- /documentation/content/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WSLink-logo 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /documentation/data/menu.yml: -------------------------------------------------------------------------------- 1 | docs: /docs/ 2 | -------------------------------------------------------------------------------- /documentation/tpl/__en__: -------------------------------------------------------------------------------- 1 | menu: 2 | docs: Docs 3 | api: API 4 | examples: Examples 5 | news: News 6 | search: Search 7 | 8 | index: 9 | get_started: Get started 10 | 11 | page: 12 | contents: Contents 13 | back_to_top: Back to Top 14 | improve: Improve this doc 15 | prev: Prev 16 | next: Next 17 | last_updated: "Last updated: %s" 18 | 19 | sidebar: 20 | docs: 21 | getting_started: Getting Started 22 | releases: Releases 23 | tools: Developer guide 24 | overview: Overview 25 | contributing: Contributing 26 | miscellaneous: Development 27 | -------------------------------------------------------------------------------- /documentation/tpl/__sidebar__: -------------------------------------------------------------------------------- 1 | docs: 2 | getting_started: 3 | overview: index.html 4 | contributing: contributing.html 5 | -------------------------------------------------------------------------------- /js/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb', 3 | rules: { 4 | 'import/no-extraneous-dependencies': ["error", { "devDependencies": true }], 5 | 'max-len': ["warn", 160, 4, {"ignoreUrls": true}], 6 | 'no-multi-spaces': ["error", { exceptions: { "ImportDeclaration": true } }], 7 | 'no-param-reassign': ["error", { props: false }], 8 | 'no-unused-vars': ["error", { args: 'none' }], 9 | 'react/jsx-filename-extension': ["error", { "extensions": [".js"] }], 10 | 'no-mixed-operators': ["error", {"allowSamePrecedence": true}], 11 | 'no-plusplus': ["error", { "allowForLoopAfterthoughts": true }], 12 | 13 | // Should fix that at some point but too much work... 14 | 'react/no-is-mounted': "warn", 15 | 'no-var': 0, 16 | 'one-var': 0, 17 | 'react/prefer-es6-class': 0, 18 | 'no-nested-ternary': 0, 19 | 'react/forbid-prop-types': 0, 20 | 'jsx-a11y/no-static-element-interactions': 0, 21 | 'react/no-unused-prop-types': 0, 22 | 23 | // Not for us ;-) 24 | 'jsx-a11y/label-has-for': 0, 25 | 'no-console': 0, 26 | 'import/no-named-as-default-member': 0, 27 | }, 28 | 'settings': { 29 | 'import/resolver': 'webpack' 30 | }, 31 | env: { 32 | browser: true, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /js/.npmignore: -------------------------------------------------------------------------------- 1 | runtime* 2 | logs 3 | __pycache__ 4 | *.pyc 5 | *.egg-info 6 | .eslintrc.js 7 | .npm-packages 8 | -------------------------------------------------------------------------------- /js/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Kitware Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 25 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /js/README.md: -------------------------------------------------------------------------------- 1 | # wslink 2 | 3 | Wslink allows easy, bi-directional communication between a python server and a 4 | javascript client over a [websocket]. The client can make RPC calls to the 5 | server, and the server can publish messages to topics that the client can 6 | subscribe to. The server can include binary attachments in these messages, 7 | which are communicated as a binary websocket message, avoiding the overhead of 8 | encoding and decoding. 9 | 10 | ## RPC and publish/subscribe 11 | 12 | The initial users of wslink driving its development are [VTK] and [ParaViewWeb]. 13 | ParaViewWeb and vtkWeb require: 14 | * RPC - a remote procedure call that can be fired by the client and return 15 | sometime later with a response from the server, possibly an error. 16 | 17 | * Publish/subscribe - client can subscribe to a topic provided by the server, 18 | possibly with a filter on the parts of interest. When the topic has updated 19 | results, the server publishes them to the client, without further action on 20 | the client's part. 21 | 22 | ## Get the whole story 23 | 24 | This package is just the client side of wslink. See the [github repo] for 25 | the full story - and to contribute or report issues! 26 | 27 | ## License 28 | Free to use in open-source and commercial projects, under the BSD-3-Clause license. 29 | 30 | [github repo]: https://github.com/kitware/wslink 31 | [ParaViewWeb]: https://www.paraview.org/web/ 32 | [VTK]: http://www.vtk.org/ 33 | [websocket]: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket 34 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wslink", 3 | "version": "0.0.0-semantically-release", 4 | "description": "Rpc and pub/sub between Python and JavaScript over WebSockets", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/kitware/wslink.git" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/kitware/wslink/issues" 11 | }, 12 | "homepage": "https://github.com/kitware/wslink#readme", 13 | "main": "dist/wslink.js", 14 | "scripts": { 15 | "prettier": "prettier --config ./prettier.config.js --write \"src/**/*.js\" \"test/**/*.js\"", 16 | "test": "npm run build:test && python ../tests/simple/server/simple.py --content ../tests/simple/www --debug", 17 | "build": "webpack", 18 | "build:test": "webpack --config webpack-test-simple.config.js", 19 | "build:release": "webpack --mode production", 20 | "doc": "kw-doc -c ../documentation/config.js", 21 | "doc:www": "kw-doc -c ../documentation/config.js -s", 22 | "doc:publish": "kw-doc -c ../documentation/config.js -p", 23 | "commit": "git cz", 24 | "semantic-release": "semantic-release" 25 | }, 26 | "author": "Kitware", 27 | "license": "BSD-3-Clause", 28 | "devDependencies": { 29 | "@babel/core": "7.20.12", 30 | "@babel/preset-env": "7.20.2", 31 | "@semantic-release/changelog": "6.0.3", 32 | "@semantic-release/git": "10.0.1", 33 | "@semantic-release/github": "^9.2.1", 34 | "babel-loader": "8.2.2", 35 | "commitizen": "^4.2.4", 36 | "expose-loader": "3.0.0", 37 | "html-webpack-plugin": "5.3.2", 38 | "kw-doc": "3.1.2", 39 | "prettier": "2.8.4", 40 | "semantic-release": "22.0.5", 41 | "semantic-release-pypi": "2.5.2", 42 | "webpack": "^5.75.0", 43 | "webpack-cli": "4.7.2" 44 | }, 45 | "config": { 46 | "commitizen": { 47 | "path": "cz-conventional-changelog" 48 | } 49 | }, 50 | "dependencies": { 51 | "@msgpack/msgpack": "^2.8.0" 52 | }, 53 | "release": { 54 | "plugins": [ 55 | "@semantic-release/commit-analyzer", 56 | "@semantic-release/release-notes-generator", 57 | "@semantic-release/npm", 58 | [ 59 | "semantic-release-pypi", 60 | { 61 | "setupPy": "../python/setup.py", 62 | "distDir": "../dist" 63 | } 64 | ], 65 | [ 66 | "@semantic-release/changelog", 67 | { 68 | "changelogFile": "CHANGELOG.md" 69 | } 70 | ], 71 | [ 72 | "@semantic-release/git", 73 | { 74 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", 75 | "assets": [ 76 | "CHANGELOG.md" 77 | ] 78 | } 79 | ], 80 | "@semantic-release/github" 81 | ] 82 | }, 83 | "publishConfig": { 84 | "access": "public" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /js/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | singleQuote: true, 4 | trailingComma: 'es5', 5 | arrowParens: 'always', 6 | }; 7 | -------------------------------------------------------------------------------- /js/src/CompositeClosureHelper/index.js: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // capitalize provided string 3 | // ---------------------------------------------------------------------------- 4 | 5 | export function capitalize(str) { 6 | return str.charAt(0).toUpperCase() + str.slice(1); 7 | } 8 | 9 | // ---------------------------------------------------------------------------- 10 | // Add isA function and register your class name 11 | // ---------------------------------------------------------------------------- 12 | 13 | function isA(publicAPI, model = {}, name = null) { 14 | if (!model.isA) { 15 | model.isA = []; 16 | } 17 | 18 | if (name) { 19 | model.isA.push(name); 20 | } 21 | 22 | if (!publicAPI.isA) { 23 | publicAPI.isA = (className) => model.isA.indexOf(className) !== -1; 24 | } 25 | } 26 | 27 | // ---------------------------------------------------------------------------- 28 | // Basic setter 29 | // ---------------------------------------------------------------------------- 30 | 31 | function set(publicAPI, model = {}, names = []) { 32 | names.forEach((name) => { 33 | publicAPI[`set${capitalize(name)}`] = (value) => { 34 | model[name] = value; 35 | }; 36 | }); 37 | } 38 | 39 | // ---------------------------------------------------------------------------- 40 | // Basic getter 41 | // ---------------------------------------------------------------------------- 42 | 43 | function get(publicAPI, model = {}, names = []) { 44 | names.forEach((name) => { 45 | publicAPI[`get${capitalize(name)}`] = () => model[name]; 46 | }); 47 | } 48 | 49 | // ---------------------------------------------------------------------------- 50 | // Add destroy function 51 | // ---------------------------------------------------------------------------- 52 | 53 | function destroy(publicAPI, model = {}) { 54 | const previousDestroy = publicAPI.destroy; 55 | 56 | if (!model.subscriptions) { 57 | model.subscriptions = []; 58 | } 59 | 60 | publicAPI.destroy = () => { 61 | if (previousDestroy) { 62 | previousDestroy(); 63 | } 64 | while (model.subscriptions && model.subscriptions.length) { 65 | model.subscriptions.pop().unsubscribe(); 66 | } 67 | Object.keys(model).forEach((field) => { 68 | delete model[field]; 69 | }); 70 | 71 | // Flag the instance beeing deleted 72 | model.deleted = true; 73 | }; 74 | } 75 | 76 | // ---------------------------------------------------------------------------- 77 | // Event handling: onXXX(callback), fireXXX(args...) 78 | // ---------------------------------------------------------------------------- 79 | 80 | function event(publicAPI, model, eventName, asynchrounous = true) { 81 | const callbacks = []; 82 | const previousDestroy = publicAPI.destroy; 83 | 84 | function off(index) { 85 | callbacks[index] = null; 86 | } 87 | 88 | function on(index) { 89 | function unsubscribe() { 90 | off(index); 91 | } 92 | return Object.freeze({ unsubscribe }); 93 | } 94 | 95 | publicAPI[`fire${capitalize(eventName)}`] = (...args) => { 96 | if (model.deleted) { 97 | console.log('instance deleted - can not call any method'); 98 | return; 99 | } 100 | 101 | function processCallbacks() { 102 | callbacks.forEach((callback) => { 103 | if (callback) { 104 | try { 105 | callback.apply(publicAPI, args); 106 | } catch (errObj) { 107 | console.log('Error event:', eventName, errObj); 108 | } 109 | } 110 | }); 111 | } 112 | 113 | if (asynchrounous) { 114 | setTimeout(processCallbacks, 0); 115 | } else { 116 | processCallbacks(); 117 | } 118 | }; 119 | 120 | publicAPI[`on${capitalize(eventName)}`] = (callback) => { 121 | if (model.deleted) { 122 | console.log('instance deleted - can not call any method'); 123 | return null; 124 | } 125 | 126 | const index = callbacks.length; 127 | callbacks.push(callback); 128 | return on(index); 129 | }; 130 | 131 | publicAPI.destroy = () => { 132 | previousDestroy(); 133 | callbacks.forEach((el, index) => off(index)); 134 | }; 135 | } 136 | 137 | // ---------------------------------------------------------------------------- 138 | // Chain function calls 139 | // ---------------------------------------------------------------------------- 140 | 141 | function chain(...fn) { 142 | return (...args) => fn.filter((i) => !!i).forEach((i) => i(...args)); 143 | } 144 | 145 | // ---------------------------------------------------------------------------- 146 | // newInstance 147 | // ---------------------------------------------------------------------------- 148 | 149 | function newInstance(extend) { 150 | return (initialValues = {}) => { 151 | const model = {}; 152 | const publicAPI = {}; 153 | extend(publicAPI, model, initialValues); 154 | return Object.freeze(publicAPI); 155 | }; 156 | } 157 | 158 | export default { 159 | chain, 160 | destroy, 161 | event, 162 | get, 163 | isA, 164 | newInstance, 165 | set, 166 | }; 167 | -------------------------------------------------------------------------------- /js/src/ProcessLauncher/api.md: -------------------------------------------------------------------------------- 1 | # ProcessLauncher 2 | 3 | The ProcessLauncher can be used to start remote python servers, or other 4 | remote processes. 5 | The ProcessLauncher is used in ParaViewWeb to start a new remote 6 | server instance to perform interactive 3D post-processing using either 7 | a VTK or a ParaView backend. 8 | 9 | ```javascript 10 | import ProcessLauncher from 'wslink/src/ProcessLauncher'; 11 | 12 | processLauncher = ProcessLauncher.newInstance({ endPoint: '/paraview' }); 13 | ``` 14 | 15 | ## ProcessLauncher.newInstance({ endPoint }) 16 | 17 | Create a process launcher that will make requests to a remote 18 | server using the provided endpoint url. 19 | 20 | ## start(config) 21 | 22 | Submit a request for a new remote process. 23 | 24 | The config object gets posted via a POST request to the endpoint provided 25 | at creation time. 26 | 27 | The current ParaViewWeb server-side **Launcher** expects at least 28 | the following object. 29 | 30 | ```js 31 | { 32 | application: 'NameOfTheProcess', 33 | } 34 | ``` 35 | 36 | But additional key/value pairs can be added depending on the needs of the targeted process. 37 | 38 | Once the remote process becomes ready a notification is sent. 39 | 40 | ## fetchConnection(sessionId) 41 | 42 | Trigger a request for getting the full connection information 43 | based on an existing sessionId. 44 | 45 | ## stop(connection) 46 | 47 | Trigger a request to terminate a remote process using the connection 48 | object that was provided at start time. 49 | 50 | ## listConnections() 51 | 52 | Return the list of already established connections. (From that instance) 53 | 54 | ## onProcessReady(callback) : subscription 55 | 56 | Register a callback for when a remote process becomes available after a start() request. 57 | 58 | The callback function will then receive a json object describing how to connect to that remote process. 59 | 60 | ```js 61 | { 62 | sessionURL: 'ws://myServer/proxy?sessionId=asdfwefasdfwerfqerfse', 63 | maybe: 'something else too' 64 | } 65 | ``` 66 | ## onProcessStopped(callback) : subscription 67 | 68 | Register a callback for when a stop request is performed. 69 | 70 | ## onFetch(callback) : subscription 71 | 72 | Register a callback for when a fetchConnection request is performed. 73 | 74 | ## onError(callback) : subscription 75 | 76 | Register a callback for when an error occured regardless of the request. 77 | 78 | ## destroy() 79 | 80 | Free memory and detatch any listeners. 81 | -------------------------------------------------------------------------------- /js/src/ProcessLauncher/index.js: -------------------------------------------------------------------------------- 1 | /* global XMLHttpRequest */ 2 | import CompositeClosureHelper from '../CompositeClosureHelper'; 3 | 4 | const connections = []; 5 | 6 | function ProcessLauncher(publicAPI, model) { 7 | publicAPI.start = (config) => { 8 | var xhr = new XMLHttpRequest(), 9 | url = model.endPoint; 10 | 11 | xhr.open('POST', url, true); 12 | 13 | if (config.headers) { 14 | Object.entries(config.headers).forEach(([key, value]) => 15 | xhr.setRequestHeader(key, value) 16 | ); 17 | delete config.headers; 18 | } 19 | 20 | xhr.responseType = 'json'; 21 | const supportsJson = 'response' in xhr && xhr.responseType === 'json'; 22 | 23 | xhr.onload = (e) => { 24 | const response = supportsJson ? xhr.response : JSON.parse(xhr.response); 25 | if (xhr.status === 200 && response && !response.error) { 26 | // Add connection to our global list 27 | connections.push(response); 28 | publicAPI.fireProcessReady(response); 29 | return; 30 | } 31 | publicAPI.fireError(xhr); 32 | }; 33 | 34 | xhr.onerror = (e) => { 35 | publicAPI.fireError(xhr); 36 | }; 37 | 38 | xhr.send(JSON.stringify(config)); 39 | }; 40 | 41 | publicAPI.fetchConnection = (sessionId) => { 42 | var xhr = new XMLHttpRequest(), 43 | url = [model.endPoint, sessionId].join('/'); 44 | 45 | xhr.open('GET', url, true); 46 | xhr.responseType = 'json'; 47 | const supportsJson = 'response' in xhr && xhr.responseType === 'json'; 48 | 49 | xhr.onload = (e) => { 50 | if (this.status === 200) { 51 | publicAPI.fireFetch( 52 | supportsJson ? xhr.response : JSON.parse(xhr.response) 53 | ); 54 | return; 55 | } 56 | publicAPI.fireError(xhr); 57 | }; 58 | 59 | xhr.onerror = (e) => { 60 | publicAPI.fireError(xhr); 61 | }; 62 | 63 | xhr.send(); 64 | }; 65 | 66 | publicAPI.stop = (connection) => { 67 | var xhr = new XMLHttpRequest(), 68 | url = [model.endPoint, connection.id].join('/'); 69 | 70 | xhr.open('DELETE', url, true); 71 | xhr.responseType = 'json'; 72 | const supportsJson = 'response' in xhr && xhr.responseType === 'json'; 73 | 74 | xhr.onload = (e) => { 75 | if (this.status === 200) { 76 | const response = supportsJson ? xhr.response : JSON.parse(xhr.response); 77 | // Remove connection from the list 78 | // FIXME / TODO 79 | publicAPI.fireProcessStopped(response); 80 | return; 81 | } 82 | publicAPI.fireError(xhr); 83 | }; 84 | xhr.onerror = (e) => { 85 | publicAPI.fireError(xhr); 86 | }; 87 | xhr.send(); 88 | }; 89 | 90 | publicAPI.listConnections = () => { 91 | return connections; 92 | }; 93 | } 94 | 95 | const DEFAULT_VALUES = { 96 | endPoint: null, 97 | }; 98 | 99 | export function extend(publicAPI, model, initialValues = {}) { 100 | Object.assign(model, DEFAULT_VALUES, initialValues); 101 | 102 | CompositeClosureHelper.destroy(publicAPI, model); 103 | CompositeClosureHelper.event(publicAPI, model, 'ProcessReady'); 104 | CompositeClosureHelper.event(publicAPI, model, 'ProcessStopped'); 105 | CompositeClosureHelper.event(publicAPI, model, 'Fetch'); 106 | CompositeClosureHelper.event(publicAPI, model, 'Error'); 107 | CompositeClosureHelper.isA(publicAPI, model, 'ProcessLauncher'); 108 | 109 | ProcessLauncher(publicAPI, model); 110 | } 111 | 112 | // ---------------------------------------------------------------------------- 113 | export const newInstance = CompositeClosureHelper.newInstance(extend); 114 | 115 | export default { newInstance, extend }; 116 | -------------------------------------------------------------------------------- /js/src/SmartConnect/api.md: -------------------------------------------------------------------------------- 1 | # SmartConnect 2 | 3 | SmartConnect will try to launch a new remote process 4 | based on the configuration and if that fails or if 5 | a sessionURL is already provided in the configuration 6 | it will establish a direct WebSocket connection using 7 | Autobahn. 8 | 9 | ## SmartConnect.newInstance({ config }) 10 | 11 | Create an instance that will use the provided configuration to 12 | connect itself to a server either by requesting a new remote 13 | process or by trying to directly connecting to it as a fallback. 14 | 15 | ## connect() 16 | 17 | Trigger the connection request. 18 | 19 | ## onConnectionReady(callback) : subscription 20 | 21 | Register callback for when the connection became ready. 22 | 23 | ## onConnectionClose(callback) : subscription 24 | 25 | Register callback for when the connection close. Callback takes 26 | two arguments, the connection object and a websocket event. 27 | If the server closes the connection after sending a close frame 28 | (https://datatracker.ietf.org/doc/html/rfc6455#section-5.5.1), the event 29 | will have the shape {code: number, reason: string}. 30 | 31 | ## onConnectionError(callback) : subscription 32 | 33 | Register callback for when the connection request failed. 34 | Callback takes two arguments, the connection object and a websocket event. 35 | 36 | ## getSession() : session 37 | 38 | Return the session associated with the connection. 39 | 40 | ## destroy() 41 | 42 | Free resources and remove any listener. 43 | -------------------------------------------------------------------------------- /js/src/SmartConnect/index.d.ts: -------------------------------------------------------------------------------- 1 | import WebsocketConnection, {WebsocketSession} from '../WebsocketConnection'; 2 | 3 | export declare const DEFAULT_SESSION_MANAGER_URL: string; 4 | 5 | export interface ISmartConnectConfig { 6 | // URL to connect to. E.g., "ws://localhost:1234". 7 | sessionURL?: string; 8 | // Endpoint of the launcher that is responsible to start the server process. 9 | // Defaults to SESSION_MANAGER_URL. 10 | sessionManagerURL?: string; 11 | // The secret token to be sent during handshake and validated by the server. 12 | secret?: string; 13 | retry?: number; 14 | } 15 | 16 | // A function that rewrites the configuration. It is called on connect. 17 | type ConfigDecorator = (config: ISmartConnectConfig) => ISmartConnectConfig; 18 | 19 | export interface ISmartConnectInitialValues { 20 | config: ISmartConnectConfig; 21 | configDecorator?: ConfigDecorator; 22 | } 23 | 24 | export type Event = any; 25 | 26 | export interface WebsocketCloseEvent { 27 | // This object reports the values of the close frame. Cf. RFC6455 section 28 | // 5.5.1. If the connection closes w/o a close frame, the following fields are unset. 29 | code?: number // RFC6455, section 7.4. 30 | reason?: string; 31 | } 32 | 33 | export interface SmartConnect { 34 | // Starts connecting to the server. 35 | connect(): void; 36 | getSession(): WebsocketSession; 37 | // Called when the connection is established 38 | onConnectionReady(cb: (c: WebsocketConnection) => void): void; 39 | // Called when the connection cannot be established. 40 | onConnectionError(cb: (c: WebsocketConnection, err: Event) => void): void; 41 | // Called when the connection is closed. 42 | onConnectionClose(cb: (c: WebsocketConnection, event: WebsocketCloseEvent) => void): void; 43 | // Close the connection and destroy this object. 44 | destroy(): void; 45 | // Return the config passed to newInstance. 46 | getConfig(): ISmartConnectConfig; 47 | getConfigDecorator(): ConfigDecorator | null; 48 | setConfigDecorator(c: ConfigDecorator): void; 49 | } 50 | 51 | /** 52 | * Creates a new SmartConnect object with the given configuration. 53 | */ 54 | export function newInstance(config: ISmartConnectInitialValues): SmartConnect; 55 | 56 | /** 57 | * Decorates a given object (publicAPI+model) with SmartConnect characteristics. 58 | */ 59 | export function extend(publicAPI: object, model: object, initialValues?: ISmartConnectInitialValues): void; 60 | 61 | export declare const SmartConnect: { 62 | newInstance: typeof newInstance; 63 | extend: typeof extend; 64 | } 65 | 66 | export default SmartConnect; 67 | -------------------------------------------------------------------------------- /js/src/SmartConnect/index.js: -------------------------------------------------------------------------------- 1 | /* global window */ 2 | import CompositeClosureHelper from '../CompositeClosureHelper'; 3 | 4 | import ProcessLauncher from '../ProcessLauncher'; 5 | import WebsocketConnection from '../WebsocketConnection'; 6 | 7 | function DEFAULT_CONFIG_DECORATOR(config) { 8 | if (config.sessionURL) { 9 | config.sessionURL = config.sessionURL.replaceAll( 10 | 'USE_HOSTNAME', 11 | window.location.hostname 12 | ); 13 | config.sessionURL = config.sessionURL.replaceAll( 14 | 'USE_HOST', 15 | window.location.host 16 | ); 17 | } 18 | return config; 19 | } 20 | 21 | function extractPathName(addOn, pathName = window.location.pathname) { 22 | if (pathName.endsWith('.html') || pathName.endsWith('.htm')) { 23 | const tokens = pathName.split('/'); 24 | tokens.pop(); 25 | pathName = tokens.join('/'); 26 | } 27 | while (pathName.length > 0 && pathName[pathName.length - 1] === '/') { 28 | pathName = pathName.substring(0, pathName.length - 1); 29 | } 30 | if (pathName.length === 0) { 31 | return addOn; 32 | } 33 | return `${pathName}${addOn}`; 34 | } 35 | 36 | export const DEFAULT_SESSION_MANAGER_URL = `${document.baseURI}paraview/`; 37 | 38 | export const DEFAULT_SESSION_URL = `${ 39 | window.location.protocol === 'https:' ? 'wss:' : 'ws:' 40 | }//${window.location.hostname}:${window.location.port}${extractPathName( 41 | '/ws' 42 | )}`; 43 | 44 | function wsConnect(publicAPI, model) { 45 | const wsConnection = WebsocketConnection.newInstance({ 46 | urls: model.config.sessionURL, 47 | secret: model.config.secret, 48 | retry: model.config.retry, 49 | wsProxy: model.config.iframe || model.config.wsProxy, 50 | }); 51 | model.subscriptions.push( 52 | wsConnection.onConnectionReady(publicAPI.readyForwarder) 53 | ); 54 | model.subscriptions.push( 55 | wsConnection.onConnectionError(publicAPI.errorForwarder) 56 | ); 57 | model.subscriptions.push( 58 | wsConnection.onConnectionClose(publicAPI.closeForwarder) 59 | ); 60 | 61 | // Add to the garbage collector 62 | model.gc.push(wsConnection); 63 | 64 | return wsConnection.connect(); 65 | } 66 | 67 | function smartConnect(publicAPI, model) { 68 | let session = null; 69 | model.gc = []; 70 | 71 | // Event forwarders 72 | publicAPI.readyForwarder = (data) => { 73 | session = data.getSession(); 74 | publicAPI.fireConnectionReady(data); 75 | }; 76 | publicAPI.errorForwarder = (data, err) => { 77 | publicAPI.fireConnectionError(data, err); 78 | }; 79 | publicAPI.closeForwarder = (data, err) => { 80 | publicAPI.fireConnectionClose(data, err); 81 | }; 82 | 83 | publicAPI.connect = () => { 84 | if (model.configDecorator) { 85 | model.config = model.configDecorator(model.config); 86 | } 87 | model.config = DEFAULT_CONFIG_DECORATOR(model.config); 88 | 89 | if (model.config.sessionURL) { 90 | // We have a direct connection URL 91 | session = wsConnect(publicAPI, model); 92 | } else if (model.config.wsProxy) { 93 | // Provide fake url if missing since we rely on a proxy 94 | model.config.sessionURL = model.config.sessionURL || "wss://proxy/"; 95 | session = wsConnect(publicAPI, model); 96 | } else { 97 | // We need to use the Launcher 98 | const launcher = ProcessLauncher.newInstance({ 99 | endPoint: model.config.sessionManagerURL || DEFAULT_SESSION_MANAGER_URL, 100 | }); 101 | 102 | model.subscriptions.push( 103 | launcher.onProcessReady((data) => { 104 | if (model.configDecorator) { 105 | model.config = model.configDecorator( 106 | Object.assign({}, model.config, data) 107 | ); 108 | } else { 109 | model.config = Object.assign({}, model.config, data); 110 | } 111 | model.config = DEFAULT_CONFIG_DECORATOR(model.config); 112 | 113 | session = wsConnect(publicAPI, model); 114 | }) 115 | ); 116 | model.subscriptions.push( 117 | launcher.onError((data) => { 118 | if (data && data.response && data.response.error) { 119 | publicAPI.errorForwarder(data, data.response.error); 120 | } else { 121 | // Try to use standard connection URL 122 | model.config.sessionURL = DEFAULT_SESSION_URL; 123 | model.config = DEFAULT_CONFIG_DECORATOR(model.config); 124 | session = wsConnect(publicAPI, model); 125 | } 126 | }) 127 | ); 128 | 129 | launcher.start(model.config); 130 | 131 | // Add to the garbage collector 132 | model.gc.push(launcher); 133 | } 134 | }; 135 | 136 | publicAPI.getSession = () => { 137 | return session; 138 | }; 139 | 140 | function cleanUp(timeout) { 141 | if (session) { 142 | if (timeout > 0) { 143 | session.call('application.exit.later', [timeout]); 144 | } 145 | session.close(); 146 | } 147 | session = null; 148 | 149 | while (model.gc.length) { 150 | model.gc.pop().destroy(); 151 | } 152 | } 153 | 154 | publicAPI.destroy = CompositeClosureHelper.chain(cleanUp, publicAPI.destroy); 155 | } 156 | 157 | const DEFAULT_VALUES = { 158 | config: {}, 159 | // configDecorator: null, 160 | }; 161 | 162 | export function extend(publicAPI, model, initialValues = {}) { 163 | Object.assign(model, DEFAULT_VALUES, initialValues); 164 | 165 | CompositeClosureHelper.destroy(publicAPI, model); 166 | CompositeClosureHelper.event(publicAPI, model, 'ConnectionReady'); 167 | CompositeClosureHelper.event(publicAPI, model, 'ConnectionClose'); 168 | CompositeClosureHelper.event(publicAPI, model, 'ConnectionError'); 169 | CompositeClosureHelper.isA(publicAPI, model, 'SmartConnect'); 170 | CompositeClosureHelper.get(publicAPI, model, ['config', 'configDecorator']); 171 | CompositeClosureHelper.set(publicAPI, model, ['configDecorator']); 172 | 173 | smartConnect(publicAPI, model); 174 | } 175 | 176 | // ---------------------------------------------------------------------------- 177 | export const newInstance = CompositeClosureHelper.newInstance(extend); 178 | 179 | export default { newInstance, extend }; 180 | -------------------------------------------------------------------------------- /js/src/WebsocketConnection/api.md: -------------------------------------------------------------------------------- 1 | # WebsocketConnection 2 | 3 | ## WebsocketConnection.newInstance({ urls }) 4 | 5 | Create an instance of a websocket connection. The urls should 6 | be a single url (string). 7 | 8 | Usually with a ProcessLauncher we will set the **urls** to **connection.sessionURL**. 9 | 10 | The input can optionally include a string to autheticate the 11 | connection during the handshake: `{ urls, secret:"wslink-secret" }` 12 | 13 | ## connect() 14 | 15 | Trigger the actual connection request with the server. 16 | 17 | ## onConnectionReady(callback) : subscription 18 | 19 | Register callback for when the connection became ready. 20 | 21 | ## onConnectionClose(callback) : subscription 22 | 23 | Register callback for when the connection close. 24 | 25 | ## getSession() : object 26 | 27 | Return null if the connection is not yet established or the session 28 | for making RPC calls. 29 | 30 | ## destroy(timeout=10) 31 | 32 | Close the connection and ask the server to automaticaly shutdown after the given timeout while removing any listener. 33 | 34 | If the provided timeout is negative, we will close the connection without asking the server to shutdown. 35 | -------------------------------------------------------------------------------- /js/src/WebsocketConnection/chunking.js: -------------------------------------------------------------------------------- 1 | // Project not setup for typescript, manually compiling this file to chunker.js 2 | // npx tsc chunking.ts --target esnext 3 | const UINT32_LENGTH = 4; 4 | const ID_LOCATION = 0; 5 | const ID_LENGTH = UINT32_LENGTH; 6 | const MESSAGE_OFFSET_LOCATION = ID_LOCATION + ID_LENGTH; 7 | const MESSAGE_OFFSET_LENGTH = UINT32_LENGTH; 8 | const MESSAGE_SIZE_LOCATION = MESSAGE_OFFSET_LOCATION + MESSAGE_OFFSET_LENGTH; 9 | const MESSAGE_SIZE_LENGTH = UINT32_LENGTH; 10 | const HEADER_LENGTH = ID_LENGTH + MESSAGE_OFFSET_LENGTH + MESSAGE_SIZE_LENGTH; 11 | function encodeHeader(id, offset, size) { 12 | const buffer = new ArrayBuffer(HEADER_LENGTH); 13 | const header = new Uint8Array(buffer); 14 | const view = new DataView(buffer); 15 | view.setUint32(ID_LOCATION, id, true); 16 | view.setUint32(MESSAGE_OFFSET_LOCATION, offset, true); 17 | view.setUint32(MESSAGE_SIZE_LOCATION, size, true); 18 | return header; 19 | } 20 | function decodeHeader(header) { 21 | const view = new DataView(header.buffer); 22 | const id = view.getUint32(ID_LOCATION, true); 23 | const offset = view.getUint32(MESSAGE_OFFSET_LOCATION, true); 24 | const size = view.getUint32(MESSAGE_SIZE_LOCATION, true); 25 | return { id, offset, size }; 26 | } 27 | function* generateChunks(message, maxSize) { 28 | const totalSize = message.byteLength; 29 | let maxContentSize; 30 | if (maxSize === 0) { 31 | maxContentSize = totalSize; 32 | } else { 33 | maxContentSize = Math.max(maxSize - HEADER_LENGTH, 1); 34 | } 35 | const id = new Uint32Array(1); 36 | crypto.getRandomValues(id); 37 | let offset = 0; 38 | while (offset < totalSize) { 39 | const contentSize = Math.min(maxContentSize, totalSize - offset); 40 | const chunk = new Uint8Array(new ArrayBuffer(HEADER_LENGTH + contentSize)); 41 | const header = encodeHeader(id[0], offset, totalSize); 42 | chunk.set(new Uint8Array(header.buffer), 0); 43 | chunk.set(message.subarray(offset, offset + contentSize), HEADER_LENGTH); 44 | yield chunk; 45 | offset += contentSize; 46 | } 47 | return; 48 | } 49 | /* 50 | This un-chunker is vulnerable to DOS. 51 | If it receives a message with a header claiming a large incoming message 52 | it will allocate the memory blindly even without actually receiving the content 53 | Chunks for a given message can come in any order 54 | Chunks across messages can be interleaved. 55 | */ 56 | class UnChunker { 57 | pendingMessages; 58 | constructor() { 59 | this.pendingMessages = {}; 60 | } 61 | releasePendingMessages() { 62 | this.pendingMessages = {}; 63 | } 64 | async processChunk(chunk, decoderFactory) { 65 | const headerBlob = chunk.slice(0, HEADER_LENGTH); 66 | const contentBlob = chunk.slice(HEADER_LENGTH); 67 | const header = new Uint8Array(await headerBlob.arrayBuffer()); 68 | const { id, offset, size: totalSize } = decodeHeader(header); 69 | let pendingMessage = this.pendingMessages[id]; 70 | if (!pendingMessage) { 71 | pendingMessage = { 72 | receivedSize: 0, 73 | content: new Uint8Array(totalSize), 74 | decoder: decoderFactory(), 75 | }; 76 | this.pendingMessages[id] = pendingMessage; 77 | } 78 | // This should never happen, but still check it 79 | if (totalSize !== pendingMessage.content.byteLength) { 80 | delete this.pendingMessages[id]; 81 | throw new Error( 82 | `Total size in chunk header for message ${id} does not match total size declared by previous chunk.` 83 | ); 84 | } 85 | const chunkContent = new Uint8Array(await contentBlob.arrayBuffer()); 86 | const content = pendingMessage.content; 87 | content.set(chunkContent, offset); 88 | pendingMessage.receivedSize += chunkContent.byteLength; 89 | if (pendingMessage.receivedSize >= totalSize) { 90 | delete this.pendingMessages[id]; 91 | try { 92 | return pendingMessage['decoder'].decode(content); 93 | } catch (e) { 94 | console.error('Malformed message: ', content.slice(0, 100)); 95 | // debugger; 96 | } 97 | } 98 | return undefined; 99 | } 100 | } 101 | // Makes sure messages are processed in order of arrival, 102 | export class SequentialTaskQueue { 103 | taskId; 104 | pendingTaskId; 105 | tasks; 106 | constructor() { 107 | this.taskId = 0; 108 | this.pendingTaskId = -1; 109 | this.tasks = {}; 110 | } 111 | enqueue(fn, ...args) { 112 | return new Promise((resolve, reject) => { 113 | const taskId = this.taskId++; 114 | this.tasks[taskId] = { fn, args, resolve, reject }; 115 | this._maybeExecuteNext(); 116 | }); 117 | } 118 | _maybeExecuteNext() { 119 | let pendingTask = this.tasks[this.pendingTaskId]; 120 | if (pendingTask) { 121 | return; 122 | } 123 | const nextPendingTaskId = this.pendingTaskId + 1; 124 | pendingTask = this.tasks[nextPendingTaskId]; 125 | if (!pendingTask) { 126 | return; 127 | } 128 | this.pendingTaskId = nextPendingTaskId; 129 | const { fn, args, resolve, reject } = pendingTask; 130 | fn(...args) 131 | .then((result) => { 132 | resolve(result); 133 | delete this.tasks[nextPendingTaskId]; 134 | this._maybeExecuteNext(); 135 | }) 136 | .catch((err) => { 137 | reject(err); 138 | delete this.tasks[nextPendingTaskId]; 139 | this._maybeExecuteNext(); 140 | }); 141 | } 142 | } 143 | /* 144 | This un-chunker is more memory efficient 145 | (each chunk is passed immediately to msgpack) 146 | and it will only allocate memory when it receives content. 147 | Chunks for a given message are expected to come sequentially 148 | Chunks across messages can be interleaved. 149 | */ 150 | class StreamUnChunker { 151 | pendingMessages; 152 | constructor() { 153 | this.pendingMessages = {}; 154 | } 155 | processChunk = async (chunk, decoderFactory) => { 156 | const headerBlob = chunk.slice(0, HEADER_LENGTH); 157 | const header = new Uint8Array(await headerBlob.arrayBuffer()); 158 | const { id, offset, size: totalSize } = decodeHeader(header); 159 | const contentBlob = chunk.slice(HEADER_LENGTH); 160 | let pendingMessage = this.pendingMessages[id]; 161 | if (!pendingMessage) { 162 | pendingMessage = { 163 | receivedSize: 0, 164 | totalSize: totalSize, 165 | decoder: decoderFactory(), 166 | }; 167 | this.pendingMessages[id] = pendingMessage; 168 | } 169 | // This should never happen, but still check it 170 | if (totalSize !== pendingMessage.totalSize) { 171 | delete this.pendingMessages[id]; 172 | throw new Error( 173 | `Total size in chunk header for message ${id} does not match total size declared by previous chunk.` 174 | ); 175 | } 176 | // This should never happen, but still check it 177 | if (offset !== pendingMessage.receivedSize) { 178 | delete this.pendingMessages[id]; 179 | throw new Error(`Received an unexpected chunk for message ${id}. 180 | Expected offset = ${pendingMessage.receivedSize}, 181 | Received offset = ${offset}.`); 182 | } 183 | let result; 184 | try { 185 | result = await pendingMessage.decoder.decodeAsync(contentBlob.stream()); 186 | } catch (e) { 187 | if (e instanceof RangeError) { 188 | // More data is needed, it should come in the next chunk 189 | result = undefined; 190 | } 191 | } 192 | pendingMessage.receivedSize += contentBlob.size; 193 | /* 194 | In principle feeding a stream to the unpacker could yield multiple outputs 195 | for example unpacker.feed(b'0123') would yield b'0', b'1', ect 196 | or concatenated packed payloads would yield two or more unpacked objects 197 | but in our use case we expect a full message to be mapped to a single object 198 | */ 199 | if (result && pendingMessage.receivedSize < totalSize) { 200 | delete this.pendingMessages[id]; 201 | throw new Error(`Received a parsable payload shorter than expected for message ${id}. 202 | Expected size = ${totalSize}, 203 | Received size = ${pendingMessage.receivedSize}.`); 204 | } 205 | if (pendingMessage.receivedSize >= totalSize) { 206 | delete this.pendingMessages[id]; 207 | } 208 | return result; 209 | }; 210 | } 211 | export { UnChunker, StreamUnChunker, generateChunks }; 212 | -------------------------------------------------------------------------------- /js/src/WebsocketConnection/chunking.ts: -------------------------------------------------------------------------------- 1 | // Project not setup for typescript, manually compiling this file to chunker.js 2 | // npx tsc chunking.ts --target esnext 3 | 4 | const UINT32_LENGTH = 4; 5 | const ID_LOCATION = 0; 6 | const ID_LENGTH = UINT32_LENGTH; 7 | const MESSAGE_OFFSET_LOCATION = ID_LOCATION + ID_LENGTH; 8 | const MESSAGE_OFFSET_LENGTH = UINT32_LENGTH; 9 | const MESSAGE_SIZE_LOCATION = MESSAGE_OFFSET_LOCATION + MESSAGE_OFFSET_LENGTH; 10 | const MESSAGE_SIZE_LENGTH = UINT32_LENGTH; 11 | 12 | const HEADER_LENGTH = ID_LENGTH + MESSAGE_OFFSET_LENGTH + MESSAGE_SIZE_LENGTH; 13 | 14 | function encodeHeader(id: number, offset: number, size: number): Uint8Array { 15 | const buffer = new ArrayBuffer(HEADER_LENGTH); 16 | const header = new Uint8Array(buffer); 17 | const view = new DataView(buffer); 18 | view.setUint32(ID_LOCATION, id, true); 19 | view.setUint32(MESSAGE_OFFSET_LOCATION, offset, true); 20 | view.setUint32(MESSAGE_SIZE_LOCATION, size, true); 21 | 22 | return header; 23 | } 24 | 25 | function decodeHeader(header: Uint8Array) { 26 | const view = new DataView(header.buffer); 27 | const id = view.getUint32(ID_LOCATION, true); 28 | const offset = view.getUint32(MESSAGE_OFFSET_LOCATION, true); 29 | const size = view.getUint32(MESSAGE_SIZE_LOCATION, true); 30 | 31 | return { id, offset, size }; 32 | } 33 | 34 | function* generateChunks(message: Uint8Array, maxSize: number) { 35 | const totalSize = message.byteLength; 36 | let maxContentSize: number; 37 | 38 | if (maxSize === 0) { 39 | maxContentSize = totalSize; 40 | } else { 41 | maxContentSize = Math.max(maxSize - HEADER_LENGTH, 1); 42 | } 43 | 44 | const id = new Uint32Array(1); 45 | crypto.getRandomValues(id); 46 | 47 | let offset = 0; 48 | 49 | while (offset < totalSize) { 50 | const contentSize = Math.min(maxContentSize, totalSize - offset); 51 | const chunk = new Uint8Array(new ArrayBuffer(HEADER_LENGTH + contentSize)); 52 | const header = encodeHeader(id[0], offset, totalSize); 53 | chunk.set(new Uint8Array(header.buffer), 0); 54 | chunk.set(message.subarray(offset, offset + contentSize), HEADER_LENGTH); 55 | 56 | yield chunk; 57 | 58 | offset += contentSize; 59 | } 60 | 61 | return; 62 | } 63 | 64 | type PendingMessage = { 65 | receivedSize: number; 66 | content: Uint8Array; 67 | decoder: any; 68 | }; 69 | 70 | /* 71 | This un-chunker is vulnerable to DOS. 72 | If it receives a message with a header claiming a large incoming message 73 | it will allocate the memory blindly even without actually receiving the content 74 | Chunks for a given message can come in any order 75 | Chunks across messages can be interleaved. 76 | */ 77 | class UnChunker { 78 | private pendingMessages: { [key: number]: PendingMessage }; 79 | 80 | constructor() { 81 | this.pendingMessages = {}; 82 | } 83 | 84 | releasePendingMessages() { 85 | this.pendingMessages = {}; 86 | } 87 | 88 | async processChunk( 89 | chunk: Blob, 90 | decoderFactory: () => any 91 | ): Promise { 92 | const headerBlob = chunk.slice(0, HEADER_LENGTH); 93 | const contentBlob = chunk.slice(HEADER_LENGTH); 94 | 95 | const header = new Uint8Array(await headerBlob.arrayBuffer()); 96 | const { id, offset, size: totalSize } = decodeHeader(header); 97 | 98 | let pendingMessage = this.pendingMessages[id]; 99 | 100 | if (!pendingMessage) { 101 | pendingMessage = { 102 | receivedSize: 0, 103 | content: new Uint8Array(totalSize), 104 | decoder: decoderFactory(), 105 | }; 106 | 107 | this.pendingMessages[id] = pendingMessage; 108 | } 109 | 110 | // This should never happen, but still check it 111 | if (totalSize !== pendingMessage.content.byteLength) { 112 | delete this.pendingMessages[id]; 113 | throw new Error( 114 | `Total size in chunk header for message ${id} does not match total size declared by previous chunk.` 115 | ); 116 | } 117 | 118 | const chunkContent = new Uint8Array(await contentBlob.arrayBuffer()); 119 | const content = pendingMessage.content; 120 | content.set(chunkContent, offset); 121 | pendingMessage.receivedSize += chunkContent.byteLength; 122 | 123 | if (pendingMessage.receivedSize >= totalSize) { 124 | delete this.pendingMessages[id]; 125 | 126 | try { 127 | return pendingMessage['decoder'].decode(content); 128 | } catch (e) { 129 | console.error('Malformed message: ', content.slice(0, 100)); 130 | // debugger; 131 | } 132 | } 133 | 134 | return undefined; 135 | } 136 | } 137 | 138 | type StreamPendingMessage = { 139 | receivedSize: number; 140 | totalSize: number; 141 | decoder: any; 142 | }; 143 | 144 | // Makes sure messages are processed in order of arrival, 145 | export class SequentialTaskQueue { 146 | taskId: number; 147 | pendingTaskId: number; 148 | tasks: { 149 | [id: number]: { 150 | fn: (...args: any) => Promise; 151 | args: any[]; 152 | resolve: (value: any) => void; 153 | reject: (err: any) => void; 154 | }; 155 | }; 156 | 157 | constructor() { 158 | this.taskId = 0; 159 | this.pendingTaskId = -1; 160 | this.tasks = {}; 161 | } 162 | 163 | enqueue(fn: (...args: any) => Promise, ...args: any[]) { 164 | return new Promise((resolve, reject) => { 165 | const taskId = this.taskId++; 166 | this.tasks[taskId] = { fn, args, resolve, reject }; 167 | this._maybeExecuteNext(); 168 | }); 169 | } 170 | 171 | _maybeExecuteNext() { 172 | let pendingTask = this.tasks[this.pendingTaskId]; 173 | 174 | if (pendingTask) { 175 | return; 176 | } 177 | 178 | const nextPendingTaskId = this.pendingTaskId + 1; 179 | 180 | pendingTask = this.tasks[nextPendingTaskId]; 181 | 182 | if (!pendingTask) { 183 | return; 184 | } 185 | 186 | this.pendingTaskId = nextPendingTaskId; 187 | 188 | const { fn, args, resolve, reject } = pendingTask; 189 | 190 | fn(...args) 191 | .then((result) => { 192 | resolve(result); 193 | delete this.tasks[nextPendingTaskId]; 194 | this._maybeExecuteNext(); 195 | }) 196 | .catch((err) => { 197 | reject(err); 198 | delete this.tasks[nextPendingTaskId]; 199 | this._maybeExecuteNext(); 200 | }); 201 | } 202 | } 203 | 204 | /* 205 | This un-chunker is more memory efficient 206 | (each chunk is passed immediately to msgpack) 207 | and it will only allocate memory when it receives content. 208 | Chunks for a given message are expected to come sequentially 209 | Chunks across messages can be interleaved. 210 | */ 211 | class StreamUnChunker { 212 | private pendingMessages: { [key: number]: StreamPendingMessage }; 213 | 214 | constructor() { 215 | this.pendingMessages = {}; 216 | } 217 | 218 | processChunk = async ( 219 | chunk: Blob, 220 | decoderFactory: () => any 221 | ): Promise => { 222 | const headerBlob = chunk.slice(0, HEADER_LENGTH); 223 | 224 | const header = new Uint8Array(await headerBlob.arrayBuffer()); 225 | const { id, offset, size: totalSize } = decodeHeader(header); 226 | 227 | const contentBlob = chunk.slice(HEADER_LENGTH); 228 | 229 | let pendingMessage = this.pendingMessages[id]; 230 | 231 | if (!pendingMessage) { 232 | pendingMessage = { 233 | receivedSize: 0, 234 | totalSize: totalSize, 235 | decoder: decoderFactory(), 236 | }; 237 | 238 | this.pendingMessages[id] = pendingMessage; 239 | } 240 | 241 | // This should never happen, but still check it 242 | if (totalSize !== pendingMessage.totalSize) { 243 | delete this.pendingMessages[id]; 244 | throw new Error( 245 | `Total size in chunk header for message ${id} does not match total size declared by previous chunk.` 246 | ); 247 | } 248 | 249 | // This should never happen, but still check it 250 | if (offset !== pendingMessage.receivedSize) { 251 | delete this.pendingMessages[id]; 252 | throw new Error( 253 | `Received an unexpected chunk for message ${id}. 254 | Expected offset = ${pendingMessage.receivedSize}, 255 | Received offset = ${offset}.` 256 | ); 257 | } 258 | 259 | let result: unknown; 260 | try { 261 | result = await pendingMessage.decoder.decodeAsync( 262 | contentBlob.stream() as any 263 | ); 264 | } catch (e) { 265 | if (e instanceof RangeError) { 266 | // More data is needed, it should come in the next chunk 267 | result = undefined; 268 | } 269 | } 270 | 271 | pendingMessage.receivedSize += contentBlob.size; 272 | 273 | /* 274 | In principle feeding a stream to the unpacker could yield multiple outputs 275 | for example unpacker.feed(b'0123') would yield b'0', b'1', ect 276 | or concatenated packed payloads would yield two or more unpacked objects 277 | but in our use case we expect a full message to be mapped to a single object 278 | */ 279 | if (result && pendingMessage.receivedSize < totalSize) { 280 | delete this.pendingMessages[id]; 281 | throw new Error( 282 | `Received a parsable payload shorter than expected for message ${id}. 283 | Expected size = ${totalSize}, 284 | Received size = ${pendingMessage.receivedSize}.` 285 | ); 286 | } 287 | 288 | if (pendingMessage.receivedSize >= totalSize) { 289 | delete this.pendingMessages[id]; 290 | } 291 | 292 | return result; 293 | }; 294 | } 295 | 296 | export { UnChunker, StreamUnChunker, generateChunks }; 297 | -------------------------------------------------------------------------------- /js/src/WebsocketConnection/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface SubscriberInfo { 2 | topic: string 3 | callback: (args: any[]) => Promise; 4 | } 5 | 6 | // Provides a generic RPC stub built on top of websocket. 7 | export interface WebsocketSession { 8 | // Issue a single-shot RPC. 9 | call(methodName: string, args?: any[], kwargs?: Record): Promise; 10 | // Subscribe to one-way messages from the server. 11 | subscribe(topic: string, callback: (args: any[]) => Promise): { 12 | // A promise to be resolved once subscription succeeds. 13 | promise: Promise; 14 | // Cancels the subscription. 15 | unsubscribe: () => Promise; 16 | } 17 | // Cancels the subscription. 18 | unsubscribe(info: SubscriberInfo): Promise; 19 | close(): Promise; 20 | 21 | /** 22 | * @param payload The binary data to send 23 | * @returns The id assigned to the binary attachment 24 | */ 25 | addAttachment(payload: Blob): string 26 | } 27 | 28 | export interface IWebsocketConnectionInitialValues { 29 | secret?: string; 30 | connection?: any; 31 | session?: any; 32 | retry?: boolean; 33 | } 34 | 35 | // Represents a single established websocket connection. 36 | export interface WebsocketConnection { 37 | getSession(): WebsocketSession; 38 | getUrl(): string | null; 39 | destroy(): void; 40 | } 41 | 42 | /** 43 | * Creates a new SmartConnect object with the given configuration. 44 | */ 45 | export function newInstance(initialValues: IWebsocketConnectionInitialValues): WebsocketConnection; 46 | 47 | /** 48 | * Decorates a given object (publicAPI+model) with WebsocketConnection characteristics. 49 | */ 50 | export function extend(publicAPI: object, model: object, initialValues?: IWebsocketConnectionInitialValues): void; 51 | 52 | export declare const WebsocketConnection: { 53 | newInstance: typeof newInstance; 54 | extend: typeof extend; 55 | } 56 | 57 | export default WebsocketConnection; 58 | -------------------------------------------------------------------------------- /js/src/WebsocketConnection/index.js: -------------------------------------------------------------------------------- 1 | // Helper borrowed from paraviewweb/src/Common/Core 2 | import CompositeClosureHelper from '../CompositeClosureHelper'; 3 | import Session from './session'; 4 | 5 | const DEFAULT_SECRET = 'wslink-secret'; 6 | 7 | function getTransportObject(url) { 8 | var idx = url.indexOf(':'), 9 | protocol = url.substring(0, idx); 10 | if (protocol === 'ws' || protocol === 'wss') { 11 | return { 12 | type: 'websocket', 13 | url, 14 | }; 15 | } 16 | 17 | throw new Error( 18 | `Unknown protocol (${protocol}) for url (${url}). Unable to create transport object.` 19 | ); 20 | } 21 | 22 | function WebsocketConnection(publicAPI, model) { 23 | // TODO Should we try to reconnect on error? 24 | 25 | publicAPI.connect = () => { 26 | // without a URL we can't do anything. 27 | if (!model.urls) return null; 28 | // concat allows a single url or a list. 29 | var uriList = [].concat(model.urls), 30 | transports = []; 31 | 32 | for (let i = 0; i < uriList.length; i += 1) { 33 | const url = uriList[i]; 34 | try { 35 | const transport = getTransportObject(url); 36 | transports.push(transport); 37 | } catch (transportCreateError) { 38 | console.error(transportCreateError); 39 | publicAPI.fireConnectionError(publicAPI, transportCreateError); 40 | return null; 41 | } 42 | } 43 | 44 | if (model.connection) { 45 | if (model.connection.url !== transports[0].url) { 46 | model.connection.close(); 47 | } else if ( 48 | model.connection.readyState === 0 || 49 | model.connection.readyState === 1 50 | ) { 51 | // already connected. 52 | return model.session; 53 | } 54 | } 55 | try { 56 | if (model.wsProxy) { 57 | model.connection = WSLINK.createWebSocket(transports[0].url); 58 | } else { 59 | model.connection = new WebSocket(transports[0].url); 60 | } 61 | } catch (err) { 62 | // If the server isn't running, we still don't enter here on Chrome - 63 | // console shows a net::ERR_CONNECTION_REFUSED error inside WebSocket 64 | console.error(err); 65 | publicAPI.fireConnectionError(publicAPI, err); 66 | return null; 67 | } 68 | 69 | model.connection.binaryType = 'blob'; 70 | if (!model.secret) model.secret = DEFAULT_SECRET; 71 | model.session = Session.newInstance({ 72 | ws: model.connection, 73 | secret: model.secret, 74 | }); 75 | 76 | model.connection.onopen = (event) => { 77 | if (model.session) { 78 | // sends handshake message - wait for reply before issuing ready() 79 | model.session.onconnect(event).then( 80 | () => { 81 | publicAPI.fireConnectionReady(publicAPI); 82 | }, 83 | (err) => { 84 | console.error('Connection error', err); 85 | publicAPI.fireConnectionError(publicAPI, err); 86 | } 87 | ); 88 | } 89 | }; 90 | 91 | model.connection.onclose = (event) => { 92 | publicAPI.fireConnectionClose(publicAPI, event); 93 | model.connection = null; 94 | // return !model.retry; // true => Stop retry 95 | }; 96 | model.connection.onerror = (event) => { 97 | publicAPI.fireConnectionError(publicAPI, event); 98 | }; 99 | // handle messages in the session. 100 | model.connection.onmessage = (event) => { 101 | model.session.onmessage(event); 102 | }; 103 | return model.session; 104 | }; 105 | 106 | publicAPI.getSession = () => model.session; 107 | 108 | publicAPI.getUrl = () => 109 | model.connection ? model.connection.url : undefined; 110 | 111 | function cleanUp(timeout = 10) { 112 | if ( 113 | model.connection && 114 | model.connection.readyState === 1 && 115 | model.session && 116 | timeout > 0 117 | ) { 118 | model.session.call('application.exit.later', [timeout]); 119 | } 120 | if (model.connection) { 121 | model.connection.close(); 122 | } 123 | model.connection = null; 124 | } 125 | 126 | publicAPI.destroy = CompositeClosureHelper.chain(cleanUp, publicAPI.destroy); 127 | } 128 | 129 | const DEFAULT_VALUES = { 130 | secret: DEFAULT_SECRET, 131 | connection: null, 132 | session: null, 133 | retry: false, 134 | wsProxy: false, // Use WSLINK.WebSocket if true else native WebSocket 135 | }; 136 | 137 | export function extend(publicAPI, model, initialValues = {}) { 138 | Object.assign(model, DEFAULT_VALUES, initialValues); 139 | 140 | CompositeClosureHelper.destroy(publicAPI, model); 141 | CompositeClosureHelper.event(publicAPI, model, 'ConnectionReady'); 142 | CompositeClosureHelper.event(publicAPI, model, 'ConnectionClose'); 143 | CompositeClosureHelper.event(publicAPI, model, 'ConnectionError'); 144 | CompositeClosureHelper.isA(publicAPI, model, 'WebsocketConnection'); 145 | 146 | WebsocketConnection(publicAPI, model); 147 | } 148 | 149 | // ---------------------------------------------------------------------------- 150 | 151 | export const newInstance = CompositeClosureHelper.newInstance(extend); 152 | 153 | // ---------------------------------------------------------------------------- 154 | 155 | export default { newInstance, extend }; 156 | -------------------------------------------------------------------------------- /js/src/WebsocketConnection/session.js: -------------------------------------------------------------------------------- 1 | // Helper borrowed from paraviewweb/src/Common/Core 2 | import CompositeClosureHelper from '../CompositeClosureHelper'; 3 | import { UnChunker, generateChunks } from './chunking'; 4 | import { Encoder, Decoder } from '@msgpack/msgpack'; 5 | 6 | function defer() { 7 | const deferred = {}; 8 | 9 | deferred.promise = new Promise(function (resolve, reject) { 10 | deferred.resolve = resolve; 11 | deferred.reject = reject; 12 | }); 13 | 14 | return deferred; 15 | } 16 | 17 | function Session(publicAPI, model) { 18 | const CLIENT_ERROR = -32099; 19 | let msgCount = 0; 20 | const inFlightRpc = {}; 21 | // matches 'rpc:client3:21' 22 | // client may be dot-separated and include '_' 23 | // number is message count - unique. 24 | // matches 'publish:dot.separated.topic:42' 25 | const regexRPC = /^(rpc|publish|system):(\w+(?:\.\w+)*):(?:\d+)$/; 26 | const subscriptions = {}; 27 | let clientID = null; 28 | let MAX_MSG_SIZE = 512 * 1024; 29 | const unchunker = new UnChunker(); 30 | 31 | // -------------------------------------------------------------------------- 32 | // Private helpers 33 | // -------------------------------------------------------------------------- 34 | 35 | function onCompleteMessage(payload) { 36 | if (!payload) return; 37 | if (!payload.id) return; 38 | if (payload.error) { 39 | const deferred = inFlightRpc[payload.id]; 40 | if (deferred) { 41 | deferred.reject(payload.error); 42 | } else { 43 | console.error('Server error:', payload.error); 44 | } 45 | } else { 46 | const match = regexRPC.exec(payload.id); 47 | if (match) { 48 | const type = match[1]; 49 | if (type === 'rpc') { 50 | const deferred = inFlightRpc[payload.id]; 51 | if (!deferred) { 52 | console.log( 53 | 'session message id without matching call, dropped', 54 | payload 55 | ); 56 | return; 57 | } 58 | deferred.resolve(payload.result); 59 | } else if (type == 'publish') { 60 | console.assert( 61 | inFlightRpc[payload.id] === undefined, 62 | 'publish message received matching in-flight rpc call' 63 | ); 64 | // regex extracts the topic for us. 65 | const topic = match[2]; 66 | if (!subscriptions[topic]) { 67 | return; 68 | } 69 | // for each callback, provide the message data. Wrap in an array, for back-compatibility with WAMP 70 | subscriptions[topic].forEach((callback) => 71 | callback([payload.result]) 72 | ); 73 | } else if (type == 'system') { 74 | // console.log('DBG system:', payload.id, payload.result); 75 | const deferred = inFlightRpc[payload.id]; 76 | if (payload.id === 'system:c0:0') { 77 | clientID = payload.result.clientID; 78 | MAX_MSG_SIZE = payload.result.maxMsgSize || MAX_MSG_SIZE; 79 | if (deferred) deferred.resolve(clientID); 80 | } else { 81 | console.error('Unknown system message', payload.id); 82 | if (deferred) 83 | deferred.reject({ 84 | code: CLIENT_ERROR, 85 | message: `Unknown system message ${payload.id}`, 86 | }); 87 | } 88 | } else { 89 | console.error('Unknown rpc id format', payload.id); 90 | } 91 | } 92 | } 93 | delete inFlightRpc[payload.id]; 94 | } 95 | 96 | // -------------------------------------------------------------------------- 97 | // Public API 98 | // -------------------------------------------------------------------------- 99 | 100 | publicAPI.onconnect = (event) => { 101 | // send hello message 102 | const deferred = defer(); 103 | const id = 'system:c0:0'; 104 | inFlightRpc[id] = deferred; 105 | 106 | const wrapper = { 107 | wslink: '1.0', 108 | id, 109 | method: 'wslink.hello', 110 | args: [{ secret: model.secret }], 111 | kwargs: {}, 112 | }; 113 | 114 | const encoder = new CustomEncoder(); 115 | const packedWrapper = encoder.encode(wrapper); 116 | 117 | for (let chunk of generateChunks(packedWrapper, MAX_MSG_SIZE)) { 118 | model.ws.send(chunk, { binary: true }); 119 | } 120 | 121 | return deferred.promise; 122 | }; 123 | 124 | // -------------------------------------------------------------------------- 125 | 126 | publicAPI.call = (method, args = [], kwargs = {}) => { 127 | // create a promise that we will use to notify the caller of the result. 128 | const deferred = defer(); 129 | // readyState OPEN === 1 130 | if (model.ws && clientID && model.ws.readyState === 1) { 131 | const id = `rpc:${clientID}:${msgCount++}`; 132 | inFlightRpc[id] = deferred; 133 | 134 | const wrapper = { wslink: '1.0', id, method, args, kwargs }; 135 | 136 | const encoder = new CustomEncoder(); 137 | const packedWrapper = encoder.encode(wrapper); 138 | 139 | for (let chunk of generateChunks(packedWrapper, MAX_MSG_SIZE)) { 140 | model.ws.send(chunk, { binary: true }); 141 | } 142 | } else { 143 | deferred.reject({ 144 | code: CLIENT_ERROR, 145 | message: `RPC call ${method} unsuccessful: connection not open`, 146 | }); 147 | } 148 | return deferred.promise; 149 | }; 150 | 151 | // -------------------------------------------------------------------------- 152 | 153 | publicAPI.subscribe = (topic, callback) => { 154 | const deferred = defer(); 155 | if (model.ws && clientID) { 156 | // we needs to track subscriptions, to trigger callback when publish is received. 157 | if (!subscriptions[topic]) subscriptions[topic] = []; 158 | subscriptions[topic].push(callback); 159 | // we can notify the server, but we don't need to, if the server always sends messages unconditionally. 160 | // model.ws.send(JSON.stringify({ wslink: '1.0', id: `subscribe:${msgCount++}`, method, args: [] })); 161 | deferred.resolve({ topic, callback }); 162 | } else { 163 | deferred.reject({ 164 | code: CLIENT_ERROR, 165 | message: `Subscribe call ${topic} unsuccessful: connection not open`, 166 | }); 167 | } 168 | return { 169 | topic, 170 | callback, 171 | promise: deferred.promise, 172 | unsubscribe: () => publicAPI.unsubscribe({ topic, callback }), 173 | }; 174 | }; 175 | 176 | // -------------------------------------------------------------------------- 177 | 178 | publicAPI.unsubscribe = (info) => { 179 | const deferred = defer(); 180 | const { topic, callback } = info; 181 | if (!subscriptions[topic]) { 182 | deferred.reject({ 183 | code: CLIENT_ERROR, 184 | message: `Unsubscribe call ${topic} unsuccessful: not subscribed`, 185 | }); 186 | return deferred.promise; 187 | } 188 | const index = subscriptions[topic].indexOf(callback); 189 | if (index !== -1) { 190 | subscriptions[topic].splice(index, 1); 191 | deferred.resolve(); 192 | } else { 193 | deferred.reject({ 194 | code: CLIENT_ERROR, 195 | message: `Unsubscribe call ${topic} unsuccessful: callback not found`, 196 | }); 197 | } 198 | return deferred.promise; 199 | }; 200 | 201 | // -------------------------------------------------------------------------- 202 | 203 | publicAPI.close = () => { 204 | const deferred = defer(); 205 | // some transports might be able to close the session without closing the connection. Not true for websocket... 206 | model.ws.close(); 207 | unchunker.releasePendingMessages(); 208 | deferred.resolve(); 209 | return deferred.promise; 210 | }; 211 | 212 | // -------------------------------------------------------------------------- 213 | 214 | function createDecoder() { 215 | return new Decoder(); 216 | } 217 | 218 | publicAPI.onmessage = async (event) => { 219 | const message = await unchunker.processChunk(event.data, createDecoder); 220 | 221 | if (message) { 222 | onCompleteMessage(message); 223 | } 224 | }; 225 | 226 | // -------------------------------------------------------------------------- 227 | 228 | publicAPI.addAttachment = (payload) => { 229 | // Deprecated method, keeping it to avoid breaking compatibility 230 | // Now that we use msgpack to pack/unpack messages, 231 | // We can have binary data directly in the object itself, 232 | // without needing to transfer it separately from the rest. 233 | // 234 | // If an ArrayBuffer is passed, ensure it gets wrapped in 235 | // a DataView (which is what the encoder expects). 236 | if (payload instanceof ArrayBuffer) { 237 | return new DataView(payload); 238 | } 239 | 240 | return payload; 241 | }; 242 | } 243 | 244 | const DEFAULT_VALUES = { 245 | secret: 'wslink-secret', 246 | ws: null, 247 | }; 248 | 249 | export function extend(publicAPI, model, initialValues = {}) { 250 | Object.assign(model, DEFAULT_VALUES, initialValues); 251 | 252 | CompositeClosureHelper.destroy(publicAPI, model); 253 | CompositeClosureHelper.isA(publicAPI, model, 'Session'); 254 | 255 | Session(publicAPI, model); 256 | } 257 | 258 | // ---------------------------------------------------------------------------- 259 | 260 | export const newInstance = CompositeClosureHelper.newInstance(extend); 261 | 262 | // ---------------------------------------------------------------------------- 263 | 264 | export default { newInstance, extend }; 265 | 266 | class CustomEncoder extends Encoder { 267 | // Unfortunately @msgpack/msgpack only supports 268 | // views of an ArrayBuffer (DataView, Uint8Array,..), 269 | // but not an ArrayBuffer itself. 270 | // They suggest using custom type extensions to support it, 271 | // but that would yield a different packed payload 272 | // (1 byte larger, but most importantly it would require 273 | // dealing with the custom type when unpacking on the server). 274 | // Since this type is too trivial to be treated differently, 275 | // and since I don't want to rely on the users always wrapping 276 | // their ArrayBuffers in a view, I'm subclassing the encoder. 277 | encodeObject(object, depth) { 278 | if (object instanceof ArrayBuffer) { 279 | object = new DataView(object); 280 | } 281 | 282 | return super.encodeObject.call(this, object, depth); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /js/src/index.js: -------------------------------------------------------------------------------- 1 | import CompositeClosureHelper from './CompositeClosureHelper'; 2 | import ProcessLauncher from './ProcessLauncher'; 3 | import SmartConnect from './SmartConnect'; 4 | import WebsocketConnection from './WebsocketConnection'; 5 | 6 | export { 7 | CompositeClosureHelper, 8 | ProcessLauncher, 9 | SmartConnect, 10 | WebsocketConnection, 11 | }; 12 | -------------------------------------------------------------------------------- /js/test/simple.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | import WebsocketConnection from '../src/WebsocketConnection'; 3 | import SmartConnect from '../src/SmartConnect'; 4 | 5 | // this template allows us to use HtmlWebpackPlugin 6 | // in webpack to generate our index.html 7 | // expose-loader makes our 'export' functions part of the 'app' global 8 | const htmlContent = ` 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 |
23 | 24 | `; 25 | 26 | const rootContainer = document.querySelector('body'); 27 | const controlContainer = document.createElement('div'); 28 | rootContainer.appendChild(controlContainer); 29 | controlContainer.innerHTML = htmlContent; 30 | 31 | const inputElement = document.querySelector('.input'); 32 | const logOutput = document.querySelector('.output'); 33 | let ws = null; 34 | let subscription = false; 35 | let session = null; 36 | 37 | function log(msg) { 38 | console.log(msg); 39 | logOutput.innerHTML += msg; 40 | logOutput.innerHTML += '\n'; 41 | } 42 | function logerr(err) { 43 | console.error(err); 44 | logOutput.innerHTML += `error: ${err.code}, "${err.message}", ${err.data}`; 45 | logOutput.innerHTML += '\n'; 46 | } 47 | 48 | export function sendInput(type) { 49 | if (!session) return; 50 | const data = JSON.parse('[' + inputElement.value + ']'); 51 | session.call(`myprotocol.${type}`, [data]).then( 52 | (result) => log('result ' + result), 53 | (err) => logerr(err) 54 | ); 55 | } 56 | export function sendImage(type) { 57 | if (!session) return; 58 | session.call(`myprotocol.${type}`, []).then( 59 | (result) => { 60 | log('result ' + result); 61 | handleMessage(result); 62 | }, 63 | (err) => logerr(err) 64 | ); 65 | } 66 | function handleMessage(inData) { 67 | let data = Array.isArray(inData) ? inData[0] : inData; 68 | let blob = data.blob || data; 69 | if (blob instanceof Blob) { 70 | const canvas = document.querySelector('.imageCanvas'); 71 | const ctx = canvas.getContext('2d'); 72 | 73 | const img = new Image(); 74 | const reader = new FileReader(); 75 | reader.onload = function (e) { 76 | img.onload = () => ctx.drawImage(img, 0, 0); 77 | img.src = e.target.result; 78 | }; 79 | reader.readAsDataURL(blob); 80 | } else { 81 | log('result ' + blob); 82 | } 83 | } 84 | 85 | export function testNesting() { 86 | if (!session) return; 87 | session.call('myprotocol.nested.image', []).then( 88 | (data) => { 89 | if (data['image']) handleMessage(data['image']); 90 | const onload = (e) => { 91 | const arr = new Uint8Array(e.target.result); 92 | if (arr.length === 4) { 93 | arr.forEach((d, i) => { 94 | if (d !== i + 1) console.error('mismatch4', d, i); 95 | }); 96 | } else if (arr.length === 6) { 97 | arr.forEach((d, i) => { 98 | if (d !== i + 5) console.error('mismatch4', d, i); 99 | }); 100 | } else { 101 | console.error('Size mismatch', arr.length); 102 | } 103 | }; 104 | data.bytesList.forEach((bl) => { 105 | const reader = new FileReader(); 106 | reader.onload = onload; 107 | reader.readAsArrayBuffer(bl); 108 | }); 109 | 110 | console.log('Nesting:', data); 111 | }, 112 | (err) => logerr(err) 113 | ); 114 | } 115 | 116 | export function sendMistake() { 117 | if (!session) return; 118 | session 119 | .call('myprotocol.mistake.TYPO', ['ignored']) 120 | .then(handleMessage, (err) => logerr(err)); 121 | } 122 | 123 | export function sendServerQuit() { 124 | if (!session) return; 125 | session.call('application.exit.later', [5]).then( 126 | (result) => log('result ' + result), 127 | (err) => logerr(err) 128 | ); 129 | } 130 | 131 | export function toggleStream() { 132 | if (!subscription) { 133 | subscription = session.subscribe('image', handleMessage); 134 | session.call('myprotocol.stream', ['image']).then( 135 | (result) => log('result ' + result), 136 | (err) => logerr(err) 137 | ); 138 | } else { 139 | session.call('myprotocol.stop', ['image']).then( 140 | (result) => log('result ' + result), 141 | (err) => logerr(err) 142 | ); 143 | // session.unsubscribe(subscription); 144 | subscription.unsubscribe(); 145 | subscription = null; 146 | } 147 | } 148 | 149 | export function wsclose() { 150 | if (!session) return; 151 | session.close(); 152 | // it's fine to destroy the WebsocketConnection, but you won't get the WS close message. 153 | // if (ws) ws.destroy(); 154 | // ws = null; 155 | } 156 | 157 | export function connect(direct = false) { 158 | ws = null; 159 | if (direct) { 160 | ws = WebsocketConnection.newInstance({ urls: 'ws://localhost:8080/ws' }); 161 | } else { 162 | const config = { application: 'simple' }; 163 | ws = SmartConnect.newInstance({ config }); 164 | } 165 | ws.onConnectionReady(() => { 166 | log('WS open'); 167 | if (!session) { 168 | session = ws.getSession(); 169 | } 170 | const canvas = document.querySelector('.imageCanvas'); 171 | const ctx = canvas.getContext('2d'); 172 | ctx.clearRect(0, 0, 300, 300); 173 | }); 174 | 175 | ws.onConnectionClose(() => { 176 | log('WS close'); 177 | }); 178 | 179 | ws.onConnectionError((event) => { 180 | log('WS error'); 181 | console.error(event); 182 | }); 183 | 184 | session = ws.connect(); 185 | } 186 | -------------------------------------------------------------------------------- /js/webpack-test-simple.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const entry = path.join(__dirname, './test/simple.js'); 4 | 5 | module.exports = { 6 | context: path.resolve(__dirname), 7 | entry, 8 | output: { 9 | filename: 'test.js', 10 | path: path.resolve(__dirname, '../tests/simple/www'), 11 | }, 12 | mode: 'development', 13 | devtool: 'inline-source-map', 14 | devServer: { 15 | contentBase: path.resolve(__dirname, '../tests/simple/www'), 16 | }, 17 | module: { 18 | rules: [ 19 | { test: entry, loader: 'expose-loader', options: { exposes: ['app']} }, 20 | { 21 | test: /\.js$/, 22 | use: [ 23 | { 24 | loader: 'babel-loader', 25 | options: { 26 | presets: ['@babel/preset-env'], 27 | }, 28 | }, 29 | ], 30 | }, 31 | ] 32 | }, 33 | plugins: [ 34 | new HtmlWebpackPlugin(), 35 | ] 36 | }; 37 | -------------------------------------------------------------------------------- /js/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | context: path.resolve(__dirname), 5 | entry: ['./src/index.js'], 6 | output: { 7 | filename: 'wslink.js', 8 | path: path.resolve(__dirname, 'dist'), 9 | library: "wslink", 10 | libraryTarget: "umd" 11 | }, 12 | mode: 'development', 13 | devtool: 'source-map', 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | use: [ 19 | { 20 | loader: 'babel-loader', 21 | options: { 22 | presets: ['@babel/preset-env'], 23 | }, 24 | }, 25 | ], 26 | }, 27 | ] 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /python/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/wslink/LICENSE 2 | -------------------------------------------------------------------------------- /python/README.rst: -------------------------------------------------------------------------------- 1 | wslink 2 | ====== 3 | 4 | Wslink allows easy, bi-directional communication between a python server and a 5 | javascript client over a websocket_. The client can make RPC calls to the 6 | server, and the server can publish messages to topics that the client can 7 | subscribe to. The server can include binary attachments in these messages, 8 | which are communicated as a binary websocket message, avoiding the overhead of 9 | encoding and decoding. 10 | 11 | RPC and publish/subscribe 12 | ------------------------- 13 | 14 | The initial users of wslink driving its development are VTK_ and ParaView_. 15 | ParaViewWeb and vtkWeb require: 16 | 17 | * RPC - a remote procedure call that can be fired by the client and return 18 | sometime later with a response from the server, possibly an error. 19 | 20 | * Publish/subscribe - client can subscribe to a topic provided by the server, 21 | possibly with a filter on the parts of interest. When the topic has updated 22 | results, the server publishes them to the client, without further action on 23 | the client's part. 24 | 25 | Get the whole story 26 | ------------------- 27 | 28 | This package is just the server side of wslink. See the `github repo`_ for 29 | the full story - and to contribute or report issues! 30 | 31 | Configure from environment variables 32 | ------------------------------------ 33 | 34 | Those only apply for the Python server and launcher. 35 | 36 | * WSLINK_LAUNCHER_GET - If set to 1 this will enable the GET endpoint for session information 37 | * WSLINK_LAUNCHER_DELETE - If set to 1 this will enable the DELETE endpoint for killing a running session 38 | * WSLINK_MAX_MSG_SIZE - Number of bytes for a message size (default: 4194304) 39 | * WSLINK_HEART_BEAT - Number of seconds between heartbeats (default: 30) 40 | * WSLINK_HTTP_HEADERS - Path to json file containing HTTP headers to be added 41 | 42 | License 43 | ------- 44 | Free to use in open-source and commercial projects, under the BSD-3-Clause license. 45 | 46 | .. _github repo: https://github.com/kitware/wslink 47 | .. _ParaView: https://www.paraview.org/ 48 | .. _VTK: http://www.vtk.org/ 49 | .. _websocket: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket 50 | -------------------------------------------------------------------------------- /python/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | # doc checking 4 | collective.checkdocs 5 | 6 | # publishing 7 | twine 8 | wheel 9 | -------------------------------------------------------------------------------- /python/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.7.4,<4 2 | msgpack>=1,<2 3 | 4 | # platform specific 5 | pypiwin32==223; sys_platform == 'win32' 6 | -------------------------------------------------------------------------------- /python/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import re 6 | 7 | # import shutil 8 | import sys 9 | 10 | # import itertools 11 | 12 | from setuptools import setup, find_packages 13 | 14 | # from setuptools.command.install import install 15 | # from distutils.dir_util import copy_tree 16 | 17 | readme = "" 18 | with open("README.rst") as f: 19 | readme = f.read() 20 | 21 | setup( 22 | name="wslink", 23 | description="Python/JavaScript library for communicating over WebSocket", 24 | long_description=readme, 25 | author="Kitware, Inc.", 26 | author_email="kitware@kitware.com", 27 | url="https://github.com/kitware/wslink", 28 | license="BSD-3-Clause", 29 | classifiers=[ 30 | "Development Status :: 5 - Production/Stable", 31 | "Environment :: Web Environment", 32 | "License :: OSI Approved :: BSD License", 33 | "Operating System :: OS Independent", 34 | "Programming Language :: Python", 35 | "Programming Language :: Python :: 3", 36 | "Programming Language :: Python :: 3.3", 37 | "Programming Language :: Python :: 3.4", 38 | "Programming Language :: Python :: 3.5", 39 | "Programming Language :: Python :: 3.6", 40 | "Programming Language :: Python :: 3.7", 41 | "Programming Language :: Python :: 3.8", 42 | "Programming Language :: Python :: 3.9", 43 | "Topic :: Software Development :: Libraries :: Python Modules", 44 | ], 45 | keywords="websocket javascript rpc pubsub", 46 | packages=find_packages("src", exclude=("tests.*", "tests")), 47 | package_dir={"": "src"}, 48 | install_requires=["aiohttp<4", "msgpack>=1,<2"], 49 | extras_require={ 50 | "ssl": ["cryptography"], 51 | }, 52 | include_package_data=True, 53 | ) 54 | -------------------------------------------------------------------------------- /python/src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/wslink/02841cc92a533ff495adde410b9f32b0eb6d377f/python/src/tests/__init__.py -------------------------------------------------------------------------------- /python/src/tests/test-wslink.conf: -------------------------------------------------------------------------------- 1 | { 2 | // Test sanitization: 3 | // cmd lines: python wslink/launcher.py tests/test-wslink.conf 4 | // curl -H "Content-Type: application/json" -X POST -d '{"application": "test","cmd":"meX","cmd2":"hello\"912"}' http://localhost:9000/paraview 5 | // check launcherLog.log for: 6 | // key cmd: sanitize meX with default 7 | // key cmd2: sanitize hello"912 with default 8 | // and hashed .txt file in temp dir for default output: 9 | // nothing-to-do nothing 10 | "configuration": { 11 | "host" : "localhost", 12 | "port" : 9000, 13 | "endpoint": "paraview", 14 | "proxy_file" : "/tmp/proxy-mapping.txt", //here 15 | "sessionURL" : "ws://${host}:${port}/ws", // here 16 | "timeout" : 25, 17 | "log_dir" : "/tmp", 18 | "fields" : [], 19 | "sanitize": { 20 | "cmd": { 21 | "type": "inList", 22 | "list": [ 23 | "me", "you", "something/else/altogether", "nothing-to-do" 24 | ], 25 | "default": "nothing-to-do" 26 | }, 27 | "cmd2": { 28 | "type": "regexp", 29 | "regexp": "^[-\\w./]+$", 30 | "default": "nothing" 31 | } 32 | } 33 | }, 34 | "sessionData" : {}, 35 | "resources" : [ { "host" : "localhost", "port_range" : [9001, 9003] } ], 36 | "properties" : {}, 37 | "apps" : { 38 | "test" : { 39 | "cmd" : [ 40 | "echo", "${cmd} ${cmd2}" 41 | ], 42 | "ready_line" : "Starting factory" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /python/src/tests/testEmitter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import asyncio 4 | 5 | from wslink.emitter import EventEmitter 6 | 7 | 8 | class TestEventEmitter(unittest.IsolatedAsyncioTestCase): 9 | def _create_listeners(self, emitter): 10 | payloads = {} 11 | 12 | def on_foo(val): 13 | payloads["foo"] = val 14 | 15 | async def on_bar(val): 16 | await asyncio.sleep(0.05) 17 | payloads["bar"] = val 18 | 19 | emitter.add_event_listener("foo", on_foo) 20 | emitter.add_event_listener("bar", on_bar) 21 | 22 | return payloads, on_foo, on_bar 23 | 24 | async def test_add_listener(self): 25 | emitter = EventEmitter() 26 | payloads, on_foo, on_bar = self._create_listeners(emitter) 27 | 28 | emitter.emit("foo", 0) 29 | emitter.emit("bar", 1) 30 | 31 | await asyncio.sleep(0.1) 32 | 33 | self.assertEqual(payloads.get("foo"), 0) 34 | self.assertEqual(payloads.get("bar"), 1) 35 | 36 | async def test_remove_listeners(self): 37 | emitter = EventEmitter() 38 | payloads, on_foo, on_bar = self._create_listeners(emitter) 39 | 40 | emitter.remove_event_listener("bar", on_bar) 41 | 42 | emitter.emit("foo", 0) 43 | emitter.emit("bar", 1) 44 | 45 | await asyncio.sleep(0.1) 46 | 47 | self.assertEqual(payloads.get("foo"), 0) 48 | self.assertEqual(payloads.get("bar"), None) 49 | 50 | async def test_clear_listeners(self): 51 | emitter = EventEmitter() 52 | payloads, on_foo, on_bar = self._create_listeners(emitter) 53 | 54 | emitter.clear() 55 | 56 | emitter.emit("foo", 0) 57 | emitter.emit("bar", 1) 58 | 59 | await asyncio.sleep(0.1) 60 | 61 | self.assertEqual(payloads.get("foo"), None) 62 | self.assertEqual(payloads.get("bar"), None) 63 | 64 | def test_event_type_runtime(self): 65 | emitter = EventEmitter(allowed_events=("foo", "bar")) 66 | 67 | emitter.emit("foo") 68 | emitter.emit("bar") 69 | self.assertRaises(ValueError, emitter.emit, "baz") 70 | 71 | self.assertSetEqual(emitter.allowed_events, {"foo", "bar"}) 72 | 73 | async def test_event_emit_call(self): 74 | emitter = EventEmitter(allowed_events=("foo", "bar")) 75 | payloads, on_foo, on_bar = self._create_listeners(emitter) 76 | 77 | self.assertSetEqual(emitter.allowed_events, {"foo", "bar"}) 78 | 79 | emitter("foo", 0) 80 | emitter("bar", 1) 81 | self.assertRaises(ValueError, emitter, "baz", 2) 82 | 83 | await asyncio.sleep(0.1) 84 | 85 | self.assertEqual(payloads.get("foo"), 0) 86 | self.assertEqual(payloads.get("bar"), 1) 87 | 88 | async def test_event_emit_attribute(self): 89 | emitter = EventEmitter(allowed_events=("foo", "bar")) 90 | payloads, on_foo, on_bar = self._create_listeners(emitter) 91 | 92 | self.assertSetEqual(emitter.allowed_events, {"foo", "bar"}) 93 | 94 | emitter.foo(0) 95 | emitter.bar(1) 96 | self.assertRaises(AttributeError, lambda: emitter.baz) 97 | 98 | await asyncio.sleep(0.1) 99 | 100 | self.assertEqual(payloads.get("foo"), 0) 101 | self.assertEqual(payloads.get("bar"), 1) 102 | 103 | 104 | if __name__ == "__main__": 105 | unittest.main() 106 | -------------------------------------------------------------------------------- /python/src/tests/testImport.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class TestImport(unittest.TestCase): 5 | def test_import_root(self): 6 | import wslink 7 | 8 | def test_import_chunking(self): 9 | import wslink.chunking 10 | 11 | def test_import_emitter(self): 12 | import wslink.emitter 13 | 14 | def test_import_launcher(self): 15 | import wslink.launcher 16 | 17 | def test_import_protocol(self): 18 | import wslink.protocol 19 | 20 | def test_import_publish(self): 21 | import wslink.publish 22 | 23 | def test_import_relay(self): 24 | import wslink.relay 25 | 26 | def test_import_server(self): 27 | import wslink.server 28 | 29 | def test_import_ssl(self): 30 | import wslink.ssl_context 31 | 32 | def test_import_uri(self): 33 | import wslink.uri 34 | 35 | def test_import_websocket(self): 36 | import wslink.websocket 37 | 38 | def test_import_backends(self): 39 | import wslink.backends 40 | 41 | def test_import_backends_aiohttp(self): 42 | import wslink.backends.aiohttp 43 | 44 | def test_import_backends_generic(self): 45 | import wslink.backends.generic 46 | 47 | @unittest.skip("ipython is not a dependency of wslink") 48 | def test_import_backends_jupyter(self): 49 | import wslink.backends.jupyter 50 | 51 | @unittest.skip("tornado is not a dependency of wslink") 52 | def test_import_backends_tornado(self): 53 | import wslink.backends.tornado 54 | 55 | 56 | if __name__ == "__main__": 57 | unittest.main() 58 | -------------------------------------------------------------------------------- /python/src/wslink/LICENSE: -------------------------------------------------------------------------------- 1 | ../../../LICENSE -------------------------------------------------------------------------------- /python/src/wslink/__init__.py: -------------------------------------------------------------------------------- 1 | r""" 2 | Wslink allows easy, bi-directional communication between a python server and a 3 | javascript client over a websocket. 4 | 5 | wslink.server creates the python server 6 | wslink.websocket handles the communication 7 | """ 8 | 9 | import asyncio 10 | import functools 11 | 12 | from .uri import checkURI 13 | 14 | __license__ = "BSD-3-Clause" 15 | 16 | 17 | def register(uri): 18 | """ 19 | Decorator for RPC procedure endpoints. 20 | """ 21 | 22 | def decorate(f): 23 | # called once when method is decorated, because we return 'f'. 24 | assert callable(f) 25 | if not hasattr(f, "_wslinkuris"): 26 | f._wslinkuris = [] 27 | f._wslinkuris.append({"uri": checkURI(uri)}) 28 | return f 29 | 30 | return decorate 31 | 32 | 33 | ############################################################################# 34 | # 35 | # scheduling methods 36 | # 37 | # Allow scheduling both callbacks and coroutines from both sync and async 38 | # methods. 39 | # 40 | ############################################################################# 41 | 42 | 43 | def schedule_callback(delay, callback, *args, **kwargs): 44 | """ 45 | Schedule callback (which is passed args and kwargs) to be called on 46 | running event loop after delay seconds (can be floating point). Returns 47 | asyncio.TimerHandle on which cancel() can be called to cancel the 48 | eventual invocation of the callback. 49 | """ 50 | # Using "asyncio.get_running_loop()" requires the event loop to be running 51 | # already, so we use "asyncio.get_event_loop()" here so that we can support 52 | # scheduling tasks before the server is started. 53 | loop = asyncio.get_event_loop() 54 | return loop.call_later(delay, functools.partial(callback, *args, **kwargs)) 55 | 56 | 57 | def schedule_coroutine(delay, coro_func, *args, done_callback=None, **kwargs): 58 | """ 59 | Creates a coroutine out of the provided coroutine function coro_func and 60 | the provided args and kwargs, then schedules the coroutine to be called 61 | on the running event loop after delay seconds (delay can be float or int). 62 | 63 | Returns asyncio.Task on which cancel() can be called to cancel the running 64 | of the coroutine. 65 | 66 | The coro_func parameter should not be a coroutine, but rather a coroutine 67 | function (a function defined with async). The reason for this is we want 68 | to defer creation of the actual coroutine until we're ready to schedule it 69 | with "ensure_future". Otherwise every time we cancel a TimerTask 70 | returned by "call_later", python prints "RuntimeWarning: coroutine 71 | '' was never awaited". 72 | """ 73 | # See method above for comment on "get_event_loop()" vs "get_running_loop()". 74 | loop = asyncio.get_event_loop() 75 | coro_partial = functools.partial(coro_func, *args, **kwargs) 76 | if done_callback is not None: 77 | return loop.call_later( 78 | delay, 79 | lambda: asyncio.ensure_future(coro_partial()).add_done_callback( 80 | done_callback 81 | ), 82 | ) 83 | return loop.call_later(delay, lambda: asyncio.ensure_future(coro_partial())) 84 | -------------------------------------------------------------------------------- /python/src/wslink/backends/__init__.py: -------------------------------------------------------------------------------- 1 | def create_webserver(server_config, backend="aiohttp"): 2 | if backend == "aiohttp": 3 | from .aiohttp import create_webserver 4 | 5 | return create_webserver(server_config) 6 | 7 | if backend == "generic": 8 | from .generic import create_webserver 9 | 10 | return create_webserver(server_config) 11 | 12 | if backend == "tornado": 13 | from .tornado import create_webserver 14 | 15 | return create_webserver(server_config) 16 | 17 | if backend == "jupyter": 18 | from .jupyter import create_webserver 19 | 20 | return create_webserver(server_config) 21 | 22 | raise Exception(f"{backend} backend is not implemented") 23 | 24 | 25 | def launcher_start(args, config, backend="aiohttp"): 26 | if backend == "aiohttp": 27 | from .aiohttp.launcher import startWebServer 28 | 29 | return startWebServer(args, config) 30 | 31 | if backend == "generic": 32 | from .generic import startWebServer 33 | 34 | return startWebServer(args, config) 35 | 36 | if backend == "tornado": 37 | from .tornado import startWebServer 38 | 39 | return startWebServer(args, config) 40 | 41 | if backend == "jupyter": 42 | from .jupyter import startWebServer 43 | 44 | return startWebServer(args, config) 45 | 46 | raise Exception(f"{backend} backend is not implemented") 47 | -------------------------------------------------------------------------------- /python/src/wslink/backends/aiohttp/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import os 3 | import logging 4 | import sys 5 | import uuid 6 | import json 7 | from pathlib import Path 8 | 9 | from wslink.protocol import WslinkHandler, AbstractWebApp 10 | 11 | 12 | # Backend specific imports 13 | import aiohttp 14 | import aiohttp.web as aiohttp_web 15 | 16 | 17 | # 4MB is the default inside aiohttp 18 | MSG_OVERHEAD = int(os.environ.get("WSLINK_MSG_OVERHEAD", 4096)) 19 | MAX_MSG_SIZE = int(os.environ.get("WSLINK_MAX_MSG_SIZE", 4194304)) 20 | HEART_BEAT = int(os.environ.get("WSLINK_HEART_BEAT", 30)) # 30 seconds 21 | HTTP_HEADERS: str | None = os.environ.get("WSLINK_HTTP_HEADERS") # path to json file 22 | 23 | if HTTP_HEADERS and Path(HTTP_HEADERS).exists(): 24 | HTTP_HEADERS: dict = json.loads(Path(HTTP_HEADERS).read_text()) 25 | 26 | STATE_KEY = aiohttp_web.AppKey("state", str) 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | def reload_settings(): 31 | global MSG_OVERHEAD, MAX_MSG_SIZE, HEART_BEAT, HTTP_HEADERS 32 | 33 | MSG_OVERHEAD = int(os.environ.get("WSLINK_MSG_OVERHEAD", MSG_OVERHEAD)) 34 | MAX_MSG_SIZE = int(os.environ.get("WSLINK_MAX_MSG_SIZE", MAX_MSG_SIZE)) 35 | HEART_BEAT = int(os.environ.get("WSLINK_HEART_BEAT", HEART_BEAT or 30)) # 30 seconds 36 | HTTP_HEADERS = os.environ.get("WSLINK_HTTP_HEADERS", HTTP_HEADERS) 37 | 38 | # Allow to skip heart beat 39 | if HEART_BEAT < 1: 40 | HEART_BEAT = None 41 | 42 | 43 | # ----------------------------------------------------------------------------- 44 | # HTTP helpers 45 | # ----------------------------------------------------------------------------- 46 | 47 | 48 | async def _root_handler(request): 49 | if request.query_string: 50 | return aiohttp.web.HTTPFound(f"index.html?{request.query_string}") 51 | return aiohttp.web.HTTPFound("index.html") 52 | 53 | 54 | def _fix_path(path): 55 | if not path.startswith("/"): 56 | return "/{0}".format(path) 57 | return path 58 | 59 | 60 | # ----------------------------------------------------------------------------- 61 | @aiohttp_web.middleware 62 | async def http_headers(request: aiohttp_web.Request, handler): 63 | response: aiohttp_web.Response = await handler(request) 64 | for k, v in HTTP_HEADERS.items(): 65 | response.headers.setdefault(k, v) 66 | 67 | return response 68 | 69 | 70 | # ----------------------------------------------------------------------------- 71 | class WebAppServer(AbstractWebApp): 72 | def __init__(self, server_config): 73 | reload_settings() 74 | AbstractWebApp.__init__(self, server_config) 75 | if HTTP_HEADERS: 76 | self.set_app(aiohttp_web.Application(middlewares=[http_headers])) 77 | else: 78 | self.set_app(aiohttp_web.Application()) 79 | self._ws_handlers = [] 80 | self._site = None 81 | self._runner = None 82 | 83 | if "ws" in server_config: 84 | routes = [] 85 | for route, server_protocol in server_config["ws"].items(): 86 | protocol_handler = AioHttpWsHandler(server_protocol, self) 87 | self._ws_handlers.append(protocol_handler) 88 | routes.append( 89 | aiohttp_web.get(_fix_path(route), protocol_handler.handleWsRequest) 90 | ) 91 | 92 | self.app.add_routes(routes) 93 | 94 | if "static" in server_config: 95 | static_routes = server_config["static"] 96 | routes = [] 97 | 98 | # Ensure longer path are registered first 99 | for route in sorted(static_routes.keys(), reverse=True): 100 | server_path = static_routes[route] 101 | routes.append( 102 | aiohttp_web.static( 103 | _fix_path(route), server_path, append_version=True 104 | ) 105 | ) 106 | 107 | # Resolve / => index.html 108 | self.app.router.add_route("GET", "/", _root_handler) 109 | self.app.add_routes(routes) 110 | 111 | self.app[STATE_KEY] = {} 112 | 113 | # ------------------------------------------------------------------------- 114 | # Server status 115 | # ------------------------------------------------------------------------- 116 | 117 | @property 118 | def runner(self): 119 | return self._runner 120 | 121 | @property 122 | def site(self): 123 | return self._site 124 | 125 | def get_port(self): 126 | """Return the actual port used by the server""" 127 | return self.runner.addresses[0][1] 128 | 129 | # ------------------------------------------------------------------------- 130 | # Life cycles 131 | # ------------------------------------------------------------------------- 132 | 133 | async def start(self, port_callback=None): 134 | self._runner = aiohttp_web.AppRunner( 135 | self.app, handle_signals=self.handle_signals 136 | ) 137 | 138 | logger.info("awaiting runner setup") 139 | await self._runner.setup() 140 | 141 | self._site = aiohttp_web.TCPSite( 142 | self._runner, self.host, self.port, ssl_context=self.ssl_context 143 | ) 144 | 145 | logger.info("awaiting site startup") 146 | await self._site.start() 147 | 148 | if port_callback is not None: 149 | port_callback(self.get_port()) 150 | 151 | logger.info("Print WSLINK_READY_MSG") 152 | STARTUP_MSG = os.environ.get("WSLINK_READY_MSG", "wslink: Starting factory") 153 | if STARTUP_MSG: 154 | # Emit an expected log message so launcher.py knows we've started up. 155 | print(STARTUP_MSG) 156 | # We've seen some issues with stdout buffering - be conservative. 157 | sys.stdout.flush() 158 | 159 | logger.info(f"Schedule auto shutdown with timout {self.timeout}") 160 | self.shutdown_schedule() 161 | 162 | logger.info("awaiting running future") 163 | await self.completion 164 | 165 | async def stop(self): 166 | # Disconnecting any connected clients of handler(s) 167 | for handler in self._ws_handlers: 168 | await handler.disconnectClients() 169 | 170 | # Neither site.stop() nor runner.cleanup() actually stop the server 171 | # as documented, but at least runner.cleanup() results in the 172 | # "on_shutdown" signal getting sent. 173 | logger.info("Performing runner.cleanup()") 174 | await self.runner.cleanup() 175 | 176 | # So to actually stop the server, the workaround is just to resolve 177 | # the future we awaited in the start method. 178 | logger.info("Stopping server") 179 | self.completion.set_result(True) 180 | 181 | 182 | class ReverseWebAppServer(AbstractWebApp): 183 | def __init__(self, server_config): 184 | reload_settings() 185 | super().__init__(server_config) 186 | self._url = server_config.get("reverse_url") 187 | self._server_protocol = server_config.get("ws_protocol") 188 | self._ws_handler = AioHttpWsHandler(self._server_protocol, self) 189 | 190 | async def start(self, port_callback=None): 191 | if port_callback is not None: 192 | port_callback(0) 193 | 194 | await self._ws_handler.reverse_connect_to(self._url) 195 | 196 | async def stop(self): 197 | client_id = self._ws_handler.reverse_connection_client_id 198 | ws = self._ws_handler.connections[client_id] 199 | await ws.close() 200 | 201 | 202 | def create_webserver(server_config): 203 | if "logging_level" in server_config and server_config["logging_level"]: 204 | logging.getLogger("wslink").setLevel(server_config["logging_level"]) 205 | 206 | # Shortcut for reverse connection 207 | if "reverse_url" in server_config: 208 | return ReverseWebAppServer(server_config) 209 | 210 | # Normal web server 211 | return WebAppServer(server_config) 212 | 213 | 214 | # ----------------------------------------------------------------------------- 215 | # WS protocol definition 216 | # ----------------------------------------------------------------------------- 217 | 218 | 219 | def is_binary(msg): 220 | return msg.type == aiohttp.WSMsgType.BINARY 221 | 222 | 223 | class AioHttpWsHandler(WslinkHandler): 224 | async def disconnectClients(self): 225 | logger.info("Closing client connections:") 226 | keys = list(self.connections.keys()) 227 | for client_id in keys: 228 | logger.info(" {0}".format(client_id)) 229 | ws = self.connections[client_id] 230 | await ws.close( 231 | code=aiohttp.WSCloseCode.GOING_AWAY, message="Server shutdown" 232 | ) 233 | 234 | self.publishManager.unregisterProtocol(self) 235 | 236 | async def handleWsRequest(self, request): 237 | client_id = str(uuid.uuid4()).replace("-", "") 238 | current_ws = aiohttp_web.WebSocketResponse( 239 | max_msg_size=MSG_OVERHEAD + MAX_MSG_SIZE, heartbeat=HEART_BEAT 240 | ) 241 | self.connections[client_id] = current_ws 242 | 243 | logger.info("client {0} connected".format(client_id)) 244 | 245 | self.web_app.shutdown_cancel() 246 | 247 | try: 248 | await current_ws.prepare(request) 249 | await self.onConnect(request, client_id) 250 | async for msg in current_ws: 251 | await self.onMessage(is_binary(msg), msg, client_id) 252 | finally: 253 | await self.onClose(client_id) 254 | 255 | del self.connections[client_id] 256 | self.authentified_client_ids.discard(client_id) 257 | 258 | logger.info("client {0} disconnected".format(client_id)) 259 | 260 | if not self.connections: 261 | logger.info("No more connections, scheduling shutdown") 262 | self.web_app.shutdown_schedule() 263 | 264 | return current_ws 265 | 266 | async def reverse_connect_to(self, url): 267 | logger.debug("reverse_connect_to: running with url %s", url) 268 | client_id = self.reverse_connection_client_id 269 | async with aiohttp.ClientSession() as session: 270 | logger.debug("reverse_connect_to: client session started") 271 | async with session.ws_connect(url) as current_ws: 272 | logger.debug("reverse_connect_to: ws started") 273 | self.connections[client_id] = current_ws 274 | logger.debug("reverse_connect_to: onConnect") 275 | await self.onConnect(url, client_id) 276 | 277 | async for msg in current_ws: 278 | if not current_ws.closed: 279 | await self.onMessage(is_binary(msg), msg, client_id) 280 | 281 | logger.debug("reverse_connect_to: onClose") 282 | await self.onClose(client_id) 283 | del self.connections[client_id] 284 | 285 | logger.debug("reverse_connect_to: exited") 286 | -------------------------------------------------------------------------------- /python/src/wslink/backends/aiohttp/launcher.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import logging 4 | import os 5 | import sys 6 | 7 | from random import choice 8 | 9 | import aiohttp.web as aiohttp_web 10 | 11 | from . import _root_handler 12 | 13 | from wslink.launcher import ( 14 | SessionManager, 15 | ProxyMappingManagerTXT, 16 | ProcessManager, 17 | validateKeySet, 18 | STATUS_BAD_REQUEST, 19 | STATUS_SERVICE_UNAVAILABLE, 20 | filterResponse, 21 | STATUS_OK, 22 | extractSessionId, 23 | STATUS_NOT_FOUND, 24 | ) 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | # =========================================================================== 29 | # Launcher ENV configuration 30 | # =========================================================================== 31 | 32 | ENABLE_GET = int(os.environ.get("WSLINK_LAUNCHER_GET", 0)) 33 | ENABLE_DELETE = int(os.environ.get("WSLINK_LAUNCHER_DELETE", 0)) 34 | 35 | 36 | # =========================================================================== 37 | # Class to implement requests to POST, GET and DELETE methods 38 | # =========================================================================== 39 | 40 | 41 | class LauncherResource(object): 42 | def __init__(self, options, config): 43 | self._options = options 44 | self._config = config 45 | self.time_to_wait = int(config["configuration"]["timeout"]) 46 | self.field_filter = config["configuration"]["fields"] 47 | self.session_manager = SessionManager( 48 | config, ProxyMappingManagerTXT(config["configuration"]["proxy_file"]) 49 | ) 50 | self.process_manager = ProcessManager(config) 51 | 52 | def __del__(self): 53 | try: 54 | # causes an exception when server is killed with Ctrl-C 55 | logger.warning("Server factory shutting down. Stopping all processes") 56 | except: 57 | pass 58 | 59 | # ======================================================================== 60 | # Handle POST request 61 | # ======================================================================== 62 | 63 | async def handle_post(self, request): 64 | payload = await request.json() 65 | 66 | # Make sure the request has all the expected keys 67 | if not validateKeySet(payload, ["application"], "Launch request"): 68 | return aiohttp_web.json_response( 69 | {"error": "The request is not complete"}, status=STATUS_BAD_REQUEST 70 | ) 71 | 72 | # Try to free any available resource 73 | id_to_free = self.process_manager.listEndedProcess() 74 | for id in id_to_free: 75 | self.session_manager.deleteSession(id) 76 | self.process_manager.stopProcess(id) 77 | 78 | # Create new session 79 | session = self.session_manager.createSession(payload) 80 | 81 | # No resource available 82 | if not session: 83 | return aiohttp_web.json_response( 84 | {"error": "All the resources are currently taken"}, 85 | status=STATUS_SERVICE_UNAVAILABLE, 86 | ) 87 | 88 | # Start process 89 | proc = self.process_manager.startProcess(session) 90 | 91 | if not proc: 92 | err_msg = "The process did not properly start. %s" % str(session["cmd"]) 93 | return aiohttp_web.json_response( 94 | {"error": err_msg}, status=STATUS_SERVICE_UNAVAILABLE 95 | ) 96 | 97 | return await self._waitForReady(session, request) 98 | 99 | # ======================================================================== 100 | # Wait for session to be ready 101 | # ======================================================================== 102 | 103 | async def _waitForReady(self, session, request): 104 | start_time = datetime.datetime.now() 105 | check_line = "ready_line" in self._config["apps"][session["application"]] 106 | count = 0 107 | 108 | while True: 109 | if self.process_manager.isReady(session, count): 110 | filterkeys = self.field_filter 111 | if session["secret"] in session["cmd"]: 112 | filterkeys = self.field_filter + ["secret"] 113 | return aiohttp_web.json_response( 114 | filterResponse(session, filterkeys), status=STATUS_OK 115 | ) 116 | 117 | elapsed_time = datetime.datetime.now() - start_time 118 | 119 | if elapsed_time.total_seconds() > self.time_to_wait: 120 | # Timeout is expired, if the process is not ready now, mark the 121 | # session as timed out, clean up the process, and return an error 122 | # response 123 | session["startTimedOut"] = True 124 | self.session_manager.deleteSession(session["id"]) 125 | self.process_manager.stopProcess(session["id"]) 126 | 127 | return aiohttp_web.json_response( 128 | { 129 | "error": "Session did not start before timeout expired. Check session logs." 130 | }, 131 | status=STATUS_SERVICE_UNAVAILABLE, 132 | ) 133 | 134 | await asyncio.sleep(1) 135 | count += 1 136 | 137 | # ========================================================================= 138 | # Handle GET request 139 | # ========================================================================= 140 | 141 | async def handle_get(self, request): 142 | id = extractSessionId(request) 143 | 144 | if not id: 145 | message = "id not provided in GET request" 146 | logger.error(message) 147 | return aiohttp_web.json_response( 148 | {"error": message}, status=STATUS_BAD_REQUEST 149 | ) 150 | 151 | logger.info("GET request received for id: %s" % id) 152 | 153 | session = self.session_manager.getSession(id) 154 | if not session: 155 | message = "No session with id: %s" % id 156 | logger.error(message) 157 | return aiohttp_web.json_response( 158 | {"error": message}, status=STATUS_BAD_REQUEST 159 | ) 160 | 161 | # Return session meta-data 162 | return aiohttp_web.json_response( 163 | filterResponse(session, self.field_filter), status=STATUS_OK 164 | ) 165 | 166 | # ========================================================================= 167 | # Handle DELETE request 168 | # ========================================================================= 169 | 170 | async def handle_delete(self, request): 171 | id = extractSessionId(request) 172 | 173 | if not id: 174 | message = "id not provided in DELETE request" 175 | logger.error(message) 176 | return aiohttp_web.json_response( 177 | {"error": message}, status=STATUS_BAD_REQUEST 178 | ) 179 | 180 | logger.info("DELETE request received for id: %s" % id) 181 | 182 | session = self.session_manager.getSession(id) 183 | if not session: 184 | message = "No session with id: %s" % id 185 | logger.error(message) 186 | return aiohttp_web.json_response( 187 | {"error": message}, status=STATUS_NOT_FOUND 188 | ) 189 | 190 | # Remove session 191 | self.session_manager.deleteSession(id) 192 | self.process_manager.stopProcess(id) 193 | 194 | message = "Deleted session with id: %s" % id 195 | logger.info(message) 196 | 197 | return aiohttp_web.json_response(session, status=STATUS_OK) 198 | 199 | 200 | # ============================================================================= 201 | # Start the web server 202 | # ============================================================================= 203 | 204 | 205 | def startWebServer(options, config): 206 | # import pdb; pdb.set_trace() 207 | # Extract properties from config 208 | log_dir = str(config["configuration"]["log_dir"]) 209 | content = str(config["configuration"]["content"]) 210 | endpoint = str(config["configuration"]["endpoint"]) 211 | host = str(config["configuration"]["host"]) 212 | port = int(config["configuration"]["port"]) 213 | sanitize = config["configuration"]["sanitize"] 214 | 215 | # Setup logging 216 | logFileName = log_dir + os.sep + "launcherLog.log" 217 | formatting = "%(asctime)s:%(levelname)s:%(name)s:%(message)s" 218 | # create file handler which logs even debug messages 219 | fh = logging.FileHandler(logFileName, mode="w") 220 | fh.setLevel(logging.DEBUG) 221 | fh.setFormatter(logging.Formatter(formatting)) 222 | logging.getLogger("wslink").addHandler(fh) 223 | if options.debug: 224 | console = logging.StreamHandler(sys.stdout) 225 | console.setLevel(logging.INFO) 226 | formatter = logging.Formatter(formatting) 227 | console.setFormatter(formatter) 228 | logging.getLogger("wslink").addHandler(console) 229 | 230 | web_app = aiohttp_web.Application() 231 | 232 | launcher_resource = LauncherResource(options, config) 233 | 234 | if not endpoint.startswith("/"): 235 | endpoint = "/{0}/".format(endpoint) 236 | 237 | routes = [ 238 | aiohttp_web.post(endpoint, launcher_resource.handle_post), 239 | ] 240 | 241 | if ENABLE_GET: 242 | routes.append(aiohttp_web.get(endpoint + "{id}", launcher_resource.handle_get)) 243 | 244 | if ENABLE_DELETE: 245 | routes.append( 246 | aiohttp_web.delete(endpoint + "{id}", launcher_resource.handle_delete) 247 | ) 248 | 249 | web_app.add_routes(routes) 250 | 251 | if len(content) > 0: 252 | web_app.router.add_route("GET", "/", _root_handler) 253 | web_app.add_routes([aiohttp_web.static("/", content)]) 254 | 255 | aiohttp_web.run_app(web_app, host=host, port=port) 256 | -------------------------------------------------------------------------------- /python/src/wslink/backends/generic/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import create_webserver, startWebServer 2 | 3 | __ALL__ = [ 4 | "create_webserver", 5 | "startWebServer", 6 | ] 7 | -------------------------------------------------------------------------------- /python/src/wslink/backends/generic/core.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import uuid 4 | from pathlib import Path 5 | import shutil 6 | 7 | from wslink.protocol import WslinkHandler, AbstractWebApp 8 | 9 | 10 | class WsConnection: 11 | def __init__(self): 12 | self._id = str(uuid.uuid4()).replace("-", "") 13 | self._ws = None 14 | self._on_message_fn = None 15 | self.closed = True 16 | 17 | # ------------------------------------------------------------------------- 18 | # Method to be used by user 19 | # ------------------------------------------------------------------------- 20 | 21 | def on_message(self, callback): 22 | self._on_message_fn = callback 23 | 24 | async def send(self, is_binary, msg): 25 | await self._ws.onMessage(is_binary, msg, self.client_id) 26 | 27 | async def close(self): 28 | await self.on_close(self._ws) 29 | 30 | # ------------------------------------------------------------------------- 31 | # Method used by FakeWS 32 | # ------------------------------------------------------------------------- 33 | 34 | @property 35 | def client_id(self): 36 | return self._id 37 | 38 | async def on_connect(self, ws): 39 | self.closed = False 40 | self._ws = ws 41 | await self._ws.onConnect( 42 | {"type": "generic", "connection": self}, self.client_id 43 | ) 44 | 45 | async def on_close(self, ws): 46 | self.closed = True 47 | if self._ws == ws: 48 | await ws.disconnect(self.client_id) 49 | self._ws = None 50 | 51 | async def send_str(self, value): 52 | await self._on_message_fn(False, value) 53 | 54 | async def send_bytes(self, value): 55 | await self._on_message_fn(True, value) 56 | 57 | 58 | class WsEndpoint(WslinkHandler): 59 | def __init__(self, protocol, web_app=None): 60 | super().__init__(protocol, web_app) 61 | 62 | async def connect(self): 63 | conn = WsConnection() 64 | self.connections[conn.client_id] = conn 65 | await conn.on_connect(self) 66 | return conn 67 | 68 | async def disconnect(self, client_or_id): 69 | client_or_id = ( 70 | client_or_id if isinstance(client_or_id, str) else client_or_id.client_id 71 | ) 72 | if client_or_id in self.connections: 73 | client = self.connections.pop(client_or_id) 74 | await client.on_close(client_or_id) 75 | 76 | 77 | class GenericServer(AbstractWebApp): 78 | def __init__(self, server_config): 79 | AbstractWebApp.__init__(self, server_config) 80 | self._websockets = {} 81 | self._stop_event = asyncio.Event() 82 | 83 | if "ws" in server_config: 84 | for route, server_protocol in server_config["ws"].items(): 85 | protocol_handler = WsEndpoint(server_protocol, self) 86 | self._websockets[route] = protocol_handler 87 | 88 | def write_static_content(self, dest_directory): 89 | dest = Path(dest_directory) 90 | dest.mkdir(exist_ok=True, parents=True) 91 | if "static" in self._config: 92 | static_routes = self._config["static"] 93 | for route in sorted(static_routes.keys()): 94 | server_path = static_routes[route] 95 | if route == "/": 96 | src = Path(server_path) 97 | for child in src.iterdir(): 98 | if child.is_dir(): 99 | shutil.copytree(child, dest / child.name) 100 | else: 101 | shutil.copy2(child, dest / child.name) 102 | else: 103 | shutil.copytree(server_path, dest / route) 104 | 105 | def __getattr__(self, attr): 106 | return self._websockets.get(attr, None) 107 | 108 | def __getitem__(self, name): 109 | return self._websockets.get(name, None) 110 | 111 | @property 112 | def ws_endpoints(self): 113 | return list(self._websockets.keys()) 114 | 115 | async def start(self, port_callback): 116 | if port_callback is not None: 117 | port_callback(self.get_port()) 118 | 119 | self._stop_event.clear() 120 | await self._stop_event.wait() 121 | 122 | async def stop(self): 123 | self._stop_event.set() 124 | 125 | 126 | def startWebServer(*args, **kwargs): 127 | raise NotImplementedError("Generic backend does not provide a launcher") 128 | 129 | 130 | def create_webserver(server_config): 131 | if "logging_level" in server_config and server_config["logging_level"]: 132 | logging.getLogger("wslink").setLevel(server_config["logging_level"]) 133 | 134 | # Reverse connection 135 | if "reverse_url" in server_config: 136 | raise NotImplementedError("Generic backend does not support reverse_url") 137 | 138 | return GenericServer(server_config) 139 | -------------------------------------------------------------------------------- /python/src/wslink/backends/jupyter/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import create_webserver, startWebServer 2 | 3 | __ALL__ = [ 4 | "create_webserver", 5 | "startWebServer", 6 | ] 7 | -------------------------------------------------------------------------------- /python/src/wslink/backends/jupyter/core.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from wslink.emitter import EventEmitter 3 | from wslink.backends.generic.core import GenericServer 4 | from IPython.core.getipython import get_ipython 5 | 6 | 7 | class WsJupyterComm(EventEmitter): 8 | def __init__(self, kernel=None): 9 | super().__init__() 10 | self.comm = None 11 | self.kernel = get_ipython().kernel if kernel is None else kernel 12 | self.kernel.comm_manager.register_target("wslink_comm", self.on_open) 13 | 14 | def send(self, data, buffers): 15 | if self.comm is not None: 16 | self.comm.send(data=data, buffers=buffers) 17 | 18 | def on_message(self, msg): 19 | self.emit("message", msg["content"]["data"], msg["buffers"]) 20 | 21 | def on_close(self, msg): 22 | self.comm = None 23 | 24 | def on_open(self, comm, msg): 25 | self.comm = comm 26 | comm.on_msg(self.on_message) 27 | comm.on_close(self.on_close) 28 | 29 | 30 | JUPYTER_COMM = None 31 | 32 | 33 | def get_jupyter_comm(kernel=None): 34 | global JUPYTER_COMM 35 | if JUPYTER_COMM is None: 36 | JUPYTER_COMM = WsJupyterComm(kernel) 37 | 38 | return JUPYTER_COMM 39 | 40 | 41 | class GenericMessage: 42 | def __init__(self, data): 43 | self.data = data 44 | 45 | 46 | class JupyterGenericServer(GenericServer): 47 | def __init__(self, server_config): 48 | super().__init__(server_config) 49 | 50 | self.trame_comm = get_jupyter_comm() 51 | self._endpoint = self[self.ws_endpoints[0]] 52 | self._name = self._endpoint.serverProtocol.server.name 53 | self._connections = {} 54 | self.trame_comm.add_event_listener("message", self.on_msg_from_comm) 55 | 56 | async def on_msg_from_server(self, client_id, binary, content): 57 | buffers = [] 58 | data = {"server": self._name, "client": client_id} 59 | 60 | if binary: 61 | buffers.append(content) 62 | else: 63 | data["payload"] = content 64 | 65 | self.trame_comm.send(data, buffers) 66 | 67 | async def on_msg_from_comm(self, data, buffers): 68 | server_name = data["server"] 69 | client_id = data["client"] 70 | 71 | if server_name != self._name: 72 | return 73 | 74 | connection = self._connections.get(client_id, None) 75 | 76 | if connection is None: 77 | connection = await self._endpoint.connect() 78 | connection.on_message(partial(self.on_msg_from_server, client_id)) 79 | self._connections[client_id] = connection 80 | 81 | is_binary = len(buffers) > 0 82 | 83 | message = None 84 | 85 | if is_binary: 86 | message = GenericMessage(buffers[0]) 87 | else: 88 | message = GenericMessage(data["payload"]) 89 | 90 | await connection.send(is_binary, message) 91 | 92 | 93 | def startWebServer(*args, **kwargs): 94 | raise NotImplementedError("Generic backend does not provide a launcher") 95 | 96 | 97 | def create_webserver(server_config): 98 | jupyter_generic_server = JupyterGenericServer(server_config) 99 | return jupyter_generic_server 100 | -------------------------------------------------------------------------------- /python/src/wslink/backends/tornado/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import create_webserver, startWebServer 2 | 3 | __ALL__ = [ 4 | "create_webserver", 5 | "startWebServer", 6 | ] 7 | -------------------------------------------------------------------------------- /python/src/wslink/backends/tornado/core.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # DISCLAIMER 3 | # ----------------------------------------------------------------------------- 4 | # This implementation is not full featured but just aim to showcase 5 | # the generic backend in a way that it can be used by anything. 6 | # For real integration, using inheritance like for aiohttp is recommended. 7 | # ----------------------------------------------------------------------------- 8 | import tornado 9 | import tempfile 10 | 11 | from tornado.websocket import WebSocketHandler 12 | 13 | 14 | class GenericMessage: 15 | def __init__(self, data): 16 | self.data = data 17 | 18 | 19 | class WsLinkWebSocket(WebSocketHandler): 20 | def __init__(self, application, request, generic_server=None, **kwargs): 21 | super().__init__(application, request, **kwargs) 22 | self._ws = generic_server[generic_server.ws_endpoints[0]] 23 | self._me = None 24 | 25 | async def on_msg_from_generic(self, binary, content): 26 | self.write_message(content, binary) 27 | 28 | def open(self): 29 | self._me = self._ws.connect() 30 | self._me.on_message(self.on_msg_from_generic) 31 | 32 | async def on_message(self, message): 33 | is_binary = not isinstance(message, str) 34 | await self._me.send(is_binary, GenericMessage(message)) 35 | 36 | def on_close(self): 37 | self._me.close() 38 | 39 | 40 | def startWebServer(*args, **kwargs): 41 | raise NotImplementedError("Generic backend does not provide a launcher") 42 | 43 | 44 | def create_webserver(server_config): 45 | # Reverse connection 46 | if "reverse_url" in server_config: 47 | raise NotImplementedError("Generic backend does not support reverse_url") 48 | 49 | from ..generic.core import create_webserver as create_generic_webserver 50 | 51 | generic_server = create_generic_webserver(server_config) 52 | 53 | # Handle static content 54 | www = tempfile.mkdtemp() 55 | generic_server.write_static_content(www) 56 | 57 | # Tornado specific 58 | handlers = [ 59 | (r"/ws", WsLinkWebSocket, {"generic_server": generic_server}), 60 | (r"/(.*)", tornado.web.StaticFileHandler, {"path": www}), 61 | ] 62 | 63 | application = tornado.web.Application(handlers) 64 | application.listen(generic_server.port) 65 | 66 | return generic_server 67 | -------------------------------------------------------------------------------- /python/src/wslink/chunking.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import secrets 4 | import msgpack 5 | from typing import Dict, Tuple, Union 6 | 7 | if sys.version_info >= (3, 8): 8 | from typing import TypedDict # pylint: disable=no-name-in-module 9 | else: 10 | from typing_extensions import TypedDict 11 | 12 | UINT32_LENGTH = 4 13 | ID_LOCATION = 0 14 | ID_LENGTH = UINT32_LENGTH 15 | MESSAGE_OFFSET_LOCATION = ID_LOCATION + ID_LENGTH 16 | MESSAGE_OFFSET_LENGTH = UINT32_LENGTH 17 | MESSAGE_SIZE_LOCATION = MESSAGE_OFFSET_LOCATION + MESSAGE_OFFSET_LENGTH 18 | MESSAGE_SIZE_LENGTH = UINT32_LENGTH 19 | 20 | HEADER_LENGTH = ID_LENGTH + MESSAGE_OFFSET_LENGTH + MESSAGE_SIZE_LENGTH 21 | 22 | 23 | def _encode_header(id: int, offset: int, size: int) -> bytes: 24 | return ( 25 | id.to_bytes(ID_LENGTH, "little", signed=False) 26 | + offset.to_bytes(MESSAGE_OFFSET_LENGTH, "little", signed=False) 27 | + size.to_bytes(MESSAGE_SIZE_LENGTH, "little", signed=False) 28 | ) 29 | 30 | 31 | def _decode_header(header: bytes) -> Tuple[int, int, int]: 32 | id = int.from_bytes( 33 | header[ID_LOCATION:ID_LENGTH], 34 | "little", 35 | signed=False, 36 | ) 37 | offset = int.from_bytes( 38 | header[ 39 | MESSAGE_OFFSET_LOCATION : MESSAGE_OFFSET_LOCATION + MESSAGE_OFFSET_LENGTH 40 | ], 41 | "little", 42 | signed=False, 43 | ) 44 | size = int.from_bytes( 45 | header[MESSAGE_SIZE_LOCATION : MESSAGE_SIZE_LOCATION + MESSAGE_SIZE_LENGTH], 46 | "little", 47 | signed=False, 48 | ) 49 | return id, offset, size 50 | 51 | 52 | def generate_chunks(message: bytes, max_size: int): 53 | total_size = len(message) 54 | 55 | if max_size == 0: 56 | max_content_size = total_size 57 | else: 58 | max_content_size = max(max_size - HEADER_LENGTH, 1) 59 | 60 | id = int.from_bytes(secrets.token_bytes(ID_LENGTH), "little", signed=False) 61 | 62 | offset = 0 63 | 64 | while offset < total_size: 65 | header = _encode_header(id, offset, total_size) 66 | chunk_content = message[offset : offset + max_content_size] 67 | 68 | yield header + chunk_content 69 | 70 | offset += max_content_size 71 | 72 | return 73 | 74 | 75 | class PendingMessage(TypedDict): 76 | received_size: int 77 | content: bytearray 78 | 79 | 80 | # This un-chunker is vulnerable to DOS. 81 | # If it receives a message with a header claiming a large incoming message 82 | # it will allocate the memory blindly even without actually receiving the content 83 | # Chunks for a given message can come in any order 84 | # Chunks across messages can be interleaved. 85 | class UnChunker: 86 | pending_messages: Dict[bytes, PendingMessage] 87 | max_message_size: int 88 | 89 | def __init__(self): 90 | self.pending_messages = {} 91 | self.max_message_size = int(os.environ.get("WSLINK_AUTH_MSG_SIZE", 512)) 92 | 93 | def set_max_message_size(self, size): 94 | self.max_message_size = size 95 | 96 | def release_pending_messages(self): 97 | self.pending_messages = {} 98 | 99 | def process_chunk(self, chunk: bytes) -> Union[bytes, None]: 100 | header, chunk_content = chunk[:HEADER_LENGTH], chunk[HEADER_LENGTH:] 101 | id, offset, total_size = _decode_header(header) 102 | 103 | pending_message = self.pending_messages.get(id, None) 104 | 105 | if pending_message is None: 106 | if total_size > self.max_message_size: 107 | raise ValueError( 108 | f"""Total size for message {id} exceeds the allocation limit allowed. 109 | Maximum size = {self.max_message_size}, 110 | Received size = {total_size}.""" 111 | ) 112 | 113 | pending_message = PendingMessage( 114 | received_size=0, content=bytearray(total_size) 115 | ) 116 | self.pending_messages[id] = pending_message 117 | 118 | # This should never happen, but still check it 119 | if total_size != len(pending_message["content"]): 120 | del self.pending_messages[id] 121 | raise ValueError( 122 | f"Total size in chunk header for message {id} does not match total size declared by previous chunk." 123 | ) 124 | 125 | content_size = len(chunk_content) 126 | content_view = memoryview(pending_message["content"]) 127 | content_view[offset : offset + content_size] = chunk_content 128 | pending_message["received_size"] += content_size 129 | 130 | if pending_message["received_size"] >= total_size: 131 | full_message = pending_message["content"] 132 | del self.pending_messages[id] 133 | return msgpack.unpackb(bytes(full_message)) 134 | 135 | return None 136 | 137 | 138 | class StreamPendingMessage(TypedDict): 139 | received_size: int 140 | total_size: int 141 | unpacker: msgpack.Unpacker 142 | 143 | 144 | # This un-chunker is more memory efficient 145 | # (each chunk is passed immediately to msgpack) 146 | # and it will only allocate memory when it receives content. 147 | # Chunks for a given message are expected to come sequentially 148 | # Chunks across messages can be interleaved. 149 | class StreamUnChunker: 150 | pending_messages: Dict[bytes, StreamPendingMessage] 151 | 152 | def __init__(self): 153 | self.pending_messages = {} 154 | 155 | def set_max_message_size(self, _size): 156 | pass 157 | 158 | def release_pending_messages(self): 159 | self.pending_messages = {} 160 | 161 | def process_chunk(self, chunk: bytes) -> Union[bytes, None]: 162 | header, chunk_content = chunk[:HEADER_LENGTH], chunk[HEADER_LENGTH:] 163 | id, offset, total_size = _decode_header(header) 164 | 165 | pending_message = self.pending_messages.get(id, None) 166 | 167 | if pending_message is None: 168 | pending_message = StreamPendingMessage( 169 | received_size=0, 170 | total_size=total_size, 171 | unpacker=msgpack.Unpacker(max_buffer_size=total_size), 172 | ) 173 | self.pending_messages[id] = pending_message 174 | 175 | # This should never happen, but still check it 176 | if offset != pending_message["received_size"]: 177 | del self.pending_messages[id] 178 | raise ValueError( 179 | f"""Received an unexpected chunk for message {id}. 180 | Expected offset = {pending_message['received_size']}, 181 | Received offset = {offset}.""" 182 | ) 183 | 184 | # This should never happen, but still check it 185 | if total_size != pending_message["total_size"]: 186 | del self.pending_messages[id] 187 | raise ValueError( 188 | f"""Received an unexpected total size in chunk header for message {id}. 189 | Expected size = {pending_message['total_size']}, 190 | Received size = {total_size}.""" 191 | ) 192 | 193 | content_size = len(chunk_content) 194 | pending_message["received_size"] += content_size 195 | 196 | unpacker = pending_message["unpacker"] 197 | unpacker.feed(chunk_content) 198 | 199 | full_message = None 200 | 201 | try: 202 | full_message = unpacker.unpack() 203 | except msgpack.OutOfData: 204 | pass # message is incomplete, keep ingesting chunks 205 | 206 | if full_message is not None: 207 | del self.pending_messages[id] 208 | 209 | if pending_message["received_size"] < total_size: 210 | # In principle feeding a stream to the unpacker could yield multiple outputs 211 | # for example unpacker.feed(b'0123') would yield b'0', b'1', ect 212 | # or concatenated packed payloads would yield two or more unpacked objects 213 | # but in our use case we expect a full message to be mapped to a single object 214 | raise ValueError( 215 | f"""Received a parsable payload shorter than expected for message {id}. 216 | Expected size = {total_size}, 217 | Received size = {pending_message['received_size']}.""" 218 | ) 219 | 220 | return full_message 221 | -------------------------------------------------------------------------------- /python/src/wslink/emitter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | 4 | 5 | class EventEmitter: 6 | def __init__(self, allowed_events=None): 7 | self._listeners = {} 8 | 9 | if allowed_events is None: 10 | allowed_events = [] 11 | 12 | self._allowed_events = set(allowed_events) 13 | 14 | for event in self._allowed_events: 15 | setattr(self, event, functools.partial(self.emit, event)) 16 | 17 | def clear(self): 18 | self._listeners = {} 19 | 20 | def __call__(self, event, *args, **kwargs): 21 | self.emit(event, *args, **kwargs) 22 | 23 | def emit(self, event, *args, **kwargs): 24 | self._validate_event(event) 25 | 26 | listeners = self._listeners.get(event) 27 | if listeners is None: 28 | return 29 | 30 | loop = asyncio.get_running_loop() 31 | coroutine_run = ( 32 | loop.create_task if (loop and loop.is_running()) else asyncio.run 33 | ) 34 | 35 | for listener in listeners: 36 | if asyncio.iscoroutinefunction(listener): 37 | coroutine_run(listener(*args, **kwargs)) 38 | else: 39 | listener(*args, **kwargs) 40 | 41 | def add_event_listener(self, event, listener): 42 | self._validate_event(event) 43 | 44 | listeners = self._listeners.get(event) 45 | if listeners is None: 46 | listeners = set() 47 | self._listeners[event] = listeners 48 | 49 | listeners.add(listener) 50 | 51 | def remove_event_listener(self, event, listener): 52 | self._validate_event(event) 53 | 54 | listeners = self._listeners.get(event) 55 | if listeners is None: 56 | return 57 | 58 | if listener in listeners: 59 | listeners.remove(listener) 60 | 61 | def has(self, event): 62 | return self.listeners_count(event) > 0 63 | 64 | def listeners_count(self, event): 65 | self._validate_event(event) 66 | 67 | listeners = self._listeners.get(event) 68 | if listeners is None: 69 | return 0 70 | 71 | return len(listeners) 72 | 73 | @property 74 | def allowed_events(self): 75 | return self._allowed_events 76 | 77 | def _validate_event(self, event): 78 | if len(self.allowed_events) == 0: 79 | return 80 | 81 | if event not in self.allowed_events: 82 | raise ValueError( 83 | f"'{event}' is not a known event of this EventEmitter: {self.allowed_events}" 84 | ) 85 | -------------------------------------------------------------------------------- /python/src/wslink/publish.py: -------------------------------------------------------------------------------- 1 | from . import schedule_coroutine 2 | 3 | # ============================================================================= 4 | # singleton publish manager 5 | 6 | 7 | class PublishManager(object): 8 | def __init__(self): 9 | self.protocols = [] 10 | self.publishCount = 0 11 | 12 | def registerProtocol(self, protocol): 13 | self.protocols.append(protocol) 14 | 15 | def unregisterProtocol(self, protocol): 16 | if protocol in self.protocols: 17 | self.protocols.remove(protocol) 18 | 19 | def addAttachment(self, payload): 20 | """Deprecated method, keeping it to avoid breaking compatibility 21 | Now that we use msgpack to pack/unpack messages, 22 | We can have binary data directly in the object itself, 23 | without needing to transfer it separately from the rest.""" 24 | return payload 25 | 26 | def publish(self, topic, data, client_id=None, skip_last_active_client=False): 27 | for protocol in self.protocols: 28 | # The client is unknown - we send to any client who is subscribed to the topic 29 | rpcid = "publish:{0}:{1}".format(topic, self.publishCount) 30 | protocol.network_monitor.on_enter() 31 | schedule_coroutine( 32 | 0, 33 | protocol.sendWrappedMessage, 34 | rpcid, 35 | data, 36 | client_id=client_id, 37 | skip_last_active_client=skip_last_active_client, 38 | # for schedule_coroutine call 39 | done_callback=protocol.network_monitor.on_exit, 40 | ) 41 | 42 | 43 | # singleton, used by all instances of WslinkWebSocketServerProtocol 44 | publishManager = PublishManager() 45 | -------------------------------------------------------------------------------- /python/src/wslink/relay.py: -------------------------------------------------------------------------------- 1 | from wslink.backends.aiohttp.relay import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /python/src/wslink/server.py: -------------------------------------------------------------------------------- 1 | r"""server is a module that enables using python through a web-server. 2 | 3 | This module can be used as the entry point to the application. In that case, it 4 | sets up a web-server. 5 | web-pages are determines by the command line arguments passed in. 6 | Use "--help" to list the supported arguments. 7 | 8 | """ 9 | 10 | import argparse 11 | import asyncio 12 | import logging 13 | 14 | from wslink import websocket as wsl 15 | from wslink import backends 16 | 17 | ws_server = None 18 | 19 | 20 | # ============================================================================= 21 | # Setup default arguments to be parsed 22 | # --nosignalhandlers 23 | # --debug 24 | # --host localhost 25 | # -p, --port 8080 26 | # --timeout 300 (seconds) 27 | # --content '/www' (No content means WebSocket only) 28 | # --authKey vtkweb-secret 29 | # ============================================================================= 30 | 31 | 32 | def add_arguments(parser): 33 | """ 34 | Add arguments known to this module. parser must be 35 | argparse.ArgumentParser instance. 36 | """ 37 | 38 | parser.add_argument( 39 | "--debug", help="log debugging messages to stdout", action="store_true" 40 | ) 41 | parser.add_argument( 42 | "--nosignalhandlers", 43 | help="Prevent installation of signal handlers so server can be started inside a thread.", 44 | action="store_true", 45 | ) 46 | parser.add_argument( 47 | "--host", 48 | type=str, 49 | default="localhost", 50 | help="the interface for the web-server to listen on (default: 0.0.0.0)", 51 | ) 52 | parser.add_argument( 53 | "-p", 54 | "--port", 55 | type=int, 56 | default=8080, 57 | help="port number for the web-server to listen on (default: 8080)", 58 | ) 59 | parser.add_argument( 60 | "--timeout", 61 | type=int, 62 | default=300, 63 | help="timeout for reaping process on idle in seconds (default: 300s, 0 to disable)", 64 | ) 65 | parser.add_argument( 66 | "--content", 67 | default="", 68 | help="root for web-pages to serve (default: none)", 69 | ) 70 | parser.add_argument( 71 | "--authKey", 72 | default="wslink-secret", 73 | help="Authentication key for clients to connect to the WebSocket.", 74 | ) 75 | parser.add_argument( 76 | "--ws-endpoint", 77 | type=str, 78 | default="ws", 79 | dest="ws", 80 | help="Specify WebSocket endpoint. (e.g. foo/bar/ws, Default: ws)", 81 | ) 82 | parser.add_argument( 83 | "--no-ws-endpoint", 84 | action="store_true", 85 | dest="nows", 86 | help="If provided, disables the websocket endpoint", 87 | ) 88 | parser.add_argument( 89 | "--fs-endpoints", 90 | default="", 91 | dest="fsEndpoints", 92 | help="add another fs location to a specific endpoint (i.e: data=/Users/seb/Download|images=/Users/seb/Pictures)", 93 | ) 94 | parser.add_argument( 95 | "--reverse-url", 96 | dest="reverse_url", 97 | help="Make the server act as a client to connect to a ws relay", 98 | ) 99 | parser.add_argument( 100 | "--ssl", 101 | type=str, 102 | default="", 103 | dest="ssl", 104 | help="add a tuple file [certificate, key] (i.e: --ssl 'certificate,key') or adhoc string to generate temporary certificate (i.e: --ssl 'adhoc')", 105 | ) 106 | 107 | return parser 108 | 109 | 110 | # ============================================================================= 111 | # Parse arguments and start webserver 112 | # ============================================================================= 113 | 114 | 115 | def start(argv=None, protocol=wsl.ServerProtocol, description="wslink web-server"): 116 | """ 117 | Sets up the web-server using with __name__ == '__main__'. This can also be 118 | called directly. Pass the optional protocol to override the protocol used. 119 | Default is ServerProtocol. 120 | """ 121 | parser = argparse.ArgumentParser(description=description) 122 | add_arguments(parser) 123 | args = parser.parse_args(argv) 124 | # configure protocol, if available 125 | try: 126 | protocol.configure(args) 127 | except AttributeError: 128 | pass 129 | 130 | start_webserver(options=args, protocol=protocol) 131 | 132 | 133 | # ============================================================================= 134 | # Stop webserver 135 | # ============================================================================= 136 | def stop_webserver(): 137 | if ws_server: 138 | loop = asyncio.get_event_loop() 139 | return loop.create_task(ws_server.stop()) 140 | 141 | 142 | # ============================================================================= 143 | # Get webserver port (useful when 0 is provided and a dynamic one was picked) 144 | # ============================================================================= 145 | def get_port(): 146 | if ws_server: 147 | return ws_server.get_port() 148 | return -1 149 | 150 | 151 | # ============================================================================= 152 | # Given a configuration file, create and return a webserver 153 | # 154 | # config = { 155 | # "host": "0.0.0.0", 156 | # "port": 8081 157 | # "ws": { 158 | # "/ws": serverProtocolInstance, 159 | # ... 160 | # }, 161 | # static_routes: { 162 | # '/static': .../path/to/files, 163 | # ... 164 | # }, 165 | # } 166 | # ============================================================================= 167 | def create_webserver(server_config, backend="aiohttp"): 168 | return backends.create_webserver(server_config, backend=backend) 169 | 170 | 171 | # ============================================================================= 172 | # Generate a webserver config from command line options, create a webserver, 173 | # and start it. 174 | # ============================================================================= 175 | def start_webserver( 176 | options, 177 | protocol=wsl.ServerProtocol, 178 | disableLogging=False, 179 | backend="aiohttp", 180 | exec_mode="main", 181 | **kwargs, 182 | ): 183 | """ 184 | Starts the web-server with the given protocol. Options must be an object 185 | with the following members: 186 | options.host : the interface for the web-server to listen on 187 | options.port : port number for the web-server to listen on 188 | options.timeout : timeout for reaping process on idle in seconds 189 | options.content : root for web-pages to serve. 190 | """ 191 | global ws_server 192 | 193 | # Create default or custom ServerProtocol 194 | wslinkServer = protocol() 195 | 196 | if disableLogging: 197 | logging_level = None 198 | elif options.debug: 199 | logging_level = logging.DEBUG 200 | else: 201 | logging_level = logging.ERROR 202 | 203 | if options.reverse_url: 204 | server_config = { 205 | "reverse_url": options.reverse_url, 206 | "ws_protocol": wslinkServer, 207 | "logging_level": logging_level, 208 | } 209 | else: 210 | server_config = { 211 | "host": options.host, 212 | "port": options.port, 213 | "timeout": options.timeout, 214 | "logging_level": logging_level, 215 | } 216 | 217 | # Configure websocket endpoint 218 | if not options.nows: 219 | server_config["ws"] = {} 220 | server_config["ws"][options.ws] = wslinkServer 221 | 222 | # Configure default static route if --content requested 223 | if len(options.content) > 0: 224 | server_config["static"] = {} 225 | # Static HTTP + WebSocket 226 | server_config["static"]["/"] = options.content 227 | 228 | # Configure any other static routes 229 | if len(options.fsEndpoints) > 3: 230 | if "static" not in server_config: 231 | server_config["static"] = {} 232 | 233 | for fsResourceInfo in options.fsEndpoints.split("|"): 234 | infoSplit = fsResourceInfo.split("=") 235 | server_config["static"][infoSplit[0]] = infoSplit[1] 236 | 237 | # Confifugre SSL 238 | if len(options.ssl) > 0: 239 | from .ssl_context import generate_ssl_pair, ssl 240 | 241 | if options.ssl == "adhoc": 242 | options.ssl = generate_ssl_pair(server_config["host"]) 243 | else: 244 | tokens = options.ssl.split(",") 245 | if len(tokens) != 2: 246 | raise Exception( 247 | f'ssl configure must be "adhoc" or a tuple of files "cert,key"' 248 | ) 249 | options.ssl = tokens 250 | cert, key = options.ssl 251 | context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 252 | context.load_cert_chain(cert, key) 253 | server_config["ssl"] = context 254 | 255 | server_config["handle_signals"] = not options.nosignalhandlers 256 | 257 | # Create the webserver and start it 258 | ws_server = create_webserver(server_config, backend=backend) 259 | 260 | # Once we have python 3.7 minimum, we can start the server with asyncio.run() 261 | # asyncio.run(ws_server.start()) 262 | 263 | # Until then, we can start the server this way 264 | loop = asyncio.get_event_loop() 265 | 266 | port_callback = None 267 | if hasattr(wslinkServer, "port_callback"): 268 | port_callback = wslinkServer.port_callback 269 | 270 | if hasattr(wslinkServer, "set_server"): 271 | wslinkServer.set_server(ws_server) 272 | 273 | def create_coroutine(): 274 | return ws_server.start(port_callback) 275 | 276 | def main_exec(): 277 | # Block until webapp exits 278 | try: 279 | loop.run_until_complete(create_coroutine()) 280 | except SystemExit: 281 | # backend gracefully exit (due to timeout or SIGINT/SIGTERM) 282 | pass 283 | 284 | def task_exec(): 285 | return loop.create_task(create_coroutine()) 286 | 287 | exec_modes = { 288 | "main": main_exec, 289 | "task": task_exec, 290 | "coroutine": create_coroutine, 291 | } 292 | 293 | if exec_mode not in exec_modes: 294 | raise Exception(f"Unknown exec_mode: {exec_mode}") 295 | 296 | return exec_modes[exec_mode]() 297 | 298 | 299 | if __name__ == "__main__": 300 | start() 301 | -------------------------------------------------------------------------------- /python/src/wslink/ssl_context.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | 3 | 4 | def load_ssl_context(cert_file, pkey_file): 5 | context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 6 | context.load_cert_chain(cert_file, pkey_file) 7 | return context 8 | 9 | 10 | def save_ssl_files(cert, pkey): 11 | import atexit 12 | import os 13 | import tempfile 14 | from cryptography.hazmat.primitives import serialization 15 | 16 | cert_handle, cert_file = tempfile.mkstemp() 17 | pkey_handle, pkey_file = tempfile.mkstemp() 18 | atexit.register(os.remove, pkey_file) 19 | atexit.register(os.remove, cert_file) 20 | 21 | os.write(cert_handle, cert.public_bytes(serialization.Encoding.PEM)) 22 | os.write( 23 | pkey_handle, 24 | pkey.private_bytes( 25 | encoding=serialization.Encoding.PEM, 26 | format=serialization.PrivateFormat.TraditionalOpenSSL, 27 | encryption_algorithm=serialization.NoEncryption(), 28 | ), 29 | ) 30 | 31 | os.close(cert_handle) 32 | os.close(pkey_handle) 33 | return cert_file, pkey_file 34 | 35 | 36 | def generate_ssl_pair(host): 37 | try: 38 | from cryptography import x509 39 | from cryptography.x509.oid import NameOID 40 | from cryptography.hazmat.primitives import hashes 41 | from cryptography.hazmat.primitives.asymmetric import rsa 42 | import datetime 43 | except ImportError: 44 | raise TypeError( 45 | "Using ad-hoc certificates requires the cryptography library." 46 | ) from None 47 | cn = f"*.{host}/CN={host}" 48 | pkey = rsa.generate_private_key(public_exponent=65537, key_size=2048) 49 | subject = x509.Name( 50 | [ 51 | x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Dummy Certificate"), 52 | x509.NameAttribute(NameOID.COMMON_NAME, cn), 53 | ] 54 | ) 55 | one_day = datetime.timedelta(1, 0, 0) 56 | cert = ( 57 | x509.CertificateBuilder() 58 | .subject_name(subject) 59 | .issuer_name(subject) 60 | .public_key(pkey.public_key()) 61 | .serial_number(x509.random_serial_number()) 62 | .not_valid_before(datetime.datetime.today() - one_day) 63 | .not_valid_after(datetime.datetime.today() + (one_day * 365)) 64 | .add_extension(x509.ExtendedKeyUsage([x509.OID_SERVER_AUTH]), critical=False) 65 | .add_extension(x509.SubjectAlternativeName([x509.DNSName(cn)]), critical=False) 66 | .sign(private_key=pkey, algorithm=hashes.SHA256()) 67 | ) 68 | return save_ssl_files(cert, pkey) 69 | -------------------------------------------------------------------------------- /python/src/wslink/uri.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | componentRegex = re.compile(r"^[a-z][a-z0-9_]*$") 4 | 5 | 6 | def checkURI(uri): 7 | """ 8 | uri: lowercase, dot separated string. 9 | throws exception if invalid. 10 | returns: uri 11 | """ 12 | 13 | components = uri.split(".") 14 | for component in components: 15 | match = componentRegex.match(component) 16 | if not match: 17 | raise Exception("invalid URI") 18 | return uri 19 | -------------------------------------------------------------------------------- /python/src/wslink/websocket.py: -------------------------------------------------------------------------------- 1 | r""" 2 | This module implements the core RPC and publish APIs. Developers can extend 3 | LinkProtocol to provide additional RPC callbacks for their web-applications. Then extend 4 | ServerProtocol to hook all the needed LinkProtocols together. 5 | """ 6 | 7 | import logging 8 | import asyncio 9 | 10 | from wslink import register as exportRpc 11 | from wslink import schedule_callback 12 | from wslink.emitter import EventEmitter 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | # ============================================================================= 18 | # 19 | # Base class for objects that can accept RPC calls or publish over wslink 20 | # 21 | # ============================================================================= 22 | 23 | 24 | class LinkProtocol(object): 25 | """ 26 | Subclass this to communicate with wslink clients. LinkProtocol 27 | objects provide rpc and pub/sub actions. 28 | """ 29 | 30 | def __init__(self): 31 | # need a no-op in case they are called before connect. 32 | self.publish = lambda x, y: None 33 | self.addAttachment = lambda x: None 34 | self.coreServer = None 35 | 36 | def init(self, publish, addAttachment, stopServer): 37 | self.publish = publish 38 | self.addAttachment = addAttachment 39 | self.stopServer = stopServer 40 | 41 | def getSharedObject(self, key): 42 | if self.coreServer: 43 | return self.coreServer.getSharedObject(key) 44 | return None 45 | 46 | def onConnect(self, request, client_id): 47 | """Called when a new websocket connection is established. 48 | 49 | request is the HTTP request header, and client_id an opaque string that 50 | identifies the connection. The default implementation is a noop. A 51 | subclass may redefine it. 52 | 53 | """ 54 | 55 | pass 56 | 57 | def onClose(self, client_id): 58 | """Called when a websocket connection is closed.""" 59 | 60 | pass 61 | 62 | 63 | # ============================================================================= 64 | # 65 | # Base class for wslink ServerProtocol objects 66 | # 67 | # ============================================================================= 68 | 69 | 70 | class NetworkMonitor: 71 | """ 72 | Provide context manager for increase/decrease pending request 73 | either synchronously or asynchronously. 74 | 75 | The Asynchronous version also await completion. 76 | """ 77 | 78 | def __init__(self): 79 | 80 | self.pending = 0 81 | self.event = asyncio.Event() 82 | 83 | def network_call_completed(self): 84 | """Trigger completion event""" 85 | self.event.set() 86 | 87 | def on_enter(self, *args, **kwargs): 88 | """Increase pending request""" 89 | self.pending += 1 90 | 91 | def on_exit(self, *args, **kwargs): 92 | """Decrease pending request and trigger completion event if we reach 0 pending request""" 93 | self.pending -= 1 94 | if self.pending == 0 and not self.event.is_set(): 95 | self.event.set() 96 | 97 | # Sync ctx manager 98 | def __enter__(self): 99 | self.on_enter() 100 | return self 101 | 102 | def __exit__(self, exc_type, exc_value, exc_traceback): 103 | self.on_exit() 104 | 105 | # Async ctx manager 106 | async def __aenter__(self): 107 | self.on_enter() 108 | return self 109 | 110 | async def __aexit__(self, exc_t, exc_v, exc_tb): 111 | self.on_exit() 112 | await self.completion() 113 | 114 | async def completion(self): 115 | """Await completion of any pending network request""" 116 | while self.pending: 117 | self.event.clear() 118 | await self.event.wait() 119 | 120 | 121 | class ServerProtocol(object): 122 | """ 123 | Defines the core server protocol for wslink. Gathers a list of LinkProtocol 124 | objects that provide rpc and publish functionality. 125 | """ 126 | 127 | def __init__(self): 128 | self.network_monitor = NetworkMonitor() 129 | self.log_emitter = EventEmitter( 130 | allowed_events=["exception", "error", "critical", "info", "debug"] 131 | ) 132 | self.linkProtocols = [] 133 | self.secret = None 134 | self.initialize() 135 | 136 | def init(self, publish, addAttachment, stopServer): 137 | self.publish = publish 138 | self.addAttachment = addAttachment 139 | self.stopServer = stopServer 140 | 141 | def initialize(self): 142 | """ 143 | Let sub classes define what they need to do to properly initialize 144 | themselves. 145 | """ 146 | pass 147 | 148 | def setSharedObject(self, key, shared): 149 | if not hasattr(self, "sharedObjects"): 150 | self.sharedObjects = {} 151 | if shared == None and key in self.sharedObjects: 152 | del self.sharedObjects[key] 153 | else: 154 | self.sharedObjects[key] = shared 155 | 156 | def getSharedObject(self, key): 157 | if key in self.sharedObjects: 158 | return self.sharedObjects[key] 159 | else: 160 | return None 161 | 162 | def registerLinkProtocol(self, protocol): 163 | assert isinstance(protocol, LinkProtocol) 164 | protocol.coreServer = self 165 | self.linkProtocols.append(protocol) 166 | 167 | # Note: this can only be used _before_ a connection is made - 168 | # otherwise the WslinkWebSocketServerProtocol will already have stored references to 169 | # the RPC methods in the protocol. 170 | def unregisterLinkProtocol(self, protocol): 171 | assert isinstance(protocol, LinkProtocol) 172 | protocol.coreServer = None 173 | try: 174 | self.linkProtocols.remove(protocol) 175 | except ValueError as e: 176 | error_message = "Link protocol missing from registered list." 177 | logger.error(error_message) 178 | self.log_emitter("error", error_message) 179 | 180 | def getLinkProtocols(self): 181 | return self.linkProtocols 182 | 183 | def updateSecret(self, newSecret): 184 | self.secret = newSecret 185 | 186 | def onConnect(self, request, client_id): 187 | """Called when a new websocket connection is established. 188 | 189 | request is the HTTP request header, and client_id an opaque string that 190 | identifies the connection. The default implementation is a noop. A 191 | subclass may redefine it. 192 | 193 | """ 194 | 195 | pass 196 | 197 | def onClose(self, client_id): 198 | """Called when a websocket connection is closed.""" 199 | 200 | pass 201 | 202 | @exportRpc("application.exit") 203 | def exit(self): 204 | """RPC callback to exit""" 205 | self.stopServer() 206 | 207 | @exportRpc("application.exit.later") 208 | def exitLater(self, secondsLater=60): 209 | """RPC callback to exit after a short delay""" 210 | print(f"schedule exit for {secondsLater} seconds from now") 211 | schedule_callback(secondsLater, self.stopServer) 212 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/README.md: -------------------------------------------------------------------------------- 1 | # Chat 2 | 3 | This is a test application that illustrate how to setup a Python server with a Web Client along with a C++ client. 4 | 5 | ## Python server 6 | 7 | If you have `wslink` installed on your python you can run the following command 8 | 9 | ```sh 10 | python ./server/chat.py --content ./www --port 8080 11 | ``` 12 | 13 | Then open your browser on `http://localhost:8080/` 14 | 15 | You can also use `ParaView/pvpython` to run the same command so you don't have to worry about Python and wslink availability. 16 | 17 | ## Web client 18 | 19 | The following set of commands will transpile the JavaScript code and generate the `./www` directory that is served by the server process. 20 | That bundled version has already been publish to the repository but in case you want to update or edit the code, the following commands will update that directory. 21 | 22 | ```sh 23 | cd clients/js 24 | npm install 25 | npm run build 26 | ``` 27 | 28 | ## C++ client 29 | 30 | ...todo... 31 | 32 | ## Running via the launcher 33 | 34 | ``` 35 | python -m wslink.launcher ./launcher.config 36 | ``` 37 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/cpp/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.9) 2 | 3 | project(wsSimpleClient) 4 | 5 | # for json support 6 | # see https://github.com/nlohmann/json 7 | find_package(nlohmann_json REQUIRED) 8 | 9 | # we also use beast which is part of boost 10 | set(Boost_USE_STATIC_LIBS ON) 11 | find_package(Boost 1.65 REQUIRED) #COMPONENTS thread) 12 | include_directories(${Boost_INCLUDE_DIRS}) 13 | 14 | # Request C++11 standard, using new CMake variables. 15 | set(CMAKE_CXX_STANDARD 11) 16 | set(CMAKE_CXX_STANDARD_REQUIRED True) 17 | set(CMAKE_CXX_EXTENSIONS False) 18 | 19 | add_executable(cli_test cli_test.cc) 20 | target_link_libraries(cli_test PRIVATE nlohmann_json::nlohmann_json) 21 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/cpp/README.md: -------------------------------------------------------------------------------- 1 | # Chat Client 2 | 3 | C++ implementation of client for the chat server example for wslink. 4 | 5 | ## How to build 6 | 7 | Create an adjacent directory by `wslink` named for example `wslink-build` and 8 | from there issue the following command: 9 | 10 | ```sh 11 | cmake ../wslink/tests/chat-rpc-pub-sub/clients/cpp/ 12 | 13 | make 14 | ``` 15 | 16 | ## How to run 17 | 18 | ### Python server 19 | 20 | If you have `wslink` installed on your python distribution you can run 21 | the following command: 22 | 23 | ```sh 24 | python ./server/server.py --content ./www --port 8080 25 | ``` 26 | 27 | You can also use `ParaView/pvpython` to run the same command so you don't have 28 | to worry about Python and wslink availability. 29 | 30 | ### Our C++ client 31 | 32 | Run the binary previous compiled: 33 | 34 | ```sh 35 | ./cli_test 36 | 37 | "publish:wslink.communication.channel:0" -> "Nice to meet you" 38 | "publish:wslink.communication.channel:0" -> "What's your name?" 39 | hello 40 | "publish:wslink.communication.channel:0" -> "py server: hello" 41 | 42 | ``` 43 | ![demo](demo.gif) 44 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/cpp/cli_test.cc: -------------------------------------------------------------------------------- 1 | #include "../../../../cpp/wsWebsocketConnection.h" 2 | 3 | #include 4 | #include 5 | 6 | const char* TOPIC = "wslink.communication.channel"; 7 | const char* SECRET = "wslink-secret"; 8 | const char* HOST = "127.0.0.1"; 9 | const char* PORT = "8080"; 10 | const char* TARGET = "/ws"; 11 | 12 | using std::cout; 13 | using std::endl; 14 | 15 | int main(int argc, char *argv[]) 16 | { 17 | wsWebsocketConnection ws{SECRET}; 18 | ws.connect(HOST, PORT, TARGET); 19 | 20 | ws.subscribe(TOPIC, 21 | [](const json& json) -> void 22 | { 23 | cout << json["id"] << " -> " << json["result"] << endl; 24 | }); 25 | 26 | std::string line; 27 | while (std::cin >> line) 28 | { 29 | json args = { line.c_str() }; 30 | ws.send("wslink.say.hello", nullptr, &args); 31 | } 32 | 33 | json result = {}; 34 | ws.send("wslink.stop.talking", &result); 35 | 36 | return EXIT_SUCCESS; 37 | } 38 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/cpp/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/wslink/02841cc92a533ff495adde410b9f32b0eb6d377f/tests/chat-rpc-pub-sub/clients/cpp/demo.gif -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/js/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("@rushstack/eslint-patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/eslint-config-prettier", 10 | ], 11 | parserOptions: { 12 | ecmaVersion: "latest", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/js/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/js/README.md: -------------------------------------------------------------------------------- 1 | # wslink-pubsub 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/js/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 |
12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wslink-pubsub", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "vite build", 7 | "debug": "vite build --sourcemap -m dev", 8 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore --ignore-pattern public" 9 | }, 10 | "dependencies": { 11 | "@vitejs/plugin-vue2": "^2.2.0", 12 | "vue": "^2.7.14", 13 | "wslink": "^1.10.0" 14 | }, 15 | "devDependencies": { 16 | "@rushstack/eslint-patch": "^1.1.4", 17 | "@vue/eslint-config-prettier": "^7.0.0", 18 | "eslint": "^8.33.0", 19 | "eslint-plugin-vue": "^9.3.0", 20 | "prettier": "^2.7.1", 21 | "vite": "^4.1.0" 22 | }, 23 | "browserslist": [ 24 | "> 1%", 25 | "last 2 versions" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tests/chat-rpc-pub-sub/clients/js/src/App.vue: -------------------------------------------------------------------------------- 1 |