├── deps.ts ├── assets └── img │ └── screenshot_example_app.png ├── .gitignore ├── tests ├── deps.ts ├── integration │ ├── https_test.ts │ ├── server_throws_when_client_sends_to_non_existing_channel_test.ts │ ├── server.csr │ ├── server.crt │ ├── paths_test.ts │ ├── server.key │ ├── clients_arent_overwritten_test.ts │ └── tests.ts └── unit │ └── server_test.ts ├── mod.ts ├── console ├── bumper_ci_service.ts └── bumper_ci_service_files.ts ├── egg.json ├── src ├── types.ts ├── channel.ts ├── client.ts ├── websocket_client.ts └── server.ts ├── .github ├── workflows │ ├── release_drafter.yml │ ├── release.yml │ ├── pre_release.yml │ └── master.yml └── release_drafter_config.yml ├── README.md ├── LICENSE └── logo.svg /deps.ts: -------------------------------------------------------------------------------- 1 | export { Server as StdServer } from "https://deno.land/std@0.168.0/http/server.ts"; 2 | -------------------------------------------------------------------------------- /assets/img/screenshot_example_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drashland/wocket/HEAD/assets/img/screenshot_example_app.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # files 2 | .DS_Store 3 | 4 | # directories 5 | tmp/* 6 | .idea 7 | 8 | # exceptions 9 | !.gitkeep 10 | 11 | -------------------------------------------------------------------------------- /tests/deps.ts: -------------------------------------------------------------------------------- 1 | export { deferred, delay } from "https://deno.land/std@0.168.0/async/mod.ts"; 2 | export { 3 | assertEquals, 4 | assertRejects, 5 | } from "https://deno.land/std@0.168.0/testing/asserts.ts"; 6 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export { Channel } from "./src/channel.ts"; 2 | export { Client } from "./src/client.ts"; 3 | export { Server } from "./src/server.ts"; 4 | export type { IOptions } from "./src/server.ts"; 5 | export { WebSocketClient } from "./src/websocket_client.ts"; 6 | -------------------------------------------------------------------------------- /console/bumper_ci_service.ts: -------------------------------------------------------------------------------- 1 | import { BumperService } from "https://raw.githubusercontent.com/drashland/services/master/ci/bumper_service.ts"; 2 | import { bumperFiles, preReleaseFiles } from "./bumper_ci_service_files.ts"; 3 | 4 | const b = new BumperService("wocket", Deno.args); 5 | 6 | if (b.isForPreRelease()) { 7 | b.bump(preReleaseFiles); 8 | } else { 9 | b.bump(bumperFiles); 10 | } 11 | -------------------------------------------------------------------------------- /egg.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wocket", 3 | "description": "A WebSocket library for Deno.", 4 | "version": "1.0.0", 5 | "stable": true, 6 | "repository": "https://github.com/drashland/wocket", 7 | "files": [ 8 | "./mod.ts", 9 | "./deps.ts", 10 | "./src/*", 11 | "./README.md", 12 | "./logo.svg", 13 | "LICENSE" 14 | ], 15 | "checkAll": false, 16 | "entry": "./mod.ts", 17 | "unlisted": false, 18 | "checkTests": false, 19 | "check": true, 20 | "homepage": "", 21 | "ignore": [] 22 | } 23 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | type AlwaysProps = { 2 | id: number; 3 | }; 4 | 5 | type ReservedChannelProps = ChannelName extends "connect" 6 | ? { queryParams: URLSearchParams } 7 | : ChannelName extends "disconnect" ? { code: number; reason: string } 8 | : Record; 9 | 10 | /** 11 | * A callback to execute when a Channel object receives events. 12 | */ 13 | export type OnChannelCallback = ( 14 | event: CustomEvent< 15 | { packet: CustomProps } & ReservedChannelProps & AlwaysProps 16 | >, 17 | ) => void; 18 | -------------------------------------------------------------------------------- /console/bumper_ci_service_files.ts: -------------------------------------------------------------------------------- 1 | export const regexes = { 2 | // deno-lint-ignore camelcase 3 | const_statements: /version = ".+"/g, 4 | // deno-lint-ignore camelcase 5 | egg_json: /"version": ".+"/, 6 | // deno-lint-ignore camelcase 7 | import_export_statements: /wocket@v[0-9\.]+[0-9\.]+[0-9\.]/g, 8 | // deno-lint-ignore camelcase 9 | yml_deno: /deno: \[".+"\]/g, 10 | }; 11 | 12 | export const preReleaseFiles = [ 13 | { 14 | filename: "./egg.json", 15 | replaceTheRegex: regexes.egg_json, 16 | replaceWith: `"version": "{{ thisModulesLatestVersion }}"`, 17 | }, 18 | ]; 19 | 20 | export const bumperFiles = []; 21 | -------------------------------------------------------------------------------- /.github/workflows/release_drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - master 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | - uses: release-drafter/release-drafter@v5 15 | with: 16 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml 17 | config-name: release_drafter_config.yml 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.CI_USER_PAT }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish-egg: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Notify the castle about this release 10 | run: | 11 | curl -X POST \ 12 | -u "${{ secrets.CI_USER_NAME }}:${{ secrets.CI_USER_PAT }}" \ 13 | -H "Accept: application/vnd.github.everest-preview+json" \ 14 | -H "Content-Type: application/json" \ 15 | --data '{"event_type": "release", "client_payload": { "repo": "wocket", "module": "wocket", "version": "${{ github.ref }}" }}' \ 16 | https://api.github.com/repos/drashland/castle/dispatches 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wocket 2 | 3 | [![Latest Release](https://img.shields.io/github/release/drashland/wocket.svg?color=bright_green&label=latest)](https://github.com/drashland/wocket/releases/latest) 4 | [![CI](https://img.shields.io/github/actions/workflow/status/drashland/wocket/master.yml?branch=main&label=branch:main)](https://github.com/drashland/wocket/actions/workflows/master.yml?query=branch%3Amain) 5 | [![Drash Land Discord](https://img.shields.io/badge/discord-join-blue?logo=discord)](https://discord.gg/RFsCSaHRWK) 6 | 7 | Drash Land - Wocket logo 8 | 9 | Wocket is a WebSocket library for Deno. 10 | 11 | View the full documentation at https://drash.land/wocket. 12 | 13 | In the event the documentation pages are not accessible, please view the raw 14 | version of the documentation at 15 | https://github.com/drashland/website-v2/tree/main/docs. 16 | -------------------------------------------------------------------------------- /.github/workflows/pre_release.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release 2 | 3 | on: 4 | create 5 | 6 | jobs: 7 | 8 | # Make a PR to master from a new branch with changes, and delete the created one 9 | pre-release: 10 | 11 | # Only run when a release-v* branch is created, and not by drashbot 12 | if: contains(github.ref, 'release-v') && !contains(github.event.sender.login, 'drashbot') 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Notify the castle about this pre-release 18 | run: | 19 | curl -X POST \ 20 | -u "${{ secrets.CI_USER_NAME }}:${{ secrets.CI_USER_PAT }}" \ 21 | -H "Accept: application/vnd.github.everest-preview+json" \ 22 | -H "Content-Type: application/json" \ 23 | --data '{"event_type": "pre_release", "client_payload": { "repo": "wocket", "module": "wocket", "version": "${{ github.ref }}" }}' \ 24 | https://api.github.com/repos/drashland/castle/dispatches 25 | -------------------------------------------------------------------------------- /src/channel.ts: -------------------------------------------------------------------------------- 1 | type Cb = 2 | | ((event: CustomEvent) => void) 3 | | ((event: CustomEvent) => Promise); 4 | 5 | /** 6 | * This class represents channels, also known as "rooms" to some, and is 7 | * responsible for the following: 8 | * 9 | * - Connecting clients 10 | * - Disconnecting clients 11 | */ 12 | export class Channel { 13 | public name: string; 14 | /** 15 | * The callback to execute when a client connects to this channel. 16 | */ 17 | public callback: Cb; 18 | 19 | ////////////////////////////////////////////////////////////////////////////// 20 | // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// 21 | ////////////////////////////////////////////////////////////////////////////// 22 | 23 | /** 24 | * Construct an object of this class. 25 | * 26 | * @param name - The name of this channel. 27 | */ 28 | constructor(name: string, cb: Cb) { 29 | this.callback = cb; 30 | this.name = name; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | tests: 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest, macos-latest] 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Install Deno 22 | uses: denoland/setup-deno@v1 23 | 24 | - name: Unit 25 | run: | 26 | deno test --allow-all tests/unit 27 | 28 | # - name: Integration 29 | # run: | 30 | # deno test --allow-all --unsafely-ignore-certificate-errors tests/integration 31 | 32 | linter: 33 | # Only one OS is required since fmt is cross platform 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - uses: actions/checkout@v2 38 | 39 | - name: Install Deno 40 | uses: denoland/setup-deno@v1 41 | 42 | - name: Formatter 43 | run: deno fmt --check 44 | 45 | - name: Linter 46 | run: deno lint 47 | 48 | -------------------------------------------------------------------------------- /tests/integration/https_test.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "../../mod.ts"; 2 | import { deferred } from "../deps.ts"; 3 | 4 | //////////////////////////////////////////////////////////////////////////////// 5 | // TESTS /////////////////////////////////////////////////////////////////////// 6 | //////////////////////////////////////////////////////////////////////////////// 7 | 8 | Deno.test("HTTPS works", async () => { 9 | const server = new Server({ 10 | hostname: "localhost", 11 | port: 3000, 12 | protocol: "wss", 13 | certFile: "./tests/integration/server.crt", 14 | keyFile: "./tests/integration/server.key", 15 | }); 16 | server.run(); 17 | const client = new WebSocket( 18 | server.address, 19 | ); 20 | const promise = deferred(); 21 | client.onopen = function () { 22 | client.close(); 23 | }; 24 | client.onerror = function (err) { 25 | console.error(err); 26 | throw new Error("Fix meeeee, i dont work :("); 27 | }; 28 | client.onclose = function () { 29 | promise.resolve(); 30 | }; 31 | await promise; 32 | await server.close(); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/integration/server_throws_when_client_sends_to_non_existing_channel_test.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "../../mod.ts"; 2 | import { assertEquals, deferred } from "../deps.ts"; 3 | import { WebSocketClient } from "../../src/websocket_client.ts"; 4 | 5 | Deno.test("Server throws when client sends a message to a channel that doesn't exist", async () => { 6 | const server = new Server({ 7 | hostname: "localhost", 8 | port: 1447, 9 | protocol: "ws", 10 | }); 11 | server.run(); 12 | const msgPromise = deferred(); 13 | 14 | const client = new WebSocketClient(server.address); 15 | client.onmessage = (e) => msgPromise.resolve(e); 16 | const p = deferred(); 17 | client.onopen = () => p.resolve(); 18 | await p; 19 | client.to("usersssss", "hello"); 20 | const msg = await msgPromise; 21 | const p2 = deferred(); 22 | client.onclose = () => p2.resolve(); 23 | client.close(); 24 | await p2; 25 | await server.close(); 26 | assertEquals( 27 | msg.data, 28 | `The channel "usersssss" doesn't exist as the server hasn't created a listener for it`, 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2022 Drash Land 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/integration/server.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDUjCCAjoCCQDgYJ0va8AdODANBgkqhkiG9w0BAQsFADBrMQswCQYDVQQGEwJV 3 | UzEXMBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExDTALBgNVBAcMBEFwZXgxDjAMBgNV 4 | BAoMBURyYXNoMSQwIgYJKoZIhvcNAQkBFhVlcmljLmNyb29rc0BnbWFpbC5jb20w 5 | HhcNMjAwNDE4MDUxNTQwWhcNMjEwNDE4MDUxNTQwWjBrMQswCQYDVQQGEwJVUzEX 6 | MBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExDTALBgNVBAcMBEFwZXgxDjAMBgNVBAoM 7 | BURyYXNoMSQwIgYJKoZIhvcNAQkBFhVlcmljLmNyb29rc0BnbWFpbC5jb20wggEi 8 | MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCTEX+Y9kPJ0j8x1+CvN2MvR0Mh 9 | Vn5hlfd1oAkvuGLjaPwbHi/skDQ7aiPg/rHLmR7BPStNRsK3695Q37J/RqUFOuJc 10 | ZlH6Mg19ruQfQr85kPSUygtV8jAYguVI8yJdGeWw7zuxICmmzFdaf5akf4jfbnip 11 | 7aovQ9UOeffSs3B15DdYT1ijdE/NKHdr16G+SGB9C/nlSIBIE6lduyg1Wgi2wzYm 12 | aGPPEV83wOgzpBgumyLETL78M4FCxdLt30VBkVQYkXc9cmEs3vSO2Oe2dmzfgE8C 13 | 4vWrE4NjNoLuNQ+ufyGAkD8YQNkETr8P6SfE9FbrTI2g2+wqG9k82B1+GoiDAgMB 14 | AAEwDQYJKoZIhvcNAQELBQADggEBAGzl2iWNwQBHhgypNVp6vQjyd6LH7XD5VmTh 15 | 5XPeb7dWeRh8fMEr0lxJ1JoMwXoHb65AjyypZz39J7rqHp/+agrL96g+5KiFS24l 16 | 8qXsIMyAOuYcf0O45Cm1LNeWfkaKoq2sbbCRgNPD09FjWCoDIrXb5BA/i//+Bqi3 17 | Yi/6gB8OyKDcp5JV4PtWlh2yXU/RMed3/cOAinD1HMsHQsR7/PFbKcD3O4sjjOHn 18 | 7XLWODZI7NTkeHst0QojVZl/9gCN/BWgsSdTTD+mTmHkPIwr3iQo/1QOhIlGZUIn 19 | T1yGL28DLsqTCDmwcuSarWFPo/kQYd9A/XrU6Z82HfOEpWtd898= 20 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /tests/integration/server.crt: -------------------------------------------------------------------------------- 1 | 2 | -----BEGIN CERTIFICATE----- 3 | MIIDUjCCAjoCCQDgYJ0va8AdODANBgkqhkiG9w0BAQsFADBrMQswCQYDVQQGEwJV 4 | UzEXMBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExDTALBgNVBAcMBEFwZXgxDjAMBgNV 5 | BAoMBURyYXNoMSQwIgYJKoZIhvcNAQkBFhVlcmljLmNyb29rc0BnbWFpbC5jb20w 6 | HhcNMjAwNDE4MDUxNTQwWhcNMjEwNDE4MDUxNTQwWjBrMQswCQYDVQQGEwJVUzEX 7 | MBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExDTALBgNVBAcMBEFwZXgxDjAMBgNVBAoM 8 | BURyYXNoMSQwIgYJKoZIhvcNAQkBFhVlcmljLmNyb29rc0BnbWFpbC5jb20wggEi 9 | MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCTEX+Y9kPJ0j8x1+CvN2MvR0Mh 10 | Vn5hlfd1oAkvuGLjaPwbHi/skDQ7aiPg/rHLmR7BPStNRsK3695Q37J/RqUFOuJc 11 | ZlH6Mg19ruQfQr85kPSUygtV8jAYguVI8yJdGeWw7zuxICmmzFdaf5akf4jfbnip 12 | 7aovQ9UOeffSs3B15DdYT1ijdE/NKHdr16G+SGB9C/nlSIBIE6lduyg1Wgi2wzYm 13 | aGPPEV83wOgzpBgumyLETL78M4FCxdLt30VBkVQYkXc9cmEs3vSO2Oe2dmzfgE8C 14 | 4vWrE4NjNoLuNQ+ufyGAkD8YQNkETr8P6SfE9FbrTI2g2+wqG9k82B1+GoiDAgMB 15 | AAEwDQYJKoZIhvcNAQELBQADggEBAGzl2iWNwQBHhgypNVp6vQjyd6LH7XD5VmTh 16 | 5XPeb7dWeRh8fMEr0lxJ1JoMwXoHb65AjyypZz39J7rqHp/+agrL96g+5KiFS24l 17 | 8qXsIMyAOuYcf0O45Cm1LNeWfkaKoq2sbbCRgNPD09FjWCoDIrXb5BA/i//+Bqi3 18 | Yi/6gB8OyKDcp5JV4PtWlh2yXU/RMed3/cOAinD1HMsHQsR7/PFbKcD3O4sjjOHn 19 | 7XLWODZI7NTkeHst0QojVZl/9gCN/BWgsSdTTD+mTmHkPIwr3iQo/1QOhIlGZUIn 20 | T1yGL28DLsqTCDmwcuSarWFPo/kQYd9A/XrU6Z82HfOEpWtd898= 21 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /.github/release_drafter_config.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | 4 | change-template: '- $TITLE (#$NUMBER)' 5 | 6 | # Only add to the draft release when a PR has one of these labels 7 | include-labels: 8 | - 'Type: Major' 9 | - 'Type: Minor' 10 | - 'Type: Patch' 11 | - 'Type: Chore' 12 | 13 | # Here is how we determine what version the release would be, by using labels. Eg when "minor" is used, the drafter knows to bump up to a new minor version 14 | version-resolver: 15 | major: 16 | labels: 17 | - 'Type: Major' 18 | minor: 19 | labels: 20 | - 'Type: Minor' 21 | patch: 22 | labels: 23 | - 'Type: Patch' 24 | - 'Type: Chore' # allow our chore PR's to just be patches too 25 | default: patch 26 | 27 | # What our release will look like. If no draft has been created, then this will be used, otherwise $CHANGES just gets addedd 28 | template: | 29 | __Compatibility__ 30 | 31 | * Requires Deno v or higher 32 | * Uses Deno std@ 33 | 34 | __Importing__ 35 | 36 | * Import this latest release by using the following in your project(s): 37 | ```typescript 38 | import { Server } from "https://deno.land/x/wocket@v$RESOLVED_VERSION/mod.ts"; 39 | ``` 40 | 41 | __Updates__ 42 | 43 | $CHANGES 44 | -------------------------------------------------------------------------------- /tests/integration/paths_test.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "../../mod.ts"; 2 | import { assertEquals, deferred } from "../deps.ts"; 3 | import { WebSocketClient } from "../../src/websocket_client.ts"; 4 | 5 | Deno.test("Can use paths with the server", async () => { 6 | const server = new Server({ 7 | hostname: "localhost", 8 | port: 1447, 9 | protocol: "ws", 10 | path: "/my-app/users", 11 | }); 12 | server.run(); 13 | 14 | // client1 should close normally 15 | const client1 = new WebSocketClient(server.address + "/my-app/users"); 16 | const p1 = deferred<{ 17 | code: number; 18 | reason: string; 19 | }>(); 20 | client1.onclose = (e) => { 21 | p1.resolve({ 22 | code: e.code, 23 | reason: e.reason, 24 | }); 25 | }; 26 | client1.close(); 27 | const p1Result = await p1; 28 | 29 | // Client2 should be closed by server 30 | const client2 = new WebSocketClient(server.address); 31 | const p2 = deferred(); 32 | // deno-lint-ignore ban-ts-comment 33 | // @ts-ignore 34 | client2.onerror = (e) => p2.resolve(e.message); 35 | const p2Result = await p2; 36 | 37 | await server.close(); 38 | 39 | assertEquals(p1Result, { 40 | code: 0, 41 | reason: "", 42 | }); 43 | assertEquals( 44 | p2Result, 45 | "NetworkError: failed to connect to WebSocket: HTTP error: 406 Not Acceptable", 46 | ); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/integration/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAkxF/mPZDydI/MdfgrzdjL0dDIVZ+YZX3daAJL7hi42j8Gx4v 3 | 7JA0O2oj4P6xy5kewT0rTUbCt+veUN+yf0alBTriXGZR+jINfa7kH0K/OZD0lMoL 4 | VfIwGILlSPMiXRnlsO87sSAppsxXWn+WpH+I3254qe2qL0PVDnn30rNwdeQ3WE9Y 5 | o3RPzSh3a9ehvkhgfQv55UiASBOpXbsoNVoItsM2JmhjzxFfN8DoM6QYLpsixEy+ 6 | /DOBQsXS7d9FQZFUGJF3PXJhLN70jtjntnZs34BPAuL1qxODYzaC7jUPrn8hgJA/ 7 | GEDZBE6/D+knxPRW60yNoNvsKhvZPNgdfhqIgwIDAQABAoIBAHh7GFX+QVCALgcu 8 | PEIulNGxhpw0GHrneX9iKMRwQIregdHRbB9Xj4KxFE2JOax6d0iFTQkUlAZKc6k7 9 | aSZ9gEkFkVVy+WuHP6gb84RskO2fA880qg6xxpb9/MpDbH5q7dGCWxtjJtwfgNyB 10 | s651UHMCNFW5fvcTkeI3Jz/0gogAvS67jk9gD4MNFMMYFLXUmU3EwVO4bym1E/tI 11 | 9ql/RSVFZG75zAouDhojN0TxLh4xD4HJCJetOeEwKHeddKMN7Owt7356bZWQkDEL 12 | IBM2kctoMq+irZefg3BY6jO/PiAYP2rnBrfzVEMFod/kV8avHsQdzJrQAElCgDP1 13 | 4ANNkXECgYEAws4a8Y0KpbnpEtpbUEO29rYsiudib/eRVFnrGoYBLZa8QoR+W7eb 14 | 9DSoxPQmdmG0IV4GdyczV2JJtYZDVUzx5ipkKGVkEopBYgSkD9j/pxNjNrN5bOMf 15 | G31/D7Gv/061czNlGfDmiF6vMIzyXx90BJBNMPzpLa5bTN989KBNTrsCgYEAwUR8 16 | PEYxcWoDA5UNkDvHY3snSP8Z9h4IIlPBs5BQ4p4+YPEWzhFHU8VdFrYu90hvo8WW 17 | m9NGgZc8S0uD54XfszseC+kmd6RMSQfGh8H3qXDhADjXtRETF/zZqMyaaqTn6Kij 18 | vVJLhK5BFHxrxfAiNt2a+6b7kJVc+MNFcgO5pNkCgYAlXdmHOfGv5mBR0Haxh7uh 19 | yUH0Bvw30/oZfwH83XV+ZCqKa9W9DBQrHKq+1MJH9OfTerCszGUpvt26px1lUFQa 20 | PUlnAkPW1pRCE/fQXmRzjOF8DaMeAtbltAXaYdALnfJqPZKIDlB6Ggdqva6EFolv 21 | kqpr2id0LryumzPhzJnnnwKBgGT23yQp01iZdjuf2XcZE5/PzYUf57Mgm0U7ljy1 22 | TtwkqRfBuxUqB6Ymu+vKfxymFdRru7NqGzJGDLSVHbKMyIF8h8TXJ0ZnaYbZbgir 23 | 9zLoilKwX1fzNJNaf2bkhGLkBVcRCoE0Bcmpssv999tvCKC7AmUnJdKxhOFcOeJH 24 | Oet5AoGBAIUwH3iRYptdkPpSumNIypSBK1CxFxbjgTt7OHJJ5Y7cKl8hBJGi2l93 25 | S1KBW0Y48TJH8Virqty2KzoUmTD1QC1pPRoJQPxWTGAw5yWY3X2eFD2qzQGExHky 26 | DBtfPsniHwpoEEQWCbj5ASJZa4490T0ONFGdg+kHiVcv+aGeb9PE 27 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This class represents a single end-user client. It contains information about 3 | * their connection ID (when they first connected to the server), their web 4 | * socket connection, and more. 5 | */ 6 | export class Client { 7 | /** 8 | * This client's ID, which is the ID of its socket connection when it 9 | * connected to the server. For example: 10 | * 11 | * const clientId = conn.rid; 12 | */ 13 | public id: number; 14 | 15 | /** 16 | * Not used internally, added to allow users to assign 17 | * uuids to clients if they wanted to, mainly to remove any 18 | * possible type errors 19 | * 20 | * @example 21 | * ```js 22 | * server.on('connect', e => { 23 | * const client = server.clients.get(e.detail.id) 24 | * client.uuid = crypto.randomUUID(); // anytime uuid is used, there will be no type errors 25 | * }) 26 | * ``` 27 | */ 28 | public uuid = ""; 29 | 30 | /** 31 | * This client's WebSocket instance. 32 | */ 33 | public socket: WebSocket; 34 | 35 | ////////////////////////////////////////////////////////////////////////////// 36 | // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// 37 | ////////////////////////////////////////////////////////////////////////////// 38 | 39 | /** 40 | * Construct an object of this class. 41 | * 42 | * @param id - The client's connection ID (given by the server when the client 43 | * connects to the server). 44 | * @param socket - The socket connection (given by the server when the client 45 | * connects to the server). Use this to send events back to the client. For 46 | * example: 47 | * 48 | * this.socket.send("something"); 49 | */ 50 | constructor(id: number, socket: WebSocket) { 51 | this.id = id; 52 | this.socket = socket; 53 | } 54 | 55 | public send( 56 | message: string | ArrayBufferLike | Blob | ArrayBufferView, 57 | ): void { 58 | this.socket.send(message); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/websocket_client.ts: -------------------------------------------------------------------------------- 1 | type Callback = (message: Record) => void; 2 | 3 | /** 4 | * A helper class built on top of the native WebSocket, to make it easier to 5 | * send messages to channels, and listen for messages on channels. 6 | * 7 | * Specifically built for Drash. 8 | * 9 | * Only defined an onmessage handler. 10 | */ 11 | export class WebSocketClient extends WebSocket { 12 | #handlers: Map = new Map(); 13 | 14 | constructor(url: string) { 15 | super(url); 16 | this.onmessage = (e) => { 17 | try { 18 | const packet = JSON.parse(e.data); 19 | const { channel, message } = packet; 20 | const handler = this.#handlers.get(channel); 21 | if (handler) { 22 | handler(message); 23 | } 24 | } catch (err) { 25 | if (err instanceof SyntaxError && typeof e.data === "string") { // problem parsing the message, will be us sending an error message 26 | throw new Error(e.data); 27 | } 28 | throw err; 29 | } 30 | }; 31 | } 32 | 33 | /** 34 | * Register a listener for a channel name 35 | * 36 | * @param channelName - The channel name to listen on 37 | * @param cb - The handler 38 | * 39 | * @example 40 | * ```js 41 | * on<{ name: string }>("user", message => { 42 | * console.log(message.user.name); 43 | * }) 44 | * ``` 45 | */ 46 | public on>( 47 | channelName: string, 48 | cb: (message: T) => void, 49 | ) { 50 | this.#handlers.set(channelName, cb as Callback); 51 | } 52 | 53 | /** 54 | * Send a message to the server 55 | * 56 | * @param channelName - The channel name to send to 57 | * @param message - The message to send to the channel 58 | */ 59 | public to( 60 | channelName: string, 61 | message: Record | string | Uint8Array, 62 | ): void { 63 | if (this.readyState === WebSocket.CONNECTING) { 64 | return this.to(channelName, message); 65 | } 66 | const packet = JSON.stringify({ 67 | channel: channelName, 68 | message, 69 | }); 70 | this.send(packet); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | back 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/integration/clients_arent_overwritten_test.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "../../mod.ts"; 2 | import { assertEquals, deferred } from "../deps.ts"; 3 | import { WebSocketClient } from "../../src/websocket_client.ts"; 4 | 5 | Deno.test("If 2 clients, and client 1 leaves, client 2 wont be overwritten", async () => { 6 | // Say the clients looks like: Map { 1: ..., 2: ... } 7 | // Client 1 leaves: Map { 2: ... } 8 | // Before, we did `clients.set(clients.size + 1, ...)`, and as `clients.size` would evaluate 9 | // to "2", it'd overwrtie the client. 10 | // Now, we find the *next* available id 11 | 12 | const server = new Server({ 13 | hostname: "localhost", 14 | port: 1447, 15 | protocol: "ws", 16 | }); 17 | server.run(); 18 | server.on("connect", (e) => { 19 | server.to("connected", { 20 | yourId: e.detail.id, 21 | }, e.detail.id); 22 | }); 23 | 24 | const client1IdPromise = deferred(); 25 | const client2IdPromise = deferred(); 26 | 27 | const client1 = new WebSocketClient(server.address); 28 | client1.on<{ yourId: number }>("connected", (e) => { 29 | const { yourId } = e; 30 | client1IdPromise.resolve(yourId); 31 | }); 32 | 33 | const client1Id = await client1IdPromise; 34 | 35 | const client2 = new WebSocketClient(server.address); 36 | client2.on<{ yourId: number }>("connected", (e) => { 37 | const { yourId } = e; 38 | client2IdPromise.resolve(yourId); 39 | }); 40 | 41 | const client2Id = await client2IdPromise; 42 | 43 | try { 44 | assertEquals(client1Id, 1); 45 | assertEquals(client2Id, 2); 46 | } catch (e) { 47 | const p = deferred(); 48 | client1.onclose = () => { 49 | client2.close(); 50 | }; 51 | client2.onclose = () => { 52 | p.resolve(); 53 | }; 54 | client1.close(); 55 | await p; 56 | throw e; 57 | } 58 | 59 | let p = deferred(); 60 | client1.onclose = () => p.resolve(); 61 | client1.close(); 62 | await p; 63 | assertEquals(server.clients.size, 1); 64 | assertEquals(server.clients.get(1), undefined); 65 | assertEquals(!!server.clients.get(2), true); 66 | 67 | // client3 should have id of 1, client4 should have id of 3 68 | const client3IdPromise = deferred(); 69 | const client4IdPromise = deferred(); 70 | const client3 = new WebSocketClient(server.address); 71 | client3.on<{ yourId: number }>("connected", (e) => { 72 | const { yourId } = e; 73 | client3IdPromise.resolve(yourId); 74 | }); 75 | 76 | const client3Id = await client3IdPromise; 77 | 78 | const client4 = new WebSocketClient(server.address); 79 | client4.on<{ yourId: number }>("connected", (e) => { 80 | const { yourId } = e; 81 | client4IdPromise.resolve(yourId); 82 | }); 83 | 84 | const client4Id = await client4IdPromise; 85 | 86 | assertEquals(server.clients.size, 3); 87 | 88 | p = deferred(); 89 | client2.onclose = () => client3.close(); 90 | client3.onclose = () => client4.close(); 91 | client4.onclose = () => p.resolve(); 92 | client2.close(); 93 | await p; 94 | await server.close(); 95 | 96 | assertEquals(client3Id, 1), assertEquals(client4Id, 3); 97 | }); 98 | -------------------------------------------------------------------------------- /tests/integration/tests.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "../../mod.ts"; 2 | import { assertEquals, deferred } from "../deps.ts"; 3 | import { WebSocketClient } from "../../src/websocket_client.ts"; 4 | 5 | Deno.test("Full fledged end to end test", async () => { 6 | const server = new Server({ 7 | hostname: "localhost", 8 | port: 1447, 9 | protocol: "ws", 10 | }); 11 | 12 | server.run(); 13 | 14 | const p = deferred(); 15 | 16 | const connectCalled: number[] = []; 17 | 18 | // Example using the connect handler 19 | server.on("connect", (e) => { 20 | const { id } = e.detail; 21 | connectCalled.push(id); 22 | }); 23 | 24 | let disconnectCalled = null; 25 | 26 | // Example using the disconnect handler 27 | server.on("disconnect", ( 28 | e, 29 | ) => { 30 | const { id, code, reason } = e.detail; 31 | disconnectCalled = { 32 | id, 33 | code, 34 | reason, 35 | }; 36 | }); 37 | 38 | let channelCalled = null; 39 | 40 | // Example using generics + custom channel handler and getting the data from the event 41 | type UserMessage = { 42 | username: string; 43 | sender: number; 44 | id: number; // client socket id 45 | }; 46 | server.on("channel", (event) => { 47 | const { username, sender, id } = event.detail.packet; 48 | channelCalled = { 49 | username, 50 | sender, 51 | id, 52 | }; 53 | 54 | // Example sending to all clients, specifying which channel it is for 55 | server.to("chat-message", { 56 | message: "yep, the server got your msg :)", 57 | }); 58 | 59 | // example sending to a specific client 60 | server.to("chat-message", { 61 | message: "this message is only for you *wink wink*", 62 | }, 0); 63 | 64 | // example sending to all OTHER clients TODO :: broadcast? 65 | server.broadcast("chat-message", { 66 | message: "You got this message but client with id of " + id + 67 | " shouldnt have", 68 | }, 0); 69 | }); 70 | 71 | const client1ReceivedMessages: string[] = []; 72 | const client2ReceivedMessages: string[] = []; 73 | 74 | const client = new WebSocketClient("ws://localhost:1447"); 75 | const p1 = deferred(); 76 | client.onopen = () => p1.resolve(); 77 | await p1; 78 | const client2 = new WebSocketClient(server.address); 79 | const pTwo = deferred(); 80 | client2.onopen = () => pTwo.resolve(); 81 | await pTwo; 82 | client.on<{ message: string }>("chat-message", (packet) => { 83 | const { message } = packet; 84 | client1ReceivedMessages.push(message); 85 | }); 86 | client2.on<{ message: string }>("chat-message", (packet) => { 87 | const { message } = packet; 88 | client2ReceivedMessages.push(message); 89 | if (client2ReceivedMessages.length === 2) { 90 | p.resolve(); 91 | } 92 | }); 93 | client.to("channel", { 94 | username: "darth vader", 95 | sender: 69, 96 | }); 97 | 98 | await p; 99 | const p2 = deferred(); 100 | const p3 = deferred(); 101 | client.onclose = () => p2.resolve(); 102 | client2.onclose = () => p3.resolve(); 103 | client.close(); 104 | client2.close(); 105 | await p2; 106 | await p3; 107 | await server.close(); 108 | assertEquals(connectCalled, [0, 1]); 109 | assertEquals(disconnectCalled, { 110 | code: 1005, 111 | id: 1, 112 | reason: "", 113 | }); 114 | assertEquals(channelCalled, { 115 | username: "darth vader", 116 | sender: 69, 117 | id: 0, 118 | }); 119 | assertEquals(client1ReceivedMessages, [ 120 | "yep, the server got your msg :)", 121 | "this message is only for you *wink wink*", 122 | ]); 123 | assertEquals(client2ReceivedMessages, [ 124 | "yep, the server got your msg :)", 125 | "You got this message but client with id of 0 shouldnt have", 126 | ]); 127 | }); 128 | -------------------------------------------------------------------------------- /tests/unit/server_test.ts: -------------------------------------------------------------------------------- 1 | import { Channel, Server } from "../../mod.ts"; 2 | import { Client } from "../../src/client.ts"; 3 | import { assertEquals, deferred } from "../deps.ts"; 4 | 5 | Deno.test("close()", async (t) => { 6 | await t.step("Should close the server", async () => { 7 | const server = new Server({ 8 | hostname: "localhost", 9 | port: 1337, 10 | protocol: "ws", 11 | }); 12 | server.run(); 13 | const client = new WebSocket(server.address); 14 | const p = deferred(); 15 | client.onopen = () => p.resolve(); 16 | await p; 17 | const p2 = deferred(); 18 | client.onclose = () => p2.resolve(); 19 | client.close(); 20 | await p2; 21 | await server.close(); 22 | }); 23 | await t.step( 24 | "Should not error if server is not set or not defined", 25 | async () => { 26 | const server = new Server({ 27 | hostname: "localhost", 28 | port: 1337, 29 | protocol: "ws", 30 | }); 31 | await server.close(); 32 | }, 33 | ); 34 | await t.step("Should not error if server is already closed", async () => { 35 | const server = new Server({ 36 | hostname: "localhost", 37 | port: 1337, 38 | protocol: "ws", 39 | }); 40 | await server.close(); 41 | await server.close(); 42 | }); 43 | }); 44 | Deno.test("uuid", async (t) => { 45 | await t.step("Should set the uuid on the clients", () => { 46 | const server = new Server({ 47 | hostname: "localhost", 48 | port: 1337, 49 | protocol: "ws", 50 | }); 51 | server.clients.set(1, new Client(1, null as unknown as WebSocket)); 52 | let client = server.clients.get(1) as Client; 53 | client.uuid = "hello world"; 54 | client = server.clients.get(1) as Client; 55 | assertEquals(client.uuid, "hello world"); 56 | }); 57 | }); 58 | 59 | Deno.test("on()", async (t) => { 60 | await t.step("Registers callbacks for the name", () => { 61 | const server = new Server({ 62 | hostname: "localhost", 63 | port: 1337, 64 | protocol: "ws", 65 | }); 66 | let yes = false; 67 | server.on("$1000", () => { 68 | yes = true; 69 | }); 70 | const channel = server.channels.get("$1000") as Channel; // this should be set now 71 | const cb = channel.callback; 72 | cb("" as unknown as CustomEvent); 73 | assertEquals(yes, true); 74 | }); 75 | 76 | await t.step("Type checks pass when using generics for channels", () => { 77 | const server = new Server({ 78 | hostname: "localhost", 79 | port: 1337, 80 | protocol: "ws", 81 | }); 82 | server.on("connect", (e) => { 83 | e.detail.id.toPrecision(); 84 | }); 85 | server.on("disconnect", (e) => { 86 | e.detail.id.toPrecision(); 87 | e.detail.code.toPrecision(); 88 | e.detail.reason.replace("", ""); 89 | }); 90 | server.on<{ 91 | name: string; 92 | }>("custom-channel", (e) => { 93 | e.detail.id; 94 | e.detail.packet.name; 95 | }); 96 | }); 97 | }); 98 | 99 | Deno.test("to()", async (t) => { 100 | await t.step("Should send a message to all clienta", async () => { 101 | const server = new Server({ 102 | hostname: "localhost", 103 | port: 1337, 104 | protocol: "ws", 105 | }); 106 | server.run(); 107 | let message1 = ""; 108 | const socket1 = { 109 | send: (msg: string) => { 110 | message1 = msg; 111 | }, 112 | } as unknown as WebSocket; 113 | let message2 = ""; 114 | const socket2 = { 115 | send: (msg: string) => { 116 | message2 = msg; 117 | }, 118 | } as unknown as WebSocket; 119 | const client1 = new Client(10, socket1); 120 | const client2 = new Client(11, socket2); 121 | server.clients.set(10, client1); 122 | server.clients.set(11, client2); 123 | server.to("test channel", { 124 | message: "from test", 125 | }); 126 | await server.close(); 127 | assertEquals( 128 | message1, 129 | '{"channel":"test channel","message":{"message":"from test"}}', 130 | ); 131 | assertEquals( 132 | message2, 133 | '{"channel":"test channel","message":{"message":"from test"}}', 134 | ); 135 | }); 136 | await t.step("Should send a message to a specific client", async () => { 137 | const server = new Server({ 138 | hostname: "localhost", 139 | port: 1337, 140 | protocol: "ws", 141 | }); 142 | server.run(); 143 | let message1 = ""; 144 | const socket1 = { 145 | send: (msg: string) => { 146 | message1 = msg; 147 | }, 148 | } as unknown as WebSocket; 149 | let message2 = ""; 150 | const socket2 = { 151 | send: (msg: string) => { 152 | message2 = msg; 153 | }, 154 | } as unknown as WebSocket; 155 | const client1 = new Client(10, socket1); 156 | const client2 = new Client(11, socket2); 157 | server.clients.set(10, client1); 158 | server.clients.set(11, client2); 159 | server.to("test channel", { 160 | message: "from test", 161 | }, 11); 162 | await server.close(); 163 | assertEquals(message1, ""); 164 | assertEquals( 165 | message2, 166 | '{"channel":"test channel","message":{"message":"from test"}}', 167 | ); 168 | }); 169 | }); 170 | 171 | Deno.test("run()", async (t) => { 172 | await t.step("Runs the server", async () => { 173 | const server = new Server({ 174 | hostname: "localhost", 175 | port: 1337, 176 | protocol: "ws", 177 | }); 178 | server.run(); 179 | const client = new WebSocket(server.address); 180 | const p = deferred(); 181 | client.onopen = () => p.resolve(); 182 | await p; 183 | const p2 = deferred(); 184 | client.onclose = () => p2.resolve(); 185 | client.close(); 186 | await p2; 187 | await server.close(); 188 | }); 189 | }); 190 | 191 | Deno.test("searchParams", async (t) => { 192 | await t.step("Should set the search params", async () => { 193 | const server = new Server({ 194 | hostname: "localhost", 195 | port: 1337, 196 | protocol: "ws", 197 | }); 198 | const connected = deferred(); 199 | server.on("connect", (e) => { 200 | connected.resolve(e.detail.queryParams); 201 | }); 202 | server.run(); 203 | const client = new WebSocket(server.address + "?name=edward"); 204 | const p = deferred(); 205 | client.onopen = () => p.resolve(); 206 | await p; 207 | const queryParams = await connected; 208 | const p2 = deferred(); 209 | client.onclose = () => p2.resolve(); 210 | client.close(); 211 | await p2; 212 | await server.close(); 213 | assertEquals(queryParams.get("name"), "edward"); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { StdServer } from "../deps.ts"; 2 | import { Channel } from "./channel.ts"; 3 | import { Client } from "./client.ts"; 4 | import { OnChannelCallback } from "./types.ts"; 5 | 6 | export interface IOptions { 7 | /** The hostname to start the websocket server on */ 8 | hostname: string; 9 | /** The port to start the websocket server on */ 10 | port: number; 11 | /** Protocol for the server */ 12 | protocol: "ws" | "wss"; 13 | /** Path to the key file if using wss */ 14 | keyFile?: string; 15 | /** Path to the cert file if using wss */ 16 | certFile?: string; 17 | /** The path of which this server will handle connections for. Defaults to "/" */ 18 | path?: string; 19 | } 20 | 21 | type TRequestHandler = (r: Request) => Promise; 22 | 23 | /** 24 | * A class to create a websocket server, handling clients connecting, 25 | * and being able to handle messages from them, and send messages to them 26 | */ 27 | export class Server { 28 | #options: IOptions; 29 | 30 | /** 31 | * A map of all created channels. The key is the channel name and the value is 32 | * the channel object. 33 | */ 34 | public channels: Map = new Map(); 35 | 36 | /** 37 | * A map of all clients connected to this server. The key is the client's ID 38 | * and the value is the client object. 39 | */ 40 | public clients: Map = new Map(); 41 | 42 | /** 43 | * Our server instance that is serving the app 44 | */ 45 | #server!: StdServer; 46 | 47 | /** 48 | * A promise we need to await after calling close() on #server 49 | */ 50 | #serverPromise!: Promise; 51 | 52 | //// CONSTRUCTOR ///////////////////////////////////////////////////////////// 53 | 54 | /** 55 | * @param options 56 | */ 57 | constructor(options: IOptions) { 58 | this.#options = options; 59 | } 60 | 61 | /** 62 | * Get the full address that this server is running on. 63 | */ 64 | get address(): string { 65 | return `${this.#options.protocol}://${this.#options.hostname}:${this.#options.port}`; 66 | } 67 | 68 | //// PUBLIC ////////////////////////////////////////////////////////////////// 69 | 70 | /** 71 | * Broadcast to other clients in a channel excluding the one passed in 72 | * 73 | * @param channelName - Channel to send the message to 74 | * @param message - The message to send 75 | * @param id - Id of client it ignore (not send the message to) 76 | * 77 | * @example 78 | * ```ts 79 | * interface SomeEvent { id: number } 80 | * server.on("some-event", (event: CustomEvent) => { 81 | * const { id } = event.detail 82 | * server.broadcast("some-channel", { 83 | * message: "Shh everybody! " + id + " won't be getting this message!" 84 | * }, id) 85 | * }) 86 | * ``` 87 | */ 88 | public broadcast( 89 | channelName: string, 90 | message: Record, 91 | id: number, 92 | ) { 93 | for (const clientId of this.clients.keys()) { 94 | if (clientId !== id) { // ignore sending to client that has the passed in id 95 | this.#send(clientId, channelName, message); 96 | } 97 | } 98 | } 99 | 100 | /** 101 | * Send a message to the channel, so clients listening on that channel 102 | * will receive this message 103 | * 104 | * @param channelName - The channel to send the message to 105 | * @param message - The message to send 106 | * @param onlySendTo - Id of a client, if you only wish to send the message to just that client 107 | */ 108 | public to( 109 | channelName: string, 110 | message: Record | string, 111 | onlySendTo?: number, 112 | ): void { 113 | // If sending to a specific client, only do that 114 | if (onlySendTo !== undefined) { 115 | const id = onlySendTo; 116 | this.#send(id, channelName, message); 117 | return; 118 | } 119 | // Otherwise send to all clients 120 | for (const clientId of this.clients.keys()) { 121 | this.#send(clientId, channelName, message); 122 | } 123 | } 124 | 125 | /** 126 | * Do the following: 127 | * 128 | * 1. Create a channel (if it does not already exist). 129 | * 2. Add a callback to that channel. This callback will be executed when 130 | * events are sent to the channel. 131 | * 3. Store the callback in the list of callbacks that the channel has. 132 | * 133 | * @param channelName - The name of the channel. 134 | * @param cb - See OnChannelCallback in the `types.ts` file. 135 | */ 136 | public on< 137 | CustomProps extends Record | string | Uint8Array, 138 | // Ignore because we need to use any to pass the channelName parameter to the generic 139 | // deno-lint-ignore no-explicit-any 140 | ChannelName extends string = any, 141 | >( 142 | channelName: ChannelName, 143 | cb: OnChannelCallback, 144 | ): void { 145 | const channel = new Channel(channelName, cb); // even if one exists, overwrite it 146 | this.channels.set(channelName, channel); 147 | } 148 | 149 | /** 150 | * Run the sever. 151 | */ 152 | run(): void { 153 | this.#server = new StdServer({ 154 | hostname: this.#options.hostname, 155 | port: this.#options.port, 156 | handler: this.#getHandler(), 157 | }); 158 | if (this.#options.protocol === "ws") { 159 | this.#serverPromise = this.#server.listenAndServe(); 160 | } 161 | if (this.#options.protocol === "wss") { 162 | this.#serverPromise = this.#server.listenAndServeTls( 163 | this.#options.certFile as string, 164 | this.#options.keyFile as string, 165 | ); 166 | } 167 | } 168 | 169 | /** 170 | * Close the server, stopping all resources and breaking 171 | * all connections to clients 172 | */ 173 | public async close(): Promise { 174 | try { 175 | this.#server.close(); 176 | await this.#serverPromise; 177 | } catch (_e) { 178 | // Do nothing, the server was probably already closed 179 | } 180 | } 181 | 182 | //// PRIVATE ///////////////////////////////////////////////////////////////// 183 | 184 | /** 185 | * Get the request handler for the server. 186 | * 187 | * @returns The request handler. 188 | */ 189 | #getHandler(): TRequestHandler { 190 | const clients = this.clients; 191 | const channels = this.channels; 192 | const options = this.#options; 193 | 194 | // deno-lint-ignore require-await 195 | return async function (r: Request): Promise { 196 | const url = new URL(r.url); 197 | const { pathname } = url; 198 | if (options.path && options.path !== pathname) { 199 | return new Response( 200 | "The client has not specified the correct path that the server is listening on.", 201 | { 202 | status: 406, 203 | }, 204 | ); 205 | } 206 | const { socket, response } = Deno.upgradeWebSocket(r); 207 | 208 | // Create the client and find the best available id to use 209 | let id = 1; 210 | while (true) { 211 | if (clients.get(id)) { 212 | id++; 213 | continue; 214 | } 215 | // No client exists with `id`, so use that 216 | break; 217 | } 218 | const client = new Client(id, socket); 219 | clients.set(id, client); 220 | 221 | socket.onopen = () => { 222 | // Call the connect callback if defined by the user 223 | const channel = channels.get("connect"); 224 | const connectEvent = new CustomEvent("connect", { 225 | detail: { 226 | id: client.id, 227 | queryParams: url.searchParams, 228 | }, 229 | }); 230 | if (channel) channel.callback(connectEvent); 231 | }; 232 | 233 | // When the socket calls `.send()`, then do the following 234 | socket.onmessage = (message: MessageEvent) => { 235 | try { 236 | if ("data" in message && typeof message.data === "string") { 237 | const json = JSON.parse(message.data); // TODO wrap in a try catch, if error throw then send error message to client maybe? ie malformed request 238 | // Get the channel they want to send the msg to 239 | const channel = channels.get(json.channel); 240 | if (!channel) { 241 | socket.send( 242 | `The channel "${json.channel}" doesn't exist as the server hasn't created a listener for it`, 243 | ); 244 | return; 245 | } 246 | // construct the event 247 | const customEvent = new CustomEvent(channel.name, { 248 | detail: { 249 | packet: json.message, 250 | id: client.id, 251 | }, 252 | }); 253 | // Call the user defined handler for the channel. Essentially a `server.on("channel", ...)` will be called 254 | const callback = channel.callback; 255 | callback(customEvent); 256 | } 257 | } catch (error) { 258 | socket.send(error.message); 259 | } 260 | }; 261 | 262 | // When the socket calls `.close()`, then do the following 263 | socket.onclose = (ev: CloseEvent) => { 264 | // Remove the client 265 | clients.delete(client.id); 266 | // Call the disconnect handler if defined 267 | const { code, reason } = ev; 268 | const disconnectEvent = new CustomEvent("disconnect", { 269 | detail: { 270 | id: client.id, 271 | code, 272 | reason, 273 | }, 274 | }); 275 | const disconnectHandler = channels.get("disconnect"); 276 | if (disconnectHandler) { 277 | disconnectHandler.callback(disconnectEvent); 278 | } 279 | }; 280 | return response; 281 | }; 282 | } 283 | 284 | #send( 285 | clientId: number, 286 | channelName: string, 287 | message: Record | string, 288 | ) { 289 | const client = this.clients.get(clientId); 290 | client!.socket.send(JSON.stringify({ 291 | channel: channelName, 292 | message: message, 293 | })); 294 | } 295 | } 296 | --------------------------------------------------------------------------------