├── .devcontainer └── devcontainer.json ├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── node.js.yml │ ├── prettier.yml │ └── release.yml ├── .gitignore ├── .gitpod.yml ├── .prettierignore ├── .prettierrc.toml ├── .releaserc.json ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── PRIVACY.md ├── README.md ├── __test__ ├── messageHandler │ ├── handlers │ │ ├── heartbeat │ │ │ └── index.spec.ts │ │ └── transmission │ │ │ └── index.spec.ts │ └── handlersRegistry.spec.ts ├── models │ ├── messageQueue.spec.ts │ └── realm.spec.ts ├── peerjs.spec.ts ├── services │ ├── checkBrokenConnections │ │ └── index.spec.ts │ ├── messagesExpire │ │ └── index.spec.ts │ └── webSocketServer │ │ └── index.spec.ts └── utils.ts ├── app.json ├── bin └── peerjs.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── api │ ├── README.md │ ├── index.ts │ └── v1 │ │ └── public │ │ └── index.ts ├── config │ └── index.ts ├── enums.ts ├── index.ts ├── instance.ts ├── messageHandler │ ├── handler.ts │ ├── handlers │ │ ├── heartbeat │ │ │ └── index.ts │ │ ├── index.ts │ │ └── transmission │ │ │ └── index.ts │ ├── handlersRegistry.ts │ └── index.ts ├── models │ ├── client.ts │ ├── message.ts │ ├── messageQueue.ts │ └── realm.ts └── services │ ├── checkBrokenConnections │ └── index.ts │ ├── messagesExpire │ └── index.ts │ └── webSocketServer │ └── index.ts └── tsconfig.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:0-18", 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 | "forwardPorts": [9000], 13 | 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | "postCreateCommand": "npm clean-install" 16 | 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | 20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 | // "remoteUser": "root" 22 | } 23 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .nyc_output 3 | .parcel-cache 4 | coverage 5 | dist 6 | node_modules -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended-type-checked", 6 | "plugin:@typescript-eslint/strict-type-checked", 7 | "plugin:@typescript-eslint/stylistic-type-checked" 8 | ], 9 | "ignorePatterns": ["coverage", "jest.config.js", "dist", "__test__"], 10 | "env": { 11 | "node": true, 12 | "es6": true 13 | }, 14 | "parserOptions": { 15 | "project": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | pull_request: 9 | branches: ["master"] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [16.x, 18.x, 20.x] 18 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: "npm" 27 | - run: npm ci 28 | - run: npm run build 29 | - run: npm run lint 30 | - run: npm run coverage 31 | - name: Publish code coverage to CodeClimate 32 | uses: paambaati/codeclimate-action@v5.0.0 33 | env: 34 | CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}} 35 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | # From https://til.simonwillison.net/github-actions/prettier-github-actions 2 | name: Check JavaScript for conformance with Prettier 3 | 4 | on: 5 | push: 6 | pull_request: 7 | 8 | jobs: 9 | prettier: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out repo 13 | uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 16 17 | cache: "npm" 18 | - run: npm ci 19 | - name: Run prettier 20 | run: |- 21 | npm run format:check 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - rc 6 | - stable 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: "lts/*" 22 | - name: Install dependencies 23 | run: npm ci 24 | - name: Build 25 | run: npm run build 26 | - name: Import GPG key 27 | id: import_gpg 28 | uses: crazy-max/ghaction-import-gpg@v5 29 | with: 30 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 31 | passphrase: ${{ secrets.GPG_PASSPHRASE }} 32 | git_user_signingkey: true 33 | git_commit_gpgsign: true 34 | - name: Release 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | GIT_COMMITTER_NAME: ${{ steps.import_gpg.outputs.name }} 39 | GIT_COMMITTER_EMAIL: ${{ steps.import_gpg.outputs.email }} 40 | DOCKER_REGISTRY_USER: ${{ secrets.DOCKERHUB_USERNAME }} 41 | DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} 42 | run: npx semantic-release 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | coverage 3 | *.seed 4 | *.log 5 | *.csv 6 | *.dat 7 | *.out 8 | *.pid 9 | *.gz 10 | 11 | .parcel-cache 12 | dist 13 | pids 14 | logs 15 | results 16 | 17 | node_modules 18 | npm-debug.log 19 | 20 | .idea 21 | .cache 22 | .vscode 23 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - init: npm i 3 | command: npm start 4 | 5 | ports: 6 | - port: 9000 7 | onOpen: open-preview 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | 4 | # semantic-release 5 | CHANGELOG.md 6 | -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | trailingComma = "all" 2 | semi = true 3 | useTabs = true -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "stable", 4 | { 5 | "name": "rc", 6 | "prerelease": true 7 | } 8 | ], 9 | "plugins": [ 10 | "@semantic-release/commit-analyzer", 11 | "@semantic-release/release-notes-generator", 12 | "@semantic-release/changelog", 13 | "@semantic-release/npm", 14 | "@semantic-release/git", 15 | "@semantic-release/github", 16 | [ 17 | "@codedependant/semantic-release-docker", 18 | { 19 | "dockerTags": [ 20 | "{{#if prerelease.[0]}}{{prerelease.[0]}}{{else}}latest{{/if}}", 21 | "{{major}}-{{#if prerelease.[0]}}{{prerelease.[0]}}{{else}}latest{{/if}}", 22 | "{{major}}.{{minor}}-{{#if prerelease.[0]}}{{prerelease.[0]}}{{else}}latest{{/if}}", 23 | "{{version}}" 24 | ], 25 | "dockerImage": "peerjs-server", 26 | "dockerPlatform": ["linux/amd64", "linux/arm64"], 27 | "dockerFile": "Dockerfile", 28 | "dockerProject": "peerjs" 29 | } 30 | ] 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.2](https://github.com/peers/peerjs-server/compare/v1.0.1...v1.0.2) (2023-12-05) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **deps:** update dependency @types/express to v4.17.18 ([f4bcc16](https://github.com/peers/peerjs-server/commit/f4bcc1651598f349626b67099c7a39b01b417f6c)) 7 | * **deps:** update dependency @types/express to v4.17.19 ([ebec5b0](https://github.com/peers/peerjs-server/commit/ebec5b07aa29e951058d64a5c98efa238ce0069a)) 8 | * **deps:** update dependency @types/express to v4.17.20 ([a6c01fd](https://github.com/peers/peerjs-server/commit/a6c01fd47bfd89fa74a716650a44643612a659aa)) 9 | * **deps:** update dependency @types/express to v4.17.21 ([80df87f](https://github.com/peers/peerjs-server/commit/80df87f3da63624e6c7107453f3789e76d688798)) 10 | * reduce unnecessary timeouts ([638af56](https://github.com/peers/peerjs-server/commit/638af56f679881194f60b29ae7f7bb7b5756662a)), closes [#431](https://github.com/peers/peerjs-server/issues/431) 11 | 12 | ## [1.0.1](https://github.com/peers/peerjs-server/compare/v1.0.0...v1.0.1) (2023-08-25) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * **deps:** update dependency node-fetch to v3.3.1 ([2275ce3](https://github.com/peers/peerjs-server/commit/2275ce35eb3c44e0a22fd4a4e0d2dca66ebd1219)) 18 | * **deps:** update dependency node-fetch to v3.3.2 ([05a1833](https://github.com/peers/peerjs-server/commit/05a1833363b0592ee7941dd295c3ca9ac64d9d04)) 19 | * **deps:** update dependency yargs to v17.7.2 ([23b4e47](https://github.com/peers/peerjs-server/commit/23b4e47fb32a773d48027b0cdd0c10be7fad27cb)) 20 | * remove confusing version number ([f6314df](https://github.com/peers/peerjs-server/commit/f6314df40c37add66deac5dd8487ad54e4814237)) 21 | 22 | 23 | ### Reverts 24 | 25 | * Revert "build: deploy to fly.io" ([c3de627](https://github.com/peers/peerjs-server/commit/c3de627fad9ddd22230793fb3f6757d402a052a5)) 26 | * Revert "chore: Configure Mend Bolt for GitHub (#349)" ([fd00aef](https://github.com/peers/peerjs-server/commit/fd00aef9809dd60bb7e36b2fdc58e47a1e4b036c)), closes [#349](https://github.com/peers/peerjs-server/issues/349) 27 | 28 | # [1.0.0](https://github.com/peers/peerjs-server/compare/v0.6.1...v1.0.0) (2023-03-07) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * **deps:** update dependency ws to v8 ([1ecc94b](https://github.com/peers/peerjs-server/commit/1ecc94b887d23ac59b3622a2fefc9fdab24f170f)) 34 | * import from ESM only environments ([476299e](https://github.com/peers/peerjs-server/commit/476299ed08f73e41d175d61b4281736bf8df1ea6)) 35 | * more accurate types ([68f973a](https://github.com/peers/peerjs-server/commit/68f973afb44a1f71c9fd9a644602312d8ceda5cf)), closes [#182](https://github.com/peers/peerjs-server/issues/182) 36 | * **npm audit:** Updates all dependencies that cause `npm audit` to issue a warning ([1aaafbc](https://github.com/peers/peerjs-server/commit/1aaafbc4504224f36287fd721f6edbc27a5b9eaa)), closes [#287](https://github.com/peers/peerjs-server/issues/287) 37 | * the server could crash if a client sends invalid frames ([29394de](https://github.com/peers/peerjs-server/commit/29394dea5e1303cdf07337d39c2c93249fdd41db)) 38 | 39 | 40 | ### Features 41 | 42 | * drop Node {10,11,12,13} support ([b70ed79](https://github.com/peers/peerjs-server/commit/b70ed79d9a239593d128ea2914eea0c2107b03b2)) 43 | * ESM support ([2b73b5c](https://github.com/peers/peerjs-server/commit/2b73b5c97de4a366d6635719891b65d5f9878628)) 44 | * remove deprecated XHR fallback ([d900145](https://github.com/peers/peerjs-server/commit/d90014590160faf1d489a18ea489c28c43cd4690)) 45 | * set the PEERSERVER_PATH with an environment variable ([084fb8a](https://github.com/peers/peerjs-server/commit/084fb8a4bddfcb153a4cb861ba700c8352cd4b35)), closes [#213](https://github.com/peers/peerjs-server/issues/213) 46 | * set the PORT with an environment variable ([68a3398](https://github.com/peers/peerjs-server/commit/68a3398f54b0f45bfe8c501c627f531980823ec1)), closes [#213](https://github.com/peers/peerjs-server/issues/213) 47 | * specify cors options via cli or js ([05f12cd](https://github.com/peers/peerjs-server/commit/05f12cdc562b1a5eb9481f5116da7c001105793a)), closes [#196](https://github.com/peers/peerjs-server/issues/196) [#221](https://github.com/peers/peerjs-server/issues/221) 48 | 49 | 50 | ### Performance Improvements 51 | 52 | * use the builtin UUID generator for Peer ids instead of the `uuid` module ([5d882dd](https://github.com/peers/peerjs-server/commit/5d882dd0c6af9bed8602e0507fdf5c1d284be075)) 53 | 54 | 55 | ### BREAKING CHANGES 56 | 57 | * Requires PeerJS >= 1.0 58 | * Node >= 14 required 59 | 60 | 14 is the oldest currently supported version. See https://github.com/nodejs/release#release-schedule 61 | 62 | # PeerServer Changelog 63 | 64 | ### vNEXT 65 | 66 | 67 | ### 0.6.1 68 | 69 | * New: PeerJS Server in Docker capture ^C signal and terminate gracefully. #205 70 | * Fix: SSL options in default config. #230 71 | 72 | ### 0.6.0 73 | 74 | * New: `host` option (`--host`, `-H`). #197 Thanks @millette 75 | * Fix: Allows SNICallback instead of hardcoded key/cert. #225 Thanks @brunobg 76 | * Change: Upgrade TypeScript version to 4.1.2. 77 | 78 | ### 0.5.3 79 | 80 | * PeerServer uses yargs instead of an outdated minimist. #190 Thanks @hobindar 81 | 82 | ### 0.5.2 83 | 84 | * Fix: WebSocket server doesn't work on Windows #170 Thanks @lqdchrm 85 | 86 | ### 0.5.1 87 | 88 | * Fix: WebSocket server doesn't work when use non "/" mount path with ExpressPeerServer #132 89 | 90 | ### 0.5.0 91 | 92 | * Fix: http api not working - #163 Thanks riscoss63 93 | 94 | * Change: use "/" instead of "/myapp" as a default value for config's `path` option 95 | 96 | * New: typescript declaration file 97 | 98 | * Update deps: 99 | ```diff 100 | - "cors": "2.8.4", 101 | + "cors": "^2.8.5", 102 | - "uuid": "3.3.3", 103 | + "uuid": "^3.4.0", 104 | - "ws": "7.1.2", 105 | + "ws": "^7.2.3" 106 | ``` 107 | 108 | ### 0.4.0 109 | 110 | * New: Allow passing in custom client ID generation function - #157 Thanks @ajmar 111 | 112 | ### 0.3.2 113 | 114 | * Fixed: fix main field in package.json 115 | 116 | ### 0.3.1 117 | 118 | * Fixed: no expire message in some cases 119 | 120 | ### 0.3.0 121 | 122 | * Convert project to TypeScript 3.7.3. 123 | * Use UUID when generate client id - #152 124 | * Refactoring (add ESLint, split code into small unit) Thanks to @d07RiV @zhou-yg 125 | * Update deps. 126 | 127 | ### 0.2.6 128 | 129 | * Ensure 16 character IDs. 130 | 131 | ### 0.2.5 132 | 133 | * Takes a `path` option, which the peer server will append PeerJS routes to. 134 | * Add support for configurable server IP address. 135 | 136 | ### 0.2.1 137 | 138 | * Added test suite. 139 | * Locked node dependency for restify. 140 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM docker.io/library/node:18.20.8 as build 2 | ARG TARGETPLATFORM 3 | ARG BUILDPLATFORM 4 | RUN mkdir /peer-server 5 | WORKDIR /peer-server 6 | COPY package.json package-lock.json ./ 7 | RUN npm clean-install 8 | COPY . ./ 9 | RUN npm run build 10 | RUN npm run test 11 | 12 | FROM docker.io/library/node:18.20.8-alpine as production 13 | RUN mkdir /peer-server 14 | WORKDIR /peer-server 15 | COPY package.json package-lock.json ./ 16 | RUN npm clean-install --omit=dev 17 | COPY --from=build /peer-server/dist/bin/peerjs.js ./ 18 | ENV PORT 9000 19 | EXPOSE ${PORT} 20 | ENTRYPOINT ["node", "peerjs.js"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Michelle Bu and Eric Zhang, http://peerjs.com 2 | 3 | (The MIT License) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | **We do not collect or store any information.** 4 | 5 | While you are connected to a PeerJS server, your IP address, randomly-generated 6 | client ID, and signalling data are kept in the server's memory. With default 7 | settings, the server will remove this information from memory 60 seconds after 8 | you stop communicating with the service. (See the 9 | [`alive_timeout`](https://github.com/peers/peerjs-server#config--cli-options) 10 | setting.) 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/peers/peerjs-server.png?branch=master)](https://travis-ci.org/peers/peerjs-server) 2 | ![node](https://img.shields.io/node/v/peer) 3 | ![David](https://img.shields.io/david/peers/peerjs-server) 4 | [![npm version](https://badge.fury.io/js/peer.svg)](https://www.npmjs.com/package/peer) 5 | [![Downloads](https://img.shields.io/npm/dm/peer.svg)](https://www.npmjs.com/package/peer) 6 | [![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/peerjs/peerjs-server)](https://hub.docker.com/r/peerjs/peerjs-server) 7 | 8 | # PeerServer: A server for PeerJS 9 | 10 | PeerServer helps establishing connections between PeerJS clients. Data is not proxied through the server. 11 | 12 | Run your own server on Gitpod! 13 | 14 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/peers/peerjs-server) 15 | 16 | ### [https://peerjs.com](https://peerjs.com) 17 | 18 | ## Usage 19 | 20 | ### Run server 21 | 22 | #### Natively 23 | 24 | If you don't want to develop anything, just enter few commands below. 25 | 26 | 1. Install the package globally: 27 | ```sh 28 | $ npm install peer -g 29 | ``` 30 | 2. Run the server: 31 | 32 | ```sh 33 | $ peerjs --port 9000 --key peerjs --path /myapp 34 | 35 | Started PeerServer on ::, port: 9000, path: /myapp (v. 0.3.2) 36 | ``` 37 | 38 | 3. Check it: http://127.0.0.1:9000/myapp It should returns JSON with name, description and website fields. 39 | 40 | #### Docker 41 | 42 | Also, you can use Docker image to run a new container: 43 | 44 | ```sh 45 | $ docker run -p 9000:9000 -d peerjs/peerjs-server 46 | ``` 47 | 48 | ##### Kubernetes 49 | 50 | ```sh 51 | $ kubectl run peerjs-server --image=peerjs/peerjs-server --port 9000 --expose -- --port 9000 --path /myapp 52 | ``` 53 | 54 | ### Create a custom server: 55 | 56 | If you have your own server, you can attach PeerServer. 57 | 58 | 1. Install the package: 59 | 60 | ```bash 61 | # $ cd your-project-path 62 | 63 | # with npm 64 | $ npm install peer 65 | 66 | # with yarn 67 | $ yarn add peer 68 | ``` 69 | 70 | 2. Use PeerServer object to create a new server: 71 | 72 | ```javascript 73 | const { PeerServer } = require("peer"); 74 | 75 | const peerServer = PeerServer({ port: 9000, path: "/myapp" }); 76 | ``` 77 | 78 | 3. Check it: http://127.0.0.1:9000/myapp It should returns JSON with name, description and website fields. 79 | 80 | ### Connecting to the server from client PeerJS: 81 | 82 | ```html 83 | 90 | ``` 91 | 92 | ## Config / CLI options 93 | 94 | You can provide config object to `PeerServer` function or specify options for `peerjs` CLI. 95 | 96 | | CLI option | JS option | Description | Required | Default | 97 | | ------------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | :--------: | 98 | | `--port, -p` | `port` | Port to listen (number) | **Yes** | | 99 | | `--key, -k` | `key` | Connection key (string). Client must provide it to call API methods | No | `"peerjs"` | 100 | | `--path` | `path` | Path (string). The server responds for requests to the root URL + path. **E.g.** Set the `path` to `/myapp` and run server on 9000 port via `peerjs --port 9000 --path /myapp` Then open http://127.0.0.1:9000/myapp - you should see a JSON reponse. | No | `"/"` | 101 | | `--proxied` | `proxied` | Set `true` if PeerServer stays behind a reverse proxy (boolean) | No | `false` | 102 | | `--expire_timeout, -t` | `expire_timeout` | The amount of time after which a message sent will expire, the sender will then receive a `EXPIRE` message (milliseconds). | No | `5000` | 103 | | `--alive_timeout` | `alive_timeout` | Timeout for broken connection (milliseconds). If the server doesn't receive any data from client (includes `pong` messages), the client's connection will be destroyed. | No | `60000` | 104 | | `--concurrent_limit, -c` | `concurrent_limit` | Maximum number of clients' connections to WebSocket server (number) | No | `5000` | 105 | | `--sslkey` | `sslkey` | Path to SSL key (string) | No | | 106 | | `--sslcert` | `sslcert` | Path to SSL certificate (string) | No | | 107 | | `--allow_discovery` | `allow_discovery` | Allow to use GET `/peers` http API method to get an array of ids of all connected clients (boolean) | No | | 108 | | `--cors` | `corsOptions` | The CORS origins that can access this server | 109 | | | `generateClientId` | A function which generate random client IDs when calling `/id` API method (`() => string`) | No | `uuid/v4` | 110 | 111 | ## Using HTTPS 112 | 113 | Simply pass in PEM-encoded certificate and key. 114 | 115 | ```javascript 116 | const fs = require("fs"); 117 | const { PeerServer } = require("peer"); 118 | 119 | const peerServer = PeerServer({ 120 | port: 9000, 121 | ssl: { 122 | key: fs.readFileSync("/path/to/your/ssl/key/here.key"), 123 | cert: fs.readFileSync("/path/to/your/ssl/certificate/here.crt"), 124 | }, 125 | }); 126 | ``` 127 | 128 | You can also pass any other [SSL options accepted by https.createServer](https://nodejs.org/api/https.html#https_https_createserver_options_requestlistenerfrom), such as `SNICallback: 129 | 130 | ```javascript 131 | const fs = require("fs"); 132 | const { PeerServer } = require("peer"); 133 | 134 | const peerServer = PeerServer({ 135 | port: 9000, 136 | ssl: { 137 | SNICallback: (servername, cb) => { 138 | // your code here .... 139 | }, 140 | }, 141 | }); 142 | ``` 143 | 144 | ## Running PeerServer behind a reverse proxy 145 | 146 | Make sure to set the `proxied` option, otherwise IP based limiting will fail. 147 | The option is passed verbatim to the 148 | [expressjs `trust proxy` setting](http://expressjs.com/4x/api.html#app-settings) 149 | if it is truthy. 150 | 151 | ```javascript 152 | const { PeerServer } = require("peer"); 153 | 154 | const peerServer = PeerServer({ 155 | port: 9000, 156 | path: "/myapp", 157 | proxied: true, 158 | }); 159 | ``` 160 | 161 | ## Custom client ID generation 162 | 163 | By default, PeerServer uses `uuid/v4` npm package to generate random client IDs. 164 | 165 | You can set `generateClientId` option in config to specify a custom function to generate client IDs. 166 | 167 | ```javascript 168 | const { PeerServer } = require("peer"); 169 | 170 | const customGenerationFunction = () => 171 | (Math.random().toString(36) + "0000000000000000000").substr(2, 16); 172 | 173 | const peerServer = PeerServer({ 174 | port: 9000, 175 | path: "/myapp", 176 | generateClientId: customGenerationFunction, 177 | }); 178 | ``` 179 | 180 | Open http://127.0.0.1:9000/myapp/peerjs/id to see a new random id. 181 | 182 | ## Combining with existing express app 183 | 184 | ```javascript 185 | const express = require("express"); 186 | const { ExpressPeerServer } = require("peer"); 187 | 188 | const app = express(); 189 | 190 | app.get("/", (req, res, next) => res.send("Hello world!")); 191 | 192 | // ======= 193 | 194 | const server = app.listen(9000); 195 | 196 | const peerServer = ExpressPeerServer(server, { 197 | path: "/myapp", 198 | }); 199 | 200 | app.use("/peerjs", peerServer); 201 | 202 | // == OR == 203 | 204 | const http = require("http"); 205 | 206 | const server = http.createServer(app); 207 | const peerServer = ExpressPeerServer(server, { 208 | debug: true, 209 | path: "/myapp", 210 | }); 211 | 212 | app.use("/peerjs", peerServer); 213 | 214 | server.listen(9000); 215 | 216 | // ======== 217 | ``` 218 | 219 | Open the browser and check http://127.0.0.1:9000/peerjs/myapp 220 | 221 | ## Events 222 | 223 | The `'connection'` event is emitted when a peer connects to the server. 224 | 225 | ```javascript 226 | peerServer.on('connection', (client) => { ... }); 227 | ``` 228 | 229 | The `'disconnect'` event is emitted when a peer disconnects from the server or 230 | when the peer can no longer be reached. 231 | 232 | ```javascript 233 | peerServer.on('disconnect', (client) => { ... }); 234 | ``` 235 | 236 | ## HTTP API 237 | 238 | Read [/src/api/README.md](src/api/README.md) 239 | 240 | ## Running tests 241 | 242 | ```sh 243 | $ npm test 244 | ``` 245 | 246 | ## Docker 247 | 248 | We have 'ready to use' images on docker hub: 249 | https://hub.docker.com/r/peerjs/peerjs-server 250 | 251 | To run the latest image: 252 | 253 | ```sh 254 | $ docker run -p 9000:9000 -d peerjs/peerjs-server 255 | ``` 256 | 257 | You can build a new image simply by calling: 258 | 259 | ```sh 260 | $ docker build -t myimage https://github.com/peers/peerjs-server.git 261 | ``` 262 | 263 | To run the image execute this: 264 | 265 | ```sh 266 | $ docker run -p 9000:9000 -d myimage 267 | ``` 268 | 269 | This will start a peerjs server on port 9000 exposed on port 9000 with key `peerjs` on path `/myapp`. 270 | 271 | Open your browser with http://localhost:9000/myapp It should returns JSON with name, description and website fields. http://localhost:9000/myapp/peerjs/id - should returns a random string (random client id) 272 | 273 | ## Running in Google App Engine 274 | 275 | Google App Engine will create an HTTPS certificate for you automatically, 276 | making this by far the easiest way to deploy PeerJS in the Google Cloud 277 | Platform. 278 | 279 | 1. Create a `package.json` file for GAE to read: 280 | 281 | ```sh 282 | echo "{}" > package.json 283 | npm install express@latest peer@latest 284 | ``` 285 | 286 | 2. Create an `app.yaml` file to configure the GAE application. 287 | 288 | ```yaml 289 | runtime: nodejs 290 | 291 | # Flex environment required for WebSocket support, which is required for PeerJS. 292 | env: flex 293 | 294 | # Limit resources to one instance, one CPU, very little memory or disk. 295 | manual_scaling: 296 | instances: 1 297 | resources: 298 | cpu: 1 299 | memory_gb: 0.5 300 | disk_size_gb: 0.5 301 | ``` 302 | 303 | 3. Create `server.js` (which node will run by default for the `start` script): 304 | 305 | ```js 306 | const express = require("express"); 307 | const { ExpressPeerServer } = require("peer"); 308 | const app = express(); 309 | 310 | app.enable("trust proxy"); 311 | 312 | const PORT = process.env.PORT || 9000; 313 | const server = app.listen(PORT, () => { 314 | console.log(`App listening on port ${PORT}`); 315 | console.log("Press Ctrl+C to quit."); 316 | }); 317 | 318 | const peerServer = ExpressPeerServer(server, { 319 | path: "/", 320 | }); 321 | 322 | app.use("/", peerServer); 323 | 324 | module.exports = app; 325 | ``` 326 | 327 | 4. Deploy to an existing GAE project (assuming you are already logged in via 328 | `gcloud`), replacing `YOUR-PROJECT-ID-HERE` with your particular project ID: 329 | 330 | ```sh 331 | gcloud app deploy --project=YOUR-PROJECT-ID-HERE --promote --quiet app.yaml 332 | ``` 333 | 334 | ## Privacy 335 | 336 | See [PRIVACY.md](https://github.com/peers/peerjs-server/blob/master/PRIVACY.md) 337 | 338 | ## Problems? 339 | 340 | Discuss PeerJS on our Discord community: 341 | https://discord.gg/Ud2PvAtK37 342 | 343 | Please post any bugs as a Github issue. 344 | -------------------------------------------------------------------------------- /__test__/messageHandler/handlers/heartbeat/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | 3 | import { Client } from "../../../../src/models/client.ts"; 4 | import { HeartbeatHandler } from "../../../../src/messageHandler/handlers/index.ts"; 5 | 6 | describe("Heartbeat handler", () => { 7 | it("should update last ping time", () => { 8 | const client = new Client({ id: "id", token: "" }); 9 | client.setLastPing(0); 10 | 11 | const nowTime = new Date().getTime(); 12 | 13 | HeartbeatHandler(client); 14 | expect(client.getLastPing()).toBeGreaterThanOrEqual(nowTime - 2); 15 | expect(nowTime).toBeGreaterThanOrEqual(client.getLastPing() - 2); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /__test__/messageHandler/handlers/transmission/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | 3 | import { Client } from "../../../../src/models/client.ts"; 4 | import { TransmissionHandler } from "../../../../src/messageHandler/handlers/index.ts"; 5 | import { Realm } from "../../../../src/models/realm.ts"; 6 | import { MessageType } from "../../../../src/enums.ts"; 7 | import type WebSocket from "ws"; 8 | 9 | const createFakeSocket = (): WebSocket => { 10 | /* eslint-disable @typescript-eslint/no-empty-function */ 11 | const sock = { 12 | send: (): void => {}, 13 | close: (): void => {}, 14 | on: (): void => {}, 15 | }; 16 | /* eslint-enable @typescript-eslint/no-empty-function */ 17 | 18 | return sock as unknown as WebSocket; 19 | }; 20 | 21 | describe("Transmission handler", () => { 22 | it("should save message in queue when destination client not connected", () => { 23 | const realm = new Realm(); 24 | const handleTransmission = TransmissionHandler({ realm }); 25 | 26 | const clientFrom = new Client({ id: "id1", token: "" }); 27 | const idTo = "id2"; 28 | realm.setClient(clientFrom, clientFrom.getId()); 29 | 30 | handleTransmission(clientFrom, { 31 | type: MessageType.OFFER, 32 | src: clientFrom.getId(), 33 | dst: idTo, 34 | }); 35 | 36 | expect(realm.getMessageQueueById(idTo)?.getMessages().length).toBe(1); 37 | }); 38 | 39 | it("should not save LEAVE and EXPIRE messages in queue when destination client not connected", () => { 40 | const realm = new Realm(); 41 | const handleTransmission = TransmissionHandler({ realm }); 42 | 43 | const clientFrom = new Client({ id: "id1", token: "" }); 44 | const idTo = "id2"; 45 | realm.setClient(clientFrom, clientFrom.getId()); 46 | 47 | handleTransmission(clientFrom, { 48 | type: MessageType.LEAVE, 49 | src: clientFrom.getId(), 50 | dst: idTo, 51 | }); 52 | handleTransmission(clientFrom, { 53 | type: MessageType.EXPIRE, 54 | src: clientFrom.getId(), 55 | dst: idTo, 56 | }); 57 | 58 | expect(realm.getMessageQueueById(idTo)).toBeUndefined(); 59 | }); 60 | 61 | it("should send message to destination client when destination client connected", () => { 62 | const realm = new Realm(); 63 | const handleTransmission = TransmissionHandler({ realm }); 64 | 65 | const clientFrom = new Client({ id: "id1", token: "" }); 66 | const clientTo = new Client({ id: "id2", token: "" }); 67 | const socketTo = createFakeSocket(); 68 | clientTo.setSocket(socketTo); 69 | realm.setClient(clientTo, clientTo.getId()); 70 | 71 | let sent = false; 72 | socketTo.send = (): void => { 73 | sent = true; 74 | }; 75 | 76 | handleTransmission(clientFrom, { 77 | type: MessageType.OFFER, 78 | src: clientFrom.getId(), 79 | dst: clientTo.getId(), 80 | }); 81 | 82 | expect(sent).toBe(true); 83 | }); 84 | 85 | it("should send LEAVE message to source client when sending to destination client failed", () => { 86 | const realm = new Realm(); 87 | const handleTransmission = TransmissionHandler({ realm }); 88 | 89 | const clientFrom = new Client({ id: "id1", token: "" }); 90 | const clientTo = new Client({ id: "id2", token: "" }); 91 | const socketFrom = createFakeSocket(); 92 | const socketTo = createFakeSocket(); 93 | clientFrom.setSocket(socketFrom); 94 | clientTo.setSocket(socketTo); 95 | realm.setClient(clientFrom, clientFrom.getId()); 96 | realm.setClient(clientTo, clientTo.getId()); 97 | 98 | let sent = false; 99 | socketFrom.send = (data: string): void => { 100 | if (JSON.parse(data)?.type === MessageType.LEAVE) { 101 | sent = true; 102 | } 103 | }; 104 | 105 | socketTo.send = (): void => { 106 | throw Error(); 107 | }; 108 | 109 | handleTransmission(clientFrom, { 110 | type: MessageType.OFFER, 111 | src: clientFrom.getId(), 112 | dst: clientTo.getId(), 113 | }); 114 | 115 | expect(sent).toBe(true); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /__test__/messageHandler/handlersRegistry.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | 3 | import { HandlersRegistry } from "../../src/messageHandler/handlersRegistry.ts"; 4 | import type { Handler } from "../../src/messageHandler/handler.ts"; 5 | import { MessageType } from "../../src/enums.ts"; 6 | 7 | describe("HandlersRegistry", () => { 8 | it("should execute handler for message type", () => { 9 | const handlersRegistry = new HandlersRegistry(); 10 | 11 | let handled = false; 12 | 13 | const handler: Handler = (): boolean => { 14 | handled = true; 15 | return true; 16 | }; 17 | 18 | handlersRegistry.registerHandler(MessageType.OPEN, handler); 19 | 20 | handlersRegistry.handle(undefined, { 21 | type: MessageType.OPEN, 22 | src: "src", 23 | dst: "dst", 24 | }); 25 | 26 | expect(handled).toBe(true); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /__test__/models/messageQueue.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | 3 | import { MessageQueue } from "../../src/models/messageQueue.ts"; 4 | import { MessageType } from "../../src/enums.ts"; 5 | import type { IMessage } from "../../src/index.js"; 6 | import { wait } from "../utils.ts"; 7 | 8 | describe("MessageQueue", () => { 9 | const createTestMessage = (): IMessage => { 10 | return { 11 | type: MessageType.OPEN, 12 | src: "src", 13 | dst: "dst", 14 | }; 15 | }; 16 | 17 | describe("#addMessage", () => { 18 | it("should add message to queue", () => { 19 | const queue = new MessageQueue(); 20 | queue.addMessage(createTestMessage()); 21 | expect(queue.getMessages().length).toBe(1); 22 | }); 23 | }); 24 | 25 | describe("#readMessage", () => { 26 | it("should return undefined for empty queue", () => { 27 | const queue = new MessageQueue(); 28 | expect(queue.readMessage()).toBeUndefined(); 29 | }); 30 | 31 | it("should return message if any exists in queue", () => { 32 | const queue = new MessageQueue(); 33 | const message = createTestMessage(); 34 | queue.addMessage(message); 35 | 36 | expect(queue.readMessage()).toEqual(message); 37 | expect(queue.readMessage()).toBeUndefined(); 38 | }); 39 | }); 40 | 41 | describe("#getLastReadAt", () => { 42 | it("should not be changed if no messages when read", () => { 43 | const queue = new MessageQueue(); 44 | const lastReadAt = queue.getLastReadAt(); 45 | queue.readMessage(); 46 | expect(queue.getLastReadAt()).toBe(lastReadAt); 47 | }); 48 | 49 | it("should be changed when read message", async () => { 50 | const queue = new MessageQueue(); 51 | const lastReadAt = queue.getLastReadAt(); 52 | queue.addMessage(createTestMessage()); 53 | 54 | await wait(10); 55 | 56 | expect(queue.getLastReadAt()).toBe(lastReadAt); 57 | 58 | queue.readMessage(); 59 | 60 | expect(queue.getLastReadAt()).toBeGreaterThanOrEqual(lastReadAt + 10 - 2); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /__test__/models/realm.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | 3 | import { Realm } from "../../src/models/realm.ts"; 4 | import { Client } from "../../src/models/client.ts"; 5 | 6 | describe("Realm", () => { 7 | describe("#generateClientId", () => { 8 | it("should generate a 36-character UUID, or return function value", () => { 9 | const realm = new Realm(); 10 | expect(realm.generateClientId().length).toBe(36); 11 | expect(realm.generateClientId(() => "abcd")).toBe("abcd"); 12 | }); 13 | }); 14 | 15 | describe("#setClient", () => { 16 | it("should add client to realm", () => { 17 | const realm = new Realm(); 18 | const client = new Client({ id: "id", token: "" }); 19 | 20 | realm.setClient(client, "id"); 21 | expect(realm.getClientsIds()).toEqual(["id"]); 22 | }); 23 | }); 24 | 25 | describe("#removeClientById", () => { 26 | it("should remove client from realm", () => { 27 | const realm = new Realm(); 28 | const client = new Client({ id: "id", token: "" }); 29 | 30 | realm.setClient(client, "id"); 31 | realm.removeClientById("id"); 32 | 33 | expect(realm.getClientById("id")).toBeUndefined(); 34 | }); 35 | }); 36 | 37 | describe("#getClientsIds", () => { 38 | it("should reflects on add/remove childs", () => { 39 | const realm = new Realm(); 40 | const client = new Client({ id: "id", token: "" }); 41 | 42 | realm.setClient(client, "id"); 43 | expect(realm.getClientsIds()).toEqual(["id"]); 44 | 45 | expect(realm.getClientById("id")).toBe(client); 46 | 47 | realm.removeClientById("id"); 48 | expect(realm.getClientsIds()).toEqual([]); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /__test__/peerjs.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | 3 | import http from "http"; 4 | import expectedJson from "../app.json"; 5 | import fetch from "node-fetch"; 6 | import * as crypto from "crypto"; 7 | import { startServer } from "./utils.ts"; 8 | 9 | const PORT = "9000"; 10 | 11 | async function makeRequest() { 12 | return new Promise((resolve, reject) => { 13 | http 14 | .get(`http://localhost:${PORT}/`, (resp) => { 15 | let data = ""; 16 | 17 | resp.on("data", (chunk) => { 18 | data += chunk; 19 | }); 20 | 21 | resp.on("end", () => { 22 | resolve(JSON.parse(data)); 23 | }); 24 | }) 25 | .on("error", (err) => { 26 | console.log("Error: " + err.message); 27 | reject(err); 28 | }); 29 | }); 30 | } 31 | 32 | describe("Check bin/peerjs", () => { 33 | it("should return content of app.json file", async () => { 34 | expect.assertions(1); 35 | 36 | const ls = await startServer(); 37 | try { 38 | const resp = await makeRequest(); 39 | expect(resp).toEqual(expectedJson); 40 | } finally { 41 | ls.kill(); 42 | } 43 | }); 44 | 45 | it("should reflect the origin header in CORS by default", async () => { 46 | expect.assertions(1); 47 | 48 | const ls = await startServer(); 49 | const origin = crypto.randomUUID(); 50 | try { 51 | const res = await fetch(`http://localhost:${PORT}/peerjs/id`, { 52 | headers: { 53 | Origin: origin, 54 | }, 55 | }); 56 | expect(res.headers.get("access-control-allow-origin")).toBe(origin); 57 | } finally { 58 | ls.kill(); 59 | } 60 | }); 61 | it("should respect the CORS parameters", async () => { 62 | expect.assertions(3); 63 | 64 | const origin1 = crypto.randomUUID(); 65 | const origin2 = crypto.randomUUID(); 66 | const origin3 = crypto.randomUUID(); 67 | const ls = await startServer(["--cors", origin1, "--cors", origin2]); 68 | try { 69 | const res1 = await fetch(`http://localhost:${PORT}/peerjs/id`, { 70 | headers: { 71 | Origin: origin1, 72 | }, 73 | }); 74 | expect(res1.headers.get("access-control-allow-origin")).toBe(origin1); 75 | const res2 = await fetch(`http://localhost:${PORT}/peerjs/id`, { 76 | headers: { 77 | Origin: origin2, 78 | }, 79 | }); 80 | expect(res2.headers.get("access-control-allow-origin")).toBe(origin2); 81 | const res3 = await fetch(`http://localhost:${PORT}/peerjs/id`, { 82 | headers: { 83 | Origin: origin3, 84 | }, 85 | }); 86 | expect(res3.headers.get("access-control-allow-origin")).toBe(null); 87 | } finally { 88 | ls.kill(); 89 | } 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /__test__/services/checkBrokenConnections/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | 3 | import { Client } from "../../../src/models/client.ts"; 4 | import { Realm } from "../../../src/models/realm.ts"; 5 | import { CheckBrokenConnections } from "../../../src/services/checkBrokenConnections/index.ts"; 6 | import { wait } from "../../utils.ts"; 7 | 8 | describe("CheckBrokenConnections", () => { 9 | it("should remove client after 2 checks", async () => { 10 | const realm = new Realm(); 11 | const doubleCheckTime = 55; //~ equals to checkBrokenConnections.checkInterval * 2 12 | const checkBrokenConnections = new CheckBrokenConnections({ 13 | realm, 14 | config: { alive_timeout: doubleCheckTime }, 15 | checkInterval: 30, 16 | }); 17 | const client = new Client({ id: "id", token: "" }); 18 | realm.setClient(client, "id"); 19 | 20 | checkBrokenConnections.start(); 21 | 22 | await wait(checkBrokenConnections.checkInterval * 2 + 30); 23 | 24 | expect(realm.getClientById("id")).toBeUndefined(); 25 | 26 | checkBrokenConnections.stop(); 27 | }); 28 | 29 | it("should remove client after 1 ping", async () => { 30 | const realm = new Realm(); 31 | const doubleCheckTime = 55; //~ equals to checkBrokenConnections.checkInterval * 2 32 | const checkBrokenConnections = new CheckBrokenConnections({ 33 | realm, 34 | config: { alive_timeout: doubleCheckTime }, 35 | checkInterval: 30, 36 | }); 37 | const client = new Client({ id: "id", token: "" }); 38 | realm.setClient(client, "id"); 39 | 40 | checkBrokenConnections.start(); 41 | 42 | //set ping after first check 43 | await wait(checkBrokenConnections.checkInterval); 44 | 45 | client.setLastPing(new Date().getTime()); 46 | 47 | await wait(checkBrokenConnections.checkInterval * 2 + 10); 48 | 49 | expect(realm.getClientById("id")).toBeUndefined(); 50 | 51 | checkBrokenConnections.stop(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /__test__/services/messagesExpire/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | 3 | import { Client } from "../../../src/models/client.ts"; 4 | import { Realm } from "../../../src/models/realm.ts"; 5 | import type { IMessage } from "../../../src/index.js"; 6 | import { MessagesExpire } from "../../../src/services/messagesExpire/index.ts"; 7 | import { MessageHandler } from "../../../src/messageHandler/index.ts"; 8 | import { MessageType } from "../../../src/enums.ts"; 9 | import { wait } from "../../utils.ts"; 10 | 11 | describe("MessagesExpire", () => { 12 | const createTestMessage = (dst: string): IMessage => { 13 | return { 14 | type: MessageType.OPEN, 15 | src: "src", 16 | dst, 17 | }; 18 | }; 19 | 20 | it("should remove client if no read from queue", async () => { 21 | const realm = new Realm(); 22 | const messageHandler = new MessageHandler(realm); 23 | const checkInterval = 10; 24 | const expireTimeout = 50; 25 | const config = { 26 | cleanup_out_msgs: checkInterval, 27 | expire_timeout: expireTimeout, 28 | }; 29 | 30 | const messagesExpire = new MessagesExpire({ 31 | realm, 32 | config, 33 | messageHandler, 34 | }); 35 | 36 | const client = new Client({ id: "id", token: "" }); 37 | realm.setClient(client, "id"); 38 | realm.addMessageToQueue(client.getId(), createTestMessage("dst")); 39 | 40 | messagesExpire.startMessagesExpiration(); 41 | 42 | await wait(checkInterval * 2); 43 | 44 | expect( 45 | realm.getMessageQueueById(client.getId())?.getMessages().length, 46 | ).toBe(1); 47 | 48 | await wait(expireTimeout); 49 | 50 | expect(realm.getMessageQueueById(client.getId())).toBeUndefined(); 51 | 52 | messagesExpire.stopMessagesExpiration(); 53 | }); 54 | 55 | it("should fire EXPIRE message", async () => { 56 | const realm = new Realm(); 57 | const messageHandler = new MessageHandler(realm); 58 | const checkInterval = 10; 59 | const expireTimeout = 50; 60 | const config = { 61 | cleanup_out_msgs: checkInterval, 62 | expire_timeout: expireTimeout, 63 | }; 64 | 65 | const messagesExpire = new MessagesExpire({ 66 | realm, 67 | config, 68 | messageHandler, 69 | }); 70 | 71 | const client = new Client({ id: "id", token: "" }); 72 | realm.setClient(client, "id"); 73 | realm.addMessageToQueue(client.getId(), createTestMessage("dst1")); 74 | realm.addMessageToQueue(client.getId(), createTestMessage("dst2")); 75 | 76 | let handledCount = 0; 77 | 78 | messageHandler.handle = (client, message): boolean => { 79 | expect(client).toBeUndefined(); 80 | expect(message.type).toBe(MessageType.EXPIRE); 81 | 82 | handledCount++; 83 | 84 | return true; 85 | }; 86 | 87 | messagesExpire.startMessagesExpiration(); 88 | 89 | await wait(checkInterval * 2); 90 | await wait(expireTimeout); 91 | 92 | expect(handledCount).toBe(2); 93 | 94 | messagesExpire.stopMessagesExpiration(); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /__test__/services/webSocketServer/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | 3 | import { Server, WebSocket } from "mock-socket"; 4 | import type { Server as HttpServer } from "node:http"; 5 | import { Realm } from "../../../src/models/realm.ts"; 6 | import { WebSocketServer } from "../../../src/services/webSocketServer/index.ts"; 7 | import { Errors, MessageType } from "../../../src/enums.ts"; 8 | import { wait } from "../../utils.ts"; 9 | 10 | type Destroyable = T & { destroy?: () => Promise }; 11 | 12 | const checkOpen = async (c: WebSocket): Promise => { 13 | return new Promise((resolve) => { 14 | c.onmessage = (event: object & { data?: string }): void => { 15 | const message = JSON.parse(event.data as string); 16 | resolve(message.type === MessageType.OPEN); 17 | }; 18 | }); 19 | }; 20 | 21 | const checkSequence = async ( 22 | c: WebSocket, 23 | msgs: { type: MessageType; error?: Errors }[], 24 | ): Promise => { 25 | return new Promise((resolve) => { 26 | const restMessages = [...msgs]; 27 | 28 | const finish = (success = false): void => { 29 | resolve(success); 30 | }; 31 | 32 | c.onmessage = (event: object & { data?: string }): void => { 33 | const [mes] = restMessages; 34 | 35 | if (!mes) { 36 | return finish(); 37 | } 38 | 39 | restMessages.shift(); 40 | 41 | const message = JSON.parse(event.data as string); 42 | if (message.type !== mes.type) { 43 | return finish(); 44 | } 45 | 46 | const isOk = !mes.error || message.payload?.msg === mes.error; 47 | 48 | if (!isOk) { 49 | return finish(); 50 | } 51 | 52 | if (restMessages.length === 0) { 53 | finish(true); 54 | } 55 | }; 56 | }); 57 | }; 58 | 59 | const createTestServer = ({ 60 | realm, 61 | config, 62 | url, 63 | }: { 64 | realm: Realm; 65 | config: { path: string; key: string; concurrent_limit: number }; 66 | url: string; 67 | }): Destroyable => { 68 | const server = new Server(url) as Server & HttpServer; 69 | const webSocketServer: Destroyable = new WebSocketServer({ 70 | server, 71 | realm, 72 | config, 73 | }); 74 | 75 | server.on( 76 | "connection", 77 | ( 78 | socket: WebSocket & { 79 | on?: (eventName: string, callback: () => void) => void; 80 | }, 81 | ) => { 82 | const s = webSocketServer.socketServer; 83 | s.emit("connection", socket, { url: socket.url }); 84 | 85 | socket.onclose = (): void => { 86 | const userId = socket.url 87 | .split("?")[1] 88 | ?.split("&") 89 | .find((p) => p.startsWith("id")) 90 | ?.split("=")[1]; 91 | 92 | if (!userId) return; 93 | 94 | const client = realm.getClientById(userId); 95 | 96 | const clientSocket = client?.getSocket(); 97 | 98 | if (!clientSocket) return; 99 | 100 | (clientSocket as unknown as WebSocket).listeners[ 101 | "server::close" 102 | ]?.forEach((s: () => void) => s()); 103 | }; 104 | 105 | socket.onmessage = (event: object & { data?: string }): void => { 106 | const userId = socket.url 107 | .split("?")[1] 108 | ?.split("&") 109 | .find((p) => p.startsWith("id")) 110 | ?.split("=")[1]; 111 | 112 | if (!userId) return; 113 | 114 | const client = realm.getClientById(userId); 115 | 116 | const clientSocket = client?.getSocket(); 117 | 118 | if (!clientSocket) return; 119 | 120 | (clientSocket as unknown as WebSocket).listeners[ 121 | "server::message" 122 | ]?.forEach((s: (data: object) => void) => s(event)); 123 | }; 124 | }, 125 | ); 126 | 127 | webSocketServer.destroy = async (): Promise => { 128 | server.close(); 129 | }; 130 | 131 | return webSocketServer; 132 | }; 133 | 134 | describe("WebSocketServer", () => { 135 | it("should return valid path", () => { 136 | const realm = new Realm(); 137 | const config = { path: "/", key: "testKey", concurrent_limit: 1 }; 138 | const config2 = { ...config, path: "path" }; 139 | const server = new Server("path1") as Server & HttpServer; 140 | const server2 = new Server("path2") as Server & HttpServer; 141 | 142 | const webSocketServer = new WebSocketServer({ server, realm, config }); 143 | 144 | expect(webSocketServer.path).toBe("/peerjs"); 145 | 146 | const webSocketServer2 = new WebSocketServer({ 147 | server: server2, 148 | realm, 149 | config: config2, 150 | }); 151 | 152 | expect(webSocketServer2.path).toBe("path/peerjs"); 153 | 154 | server.stop(); 155 | server2.stop(); 156 | }); 157 | 158 | it(`should check client's params`, async () => { 159 | const realm = new Realm(); 160 | const config = { path: "/", key: "testKey", concurrent_limit: 1 }; 161 | const fakeURL = "ws://localhost:8080/peerjs"; 162 | 163 | const getError = async ( 164 | url: string, 165 | validError: Errors = Errors.INVALID_WS_PARAMETERS, 166 | ): Promise => { 167 | const webSocketServer = createTestServer({ url, realm, config }); 168 | 169 | const ws = new WebSocket(url); 170 | 171 | const errorSent = await checkSequence(ws, [ 172 | { type: MessageType.ERROR, error: validError }, 173 | ]); 174 | 175 | ws.close(); 176 | 177 | await webSocketServer.destroy?.(); 178 | 179 | return errorSent; 180 | }; 181 | 182 | expect(await getError(fakeURL)).toBe(true); 183 | expect(await getError(`${fakeURL}?key=${config.key}`)).toBe(true); 184 | expect(await getError(`${fakeURL}?key=${config.key}&id=1`)).toBe(true); 185 | expect( 186 | await getError( 187 | `${fakeURL}?key=notValidKey&id=userId&token=userToken`, 188 | Errors.INVALID_KEY, 189 | ), 190 | ).toBe(true); 191 | }); 192 | 193 | it(`should check concurrent limit`, async () => { 194 | const realm = new Realm(); 195 | const config = { path: "/", key: "testKey", concurrent_limit: 1 }; 196 | const fakeURL = "ws://localhost:8080/peerjs"; 197 | 198 | const createClient = (id: string): Destroyable => { 199 | // id in the path ensures that all mock servers listen on different urls 200 | const url = `${fakeURL}${id}?key=${config.key}&id=${id}&token=${id}`; 201 | const webSocketServer = createTestServer({ url, realm, config }); 202 | const ws: Destroyable = new WebSocket(url); 203 | 204 | ws.destroy = async (): Promise => { 205 | ws.close(); 206 | 207 | await wait(10); 208 | 209 | webSocketServer.destroy?.(); 210 | 211 | await wait(10); 212 | 213 | ws.destroy = undefined; 214 | }; 215 | 216 | return ws; 217 | }; 218 | 219 | const c1 = createClient("1"); 220 | 221 | expect(await checkOpen(c1)).toBe(true); 222 | 223 | const c2 = createClient("2"); 224 | 225 | expect( 226 | await checkSequence(c2, [ 227 | { type: MessageType.ERROR, error: Errors.CONNECTION_LIMIT_EXCEED }, 228 | ]), 229 | ).toBe(true); 230 | 231 | await c1.destroy?.(); 232 | await c2.destroy?.(); 233 | 234 | await wait(10); 235 | 236 | expect(realm.getClientsIds().length).toBe(0); 237 | 238 | const c3 = createClient("3"); 239 | 240 | expect(await checkOpen(c3)).toBe(true); 241 | 242 | await c3.destroy?.(); 243 | }); 244 | }); 245 | -------------------------------------------------------------------------------- /__test__/utils.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams, spawn } from "child_process"; 2 | import path from "path"; 3 | 4 | export const wait = (ms: number): Promise => 5 | new Promise((resolve) => setTimeout(resolve, ms)); 6 | 7 | export const startServer = (params: string[] = []) => { 8 | return new Promise((resolve, reject) => { 9 | const ls = spawn("node", [ 10 | path.join(__dirname, "../", "dist/bin/peerjs.js"), 11 | "--port", 12 | "9000", 13 | ...params, 14 | ]); 15 | ls.stdout.once("data", () => resolve(ls)); 16 | ls.stderr.once("data", () => { 17 | ls.kill(); 18 | reject(); 19 | }); 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PeerJS Server", 3 | "description": "A server side element to broker connections between PeerJS clients.", 4 | "website": "https://peerjs.com/" 5 | } 6 | -------------------------------------------------------------------------------- /bin/peerjs.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path from "node:path"; 4 | import fs from "node:fs"; 5 | const optimistUsageLength = 98; 6 | import yargs from "yargs"; 7 | import { hideBin } from "yargs/helpers"; 8 | import { PeerServer } from "../src/index.ts"; 9 | import type { AddressInfo } from "node:net"; 10 | import type { CorsOptions } from "cors"; 11 | 12 | const y = yargs(hideBin(process.argv)); 13 | 14 | const portEnvIsSet = !!process.env["PORT"]; 15 | 16 | const opts = y 17 | .usage("Usage: $0") 18 | .wrap(Math.min(optimistUsageLength, y.terminalWidth())) 19 | .options({ 20 | expire_timeout: { 21 | demandOption: false, 22 | alias: "t", 23 | describe: "timeout (milliseconds)", 24 | default: 5000, 25 | }, 26 | concurrent_limit: { 27 | demandOption: false, 28 | alias: "c", 29 | describe: "concurrent limit", 30 | default: 5000, 31 | }, 32 | alive_timeout: { 33 | demandOption: false, 34 | describe: "broken connection check timeout (milliseconds)", 35 | default: 60000, 36 | }, 37 | key: { 38 | demandOption: false, 39 | alias: "k", 40 | describe: "connection key", 41 | default: "peerjs", 42 | }, 43 | sslkey: { 44 | type: "string", 45 | demandOption: false, 46 | describe: "path to SSL key", 47 | }, 48 | sslcert: { 49 | type: "string", 50 | demandOption: false, 51 | describe: "path to SSL certificate", 52 | }, 53 | host: { 54 | type: "string", 55 | demandOption: false, 56 | alias: "H", 57 | describe: "host", 58 | }, 59 | port: { 60 | type: "number", 61 | demandOption: !portEnvIsSet, 62 | alias: "p", 63 | describe: "port", 64 | }, 65 | path: { 66 | type: "string", 67 | demandOption: false, 68 | describe: "custom path", 69 | default: process.env["PEERSERVER_PATH"] ?? "/", 70 | }, 71 | allow_discovery: { 72 | type: "boolean", 73 | demandOption: false, 74 | describe: "allow discovery of peers", 75 | }, 76 | proxied: { 77 | type: "boolean", 78 | demandOption: false, 79 | describe: "Set true if PeerServer stays behind a reverse proxy", 80 | default: false, 81 | }, 82 | cors: { 83 | type: "string", 84 | array: true, 85 | describe: "Set the list of CORS origins", 86 | }, 87 | }) 88 | .boolean("allow_discovery") 89 | .parseSync(); 90 | 91 | if (!opts.port) { 92 | // .port is only not set if the PORT env var is set 93 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 94 | opts.port = parseInt(process.env["PORT"]!); 95 | } 96 | if (opts.cors) { 97 | opts["corsOptions"] = { 98 | origin: opts.cors, 99 | } satisfies CorsOptions; 100 | } 101 | process.on("uncaughtException", function (e) { 102 | console.error("Error: " + e.toString()); 103 | }); 104 | 105 | if (opts.sslkey ?? opts.sslcert) { 106 | if (opts.sslkey && opts.sslcert) { 107 | opts["ssl"] = { 108 | key: fs.readFileSync(path.resolve(opts.sslkey)), 109 | cert: fs.readFileSync(path.resolve(opts.sslcert)), 110 | }; 111 | } else { 112 | console.error( 113 | "Warning: PeerServer will not run because either " + 114 | "the key or the certificate has not been provided.", 115 | ); 116 | process.exit(1); 117 | } 118 | } 119 | 120 | const userPath = opts.path; 121 | const server = PeerServer(opts, (server) => { 122 | const { address: host, port } = server.address() as AddressInfo; 123 | 124 | console.log( 125 | "Started PeerServer on %s, port: %s, path: %s", 126 | host, 127 | port, 128 | userPath || "/", 129 | ); 130 | 131 | const shutdownApp = () => { 132 | server.close(() => { 133 | console.log("Http server closed."); 134 | 135 | process.exit(0); 136 | }); 137 | }; 138 | 139 | process.on("SIGINT", shutdownApp); 140 | process.on("SIGTERM", shutdownApp); 141 | }); 142 | 143 | server.on("connection", (client) => { 144 | console.log(`Client connected: ${client.getId()}`); 145 | }); 146 | 147 | server.on("disconnect", (client) => { 148 | console.log(`Client disconnected: ${client.getId()}`); 149 | }); 150 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | const config = { 3 | testEnvironment: "node", 4 | transform: { 5 | "^.+\\.(t|j)sx?$": "@swc/jest", 6 | }, 7 | transformIgnorePatterns: [ 8 | // "node_modules" 9 | ], 10 | collectCoverageFrom: ["./src/**"], 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peer", 3 | "version": "1.0.2", 4 | "keywords": [ 5 | "peerjs", 6 | "webrtc", 7 | "p2p", 8 | "rtc" 9 | ], 10 | "description": "PeerJS server component", 11 | "homepage": "https://peerjs.com", 12 | "bugs": { 13 | "url": "https://github.com/peers/peerjs-server/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/peers/peerjs-server" 18 | }, 19 | "license": "MIT", 20 | "contributors": [], 21 | "type": "module", 22 | "exports": { 23 | ".": { 24 | "import": { 25 | "types": "./dist/peer.d.ts", 26 | "default": "./dist/module.mjs" 27 | }, 28 | "require": { 29 | "types": "./dist/peer.d.ts", 30 | "default": "./dist/index.cjs" 31 | } 32 | } 33 | }, 34 | "main": "dist/index.cjs", 35 | "module": "dist/module.mjs", 36 | "source": "src/index.ts", 37 | "binary": "dist/bin/peerjs.js", 38 | "types": "dist/peer.d.ts", 39 | "bin": { 40 | "peerjs": "dist/bin/peerjs.js" 41 | }, 42 | "funding": { 43 | "type": "opencollective", 44 | "url": "https://opencollective.com/peer" 45 | }, 46 | "collective": { 47 | "type": "opencollective", 48 | "url": "https://opencollective.com/peer" 49 | }, 50 | "files": [ 51 | "dist/" 52 | ], 53 | "engines": { 54 | "node": ">=14" 55 | }, 56 | "targets": { 57 | "binary": { 58 | "source": "bin/peerjs.ts" 59 | }, 60 | "main": {}, 61 | "module": {} 62 | }, 63 | "scripts": { 64 | "format": "prettier --write .", 65 | "format:check": "prettier --check .", 66 | "build": "parcel build", 67 | "lint": "eslint --ext .js,.ts . && npm run check", 68 | "check": "tsc --noEmit", 69 | "test": "npm run lint && jest", 70 | "coverage": "jest --coverage", 71 | "start": "node dist/bin/peerjs.js --port ${PORT:=9000}", 72 | "dev": "nodemon --watch src -e ts --exec 'npm run build && npm run start'", 73 | "semantic-release": "semantic-release" 74 | }, 75 | "dependencies": { 76 | "@types/express": "^4.17.3", 77 | "@types/ws": "^7.2.3 || ^8.0.0", 78 | "cors": "^2.8.5", 79 | "express": "^4.17.1", 80 | "node-fetch": "^3.3.0", 81 | "ws": "^7.2.3 || ^8.0.0", 82 | "yargs": "^17.6.2" 83 | }, 84 | "devDependencies": { 85 | "@codedependant/semantic-release-docker": "^5.0.3", 86 | "@parcel/core": "~2.15.0", 87 | "@parcel/packager-ts": "~2.15.0", 88 | "@parcel/transformer-typescript-types": "~2.15.0", 89 | "@semantic-release/changelog": "^6.0.1", 90 | "@semantic-release/git": "^10.0.1", 91 | "@swc/core": "^1.3.35", 92 | "@swc/helpers": "^0.5.1", 93 | "@swc/jest": "^0.2.24", 94 | "@tsconfig/node16": "^16.1.0", 95 | "@tsconfig/strictest": "^2.0.1", 96 | "@types/cors": "^2.8.6", 97 | "@types/jest": "^29.4.0", 98 | "@types/node": "^14.18.33", 99 | "@types/yargs": "^17.0.19", 100 | "@typescript-eslint/eslint-plugin": "^6.0.0", 101 | "@typescript-eslint/parser": "^6.0.0", 102 | "eslint": "^8.0.0", 103 | "jest": "^29.4.2", 104 | "mock-socket": "^9.1.5", 105 | "parcel": "~2.15.0", 106 | "prettier": "^3.0.0", 107 | "semantic-release": "^22.0.0", 108 | "typescript": "^5.1.6" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", ":assignAndReview(jonasgloning)"], 4 | "labels": ["dependencies"], 5 | "assignees": ["jonasgloning"], 6 | "major": { 7 | "dependencyDashboardApproval": true 8 | }, 9 | "packageRules": [ 10 | { 11 | "matchDepTypes": ["devDependencies"], 12 | "addLabels": ["dev-dependencies"], 13 | "automerge": true, 14 | "automergeType": "branch" 15 | }, 16 | { 17 | "matchUpdateTypes": ["minor", "patch"], 18 | "matchCurrentVersion": "!/^0/", 19 | "automerge": true, 20 | "automergeType": "branch" 21 | } 22 | ], 23 | "lockFileMaintenance": { 24 | "enabled": true, 25 | "automerge": true, 26 | "automergeType": "branch" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/README.md: -------------------------------------------------------------------------------- 1 | ## HTTP API 2 | 3 | In progress... 4 | 5 | The API methods available on `YOUR_ROOT_PATH` + `path` option from config. 6 | 7 | So, the base path should be like `http://127.0.0.1:9000/` or `http://127.0.0.1:9000/myapp/` if `path` option was set to `/myapp`. 8 | 9 | Endpoints: 10 | 11 | - GET `/` - return a JSON to test the server. 12 | 13 | This group of methods uses `:key` option from config: 14 | 15 | - GET `/:key/id` - return a new user id. required `:key` from config. 16 | - GET `/:key/peers` - return an array of all connected users. required `:key` from config. **IMPORTANT:** You should set `allow_discovery` to `true` in config to enable this method. It disabled by default. 17 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import cors, { CorsOptions } from "cors"; 2 | import express from "express"; 3 | import publicContent from "../../app.json"; 4 | import PublicApi from "./v1/public/index.ts"; 5 | import type { IConfig } from "../config/index.ts"; 6 | import type { IRealm } from "../models/realm.ts"; 7 | 8 | export const Api = ({ 9 | config, 10 | realm, 11 | corsOptions, 12 | }: { 13 | config: IConfig; 14 | realm: IRealm; 15 | corsOptions: CorsOptions; 16 | }): express.Router => { 17 | const app = express.Router(); 18 | 19 | app.use(cors(corsOptions)); 20 | 21 | app.get("/", (_, res) => { 22 | res.send(publicContent); 23 | }); 24 | 25 | app.use("/:key", PublicApi({ config, realm })); 26 | 27 | return app; 28 | }; 29 | -------------------------------------------------------------------------------- /src/api/v1/public/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import type { IConfig } from "../../../config/index.ts"; 3 | import type { IRealm } from "../../../models/realm.ts"; 4 | 5 | export default ({ 6 | config, 7 | realm, 8 | }: { 9 | config: IConfig; 10 | realm: IRealm; 11 | }): express.Router => { 12 | const app = express.Router(); 13 | 14 | // Retrieve guaranteed random ID. 15 | app.get("/id", (_, res: express.Response) => { 16 | res.contentType("html"); 17 | res.send(realm.generateClientId(config.generateClientId)); 18 | }); 19 | 20 | // Get a list of all peers for a key, enabled by the `allowDiscovery` flag. 21 | app.get("/peers", (_, res: express.Response) => { 22 | if (config.allow_discovery) { 23 | const clientsIds = realm.getClientsIds(); 24 | 25 | return res.send(clientsIds); 26 | } 27 | 28 | return res.sendStatus(401); 29 | }); 30 | 31 | return app; 32 | }; 33 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import type { WebSocketServer, ServerOptions } from "ws"; 2 | import type { CorsOptions } from "cors"; 3 | 4 | export interface IConfig { 5 | readonly host: string; 6 | readonly port: number; 7 | readonly expire_timeout: number; 8 | readonly alive_timeout: number; 9 | readonly key: string; 10 | readonly path: string; 11 | readonly concurrent_limit: number; 12 | readonly allow_discovery: boolean; 13 | readonly proxied: boolean | string; 14 | readonly cleanup_out_msgs: number; 15 | readonly ssl?: { 16 | key: string; 17 | cert: string; 18 | }; 19 | readonly generateClientId?: () => string; 20 | readonly createWebSocketServer?: (options: ServerOptions) => WebSocketServer; 21 | readonly corsOptions: CorsOptions; 22 | } 23 | 24 | const defaultConfig: IConfig = { 25 | host: "::", 26 | port: 9000, 27 | expire_timeout: 5000, 28 | alive_timeout: 90000, 29 | key: "peerjs", 30 | path: "/", 31 | concurrent_limit: 5000, 32 | allow_discovery: false, 33 | proxied: false, 34 | cleanup_out_msgs: 1000, 35 | corsOptions: { origin: true }, 36 | }; 37 | 38 | export default defaultConfig; 39 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum Errors { 2 | INVALID_KEY = "Invalid key provided", 3 | INVALID_TOKEN = "Invalid token provided", 4 | INVALID_WS_PARAMETERS = "No id, token, or key supplied to websocket server", 5 | CONNECTION_LIMIT_EXCEED = "Server has reached its concurrent user limit", 6 | } 7 | 8 | export enum MessageType { 9 | OPEN = "OPEN", 10 | LEAVE = "LEAVE", 11 | CANDIDATE = "CANDIDATE", 12 | OFFER = "OFFER", 13 | ANSWER = "ANSWER", 14 | EXPIRE = "EXPIRE", 15 | HEARTBEAT = "HEARTBEAT", 16 | ID_TAKEN = "ID-TAKEN", 17 | ERROR = "ERROR", 18 | } 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import express, { type Express } from "express"; 2 | import http from "node:http"; 3 | import https from "node:https"; 4 | 5 | import type { IConfig } from "./config/index.ts"; 6 | import defaultConfig from "./config/index.ts"; 7 | import type { PeerServerEvents } from "./instance.ts"; 8 | import { createInstance } from "./instance.ts"; 9 | import type { IClient } from "./models/client.ts"; 10 | import type { IMessage } from "./models/message.ts"; 11 | 12 | export type { MessageType } from "./enums.ts"; 13 | export type { IConfig, PeerServerEvents, IClient, IMessage }; 14 | 15 | function ExpressPeerServer( 16 | server: https.Server | http.Server, 17 | options?: Partial, 18 | ) { 19 | const app = express(); 20 | 21 | const newOptions: IConfig = { 22 | ...defaultConfig, 23 | ...options, 24 | }; 25 | 26 | if (newOptions.proxied) { 27 | app.set( 28 | "trust proxy", 29 | newOptions.proxied === "false" ? false : !!newOptions.proxied, 30 | ); 31 | } 32 | 33 | app.on("mount", () => { 34 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 35 | if (!server) { 36 | throw new Error( 37 | "Server is not passed to constructor - " + "can't start PeerServer", 38 | ); 39 | } 40 | 41 | createInstance({ app, server, options: newOptions }); 42 | }); 43 | 44 | return app as Express & PeerServerEvents; 45 | } 46 | 47 | function PeerServer( 48 | options: Partial = {}, 49 | callback?: (server: https.Server | http.Server) => void, 50 | ) { 51 | const app = express(); 52 | 53 | let newOptions: IConfig = { 54 | ...defaultConfig, 55 | ...options, 56 | }; 57 | 58 | const port = newOptions.port; 59 | const host = newOptions.host; 60 | 61 | let server: https.Server | http.Server; 62 | 63 | const { ssl, ...restOptions } = newOptions; 64 | if (ssl && Object.keys(ssl).length) { 65 | server = https.createServer(ssl, app); 66 | 67 | newOptions = restOptions; 68 | } else { 69 | server = http.createServer(app); 70 | } 71 | 72 | const peerjs = ExpressPeerServer(server, newOptions); 73 | app.use(peerjs); 74 | 75 | server.listen(port, host, () => callback?.(server)); 76 | 77 | return peerjs; 78 | } 79 | 80 | export { ExpressPeerServer, PeerServer }; 81 | -------------------------------------------------------------------------------- /src/instance.ts: -------------------------------------------------------------------------------- 1 | import type express from "express"; 2 | import type { Server as HttpServer } from "node:http"; 3 | import type { Server as HttpsServer } from "node:https"; 4 | import path from "node:path"; 5 | import type { IRealm } from "./models/realm.ts"; 6 | import { Realm } from "./models/realm.ts"; 7 | import { CheckBrokenConnections } from "./services/checkBrokenConnections/index.ts"; 8 | import type { IMessagesExpire } from "./services/messagesExpire/index.ts"; 9 | import { MessagesExpire } from "./services/messagesExpire/index.ts"; 10 | import type { IWebSocketServer } from "./services/webSocketServer/index.ts"; 11 | import { WebSocketServer } from "./services/webSocketServer/index.ts"; 12 | import { MessageHandler } from "./messageHandler/index.ts"; 13 | import { Api } from "./api/index.ts"; 14 | import type { IClient } from "./models/client.ts"; 15 | import type { IMessage } from "./models/message.ts"; 16 | import type { IConfig } from "./config/index.ts"; 17 | 18 | export interface PeerServerEvents { 19 | on(event: "connection", listener: (client: IClient) => void): this; 20 | on( 21 | event: "message", 22 | listener: (client: IClient, message: IMessage) => void, 23 | ): this; 24 | // eslint-disable-next-line @typescript-eslint/unified-signatures 25 | on(event: "disconnect", listener: (client: IClient) => void): this; 26 | on(event: "error", listener: (client: Error) => void): this; 27 | } 28 | 29 | export const createInstance = ({ 30 | app, 31 | server, 32 | options, 33 | }: { 34 | app: express.Application; 35 | server: HttpServer | HttpsServer; 36 | options: IConfig; 37 | }): void => { 38 | const config = options; 39 | const realm: IRealm = new Realm(); 40 | const messageHandler = new MessageHandler(realm); 41 | 42 | const api = Api({ config, realm, corsOptions: options.corsOptions }); 43 | const messagesExpire: IMessagesExpire = new MessagesExpire({ 44 | realm, 45 | config, 46 | messageHandler, 47 | }); 48 | const checkBrokenConnections = new CheckBrokenConnections({ 49 | realm, 50 | config, 51 | onClose: (client) => { 52 | app.emit("disconnect", client); 53 | }, 54 | }); 55 | 56 | app.use(options.path, api); 57 | 58 | //use mountpath for WS server 59 | const customConfig = { 60 | ...config, 61 | path: path.posix.join(app.path(), options.path, "/"), 62 | }; 63 | 64 | const wss: IWebSocketServer = new WebSocketServer({ 65 | server, 66 | realm, 67 | config: customConfig, 68 | }); 69 | 70 | wss.on("connection", (client: IClient) => { 71 | const messageQueue = realm.getMessageQueueById(client.getId()); 72 | 73 | if (messageQueue) { 74 | let message: IMessage | undefined; 75 | 76 | while ((message = messageQueue.readMessage())) { 77 | messageHandler.handle(client, message); 78 | } 79 | realm.clearMessageQueue(client.getId()); 80 | } 81 | 82 | app.emit("connection", client); 83 | }); 84 | 85 | wss.on("message", (client: IClient, message: IMessage) => { 86 | app.emit("message", client, message); 87 | messageHandler.handle(client, message); 88 | }); 89 | 90 | wss.on("close", (client: IClient) => { 91 | app.emit("disconnect", client); 92 | }); 93 | 94 | wss.on("error", (error: Error) => { 95 | app.emit("error", error); 96 | }); 97 | 98 | messagesExpire.startMessagesExpiration(); 99 | checkBrokenConnections.start(); 100 | }; 101 | -------------------------------------------------------------------------------- /src/messageHandler/handler.ts: -------------------------------------------------------------------------------- 1 | import type { IClient } from "../models/client.ts"; 2 | import type { IMessage } from "../models/message.ts"; 3 | 4 | export type Handler = ( 5 | client: IClient | undefined, 6 | message: IMessage, 7 | ) => boolean; 8 | -------------------------------------------------------------------------------- /src/messageHandler/handlers/heartbeat/index.ts: -------------------------------------------------------------------------------- 1 | import type { IClient } from "../../../models/client.ts"; 2 | 3 | export const HeartbeatHandler = (client: IClient | undefined): boolean => { 4 | if (client) { 5 | const nowTime = new Date().getTime(); 6 | client.setLastPing(nowTime); 7 | } 8 | 9 | return true; 10 | }; 11 | -------------------------------------------------------------------------------- /src/messageHandler/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export { HeartbeatHandler } from "./heartbeat/index.ts"; 2 | export { TransmissionHandler } from "./transmission/index.ts"; 3 | -------------------------------------------------------------------------------- /src/messageHandler/handlers/transmission/index.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from "../../../enums.ts"; 2 | import type { IClient } from "../../../models/client.ts"; 3 | import type { IMessage } from "../../../models/message.ts"; 4 | import type { IRealm } from "../../../models/realm.ts"; 5 | 6 | export const TransmissionHandler = ({ 7 | realm, 8 | }: { 9 | realm: IRealm; 10 | }): ((client: IClient | undefined, message: IMessage) => boolean) => { 11 | const handle = (client: IClient | undefined, message: IMessage) => { 12 | const type = message.type; 13 | const srcId = message.src; 14 | const dstId = message.dst; 15 | 16 | const destinationClient = realm.getClientById(dstId); 17 | 18 | // User is connected! 19 | if (destinationClient) { 20 | const socket = destinationClient.getSocket(); 21 | try { 22 | if (socket) { 23 | const data = JSON.stringify(message); 24 | 25 | socket.send(data); 26 | } else { 27 | // Neither socket no res available. Peer dead? 28 | throw new Error("Peer dead"); 29 | } 30 | } catch (e) { 31 | // This happens when a peer disconnects without closing connections and 32 | // the associated WebSocket has not closed. 33 | // Tell other side to stop trying. 34 | if (socket) { 35 | socket.close(); 36 | } else { 37 | realm.removeClientById(destinationClient.getId()); 38 | } 39 | 40 | handle(client, { 41 | type: MessageType.LEAVE, 42 | src: dstId, 43 | dst: srcId, 44 | }); 45 | } 46 | } else { 47 | // Wait for this client to connect/reconnect (XHR) for important 48 | // messages. 49 | const ignoredTypes = [MessageType.LEAVE, MessageType.EXPIRE]; 50 | 51 | if (!ignoredTypes.includes(type) && dstId) { 52 | realm.addMessageToQueue(dstId, message); 53 | } else if (type === MessageType.LEAVE && !dstId) { 54 | realm.removeClientById(srcId); 55 | } else { 56 | // Unavailable destination specified with message LEAVE or EXPIRE 57 | // Ignore 58 | } 59 | } 60 | 61 | return true; 62 | }; 63 | 64 | return handle; 65 | }; 66 | -------------------------------------------------------------------------------- /src/messageHandler/handlersRegistry.ts: -------------------------------------------------------------------------------- 1 | import type { MessageType } from "../enums.ts"; 2 | import type { IClient } from "../models/client.ts"; 3 | import type { IMessage } from "../models/message.ts"; 4 | import type { Handler } from "./handler.ts"; 5 | 6 | export interface IHandlersRegistry { 7 | registerHandler(messageType: MessageType, handler: Handler): void; 8 | handle(client: IClient | undefined, message: IMessage): boolean; 9 | } 10 | 11 | export class HandlersRegistry implements IHandlersRegistry { 12 | private readonly handlers = new Map(); 13 | 14 | public registerHandler(messageType: MessageType, handler: Handler): void { 15 | if (this.handlers.has(messageType)) return; 16 | 17 | this.handlers.set(messageType, handler); 18 | } 19 | 20 | public handle(client: IClient | undefined, message: IMessage): boolean { 21 | const { type } = message; 22 | 23 | const handler = this.handlers.get(type); 24 | 25 | if (!handler) return false; 26 | 27 | return handler(client, message); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/messageHandler/index.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from "../enums.ts"; 2 | import { HeartbeatHandler, TransmissionHandler } from "./handlers/index.ts"; 3 | import type { IHandlersRegistry } from "./handlersRegistry.ts"; 4 | import { HandlersRegistry } from "./handlersRegistry.ts"; 5 | import type { IClient } from "../models/client.ts"; 6 | import type { IMessage } from "../models/message.ts"; 7 | import type { IRealm } from "../models/realm.ts"; 8 | import type { Handler } from "./handler.ts"; 9 | 10 | export interface IMessageHandler { 11 | handle(client: IClient | undefined, message: IMessage): boolean; 12 | } 13 | 14 | export class MessageHandler implements IMessageHandler { 15 | constructor( 16 | realm: IRealm, 17 | private readonly handlersRegistry: IHandlersRegistry = new HandlersRegistry(), 18 | ) { 19 | const transmissionHandler: Handler = TransmissionHandler({ realm }); 20 | const heartbeatHandler: Handler = HeartbeatHandler; 21 | 22 | const handleTransmission: Handler = ( 23 | client: IClient | undefined, 24 | { type, src, dst, payload }: IMessage, 25 | ): boolean => { 26 | return transmissionHandler(client, { 27 | type, 28 | src, 29 | dst, 30 | payload, 31 | }); 32 | }; 33 | 34 | const handleHeartbeat = (client: IClient | undefined, message: IMessage) => 35 | heartbeatHandler(client, message); 36 | 37 | this.handlersRegistry.registerHandler( 38 | MessageType.HEARTBEAT, 39 | handleHeartbeat, 40 | ); 41 | this.handlersRegistry.registerHandler( 42 | MessageType.OFFER, 43 | handleTransmission, 44 | ); 45 | this.handlersRegistry.registerHandler( 46 | MessageType.ANSWER, 47 | handleTransmission, 48 | ); 49 | this.handlersRegistry.registerHandler( 50 | MessageType.CANDIDATE, 51 | handleTransmission, 52 | ); 53 | this.handlersRegistry.registerHandler( 54 | MessageType.LEAVE, 55 | handleTransmission, 56 | ); 57 | this.handlersRegistry.registerHandler( 58 | MessageType.EXPIRE, 59 | handleTransmission, 60 | ); 61 | } 62 | 63 | public handle(client: IClient | undefined, message: IMessage): boolean { 64 | return this.handlersRegistry.handle(client, message); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/models/client.ts: -------------------------------------------------------------------------------- 1 | import type WebSocket from "ws"; 2 | 3 | export interface IClient { 4 | getId(): string; 5 | 6 | getToken(): string; 7 | 8 | getSocket(): WebSocket | null; 9 | 10 | setSocket(socket: WebSocket | null): void; 11 | 12 | getLastPing(): number; 13 | 14 | setLastPing(lastPing: number): void; 15 | 16 | send(data: T): void; 17 | } 18 | 19 | export class Client implements IClient { 20 | private readonly id: string; 21 | private readonly token: string; 22 | private socket: WebSocket | null = null; 23 | private lastPing: number = new Date().getTime(); 24 | 25 | constructor({ id, token }: { id: string; token: string }) { 26 | this.id = id; 27 | this.token = token; 28 | } 29 | 30 | public getId(): string { 31 | return this.id; 32 | } 33 | 34 | public getToken(): string { 35 | return this.token; 36 | } 37 | 38 | public getSocket(): WebSocket | null { 39 | return this.socket; 40 | } 41 | 42 | public setSocket(socket: WebSocket | null): void { 43 | this.socket = socket; 44 | } 45 | 46 | public getLastPing(): number { 47 | return this.lastPing; 48 | } 49 | 50 | public setLastPing(lastPing: number): void { 51 | this.lastPing = lastPing; 52 | } 53 | 54 | public send(data: T): void { 55 | this.socket?.send(JSON.stringify(data)); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/models/message.ts: -------------------------------------------------------------------------------- 1 | import type { MessageType } from "../enums.ts"; 2 | 3 | export interface IMessage { 4 | readonly type: MessageType; 5 | readonly src: string; 6 | readonly dst: string; 7 | readonly payload?: string | undefined; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/messageQueue.ts: -------------------------------------------------------------------------------- 1 | import type { IMessage } from "./message.ts"; 2 | 3 | export interface IMessageQueue { 4 | getLastReadAt(): number; 5 | 6 | addMessage(message: IMessage): void; 7 | 8 | readMessage(): IMessage | undefined; 9 | 10 | getMessages(): IMessage[]; 11 | } 12 | 13 | export class MessageQueue implements IMessageQueue { 14 | private lastReadAt: number = new Date().getTime(); 15 | private readonly messages: IMessage[] = []; 16 | 17 | public getLastReadAt(): number { 18 | return this.lastReadAt; 19 | } 20 | 21 | public addMessage(message: IMessage): void { 22 | this.messages.push(message); 23 | } 24 | 25 | public readMessage(): IMessage | undefined { 26 | if (this.messages.length > 0) { 27 | this.lastReadAt = new Date().getTime(); 28 | return this.messages.shift(); 29 | } 30 | 31 | return undefined; 32 | } 33 | 34 | public getMessages(): IMessage[] { 35 | return this.messages; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/models/realm.ts: -------------------------------------------------------------------------------- 1 | import type { IMessageQueue } from "./messageQueue.ts"; 2 | import { MessageQueue } from "./messageQueue.ts"; 3 | import { randomUUID } from "node:crypto"; 4 | import type { IClient } from "./client.ts"; 5 | import type { IMessage } from "./message.ts"; 6 | 7 | export interface IRealm { 8 | getClientsIds(): string[]; 9 | 10 | getClientById(clientId: string): IClient | undefined; 11 | 12 | getClientsIdsWithQueue(): string[]; 13 | 14 | setClient(client: IClient, id: string): void; 15 | 16 | removeClientById(id: string): boolean; 17 | 18 | getMessageQueueById(id: string): IMessageQueue | undefined; 19 | 20 | addMessageToQueue(id: string, message: IMessage): void; 21 | 22 | clearMessageQueue(id: string): void; 23 | 24 | generateClientId(generateClientId?: () => string): string; 25 | } 26 | 27 | export class Realm implements IRealm { 28 | private readonly clients = new Map(); 29 | private readonly messageQueues = new Map(); 30 | 31 | public getClientsIds(): string[] { 32 | return [...this.clients.keys()]; 33 | } 34 | 35 | public getClientById(clientId: string): IClient | undefined { 36 | return this.clients.get(clientId); 37 | } 38 | 39 | public getClientsIdsWithQueue(): string[] { 40 | return [...this.messageQueues.keys()]; 41 | } 42 | 43 | public setClient(client: IClient, id: string): void { 44 | this.clients.set(id, client); 45 | } 46 | 47 | public removeClientById(id: string): boolean { 48 | const client = this.getClientById(id); 49 | 50 | if (!client) return false; 51 | 52 | this.clients.delete(id); 53 | 54 | return true; 55 | } 56 | 57 | public getMessageQueueById(id: string): IMessageQueue | undefined { 58 | return this.messageQueues.get(id); 59 | } 60 | 61 | public addMessageToQueue(id: string, message: IMessage): void { 62 | if (!this.getMessageQueueById(id)) { 63 | this.messageQueues.set(id, new MessageQueue()); 64 | } 65 | 66 | this.getMessageQueueById(id)?.addMessage(message); 67 | } 68 | 69 | public clearMessageQueue(id: string): void { 70 | this.messageQueues.delete(id); 71 | } 72 | 73 | public generateClientId(generateClientId?: () => string): string { 74 | const generateId = generateClientId ? generateClientId : randomUUID; 75 | 76 | let clientId = generateId(); 77 | 78 | while (this.getClientById(clientId)) { 79 | clientId = generateId(); 80 | } 81 | 82 | return clientId; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/services/checkBrokenConnections/index.ts: -------------------------------------------------------------------------------- 1 | import type { IConfig } from "../../config/index.ts"; 2 | import type { IClient } from "../../models/client.ts"; 3 | import type { IRealm } from "../../models/realm.ts"; 4 | 5 | const DEFAULT_CHECK_INTERVAL = 300; 6 | 7 | type CustomConfig = Pick; 8 | 9 | export class CheckBrokenConnections { 10 | public readonly checkInterval: number; 11 | private timeoutId: NodeJS.Timeout | null = null; 12 | private readonly realm: IRealm; 13 | private readonly config: CustomConfig; 14 | private readonly onClose?: (client: IClient) => void; 15 | 16 | constructor({ 17 | realm, 18 | config, 19 | checkInterval = DEFAULT_CHECK_INTERVAL, 20 | onClose, 21 | }: { 22 | realm: IRealm; 23 | config: CustomConfig; 24 | checkInterval?: number; 25 | onClose?: (client: IClient) => void; 26 | }) { 27 | this.realm = realm; 28 | this.config = config; 29 | this.onClose = onClose; 30 | this.checkInterval = checkInterval; 31 | } 32 | 33 | public start(): void { 34 | if (this.timeoutId) { 35 | clearTimeout(this.timeoutId); 36 | } 37 | 38 | this.timeoutId = setTimeout(() => { 39 | this.checkConnections(); 40 | 41 | this.timeoutId = null; 42 | 43 | this.start(); 44 | }, this.checkInterval); 45 | } 46 | 47 | public stop(): void { 48 | if (this.timeoutId) { 49 | clearTimeout(this.timeoutId); 50 | this.timeoutId = null; 51 | } 52 | } 53 | 54 | private checkConnections(): void { 55 | const clientsIds = this.realm.getClientsIds(); 56 | 57 | const now = new Date().getTime(); 58 | const { alive_timeout: aliveTimeout } = this.config; 59 | 60 | for (const clientId of clientsIds) { 61 | const client = this.realm.getClientById(clientId); 62 | 63 | if (!client) continue; 64 | 65 | const timeSinceLastPing = now - client.getLastPing(); 66 | 67 | if (timeSinceLastPing < aliveTimeout) continue; 68 | 69 | try { 70 | client.getSocket()?.close(); 71 | } finally { 72 | this.realm.clearMessageQueue(clientId); 73 | this.realm.removeClientById(clientId); 74 | 75 | client.setSocket(null); 76 | 77 | this.onClose?.(client); 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/services/messagesExpire/index.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from "../../enums.ts"; 2 | import type { IConfig } from "../../config/index.ts"; 3 | import type { IMessageHandler } from "../../messageHandler/index.ts"; 4 | import type { IRealm } from "../../models/realm.ts"; 5 | 6 | export interface IMessagesExpire { 7 | startMessagesExpiration(): void; 8 | stopMessagesExpiration(): void; 9 | } 10 | 11 | type CustomConfig = Pick; 12 | 13 | export class MessagesExpire implements IMessagesExpire { 14 | private readonly realm: IRealm; 15 | private readonly config: CustomConfig; 16 | private readonly messageHandler: IMessageHandler; 17 | 18 | private timeoutId: NodeJS.Timeout | null = null; 19 | 20 | constructor({ 21 | realm, 22 | config, 23 | messageHandler, 24 | }: { 25 | realm: IRealm; 26 | config: CustomConfig; 27 | messageHandler: IMessageHandler; 28 | }) { 29 | this.realm = realm; 30 | this.config = config; 31 | this.messageHandler = messageHandler; 32 | } 33 | 34 | public startMessagesExpiration(): void { 35 | if (this.timeoutId) { 36 | clearTimeout(this.timeoutId); 37 | } 38 | 39 | // Clean up outstanding messages 40 | this.timeoutId = setTimeout(() => { 41 | this.pruneOutstanding(); 42 | 43 | this.timeoutId = null; 44 | 45 | this.startMessagesExpiration(); 46 | }, this.config.cleanup_out_msgs); 47 | } 48 | 49 | public stopMessagesExpiration(): void { 50 | if (this.timeoutId) { 51 | clearTimeout(this.timeoutId); 52 | this.timeoutId = null; 53 | } 54 | } 55 | 56 | private pruneOutstanding(): void { 57 | const destinationClientsIds = this.realm.getClientsIdsWithQueue(); 58 | 59 | const now = new Date().getTime(); 60 | const maxDiff = this.config.expire_timeout; 61 | 62 | const seen: Record = {}; 63 | 64 | for (const destinationClientId of destinationClientsIds) { 65 | const messageQueue = this.realm.getMessageQueueById(destinationClientId); 66 | 67 | if (!messageQueue) continue; 68 | 69 | const lastReadDiff = now - messageQueue.getLastReadAt(); 70 | 71 | if (lastReadDiff < maxDiff) continue; 72 | 73 | const messages = messageQueue.getMessages(); 74 | 75 | for (const message of messages) { 76 | const seenKey = `${message.src}_${message.dst}`; 77 | 78 | if (!seen[seenKey]) { 79 | this.messageHandler.handle(undefined, { 80 | type: MessageType.EXPIRE, 81 | src: message.dst, 82 | dst: message.src, 83 | }); 84 | 85 | seen[seenKey] = true; 86 | } 87 | } 88 | 89 | this.realm.clearMessageQueue(destinationClientId); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/services/webSocketServer/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "node:events"; 2 | import type { IncomingMessage } from "node:http"; 3 | import type WebSocket from "ws"; 4 | import { Errors, MessageType } from "../../enums.ts"; 5 | import type { IClient } from "../../models/client.ts"; 6 | import { Client } from "../../models/client.ts"; 7 | import type { IConfig } from "../../config/index.ts"; 8 | import type { IRealm } from "../../models/realm.ts"; 9 | import { WebSocketServer as Server } from "ws"; 10 | import type { Server as HttpServer } from "node:http"; 11 | import type { Server as HttpsServer } from "node:https"; 12 | import { IMessage } from "../../models/message.js"; 13 | 14 | export interface IWebSocketServer extends EventEmitter { 15 | readonly path: string; 16 | } 17 | 18 | type CustomConfig = Pick< 19 | IConfig, 20 | "path" | "key" | "concurrent_limit" | "createWebSocketServer" 21 | >; 22 | 23 | const WS_PATH = "peerjs"; 24 | 25 | export class WebSocketServer extends EventEmitter implements IWebSocketServer { 26 | public readonly path: string; 27 | private readonly realm: IRealm; 28 | private readonly config: CustomConfig; 29 | public readonly socketServer: Server; 30 | 31 | constructor({ 32 | server, 33 | realm, 34 | config, 35 | }: { 36 | server: HttpServer | HttpsServer; 37 | realm: IRealm; 38 | config: CustomConfig; 39 | }) { 40 | super(); 41 | 42 | this.setMaxListeners(0); 43 | 44 | this.realm = realm; 45 | this.config = config; 46 | 47 | const path = this.config.path; 48 | this.path = `${path}${path.endsWith("/") ? "" : "/"}${WS_PATH}`; 49 | 50 | const options: WebSocket.ServerOptions = { 51 | path: this.path, 52 | server, 53 | }; 54 | 55 | this.socketServer = config.createWebSocketServer 56 | ? config.createWebSocketServer(options) 57 | : new Server(options); 58 | 59 | this.socketServer.on("connection", (socket, req) => { 60 | this._onSocketConnection(socket, req); 61 | }); 62 | this.socketServer.on("error", (error: Error) => { 63 | this._onSocketError(error); 64 | }); 65 | } 66 | 67 | private _onSocketConnection(socket: WebSocket, req: IncomingMessage): void { 68 | // An unhandled socket error might crash the server. Handle it first. 69 | socket.on("error", (error) => { 70 | this._onSocketError(error); 71 | }); 72 | 73 | // We are only interested in the query, the base url is therefore not relevant 74 | const { searchParams } = new URL(req.url ?? "", "https://peerjs"); 75 | const { id, token, key } = Object.fromEntries(searchParams.entries()); 76 | 77 | if (!id || !token || !key) { 78 | this._sendErrorAndClose(socket, Errors.INVALID_WS_PARAMETERS); 79 | return; 80 | } 81 | 82 | if (key !== this.config.key) { 83 | this._sendErrorAndClose(socket, Errors.INVALID_KEY); 84 | return; 85 | } 86 | 87 | const client = this.realm.getClientById(id); 88 | 89 | if (client) { 90 | if (token !== client.getToken()) { 91 | // ID-taken, invalid token 92 | socket.send( 93 | JSON.stringify({ 94 | type: MessageType.ID_TAKEN, 95 | payload: { msg: "ID is taken" }, 96 | }), 97 | ); 98 | 99 | socket.close(); 100 | return; 101 | } 102 | 103 | this._configureWS(socket, client); 104 | return; 105 | } 106 | 107 | this._registerClient({ socket, id, token }); 108 | } 109 | 110 | private _onSocketError(error: Error): void { 111 | // handle error 112 | this.emit("error", error); 113 | } 114 | 115 | private _registerClient({ 116 | socket, 117 | id, 118 | token, 119 | }: { 120 | socket: WebSocket; 121 | id: string; 122 | token: string; 123 | }): void { 124 | // Check concurrent limit 125 | const clientsCount = this.realm.getClientsIds().length; 126 | 127 | if (clientsCount >= this.config.concurrent_limit) { 128 | this._sendErrorAndClose(socket, Errors.CONNECTION_LIMIT_EXCEED); 129 | return; 130 | } 131 | 132 | const newClient: IClient = new Client({ id, token }); 133 | this.realm.setClient(newClient, id); 134 | socket.send(JSON.stringify({ type: MessageType.OPEN })); 135 | 136 | this._configureWS(socket, newClient); 137 | } 138 | 139 | private _configureWS(socket: WebSocket, client: IClient): void { 140 | client.setSocket(socket); 141 | 142 | // Cleanup after a socket closes. 143 | socket.on("close", () => { 144 | if (client.getSocket() === socket) { 145 | this.realm.removeClientById(client.getId()); 146 | this.emit("close", client); 147 | } 148 | }); 149 | 150 | // Handle messages from peers. 151 | socket.on("message", (data) => { 152 | try { 153 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 154 | const message = JSON.parse(data.toString()) as Writable; 155 | 156 | message.src = client.getId(); 157 | 158 | this.emit("message", client, message); 159 | } catch (e) { 160 | this.emit("error", e); 161 | } 162 | }); 163 | 164 | this.emit("connection", client); 165 | } 166 | 167 | private _sendErrorAndClose(socket: WebSocket, msg: Errors): void { 168 | socket.send( 169 | JSON.stringify({ 170 | type: MessageType.ERROR, 171 | payload: { msg }, 172 | }), 173 | ); 174 | 175 | socket.close(); 176 | } 177 | } 178 | 179 | type Writable = { 180 | -readonly [K in keyof T]: T[K]; 181 | }; 182 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@tsconfig/strictest/tsconfig", "@tsconfig/node16/tsconfig"], 3 | "compilerOptions": { 4 | "lib": ["esnext"], 5 | "noEmit": true, 6 | "resolveJsonModule": true, 7 | "exactOptionalPropertyTypes": false, 8 | "allowImportingTsExtensions": true 9 | }, 10 | "include": ["./src/**/*", "__test__/**/*", "bin/**/*"] 11 | } 12 | --------------------------------------------------------------------------------