├── .VERSION_PREFIX ├── .circleci └── config.yml ├── .dir-locals.el ├── .github └── workflows │ ├── add_to_project_board.yml │ └── native_image.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── Makefile ├── README.md ├── bb.edn ├── bin ├── funnel_wrapper ├── generate_cert ├── kaocha └── proj ├── deps.edn ├── deps.local.edn ├── dev └── user.clj ├── graal_jni.json ├── pom.xml ├── repl_sessions └── comms.clj ├── resource-config.json ├── src └── lambdaisland │ ├── funnel.clj │ └── funnel │ ├── Daemon.java │ ├── log.clj │ └── version.clj ├── src_jni ├── Daemon.c └── lambdaisland_funnel_Daemon.h ├── test └── lambdaisland │ ├── funnel │ └── test_util.clj │ └── funnel_test.clj └── tests.edn /.VERSION_PREFIX: -------------------------------------------------------------------------------- 1 | 1.6 -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | kaocha: lambdaisland/kaocha@0.0.3 5 | clojure: lambdaisland/clojure@0.0.7 6 | 7 | jobs: 8 | run-kaocha: 9 | parameters: 10 | os: 11 | type: executor 12 | clojure_version: 13 | type: string 14 | executor: << parameters.os >> 15 | steps: 16 | - checkout 17 | - clojure/with_cache: 18 | cache_version: << parameters.clojure_version >> 19 | steps: 20 | - kaocha/execute: 21 | args: "--reporter documentation" 22 | clojure_version: << parameters.clojure_version >> 23 | - kaocha/upload_codecov 24 | 25 | workflows: 26 | kaocha-test: 27 | jobs: 28 | - run-kaocha: 29 | matrix: 30 | parameters: 31 | os: [clojure/openjdk17, clojure/openjdk16, clojure/openjdk15, clojure/openjdk11] 32 | clojure_version: ["1.9.0", "1.10.3", "1.11.1"] 33 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((nil . ((cider-clojure-cli-global-options . "-A:dev:test:lioss -J-Djava.library.path=lib") 2 | (cider-preferred-build-tool . clojure-cli) 3 | (cider-redirect-server-output-to-repl . t) 4 | (cider-repl-display-help-banner . nil) 5 | (clojure-toplevel-inside-comment-form . t) 6 | (eval . (define-clojure-indent 7 | (assoc 0) 8 | (ex-info 0)))))) 9 | -------------------------------------------------------------------------------- /.github/workflows/add_to_project_board.yml: -------------------------------------------------------------------------------- 1 | name: Add new pr or issue to project board 2 | 3 | on: [issues] 4 | 5 | jobs: 6 | add-to-project: 7 | uses: lambdaisland/open-source/.github/workflows/add-to-project-board.yml@main 8 | secrets: inherit 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/native_image.yml: -------------------------------------------------------------------------------- 1 | name: native_image 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - '*' 8 | 9 | env: 10 | CLOJURE_CLI_VERSION: '1.10.3.943' 11 | GRAALVM_VERSION: '19.3.1.java11' 12 | PROJECT: funnel 13 | 14 | jobs: 15 | native-image: 16 | strategy: 17 | matrix: 18 | include: 19 | - os: ubuntu-latest 20 | suffix: linux-amd64 21 | - os: macOS-latest 22 | suffix: darwin-amd64 23 | runs-on: ${{ matrix.os }} 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: graalvm/setup-graalvm@v1 27 | with: 28 | java-version: '23' 29 | distribution: 'graalvm' 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | - uses: DeLaGuardo/setup-clojure@3.5 32 | with: 33 | tools-deps: ${{ env.CLOJURE_CLI_VERSION }} 34 | - name: Build native image 35 | run: | 36 | make all 37 | ls -l ./funnel 38 | - name: Rename 39 | run: mv ${{ env.PROJECT }} ${{ env.PROJECT }}.${{ matrix.suffix }} 40 | - uses: actions/upload-artifact@master 41 | with: 42 | name: ${{ env.PROJECT }}.${{ matrix.suffix }} 43 | path: ./${{ env.PROJECT }}.${{ matrix.suffix }} 44 | 45 | create-release: 46 | needs: [native-image] 47 | if: contains(github.ref, 'refs/tags/') 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v2 51 | 52 | - uses: DeLaGuardo/setup-clojure@3.5 53 | with: 54 | tools-deps: ${{ env.CLOJURE_CLI_VERSION }} 55 | 56 | - name: Setup Babashka 57 | uses: turtlequeue/setup-babashka@v1.3.0 58 | with: 59 | babashka-version: 0.6.2 60 | 61 | - name: Get release notes 62 | id: get-release-notes 63 | run: | 64 | bin/proj gh_actions_changelog_output 65 | 66 | - uses: actions/create-release@v1 67 | id: create_release 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | tag_name: ${{ github.ref }} 72 | release_name: ${{ github.ref }} 73 | body: ${{ steps.get-release-notes.outputs.changelog }} 74 | prerelease: true 75 | 76 | - uses: actions/download-artifact@master 77 | with: 78 | name: ${{ env.PROJECT }}.linux-amd64 79 | path: tmp 80 | 81 | - uses: actions/upload-release-asset@v1 82 | id: upload-linux-release-asset 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | with: 86 | upload_url: ${{ steps.create_release.outputs.upload_url }} 87 | asset_path: ./tmp/${{ env.PROJECT }}.linux-amd64 88 | asset_name: ${{ env.PROJECT }}.linux-amd64 89 | asset_content_type: application/octet-stream 90 | 91 | - uses: actions/download-artifact@master 92 | with: 93 | name: ${{ env.PROJECT }}.darwin-amd64 94 | path: tmp 95 | 96 | - uses: actions/upload-release-asset@v1 97 | id: upload-darwin-release-asset 98 | env: 99 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 100 | with: 101 | upload_url: ${{ steps.create_release.outputs.upload_url }} 102 | asset_path: ./tmp/${{ env.PROJECT }}.darwin-amd64 103 | asset_name: ${{ env.PROJECT }}.darwin-amd64 104 | asset_content_type: application/octet-stream 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cpcache 2 | .nrepl-port 3 | target 4 | repl 5 | scratch.clj 6 | .shadow-cljs 7 | target 8 | yarn.lock 9 | node_modules/ 10 | .DS_Store 11 | classes 12 | /funnel 13 | .store 14 | lib 15 | *.class 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | ## Added 4 | 5 | ## Fixed 6 | 7 | ## Changed 8 | 9 | # 1.6.93 (2025-02-28 / be9c357) 10 | 11 | ## Fixed 12 | 13 | - Fix release process 14 | 15 | # 1.5.85 (2025-02-28 / 24334fc) 16 | 17 | ## Added 18 | 19 | - Support multiple formats, add `/?content-type=json|edn|transit` to the 20 | websocket URL. Still defaults to transit. 21 | 22 | ## Changed 23 | 24 | - Remove pedestal.log 25 | 26 | # 1.4.71 (2021-12-16 / 6ae91b0) 27 | 28 | - Testing, tooling, docs updates 29 | 30 | # 0.1.42 (2020-08-26 / 4c14cec) 31 | 32 | ## Added 33 | 34 | - Added a `--daemonize` flag so Funnel can background itself (experimental) 35 | - Added a `--logfile FILE` option to redirect output 36 | - Added a `--ws-port PORT` options 37 | - Added a `--wss-port PORT` option 38 | - Added a `--version` flag 39 | 40 | ## Changed 41 | 42 | - No longer include a default certificate 43 | - Only start WSS server when a certificate is provided 44 | - Changed the default `--keystore-password` from `"funnel"` to `"password"` 45 | (same as [bhauman/certifiable](https://github.com/bhauman/certifiable)) 46 | 47 | ## Fixed 48 | 49 | - Correctly format log messages that contain parameters (like jdk.event.security) 50 | 51 | # 0.1.16 (2020-05-26 / 81b2e61) 52 | 53 | ## Added 54 | 55 | - First prerelease version, implements `:funnel/whoami`, `:funnel/subscribe`, 56 | `:funnel/unsubscribe`, `:funnel/broadcast`, `:funnel/query`. 57 | - Selectors: `true`, vector, map. 58 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Depending on architecture we want to use either 2 | # dylib or so for library extention 3 | # 4 | ARCH=$(shell uname -s | tr '[:upper:]' '[:lower:]') 5 | ifeq ($(ARCH),darwin) 6 | EXT=dylib 7 | else 8 | EXT=so 9 | endif 10 | 11 | # Since release 9 of JDK, server is no longer 12 | # part of jre. We have to look for it inside 13 | # lib/server instead of jre/lib/server 14 | # however, for jdk 1.8 we still have to look inside 15 | # jre - this is why we have this fancy directory 16 | # checking here 17 | # 18 | JAVA_SERVER_DIR=${JAVA_HOME}/jre/lib/server 19 | ifneq "$(wildcard $(JAVA_SERVER_DIR) )" "" 20 | JAVA_SERVER_LIB=${JAVA_HOME}/jre/lib/server 21 | else 22 | JAVA_SERVER_LIB=${JAVA_HOME}/lib/server 23 | endif 24 | 25 | # depending on architecture we have to set linker settings 26 | # while linking with libjvm.so/libjvm.dylib 27 | # Be careful with mac os!! 28 | # 29 | # If you don't use -rpath while linking, your code 30 | # will be linked with /usr/local/lib/libjvm.dylib 31 | # This will be a dissaster if your custom installation 32 | # inside /Library/Java/JavaVirtualMachines/ 33 | # 34 | ifeq ($(ARCH),darwin) 35 | CC=clang 36 | CXX=clang++ 37 | LD_FLAGS_JAVA=-L${JAVA_SERVER_LIB} -rpath ${JAVA_SERVER_LIB} -ljvm 38 | LD_FLAGS=-L/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk 39 | LD_FLAGS+=-arch x86_64 40 | LD_FLAGS+=-macosx_version_min 10.14.0 -lSystem 41 | LD_FLAGS+=$(LD_FLAGS_JAVA) 42 | else 43 | CC=gcc 44 | CXX=g++ 45 | LD_FLAGS=-Wl,-rpath,${JAVA_SERVER_LIB} -L${JAVA_SERVER_LIB} -ljvm 46 | endif 47 | 48 | JVM_INCLUDE=-I${JAVA_HOME}/include -I${JAVA_HOME}/include/$(ARCH) \ 49 | 50 | all: compilejava compilec nativeimage 51 | 52 | compilejava: 53 | $(JAVA_HOME)/bin/javac --release 8 -h src_jni src/lambdaisland/funnel/Daemon.java 54 | 55 | compilec: 56 | mkdir -p lib 57 | $(CC) -g -shared -fpic -I${JAVA_HOME}/include -I${JAVA_HOME}/include/$(ARCH) src_jni/Daemon.c -o lib/libDaemon.$(EXT) 58 | 59 | nativeimage: 60 | clojure -M:native-image 61 | 62 | # $(CC) -g -static -fpic -I${JAVA_HOME}/include -I${JAVA_HOME}/include/$(ARCH) src_jni/Daemon.c -o lib/libDaemon.$(EXT) 63 | # rm -f lib/libDaemon.a 64 | # ar -rcs lib/libDaemon.a lib/libDaemon.$(EXT) 65 | # ranlib lib/libDaemon.a 66 | # rm -f lib/libDaemon.$(EXT) 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Funnel 2 | 3 | Transit-over-WebSocket Message Hub 4 | 5 | 6 | [![Github Actions](https://github.com/lambdaisland/funnel/workflows/native_image/badge.svg)](https://github.com/lambdaisland/funnel/actions) 7 | [![Cljdoc Documentation](https://cljdoc.org/badge/lambdaisland/funnel)](https://cljdoc.org/d/lambdaisland/funnel) 8 | [![Clojars Project](https://img.shields.io/clojars/v/lambdaisland/funnel.svg)](https://clojars.org/lambdaisland/funnel) 9 | 10 | 11 | ## What is it? 12 | 13 | Funnel is a WebSocket message hub. It accepts connections from multiple clients, 14 | and then acts as a go-between, funneling messages between them, with flexible 15 | mechanisms for setting up message routing, either by the sender (broadcast) or 16 | receiver (subscribe). It also provides discoverability, so clients can find out 17 | who is there to talk to. 18 | 19 | 20 | ## What is it for? 21 | 22 | Funnel grew out of the need to persist connections with JavaScript runtimes. 23 | When tooling (a REPL, a test runner, a remote object browser) needs a connection 24 | to a JavaScript runtime (say, a browser tab), then it has to wait for the 25 | browser tab to connect back. There is no way to query for existing runtimes and 26 | connect to them, we can only spawn a new one, and wait for it to call back. 27 | 28 | Funnel provides persistence and discoverability. It keeps connections to long 29 | lived processes (like a browser), so that short-lived processes (like a test 30 | runner) can discover and interact with them. 31 | 32 | In this way Funnel forms a bridge between developer tooling and JavaScript 33 | runtimes. It keeps persistent connections to runtimes so individual tools don't 34 | have to. This is particularly relevant when the tool's process lifetime is 35 | shorter than the lifetime of the JavaScript runtime. 36 | 37 | To make that concrete, a test runner invoked multiple times from the command 38 | line can run commands in the same pre-existing browser tab. 39 | 40 | Funnel accepts websocket connections on the endpoint `ws://localhost:44220`, and 41 | optionally `wss://localhost:44221`. Any messages it receives are forwarded to 42 | other clients based on active subscriptions (set up by the receiver), or based 43 | on a broadcast command inside the message (added by the sender). 44 | 45 | ## Installation 46 | 47 | You can download pre-compiled binaries for Linux and Mac OS from the 48 | [releases](https://github.com/lambdaisland/funnel/releases) page, or run it with 49 | this one-liner. 50 | 51 | ``` shell 52 | clojure -Sdeps '{:deps {lambdaisland/funnel {:mvn/version "1.6.93"}}}' -m lambdaisland.funnel --help 53 | ``` 54 | 55 | ## Build 56 | 57 | To build the native image yourself, make sure you are using GraalVMJava, and have the GNU compiler toolchain (gcc) available. 58 | 59 | With sdkman 60 | 61 | ``` 62 | sdk install java 25.ea.8-graal 63 | sdk use java 25.ea.8-graal 64 | ``` 65 | 66 | Then: 67 | 68 | ``` 69 | make all 70 | ``` 71 | 72 | ## Usage 73 | 74 | As an end user you are generally more interested in the tools that use Funnel 75 | than in Funnel itself. In that the case the only thing that matters is that 76 | Funnel is running. You can start it once and then forget about it. 77 | 78 | ``` 79 | ~/funnel 80 | ``` 81 | 82 | ### Verbosity 83 | 84 | By default Funnel provides very little output, only errors and warnings are 85 | displayed. You can increase the verbosity with `--verbose`/`-v` which can be 86 | supplied up to three times 87 | 88 | ``` shell 89 | ./funnel -v 90 | ./funnel -vv 91 | ./funnel -vvv 92 | ``` 93 | 94 | `-v` will show opening and closing of connections but not individual messages. 95 | 96 | `-vv` is a good middle ground for when you want to see what's being sent across, 97 | without being inundated with implementation details 98 | 99 | `-vvv` gets really noisy, including showing things like the websocket handshake 100 | and raw transit. 101 | 102 | When debugging issues Funnel provides a great place to inspect the flow of 103 | messages going back and forth, which can provide useful information to 104 | maintainers. When reporting bugs to Funnel-based tooling it's a good idea to 105 | capture the traffic with 106 | 107 | ``` 108 | ./funnel -vv --logfile funnel.log 109 | ``` 110 | 111 | And share the resulting `funnel.log` file as a [gist](https://gist.github.com/). 112 | 113 | ### WSS / SSL / HTTPS 114 | 115 | By default Funnel only listens for regular, non encrypted websocket connections. 116 | If you are running your development server with SSL enabled (HTTPS), then your 117 | browser will refuse to connect to a non-encrypted websocket, and things will 118 | quietly fail. 119 | 120 | In this case you need to supply Funnel with a certificate in the form a `.jks` 121 | file (Java Key Store), so that it can listen for WSS (webocket over ssl) 122 | connections. 123 | 124 | ``` 125 | ./funnel --keystore dev-cert.jks --keystore-password mypass123 126 | ``` 127 | 128 | If you already have a jks file that you are using for your dev setup then just 129 | use that. Otherwise we recommend using [Certifiable](https://github.com/bhauman/certifiable) 130 | to generate a certificate. 131 | 132 | The default password for Certifiable and for Funnel is `"password"`, so if you 133 | are using Certifiable you don't need to supply a password. 134 | 135 | ``` 136 | ./funnel --keystore ~/_certifiable_certs/localhost-1d070e4/dev-server.jks 137 | ``` 138 | 139 | ### Backgrounding 140 | 141 | Funnel acts as a registry for connected clients, so that clients that join later 142 | can query Funnel to discover who's available to talk to. This is why it's import 143 | to run Funnel as a separate long-lived process, rather than for instance 144 | embedding it into the tool that uses it. 145 | 146 | To make this easy the native-image version allows backgrounding itself, so that 147 | it detaches itself from the shell that started it, and will continue running in 148 | the background. 149 | 150 | ``` 151 | ./funnel --daemonize 152 | 588904 153 | ``` 154 | 155 | This prints the PID of the background process and exits. Use `kill -SIGINT 156 | ` to quit funnel. 157 | 158 | You can even invoke this from a start-up script like `.xsessionrc` or 159 | `.bash_profile` and forget about it. Running this multiple times is safe, if 160 | Funnel finds that another instance is already listening on its port then it will 161 | print a warning and exit with code 42. 162 | 163 | When invoked as a daemon Funnel's log output will be directed to a logfile, this 164 | defaults to `funnel.log` in the `java.io.tmpdir` (e.g. `/tmp/funnel.log`). Note 165 | that the verbosity settings still apply, so by default you won't see much in the 166 | logs unless errors occur. For more meaningful output supply one or more `-v` 167 | options. 168 | 169 | ``` 170 | ./funnel --daemonize -vv --logfile ~/funnel.log 171 | ``` 172 | 173 | ## Messages 174 | 175 | Each message Funnel receives on a websocket is decoded with Transit. If the 176 | decoded message is a map then Funnel will look for the presence of certain keys, 177 | which will trigger specific processing, before being forward to other connected 178 | clients based on active subscriptions, or the presence of a `:funnel/broadcast` 179 | key. 180 | 181 | ### `:funnel/whoami` 182 | 183 | When a message contains a `:funnel/whoami` key, then the value of that key MUST 184 | be a map with identifying information. 185 | 186 | The `:funnel/whoami` map SHOULD contain an `:id`, `:type`, and `:description`, 187 | but it can basically contain anything. Map keys SHOULD be keywords (qualified or 188 | not), map values SHOULD be atomic/primitive (e.g. strings, keywords, numbers. 189 | Not collections). Use of other types as keys or values is reserved for future 190 | extension. 191 | 192 | ``` clojure 193 | {:funnel/whoami {:id "firefox-123" 194 | :type :kaocha.cljs2/js-runtime 195 | :description "Firefox 78.0 on Linux"}} 196 | ``` 197 | 198 | The contents of this map are stored as a property of the client connection. They 199 | are used for selecting clients when routing messages, and can be returned when 200 | querying for connected clients. The client SHOULD include a whoami map in the 201 | first message they send. It can be omitted from subsequent message, since the 202 | stored map will be used. 203 | 204 | If funnel receives a new `:funnel/whoami` then it will replace the old one. 205 | 206 | ### `:funnel/subscribe` 207 | 208 | A client who wishes to receive messages sent by a subset of connected clients 209 | can send a message containing a `:funnel/subscribe`. The value of 210 | `:funnel/subscribe` is a _selector_. See the [Selector](#selector) section for 211 | defaults. 212 | 213 | 214 | ``` clojure 215 | {:funnel/whoami {:id "test-suite-abc-123" 216 | :type :kaocha.cljs2/run} 217 | :funnel/subscribe [:type :kaocha.cljs2/js-runtime]} 218 | ``` 219 | 220 | This will create a persistent subscription, all incoming messages matching the 221 | selector will be forwarded to the client that issued the subscription. A client 222 | can create multiple subscriptions. 223 | 224 | Note that the current sender is always excluded, so a message is never sent back 225 | to the sender, even if a subscription or broadcast selector matches the 226 | `:funnel/whoami` of the sender. 227 | 228 | ### `:funnel/unsubscribe` 229 | 230 | To remove a subscription, use the `:funnel/unsubscribe` key with the same 231 | selector used in `:funnel/subscribe`. 232 | 233 | ### `:funnel/broadcast` 234 | 235 | Clients can send arbitrary messages to funnel without caring where they go. If 236 | there is a matching subscription then they will get forwarded, if not they are 237 | dropped. But a client may also choose to address a message to a specific client 238 | or subset of clients, by using `:funnel/broadcast`. The value of 239 | `:funnel/broadcast` is again a _selector_, as with `:funnel/subscribe`. 240 | 241 | ``` clojure 242 | {:type :kaocha.cljs2/fetch-test-data 243 | :funnel/broadcast [:type :kaocha.cljs2/js-runtime]} 244 | ``` 245 | 246 | When a message is received a set of recipients is determined based on any 247 | existing subscriptions, and possibly the presence of a `:funnel/broadcast` value 248 | inside the message. These are unified, so a given message is sent to a given 249 | client at most once. 250 | 251 | ### `:funnel/query` 252 | 253 | When a received message contains the `:funnel/query` key, then funnel will send 254 | a message back to the client containing a `:funnel/clients` list, which is a 255 | sequence of whoami-maps, based on the selector. 256 | 257 | ``` clojure 258 | ;; => 259 | {:funnel/query true} 260 | 261 | ;; <= 262 | {:funnel/clients 263 | [{:id "firefox-123" 264 | ,,,} 265 | ,,,]} 266 | ``` 267 | 268 | ## Selectors 269 | 270 | `:funnel/subscribe`, `:funnel/unsubscribe`, and `:funnel/query` all take a 271 | _selector_ as their associated value. A selector is an EDN value, this value is 272 | matched against the stored `:funnel/whoami` maps to select a subset of clients. 273 | 274 | Note that a message is never echoed back to the sending client, if if that 275 | client would in principle be included in the selection. 276 | 277 | ### `true` 278 | 279 | The boolean value `true` matches all connected clients (except the client the 280 | message came from). This includes clients that have connected but have not 281 | identified themselves by sending a whoami map. This is the only selector that 282 | can select clients without stored whoami data. 283 | 284 | ### two-element vector 285 | 286 | Interpreted as a key-value pair, will match all clients whose whoami map 287 | contains exactly this key and associated value. 288 | 289 | Note that while the current implementation simply compares values for equality, 290 | we only officially support (and thus guarantee backwards compatibility) for 291 | "atomic" values: strings, keywords, symbols, numbers, booleans. The behavior of 292 | collections (maps, vectors, etc) as values in whoami maps, or in selectors, is 293 | undefined, and may change in the future. 294 | 295 | ### Map 296 | 297 | Will map all clients whose whoami maps contain identical key-value pairs as the 298 | given map. Note that there may be extra information in the whoami map, this is 299 | ignored. Same caveat as above: the behavior of collections as values is reserved 300 | for future extension. 301 | 302 | ## Message forwarding 303 | 304 | When a message is received we try to decode it as transit. If the decoded value 305 | is a map then we look for the above keys and handle `:funnel/whoami`, 306 | `:funnel/subscribe`, `:funnel/unsubscribe`, and `:funnel/query`. 307 | 308 | Then we determine the recipients of the message, based on existing subscriptions, 309 | and if present on the value of `:funnel/broadcast`. 310 | 311 | Note that messages don't have to be maps, or even valid transit. In that case 312 | they are still forwarded based on active subscriptions. 313 | 314 | If a value does decode to a map, and it does not contain a `:funnel/whoami` 315 | value, then the last seen value of `:funnel/whoami` is added. 316 | 317 | Tagged values are forwarded as-is, there is no need to configure read or write 318 | handlers inside Funnel. 319 | 320 | Note that apart from the above keys clients can add any arbitrary values to 321 | their messages, and Funnel will funnel them. 322 | 323 | ## Disconnect handling 324 | 325 | When a client disconnects all matching subscribers are notified with a message 326 | of the form 327 | 328 | ``` clojure 329 | {:funnel/disconnect {:code ... :reason ... :remote? ...} 330 | :funnel/whoami {...}} 331 | ``` 332 | 333 | Subscribers are *not* notified of new connections per-se, instead when a client 334 | announces itself with a `:funnel/whoami` then that first message will be 335 | forwarded to matching subscribers (like any other message). 336 | 337 | ## Testing / debugging with jet and websocat 338 | 339 | [jet](https://github.com/borkdude/jet) lets you convert easily between EDN and 340 | Transit. [websocat](https://github.com/vi/websocat) provides a command line 341 | interface for websockets. Together they form a great way for doing ad-hoc 342 | communication with Funnel. 343 | 344 | ``` shell 345 | echo '{:funnel/query true}' | jet --to transit | websocat ws://localhost:44220 | jet --from transit 346 | ``` 347 | 348 | ## Building Funnel-based Tooling 349 | 350 | Working with Funnel is fairly straightforward, all you need is a websocket 351 | client and the ability to encode/decode transit. A typical usage pattern is to 352 | send a `:funnel/whoami` message in the websocket `onOpen` hook so you become 353 | visible to other clients. 354 | 355 | For Clojure and ClojureScript there's 356 | [funnel-client](https://github.com/lambdaisland/funnel-client), which further 357 | reduces the boilerplate and which implements this whoami-in-onopen pattern. 358 | 359 | ## Prior Art 360 | 361 | The design of Funnel is influenced by shadow-cljs's `shadow.remote`. 362 | 363 | 364 | ## Contributing 365 | 366 | Everyone has a right to submit patches to funnel, and thus become a contributor. 367 | 368 | Contributors MUST 369 | 370 | - adhere to the [LambdaIsland Clojure Style Guide](https://nextjournal.com/lambdaisland/clojure-style-guide) 371 | - write patches that solve a problem. Start by stating the problem, then supply a minimal solution. `*` 372 | - agree to license their contributions as MPL 2.0. 373 | - not break the contract with downstream consumers. `**` 374 | - not break the tests. 375 | 376 | Contributors SHOULD 377 | 378 | - update the CHANGELOG and README. 379 | - add tests for new functionality. 380 | 381 | If you submit a pull request that adheres to these rules, then it will almost 382 | certainly be merged immediately. However some things may require more 383 | consideration. If you add new dependencies, or significantly increase the API 384 | surface, then we need to decide if these changes are in line with the project's 385 | goals. In this case you can start by [writing a pitch](https://nextjournal.com/lambdaisland/pitch-template), 386 | and collecting feedback on it. 387 | 388 | `*` This goes for features too, a feature needs to solve a problem. State the problem it solves, then supply a minimal solution. 389 | 390 | `**` As long as this project has not seen a public release (i.e. is not on Clojars) 391 | we may still consider making breaking changes, if there is consensus that the 392 | changes are justified. 393 | 394 | 395 | 396 | 397 |   398 | 399 | 400 | 401 |   402 | 403 | ## Support Lambda Island Open Source 404 | 405 | funnel is part of a growing collection of quality Clojure libraries and 406 | tools released on the Lambda Island label. If you are using this project 407 | commercially then you are expected to pay it forward by 408 | [becoming a backer on Open Collective](http://opencollective.com/lambda-island#section-contribute), 409 | so that we may continue to enjoy a thriving Clojure ecosystem. 410 | 411 |   412 | 413 |   414 | 415 | 416 | 417 | 418 | ## License 419 | 420 | Copyright © 2020-2021 Arne Brasseur and Contributors 421 | 422 | Licensed under the term of the Mozilla Public License 2.0, see LICENSE. 423 | 424 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:deps 2 | {lambdaisland/open-source {:git/url "https://github.com/lambdaisland/open-source" 3 | :git/sha "28c67d7eff0dc1c38dffcff3f6c91f0aac2713c8"}}} 4 | -------------------------------------------------------------------------------- /bin/funnel_wrapper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Wrapper script for funnel to be dropped into projects, will run `funnel` from 4 | # the PATH if it exists, or otherwise download it and store it inside the 5 | # project. When using the system `funnel` it will do a version check and warn if 6 | # the version is older than what is requested. 7 | 8 | funnel_version="1.6.93" 9 | store_dir="$(pwd)/.store" 10 | install_dir="${store_dir}/funnel-${funnel_version}" 11 | 12 | system_funnel="$(which funnel)" 13 | set -e 14 | 15 | # https://stackoverflow.com/questions/4023830/how-to-compare-two-strings-in-dot-separated-version-format-in-bash 16 | vercomp () { 17 | if [[ $1 == $2 ]] 18 | then 19 | return 0 20 | fi 21 | local IFS=. 22 | local i ver1=($1) ver2=($2) 23 | # fill empty fields in ver1 with zeros 24 | for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)) 25 | do 26 | ver1[i]=0 27 | done 28 | for ((i=0; i<${#ver1[@]}; i++)) 29 | do 30 | if [[ -z ${ver2[i]} ]] 31 | then 32 | # fill empty fields in ver2 with zeros 33 | ver2[i]=0 34 | fi 35 | if ((10#${ver1[i]} > 10#${ver2[i]})) 36 | then 37 | return 1 38 | fi 39 | if ((10#${ver1[i]} < 10#${ver2[i]})) 40 | then 41 | return 2 42 | fi 43 | done 44 | return 0 45 | } 46 | 47 | if [[ -f "$system_funnel" ]]; then 48 | funnel_path="$system_funnel" 49 | elif [[ -f "$install_dir/funnel" ]]; then 50 | funnel_path="$install_dir/funnel" 51 | else 52 | case "$(uname -s)" in 53 | Linux*) platform=linux;; 54 | Darwin*) platform=darwin;; 55 | esac 56 | 57 | echo "$name $funnel_version not found, installing to $install_dir..." 58 | download_url="https://github.com/lambdaisland/funnel/releases/download/v$funnel_version/funnel.$platform-amd64" 59 | 60 | mkdir -p $install_dir 61 | funnel_path="$install_dir/funnel" 62 | echo -e "Downloading $download_url." 63 | curl -o "$funnel_path" -sL "$download_url" 64 | chmod +x "$funnel_path" 65 | fi 66 | 67 | set +e 68 | actual_version="$($funnel_path --version | sed 's^lambdaisland/funnel ^^' | sed 's^ .*^^')" 69 | 70 | vercomp $actual_version $funnel_version 71 | case $? in 72 | 0) ;; # = 73 | 1) ;; # > 74 | 2) echo "WARNING: funnel version is $actual_version, expected $funnel_version" ;; # < 75 | esac 76 | set -e 77 | 78 | exec "$funnel_path" "$@" 79 | -------------------------------------------------------------------------------- /bin/generate_cert: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | keytool -genkey -keyalg RSA -alias selfsigned -keystore resources/keystore.jks -storepass funnel -keysize 2048 -dname "CN=Funnel, OU=Unknown, O=Lambda Island, L=Unknown, ST=Unknown, C=Unknown" -ext SAN=dns:localhost,ip:127.0.0.1,ip:::1 3 | -------------------------------------------------------------------------------- /bin/kaocha: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | clojure -M:test -m kaocha.runner "$@" 4 | -------------------------------------------------------------------------------- /bin/proj: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bb 2 | 3 | (ns proj (:require [lioss.main :as lioss] 4 | [clojure.java.io :as io] 5 | [lioss.git :as git] 6 | [clojure.string :as str])) 7 | 8 | (defn update-version-file [{:keys [version sha date] :as opts}] 9 | (with-open [f (io/writer "src/lambdaisland/funnel/version.clj")] 10 | (binding [*out* f] 11 | (prn '(ns lambdaisland.funnel.version)) 12 | (println) 13 | (prn `(~'def ~'VERSION ~{:version version :date date :sha (subs sha 0 7)})))) 14 | 15 | (spit "bin/funnel_wrapper" 16 | (str/replace (slurp "bin/funnel_wrapper") 17 | #"funnel_version=\".*\"" 18 | (str "funnel_version=" (pr-str version)))) 19 | 20 | opts) 21 | 22 | (defn update-version-unreleased [opts] 23 | (update-version-file (assoc opts :version "unreleased")) 24 | (git/git! "add" "src/lambdaisland/funnel/version.clj") 25 | (git/git! "commit" "-m" "Update version.clj post release") 26 | (git/git! "push") 27 | opts) 28 | 29 | (lioss/main 30 | {:license :mpl 31 | :inception-year 2020 32 | :description "Transit-over-WebSocket Message Hub" 33 | :group-id "lambdaisland" 34 | :ci :gh-actions 35 | :pre-release-hook update-version-file 36 | :post-release-hook update-version-unreleased 37 | :commands ["update-version" 38 | {:description "update version.clj" 39 | :command update-version-file} 40 | "update-version-unreleased" 41 | {:description "update version.clj, set version to \"unreleased\"" 42 | :command update-version-unreleased}]}) 43 | 44 | 45 | ;; Local Variables: 46 | ;; mode:clojure 47 | ;; End: 48 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "lib"] 2 | 3 | :deps 4 | {org.java-websocket/Java-WebSocket {:mvn/version "1.6.0"} 5 | org.clojure/tools.cli {:mvn/version "1.1.230"} 6 | org.slf4j/slf4j-jdk14 {:mvn/version "2.0.17"} 7 | org.clojure/core.async {:mvn/version "1.7.701"} 8 | com.cognitect/transit-clj {:mvn/version "1.0.333" 9 | :exclusions [org.msgpack/msgpack]} 10 | com.cnuernber/charred {:mvn/version "1.036"}} 11 | 12 | :aliases 13 | {:test 14 | {:extra-paths ["test"] 15 | :extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"} 16 | nubank/matcher-combinators {:mvn/version "3.9.1"}}} 17 | 18 | :main 19 | {:main-opts ["-m" "lambdaisland.funnel"]} 20 | 21 | :native-image 22 | {:main-opts ["-m" "clj.native-image" "lambdaisland.funnel" 23 | "--echo" 24 | "--" 25 | "--initialize-at-build-time" 26 | "--initialize-at-run-time=lambdaisland.funnel.Daemon" 27 | "--no-fallback" 28 | "--enable-https" 29 | "-H:+UnlockExperimentalVMOptions" 30 | "-H:Name=funnel" 31 | "-H:Log=registerResource" 32 | "-H:ResourceConfigurationFiles=resource-config.json" 33 | "-H:+JNI" 34 | "-H:CLibraryPath=lib" 35 | "-Djava.library.path=lib" 36 | "-H:JNIConfigurationFiles=graal_jni.json" 37 | "-H:+ReportExceptionStackTraces" 38 | "--trace-object-instantiation=com.sun.jmx.mbeanserver.JmxMBeanServer" 39 | #_"--report-unsupported-elements-at-runtime"] 40 | :jvm-opts ["-Dclojure.compiler.direct-linking=true"] 41 | :extra-deps 42 | {com.lambdaisland/clj.native-image {:git/sha "836d24a92705f2ff556709ec40ea26a0d5c8dffa" 43 | :git/url "https://github.com/lambdaisland/clj.native-image"} 44 | }}}} 45 | -------------------------------------------------------------------------------- /deps.local.edn: -------------------------------------------------------------------------------- 1 | {:launchpad/aliases [:dev :test]} 2 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user) 2 | 3 | (defmacro jit [sym] 4 | `(requiring-resolve '~sym)) 5 | -------------------------------------------------------------------------------- /graal_jni.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "lambdaisland.funnel.Daemon", 4 | "methods": [ 5 | { "name": "daemonize", "parameterTypes": [] } 6 | ] 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | lambdaisland 5 | funnel 6 | 1.6.93 7 | funnel 8 | Transit-over-WebSocket Message Hub 9 | https://github.com/lambdaisland/funnel 10 | 2020 11 | 12 | Lambda Island 13 | https://lambdaisland.com 14 | 15 | 16 | UTF-8 17 | 18 | 19 | 20 | MPL-2.0 21 | https://www.mozilla.org/media/MPL/2.0/index.txt 22 | 23 | 24 | 25 | https://github.com/lambdaisland/funnel 26 | scm:git:git://github.com/lambdaisland/funnel.git 27 | scm:git:ssh://git@github.com/lambdaisland/funnel.git 28 | e287af9f50236ab5f4fd7226e67d434c7dce1ad3 29 | 30 | 31 | 32 | org.java-websocket 33 | Java-WebSocket 34 | 1.6.0 35 | 36 | 37 | org.clojure 38 | tools.cli 39 | 1.1.230 40 | 41 | 42 | org.slf4j 43 | slf4j-jdk14 44 | 2.0.17 45 | 46 | 47 | org.clojure 48 | core.async 49 | 1.7.701 50 | 51 | 52 | com.cognitect 53 | transit-clj 54 | 1.0.333 55 | 56 | 57 | com.cnuernber 58 | charred 59 | 1.036 60 | 61 | 62 | 63 | src 64 | 65 | 66 | src 67 | 68 | 69 | lib 70 | 71 | 72 | 73 | 74 | org.apache.maven.plugins 75 | maven-compiler-plugin 76 | 3.8.1 77 | 78 | 1.8 79 | 1.8 80 | 81 | 82 | 83 | org.apache.maven.plugins 84 | maven-jar-plugin 85 | 3.2.0 86 | 87 | 88 | 89 | e287af9f50236ab5f4fd7226e67d434c7dce1ad3 90 | 91 | 92 | 93 | 94 | 95 | org.apache.maven.plugins 96 | maven-gpg-plugin 97 | 1.6 98 | 99 | 100 | sign-artifacts 101 | verify 102 | 103 | sign 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | clojars 113 | https://repo.clojars.org/ 114 | 115 | 116 | 117 | 118 | clojars 119 | Clojars repository 120 | https://clojars.org/repo 121 | 122 | 123 | -------------------------------------------------------------------------------- /repl_sessions/comms.clj: -------------------------------------------------------------------------------- 1 | (ns repl-sessions.comms 2 | (:require [lambdaisland.funnel :as funnel] 3 | [io.pedestal.log :as log]) 4 | (:import (java.net URI) 5 | (org.java_websocket.client WebSocketClient))) 6 | 7 | (def servers (funnel/start! {:verbose 3 8 | :keystore-password "funnel"})) 9 | 10 | 11 | 12 | 13 | (def client 14 | (proxy [WebSocketClient] [(URI. "ws://localhost:44220")] 15 | (onOpen [handshake] 16 | (log/info :client/open handshake)) 17 | (onClose [code reason remote?] 18 | (log/info :client/close {:code code :reason reason :remote? remote?})) 19 | (onMessage [message] 20 | (log/info :client/message message)) 21 | (onError [ex] 22 | (log/error :client/error true :exception ex)))) 23 | 24 | (.connect client) 25 | 26 | (.send client (funnel/to-transit {:foo :bar})) 27 | 28 | {:funnel/whoami {:id "firefox-123" 29 | :type :kaocha.cljs2/js-runtime}} 30 | 31 | {:funnel/whoami {:id "kaocha-cljs-abcd" 32 | :type :kaocha.cljs2/test-run} 33 | :funnel/subscribe {:type #{:kaocha.cljs2/js-runtime}}} 34 | 35 | {:funnel/broadcast {:id "firefox-123"}} 36 | 37 | {:funnel/subscribe [:id "firefox-123"]} 38 | {:funnel/subscribe [:type :kaocha.cljs2/js-runtime]} 39 | -------------------------------------------------------------------------------- /resource-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources": [ 3 | {"pattern": "libDaemon.*"} 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/lambdaisland/funnel.clj: -------------------------------------------------------------------------------- 1 | (ns lambdaisland.funnel 2 | (:gen-class) 3 | (:require 4 | [clojure.core.async :as async] 5 | [clojure.edn :as edn] 6 | [clojure.java.io :as io] 7 | [clojure.pprint :as pprint] 8 | [clojure.tools.cli :as cli] 9 | [cognitect.transit :as transit] 10 | [charred.api :as charred] 11 | [lambdaisland.funnel.log :as log] 12 | [lambdaisland.funnel.version :as version]) 13 | (:import 14 | (com.cognitect.transit DefaultReadHandler 15 | WriteHandler) 16 | (java.io ByteArrayInputStream 17 | ByteArrayOutputStream 18 | FileInputStream 19 | FileOutputStream 20 | PrintStream 21 | IOException) 22 | (java.net InetSocketAddress Socket) 23 | (java.nio ByteBuffer) 24 | (java.nio.file Files Path Paths) 25 | (java.security KeyStore) 26 | (java.util Comparator) 27 | (javax.net.ssl SSLContext 28 | KeyManagerFactory) 29 | (lambdaisland.funnel Daemon) 30 | (org.java_websocket WebSocket 31 | WebSocketAdapter 32 | WebSocketImpl) 33 | (org.java_websocket.drafts Draft_6455) 34 | (org.java_websocket.handshake Handshakedata) 35 | (org.java_websocket.handshake ClientHandshake) 36 | (org.java_websocket.server DefaultWebSocketServerFactory 37 | DefaultSSLWebSocketServerFactory 38 | WebSocketServer) 39 | (sun.misc Signal))) 40 | 41 | (set! *warn-on-reflection* true) 42 | 43 | ;; Arbitrary high ports. I hope nobody was using these, they are ours now. This 44 | ;; is where clients expect to find us. 45 | 46 | (def ws-port 44220) 47 | (def wss-port 44221) 48 | 49 | (Thread/setDefaultUncaughtExceptionHandler 50 | (reify Thread$UncaughtExceptionHandler 51 | (uncaughtException [_ thread ex] 52 | (log/error :uncaught-exception {:thread (.getName thread)} :exception ex)))) 53 | 54 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 55 | ;; Transit 56 | 57 | ;; Pass arbitrary tagged values through as-is 58 | (deftype TaggedValue [tag rep]) 59 | 60 | (def tagged-read-handler 61 | (reify DefaultReadHandler 62 | (fromRep [_ tag rep] 63 | (TaggedValue. tag rep)))) 64 | 65 | (def tagged-write-handler 66 | (reify WriteHandler 67 | (tag [_ tv] 68 | (.-tag ^TaggedValue tv)) 69 | (rep [_ tv] 70 | (.-rep ^TaggedValue tv)) 71 | (stringRep [_ _]) 72 | (getVerboseHandler [_]))) 73 | 74 | (defn maybe-error [e] 75 | (when (and (vector? e) (= ::error (first e))) 76 | (second e))) 77 | 78 | (defmulti encode (fn [format value] format)) 79 | (defmulti decode (fn [format value] format)) 80 | 81 | (defmethod encode :transit [_ value] 82 | (try 83 | (let [out (ByteArrayOutputStream. 4096) 84 | writer (transit/writer out :json {:handlers {TaggedValue tagged-write-handler}})] 85 | (transit/write writer value) 86 | (.toString out)) 87 | (catch Exception e 88 | [::error e]))) 89 | 90 | (defmethod decode :transit [_ ^String transit] 91 | (try 92 | (let [in (ByteArrayInputStream. (.getBytes transit)) 93 | reader (transit/reader in :json {:default-handler tagged-read-handler})] 94 | (transit/read reader)) 95 | (catch Exception e 96 | [::error e]))) 97 | 98 | (defmethod encode :edn [_ value] 99 | (pr-str value)) 100 | 101 | (defmethod decode :edn [_ ^String edn] 102 | (edn/read-string edn)) 103 | 104 | (defmethod encode :json [_ value] 105 | (charred/write-json-str value)) 106 | 107 | (defmethod decode :json [_ ^String json] 108 | (charred/read-json json :key-fn keyword)) 109 | 110 | (defn match-selector? [whoami selector] 111 | (cond 112 | (nil? whoami) false 113 | (true? selector) true 114 | (vector? selector) (= (second selector) (get whoami (first selector))) 115 | (map? selector) (reduce (fn [_ [k v]] 116 | (if (= v (get whoami k)) 117 | true 118 | (reduced false))) 119 | nil 120 | selector))) 121 | 122 | (defn destinations [source broadcast-sel conns] 123 | (let [whoami (get-in conns [source :whoami])] 124 | (map key 125 | (filter 126 | (fn [[c m]] 127 | (and (or (match-selector? (:whoami m) broadcast-sel) 128 | (some #(match-selector? whoami %) (:subscriptions m))) 129 | (not= c source))) 130 | conns)))) 131 | 132 | (defn outbox [^WebSocket conn] 133 | (.getAttachment conn)) 134 | 135 | (defn handle-query [conn selector conns] 136 | (let [msg {:funnel/clients 137 | (keep (comp :whoami val) 138 | (filter 139 | (fn [[c m]] 140 | (and (match-selector? (:whoami m) selector) 141 | (not= c conn))) 142 | conns))}] 143 | (async/>!! (outbox conn) msg))) 144 | 145 | (defn handle-message [state ^WebSocket conn raw-msg] 146 | (let [msg (decode (get-in @state [conn :format]) raw-msg)] 147 | (when-let [e (maybe-error msg)] 148 | (log/warn :message-decoding-failed {:raw-msg raw-msg :desc "Raw message will be forwarded"} :exception e)) 149 | (let [[msg broadcast] 150 | (if-not (map? msg) 151 | (do 152 | (log/warn :forwarding-raw-message raw-msg) 153 | [raw-msg nil]) 154 | (do 155 | (log/debug :message msg) 156 | (when-let [whoami (:funnel/whoami msg)] 157 | (swap! state assoc-in [conn :whoami] whoami)) 158 | (when-let [selector (:funnel/subscribe msg)] 159 | (swap! state update-in [conn :subscriptions] (fnil conj #{}) selector)) 160 | (when-let [selector (:funnel/unsubscribe msg)] 161 | (swap! state update-in [conn :subscriptions] (fnil disj #{}) selector)) 162 | (when-let [selector (:funnel/query msg)] 163 | (handle-query conn selector @state)) 164 | 165 | [(if-let [whomai (:whoami (get @state conn))] 166 | (assoc msg :funnel/whoami whomai) 167 | msg) 168 | (:funnel/broadcast msg)]))] 169 | (if-let [e (maybe-error msg)] 170 | (log/error :message-encoding-failed {:msg msg} :exception e) 171 | (do 172 | (let [conns @state 173 | dests (destinations conn broadcast conns)] 174 | (log/trace :message msg :sending-to (map (comp :whoami conns) dests)) 175 | (doseq [^WebSocket c dests] 176 | (async/>!! (outbox c) msg)))))))) 177 | 178 | (defn handle-open [state ^WebSocket conn handshake] 179 | (log/info :connection-opened {:remote-socket-address (.getRemoteSocketAddress conn)}) 180 | (let [path (.getResourceDescriptor conn) 181 | format (case path 182 | "/?content-type=json" :json 183 | "/?content-type=edn" :edn 184 | "/?content-type=transit" :transit 185 | :transit) 186 | outbox (async/chan 8 (map (partial encode format)))] 187 | (.setAttachment conn outbox) 188 | (swap! state assoc-in [conn :format] format) 189 | (async/go-loop [] 190 | (when-let [^String msg (async/ " (.getName (class ex)) " user/" sym) 315 | "") 316 | "\n"))))] 317 | (doto ^java.util.logging.Handler handler 318 | (.setLevel level) 319 | (.setFormatter formatter)) 320 | (.addHandler root handler)))) 321 | 322 | (defn native-image? [] 323 | (= ["runtime" "executable"] 324 | [(System/getProperty "org.graalvm.nativeimage.imagecode") 325 | (System/getProperty "org.graalvm.nativeimage.kind")])) 326 | 327 | (defn option-specs [] 328 | (cond-> [ 329 | ["-k" "--keystore FILE" "Location of the keystore.jks file, necessary to enable SSL."] 330 | [nil "--keystore-password PASSWORD" "Password to load the keystore, defaults to \"password\"." :default "password"] 331 | [nil "--ws-port PORT" "Port for the websocket server (non-SSL)" :default ws-port :parse-fn #(Integer/parseInt %)] 332 | [nil "--wss-port PORT" "Port for the websocket server (SSL)" :default wss-port :parse-fn #(Integer/parseInt %)] 333 | ["-v" "--verbose" "Increase verbosity, -vvv is the maximum." :default 0 :update-fn inc] 334 | [nil "--version" "Print version information and exit."] 335 | [nil "--logfile FILE" "Redirect logs to file. Default is stdout, or when daemonized: /tmp/funnel.log"]] 336 | (native-image?) 337 | (conj ["-d" "--daemonize" "Run as a background process."]) 338 | :-> 339 | (conj ["-h" "--help" "Output this help information."]))) 340 | 341 | (defn print-version [] 342 | (let [{:keys [version date sha]} version/VERSION] 343 | (println "lambdaisland/funnel" version (str "(" date " / " sha ")")))) 344 | 345 | (defn print-help [summary] 346 | (println "Usage: funnel [OPTS]") 347 | (println) 348 | (println summary) 349 | (println) 350 | (print-version)) 351 | 352 | (defn ws-server [opts] 353 | (let [ws-port (:ws-port opts) 354 | state (:state opts (atom {}))] 355 | (websocket-server {:port ws-port 356 | :state state}))) 357 | 358 | (defn wss-server [{:keys [keystore keystore-password wss-port] :as opts}] 359 | (when (and keystore keystore-password) 360 | (let [state (:state opts (atom {}))] 361 | (doto (websocket-server {:port wss-port :state state}) 362 | (.setWebSocketFactory 363 | (DefaultSSLWebSocketServerFactory. 364 | (ssl-context keystore keystore-password))))))) 365 | 366 | (defn start-server [server] 367 | (when server 368 | (.start ^WebSocketServer server) 369 | (let [s @server] 370 | (if (instance? Throwable s) 371 | (throw s) 372 | s)))) 373 | 374 | (defn port-in-use! [] 375 | (println "Address already in use, is Funnel already running?") 376 | (System/exit 42)) 377 | 378 | (defn start-servers [{:keys [ws-port wss-port] :as opts}] 379 | (try 380 | (let [ws (ws-server opts) 381 | wss (wss-server opts)] 382 | (start-server ws) 383 | (start-server wss) 384 | (log/info :started (cond-> [(str "ws://localhost:" ws-port)] 385 | wss 386 | (conj (str "wss://localhost:" wss-port))))) 387 | (catch java.net.BindException e 388 | (port-in-use!)))) 389 | 390 | (defn extract-native-lib! [] 391 | (let [libdir (io/file (System/getProperty "java.io.tmpdir") (str "funnel-" (rand-int 9999999)))] 392 | (.mkdirs libdir) 393 | (doseq [libfile ["libDaemon.so" "libDaemon.dylib"] 394 | :let [resource (io/resource libfile)] 395 | :when resource] 396 | (io/copy (io/input-stream resource) (io/file libdir libfile))) 397 | (System/setProperty "java.library.path" (str libdir)) 398 | libdir)) 399 | 400 | (defn check-port-in-use! [opts] 401 | (try 402 | (let [sock (Socket. "localhost" (long (:ws-port opts ws-port)))] 403 | (.close sock) 404 | (port-in-use!)) 405 | (catch IOException e))) 406 | 407 | (defn -main [& args] 408 | (let [{:keys [options arguments summary]} (cli/parse-opts args (option-specs))] 409 | (init-logging (:verbose options) (:logfile options 410 | (when (:daemonize options) 411 | (str (io/file (System/getProperty "java.io.tmpdir") "funnel.log"))))) 412 | (cond 413 | (:help options) 414 | (do 415 | (print-help summary) 416 | (System/exit 0)) 417 | 418 | (:version options) 419 | (do 420 | (print-version) 421 | (System/exit 0)) 422 | 423 | :else 424 | (let [opts (assoc options :state (atom {}))] 425 | (log/trace :starting options) 426 | (if (:daemonize opts) 427 | (do 428 | (check-port-in-use! opts) 429 | (let [libdir (extract-native-lib!) 430 | pid (Daemon/daemonize)] 431 | (if (= 0 pid) 432 | (do 433 | (.close System/out) 434 | (.close System/err) 435 | (.close System/in) 436 | ;; Does not seem to work in the native-image build, sigh. 437 | (Signal/handle (Signal. "INT") 438 | (reify sun.misc.SignalHandler 439 | (handle [this signal] 440 | (run! #(io/delete-file % true) (file-seq libdir)) 441 | (System/exit 0)))) 442 | (start-servers opts)) 443 | (prn pid)))) 444 | (start-servers opts)))))) 445 | 446 | 447 | 448 | (comment 449 | 450 | (init-logging 3) 451 | 452 | (intern 'user 'foo 123) 453 | 454 | (log/error :foo :bar :exception (Exception. "123")) 455 | 456 | (start-servers {:ws-port 2234 :wss-port 2235}) 457 | ) 458 | -------------------------------------------------------------------------------- /src/lambdaisland/funnel/Daemon.java: -------------------------------------------------------------------------------- 1 | package lambdaisland.funnel; 2 | 3 | import java.io.*; 4 | import java.io.IOException; 5 | 6 | public class Daemon { 7 | public static native int daemonize(); 8 | 9 | static { 10 | System.loadLibrary("Daemon"); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lambdaisland/funnel/log.clj: -------------------------------------------------------------------------------- 1 | (ns lambdaisland.funnel.log 2 | "Minimal logging shim to replace pedestal.log, since it pulls in stuff that's 3 | not GraalVM compatible." 4 | (:import 5 | (java.util.logging Logger Level))) 6 | 7 | (def ^Logger logger (Logger/getLogger "lambdaisland.funnel")) 8 | 9 | (defn error [& kvs] 10 | (.log logger Level/SEVERE (pr-str (apply array-map kvs)))) 11 | 12 | (defn warn [& kvs] 13 | (.log logger Level/WARNING (pr-str (apply array-map kvs)))) 14 | 15 | (defn info [& kvs] 16 | (.log logger Level/INFO (pr-str (apply array-map kvs)))) 17 | 18 | (defn debug [& kvs] 19 | (.log logger Level/FINE (pr-str (apply array-map kvs)))) 20 | 21 | (defn trace [& kvs] 22 | (.log logger Level/FINER (pr-str (apply array-map kvs)))) 23 | -------------------------------------------------------------------------------- /src/lambdaisland/funnel/version.clj: -------------------------------------------------------------------------------- 1 | (ns lambdaisland.funnel.version) 2 | 3 | (def VERSION {:version "unreleased", :date "2025-02-28", :sha "e287af9"}) 4 | -------------------------------------------------------------------------------- /src_jni/Daemon.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include "jni.h" 6 | #include "lambdaisland_funnel_Daemon.h" 7 | 8 | JNIEXPORT jint JNICALL Java_lambdaisland_funnel_Daemon_daemonize(JNIEnv * env, jclass obj) { 9 | int pid = fork(); 10 | 11 | if (pid == 0) { // succesfully forked 12 | umask(0); 13 | 14 | int sid = setsid(); // create a new session, this detaches us from our parent 15 | 16 | if (sid < 0) { 17 | exit(EXIT_FAILURE); 18 | } 19 | 20 | if ((chdir("/")) < 0) { 21 | exit(EXIT_FAILURE); 22 | } 23 | 24 | close(STDIN_FILENO); 25 | close(STDOUT_FILENO); 26 | close(STDERR_FILENO); 27 | 28 | open("/dev/null", O_RDWR); 29 | dup(0); 30 | dup(0); 31 | } 32 | 33 | return pid; 34 | } 35 | -------------------------------------------------------------------------------- /src_jni/lambdaisland_funnel_Daemon.h: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT THIS FILE - it is machine generated */ 2 | #include 3 | /* Header for class lambdaisland_funnel_Daemon */ 4 | 5 | #ifndef _Included_lambdaisland_funnel_Daemon 6 | #define _Included_lambdaisland_funnel_Daemon 7 | #ifdef __cplusplus 8 | extern "C" { 9 | #endif 10 | /* 11 | * Class: lambdaisland_funnel_Daemon 12 | * Method: daemonize 13 | * Signature: ()I 14 | */ 15 | JNIEXPORT jint JNICALL Java_lambdaisland_funnel_Daemon_daemonize 16 | (JNIEnv *, jclass); 17 | 18 | #ifdef __cplusplus 19 | } 20 | #endif 21 | #endif 22 | -------------------------------------------------------------------------------- /test/lambdaisland/funnel/test_util.clj: -------------------------------------------------------------------------------- 1 | (ns lambdaisland.funnel.test-util 2 | (:require 3 | [clojure.java.io :as io] 4 | [clojure.test :as t] 5 | [lambdaisland.funnel :as funnel] 6 | [matcher-combinators.test] 7 | [matcher-combinators.core :as mc]) 8 | (:import 9 | (java.net URI) 10 | (java.net ServerSocket) 11 | (org.java_websocket.client WebSocketClient))) 12 | 13 | (def ^:dynamic *port* 0) 14 | 15 | (defn available-port [] 16 | (let [sock (ServerSocket. 0)] 17 | (try 18 | (.getLocalPort sock) 19 | (finally 20 | (.close sock))))) 21 | 22 | (defmacro with-available-port [& body] 23 | `(binding [*port* (available-port)] 24 | ~@body)) 25 | 26 | (defrecord TestClient [history ^WebSocketClient client] 27 | java.io.Closeable 28 | (close [this] 29 | (.close client)) 30 | clojure.lang.IFn 31 | (invoke [this message] 32 | (.send client ^String (funnel/encode :transit message)))) 33 | 34 | (defmacro will 35 | "Variant of [[clojure.test/is]] that gives the predicate a bit of time to become 36 | true." 37 | [expected] 38 | `(loop [i# 0] 39 | (if (and (not ~expected) (< i# 30)) 40 | (do 41 | (Thread/sleep 50) 42 | (recur (inc i#))) 43 | (t/is ~expected)))) 44 | 45 | (defn match? 46 | "Pure predicate version of matcher-combinators, otherwise using (will (match?)) 47 | will break." 48 | [expected actual] 49 | (mc/indicates-match? (mc/match expected actual))) 50 | 51 | (defn test-client [] 52 | (let [history (atom []) 53 | connected? (promise) 54 | client (proxy [WebSocketClient] [(URI. (str "ws://localhost:" *port*))] 55 | (onOpen [handshake] 56 | (deliver connected? true)) 57 | (onClose [code reason remote?]) 58 | (onMessage [message] 59 | (swap! history conj (funnel/decode :transit message))) 60 | (onError [ex] 61 | (println ex) 62 | ))] 63 | (.connect client) 64 | @connected? 65 | (map->TestClient 66 | {:history history 67 | :client client}))) 68 | 69 | (defn test-server 70 | ([] 71 | (test-server (atom {}))) 72 | ([state-atom] 73 | (funnel/start-server 74 | (doto (-> {:ws-port *port* 75 | :state state-atom} 76 | funnel/ws-server) 77 | (.setTcpNoDelay true))))) 78 | -------------------------------------------------------------------------------- /test/lambdaisland/funnel_test.clj: -------------------------------------------------------------------------------- 1 | (ns lambdaisland.funnel-test 2 | (:require [clojure.java.io :as io] 3 | [clojure.test :refer :all] 4 | [lambdaisland.funnel :as funnel] 5 | [lambdaisland.funnel.test-util :refer [*port* 6 | available-port 7 | test-client 8 | test-server 9 | with-available-port 10 | will match?]] 11 | [matcher-combinators.matchers :as m]) 12 | (:import (java.net URI) 13 | (java.net Socket ServerSocket ConnectException) 14 | (org.java_websocket.client WebSocketClient))) 15 | 16 | (use-fixtures :each (fn [t] (with-available-port (t)))) 17 | 18 | (deftest whoami-test 19 | (let [state (atom {})] 20 | (with-open [s (test-server state) 21 | c (test-client)] 22 | (c {:funnel/whoami {:id 123}}) 23 | (will (= [{:whoami {:id 123} 24 | :format :transit}] (vals @state))) 25 | 26 | (c {:funnel/whoami {:id :abc :hello :world}}) 27 | (will (= [{:whoami {:id :abc :hello :world} 28 | :format :transit}] (vals @state))) 29 | 30 | (with-open [c2 (test-client)] 31 | (c2 {:funnel/whoami {:root "/x/y/z"}}) 32 | 33 | (will (match? (m/in-any-order [{:whoami {:id :abc 34 | :hello :world} 35 | :format :transit} 36 | {:whoami {:root "/x/y/z"} 37 | :format :transit}]) 38 | (vals @state)))) 39 | 40 | (testing "closing will clean up the client connection" 41 | (will (= [{:whoami {:id :abc :hello :world} 42 | :format :transit}] (vals @state))))))) 43 | 44 | (deftest subscribe-test 45 | (testing "messages get forwarded to subscriber" 46 | (let [state (atom {})] 47 | (with-open [_ (test-server state) 48 | c1 (test-client) 49 | c2 (test-client) 50 | c3 (test-client)] 51 | (c1 {:funnel/whoami {:id 1} 52 | :funnel/subscribe [:id 2]}) 53 | ;; Checkpoint to prevent race conditions, we only continue when funnel 54 | ;; has registered the subscription. 55 | (will (= [{:whoami {:id 1} :subscriptions #{[:id 2]} :format :transit} 56 | {:format :transit} 57 | {:format :transit}] 58 | (vals @state))) 59 | 60 | (c2 {:funnel/whoami {:id 2} 61 | :foo :bar}) 62 | (c3 {:funnel/whoami {:id 3}}) 63 | (c2 {:foo :baz}) 64 | (will (= [{:funnel/whoami {:id 2} 65 | :foo :bar} 66 | {:funnel/whoami {:id 2} 67 | :foo :baz}] 68 | @(:history c1))) 69 | (will (= [] @(:history c2))) 70 | (will (= [] @(:history c3))))))) 71 | 72 | (deftest unsubscribe-test 73 | (let [state (atom {})] 74 | (with-open [s (test-server state) 75 | c (test-client)] 76 | (c {:funnel/subscribe [:foo :bar]}) 77 | (will (= [{:subscriptions #{[:foo :bar]} :format :transit}] (vals @state))) 78 | (c {:funnel/unsubscribe [:foo :bar]}) 79 | (will (= [{:subscriptions #{} :format :transit}] (vals @state)))))) 80 | 81 | (deftest match-selector-test 82 | (testing "vector" 83 | (is (funnel/match-selector? {:id 123} [:id 123])) 84 | (is (not (funnel/match-selector? nil [:id 123]))) 85 | (is (not (funnel/match-selector? {:id 123} [:id 456])))) 86 | 87 | (testing "true" 88 | (is (funnel/match-selector? {:id 123} true))) 89 | 90 | (testing "no whoami" 91 | (is (not (funnel/match-selector? nil true)))) 92 | 93 | (testing "map" 94 | (is (funnel/match-selector? {:type :x :subtype :a} {:type :x})) 95 | (is (funnel/match-selector? {:type :x :subtype :a} {:type :x :subtype :a})) 96 | (is (not (funnel/match-selector? {:type :x :subtype :a} {:type :x :subtype :b}))))) 97 | 98 | (deftest destinations-test 99 | (let [state {:ws1 {:whoami {:id :ws1} 100 | :subscriptions #{[:id :ws2]} 101 | :format :transit} 102 | :ws2 {:whoami {:id :ws2} 103 | :format :transit} 104 | :ws3 {:whoami {:id :ws3} 105 | :format :transit}}] 106 | 107 | (is (= [:ws1] (funnel/destinations :ws2 nil state))) 108 | (is (match? (m/in-any-order [:ws1 :ws3]) 109 | (funnel/destinations :ws2 true state))) 110 | (is (match? (m/in-any-order [:ws1 :ws3]) 111 | (funnel/destinations :ws2 [:id :ws3] state))) 112 | (is (= [:ws3] (funnel/destinations :ws1 [:id :ws3] state))))) 113 | 114 | (deftest query-test 115 | (let [state (atom {})] 116 | (with-open [s (test-server state) 117 | c1 (test-client) 118 | c2 (test-client) 119 | c3 (test-client)] 120 | (c1 {:funnel/query true}) 121 | (will (= [{:funnel/clients []}] @(:history c1))) 122 | 123 | (c1 {:funnel/whoami {:id 123 :type :x}}) 124 | (c2 {:funnel/whoami {:id 456 :type :x}}) 125 | (c3 {:funnel/whoami {:id 789 :type :y}}) 126 | (will (= 3 (count @state))) 127 | 128 | (reset! (:history c1) []) 129 | (c1 {:funnel/query true}) 130 | (will (match? [{:funnel/clients 131 | (m/in-any-order [{:id 456 :type :x} 132 | {:id 789 :type :y}])}] 133 | @(:history c1))) 134 | 135 | (c2 {:funnel/query [:id 789]}) 136 | (will (= [{:funnel/clients 137 | [{:id 789 :type :y}]}] 138 | @(:history c2))) 139 | 140 | (c3 {:funnel/query [:type :x]}) 141 | (will (match? [{:funnel/clients 142 | (m/in-any-order [{:id 123 :type :x} 143 | {:id 456 :type :x}])}] 144 | @(:history c3))))) 145 | 146 | (testing "map queries" 147 | (let [state (atom {})] 148 | (with-open [s (test-server state) 149 | c1 (test-client) 150 | c2 (test-client) 151 | c3 (test-client)] 152 | (c1 {:funnel/whoami {:id 123 :type :x :subtype :a}}) 153 | (c2 {:funnel/whoami {:id 456 :type :x :subtype :b}}) 154 | (c3 {:funnel/whoami {:id 789 :type :y :subtype :b}}) 155 | (will (= 3 (count @state))) 156 | 157 | (c1 {:funnel/query {:type :x :subtype :b}}) 158 | (will (= [{:funnel/clients 159 | [{:id 456 :type :x :subtype :b}]}] 160 | @(:history c1))))))) 161 | 162 | 163 | 164 | (comment 165 | (require '[kaocha.repl :as kaocha]) 166 | 167 | (kaocha.repl/run `destinations-test) 168 | 169 | (kaocha.repl/run 'lambdaisland.funnel-test/query-test) 170 | 171 | (alter-var-root #'*port* (constantly (available-port))) 172 | 173 | (def state (atom {})) 174 | (def s (test-server state)) 175 | 176 | (def c1 (test-client)) 177 | (def c2 (test-client)) 178 | (def c3 (test-client)) 179 | 180 | (c1 {:funnel/whoami {:id :c1}}) 181 | (c2 {:funnel/whoami {:id :c2}}) 182 | (c3 {:funnel/whoami {:id :c3}}) 183 | 184 | (c1 {:funnel/broadcast true 185 | :foo :bar}) 186 | 187 | (c1 {:funnel/query true}) 188 | (vals @state) 189 | @(:history c1) 190 | 191 | (do 192 | (.close s) 193 | (.close c1) 194 | (.close c2) 195 | (.close c3)) 196 | 197 | ) 198 | -------------------------------------------------------------------------------- /tests.edn: -------------------------------------------------------------------------------- 1 | #kaocha/v1 2 | {:plugins [#_:kaocha.plugin/notifier 3 | :print-invocations 4 | :profiling 5 | :hooks] 6 | :kaocha.hooks/pre-load [(fn [config] 7 | (require (quote clojure.java.shell)) 8 | (prn ((resolve (quote clojure.java.shell/sh)) "make" "compilejava")) 9 | config)] 10 | :kaocha.plugin.capture-output/capture-output? false} 11 | --------------------------------------------------------------------------------