├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── demos ├── README.md ├── package.json ├── simple │ ├── ClientClass.js │ ├── ServerClass.js │ └── index.js └── web │ ├── ClientClass.js │ ├── GameManager.js │ ├── ServerClass.js │ ├── game.js │ ├── index.js │ └── public │ ├── index.html │ ├── page.js │ ├── style.css │ └── ui.js ├── docs ├── API.md ├── ARCHITECTURE.md ├── CHANGELOG.md ├── DEV.md ├── HOWTO.md ├── README.md └── TODO.md ├── package-lock.json ├── package.json ├── src ├── classes.js ├── generate-socketless.js ├── index.js ├── upgraded-socket.js ├── utils.js └── webclient │ ├── classes.js │ ├── custom-router.js │ └── route-handler.js └── test ├── basic.test.js ├── connections.test.js ├── core.test.js ├── disallowed.test.js ├── server.test.js ├── syncdata.test.js └── webclient ├── auth ├── index.html └── index.js ├── basic ├── index.html └── index.js ├── dedicated ├── index.html └── index.js ├── params ├── index.html ├── index.js └── targets.js ├── pass-through ├── index.html └── index.js ├── standalone ├── index.html └── index.js ├── stateful ├── index.html └── index.js ├── statemod ├── index.html └── index.js └── webclient.test.js /.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 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | demos/node_modules 4 | demos/package-lock.json 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | demos 3 | node_modules 4 | test 5 | .gitignore 6 | TODO.md 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019-2023, Mike "Pomax" Kamermans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Socketless 2 | 3 | Socketless is a websocket-based RPC-like framework for client/server implementations, written specifically so you never have to write any websocket or RPC code. As far as the clients and servers know, there is no network, code is just "normal function-based API code", with the only caveat being that function calls that need to return values will do so asynchronously. Just like any other `async`/`await` code you're used to writing. 4 | 5 | ### The current version of `socketless` is `v6.0.0` 6 | [(See the changelog for more information)](./docs/CHANGELOG.md) 7 | 8 | # Table of contents 9 | 10 | - [Installation](#installation) 11 | - [A short example](#a-short-example) 12 | - [Check out the docs](#i-want-to-know-more) 13 | - [Get in touch](#what-if-i-want-to-get-in-touch) 14 | 15 | # Installation 16 | 17 | The `socketles` library can be installed from https://www.npmjs.com/package/socketless using your package manager of choice, and can be used in Deno by importing `"npm:socketless@4"`. 18 | 19 | **Note**: This library is written and exposed as modern ESM code, and relies on enough modern JS language features that this library is only guaranteed to work on the current generation of browsers, and current LTS version of Node. No support for older/dead browsers or end-of-life versions of Node is offered. 20 | 21 | ## Using `socketless` in the browser 22 | 23 | As the `socketless` library is code that by definition needs to run server-side, it does not provide a precompiled single-file library in a `dist` directory, nor should you ever (need to) bundle `socketless` into a front-end bundle. Instead, the library has its own mechanism for letting browsers connect, shown off in the following example and explained in more detail in the ["how to..."](docs/HOWTO.md) documentation. 24 | 25 | # A short example 26 | 27 | A short example is the easiest way to demonstrate how Socketless works. Normally, we'd put the client and server classes, as well as the code that links and runs client and server instances in their own files, but thing'll work fine if we don't, of course: 28 | 29 | ```js 30 | /** 31 | * Make our server class announce client connections: 32 | */ 33 | export class ServerClass { 34 | onConnect(client) { 35 | console.log(`[server] A client connected!`); 36 | } 37 | // And give the server a little test function that both logs and returns a value: 38 | async test() { 39 | console.log(`[server] test!`); 40 | return "success!"; 41 | } 42 | } 43 | ``` 44 | 45 | ```js 46 | /** 47 | * Then, make our client class announce its own connection, as well as browser connections: 48 | */ 49 | export class ClientClass { 50 | onConnect() { 51 | console.log(`[client] We connected to the server!`); 52 | } 53 | onBrowserConnect() { 54 | console.log(`[client] A browser connected!`); 55 | this.setState({ goodToGo: true }); 56 | } 57 | } 58 | ``` 59 | 60 | ```js 61 | /** 62 | * Then we can link those up as a `socketless` factory and run a client/server setup: 63 | */ 64 | import { linkClasses } from "socketless"; 65 | const { createWebClient, createServer } = linkClasses(ClientClass, ServerClass); 66 | const { server, webServer } = createServer(); 67 | 68 | // For demo purposes, let's use some hardcoded ports: 69 | const SERVER_PORT = 8000; 70 | const CLIENT_PORT = 3000; 71 | 72 | // So, first: create our server and start listening for connections... 73 | webServer.listen(SERVER_PORT, () => console.log(`Server running...`)); 74 | 75 | // ...then create our client, pointed at our server's URL... 76 | const serverURL = `http://localhost:${SERVER_PORT}`; 77 | const publicDir = `public`; 78 | const { client, clientWebServer } = createWebClient(serverURL, publicDir); 79 | 80 | // ...and have that start listening for browser connections, too: 81 | clientWebServer.listen(CLIENT_PORT, () => { 82 | console.log(`Client running...`); 83 | const clientURL = `http://localhost:${CLIENT_PORT}`; 84 | import(`open`).then(({ default: open }) => { 85 | console.log(`Opening a browser...`); 86 | open(clientURL); 87 | }); 88 | }); 89 | ``` 90 | 91 | Of course we'll need something for the browser to load so we'll create a minimal `index.html` and `setup.js` and stick them both in a `public` dir. First our index file: 92 | 93 | ```html 94 | 95 | 96 | 97 | 98 | Let's test our connections! 99 | 100 | 101 | 102 | 103 | 104 | 105 | ``` 106 | 107 | And then our browser JS: 108 | 109 | ```js 110 | /** 111 | * We don't need to put a "socketless.js" in our public dir, 112 | * this is a "magic import" provided by socketless itself: 113 | */ 114 | import { createBrowserClient } from "./socketless.js"; 115 | 116 | /** 117 | * And then we can build a browser UI thin client that will 118 | * automatically connect to the real client: 119 | */ 120 | createBrowserClient( 121 | class { 122 | async init() { 123 | console.log(`[browser] We're connected to our web client!`); 124 | console.log(`[browser] Calling test:`, await this.server.test()); 125 | } 126 | update(prevState) { 127 | console.log(`[browser] State updated, goodToGo: ${this.state.goodToGo}`); 128 | } 129 | } 130 | ); 131 | ``` 132 | 133 | Then we can run the above code, and see following output on the console: 134 | 135 | ``` 136 | Server running... 137 | Client running... 138 | [server] A client connected! 139 | [client] We connected to the server! 140 | Opening a browser... 141 | [client] A browser connected! 142 | [server] test! 143 | ``` 144 | 145 | And then if we check the browser's developer tools' `console` tab, we also see: 146 | 147 | ``` 148 | [browser] We're connected to our web client! setup.js:14:15 149 | [browser] State updated, goodToGo: true setup.js:18:15 150 | [browser] Calling test: success! setup.js:15:15 151 | ``` 152 | 153 | It's important to note that we don't create clients by passing them a direct reference to the `server` instance`, but instead it's given a URL to connect to: the client and server can, and typically will, run on completely different machines "anywhere on the internet". As long as the same versions of the client and server classes are used on both machines (because, say, you're running on the same branch of the same git repo) then there's nothing else you need to do... 154 | 155 | #### _It just works._ 156 | 157 | ## I want to know more! 158 | 159 | That's the spirit! Also, if this didn't whet your appetite you probably didn't need this library in the first place, but let's cut to the chase: install this library, have a look at the [documentation](./docs), probably start at the ["how to ..."](/docs/HOWTO.md) docs, and let's get this working for you! 160 | 161 | ## What if I want to get in touch? 162 | 163 | I mean there's always the issue tracker, that's a pretty solid way to get in touch in a way that'll let us cooperate on improving this library. However, if you just want to fire off a social media message, find me over on [Mastodon](https://mastodon.social/@TheRealPomax) and give me a shout. 164 | -------------------------------------------------------------------------------- /demos/README.md: -------------------------------------------------------------------------------- 1 | ## Simple client/server 2 | 3 | This is a very simple demo that shows off several clients connecting to the same server, waiting a few seconds, and then disconnecting again. Once the server has no connected clients anymore, it will shut itself down. 4 | 5 | ## Simple client/server with web interface 6 | 7 | This is an equally simple demo that shows off a server with two web clients set up to play a game of tic-tac-toe. 8 | 9 | - Open both web client URLs in a browser 10 | - make one start a game, have the other join that game 11 | - play some tic tac toe 12 | - once both clients are disconnected, the server will shut down. 13 | -------------------------------------------------------------------------------- /demos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demos", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "description": "Socketless demos", 6 | "main": "", 7 | "author": "Pomax", 8 | "license": "MIT", 9 | "bugs": { 10 | "url": "https://github.com/Pomax/socketless/issues" 11 | }, 12 | "homepage": "https://github.com/Pomax/socketless#readme", 13 | "dependencies": { 14 | "socketless": "file:.." 15 | }, 16 | "scripts": { 17 | "clean": "rm -rf node_modules && npm i", 18 | "simple": "npm run clean && node ./simple/index.js", 19 | "web": "npm run clean && node ./web/index.js" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /demos/simple/ClientClass.js: -------------------------------------------------------------------------------- 1 | export class ClientClass { 2 | constructor() { 3 | console.log("client> created"); 4 | } 5 | 6 | async onConnect() { 7 | console.log("client> connected to server"); 8 | setTimeout( 9 | () => { 10 | console.log("client> disconnecting"); 11 | this.disconnect(); 12 | }, 13 | 3000 + (2 * Math.random() - 1) * 1000, 14 | ); 15 | console.log("client> disconnecting in 3 +/- 1 seconds"); 16 | 17 | this.name = `user-${(1e6 * Math.random()).toFixed(0)}`; 18 | this.registered = await this.server.setName(this.name); 19 | console.log(`client> registered as ${this.name}: ${this.registered}`); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /demos/simple/ServerClass.js: -------------------------------------------------------------------------------- 1 | export class ServerClass { 2 | constructor() { 3 | console.log("server> created"); 4 | } 5 | 6 | async onConnect(client) { 7 | console.log( 8 | `server> new connection: ${client.id}, ${this.clients.length} clients connected`, 9 | ); 10 | } 11 | 12 | async onDisconnect(client) { 13 | console.log(`server> client ${client.id} disconnected`); 14 | if (this.clients.length === 0) { 15 | console.log(`server> no clients connected, shutting down.`); 16 | this.quit(); 17 | } 18 | } 19 | 20 | async setName(client, name) { 21 | console.log(`server> client ${client.id} is now known as ${name}`); 22 | client.__name = name; 23 | return true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /demos/simple/index.js: -------------------------------------------------------------------------------- 1 | const NUMBER_OF_CLIENTS = 4; 2 | 3 | import { ClientClass } from "./ClientClass.js"; 4 | import { ServerClass } from "./ServerClass.js"; 5 | import { linkClasses } from "socketless"; 6 | 7 | const factory = linkClasses(ClientClass, ServerClass); 8 | const { webServer } = factory.createServer(); 9 | 10 | webServer.listen(0, () => { 11 | const serverURL = `http://localhost:${webServer.address().port}`; 12 | [...new Array(NUMBER_OF_CLIENTS)].forEach(() => 13 | factory.createClient(serverURL), 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /demos/web/ClientClass.js: -------------------------------------------------------------------------------- 1 | export class ClientClass { 2 | constructor() { 3 | console.log(`web client created`); 4 | this.admin = { 5 | setId: (id) => { 6 | this.setState({ id }); 7 | console.log(`setting id to ${id}`); 8 | }, 9 | }; 10 | this.game = { 11 | list: ({ games }) => { 12 | this.setState({ gameList: games }); 13 | }, 14 | start: (gameId, startingPlayer, board) => { 15 | this.setState({ 16 | activeGame: { 17 | gameId, 18 | currentPlayer: startingPlayer, 19 | board: board, 20 | }, 21 | }); 22 | }, 23 | played: (gameId, currentPlayer, board) => { 24 | const game = this.state.activeGame; 25 | if (game.gameId === gameId) { 26 | game.currentPlayer = currentPlayer; 27 | game.board = board; 28 | } 29 | }, 30 | draw: (gameId) => { 31 | const game = this.state.activeGame; 32 | if (game.gameId === gameId) { 33 | game.draw = true; 34 | } 35 | }, 36 | won: (gameId, winner) => { 37 | const game = this.state.activeGame; 38 | if (game.gameId === gameId) { 39 | game.winner = winner; 40 | } 41 | }, 42 | }; 43 | } 44 | 45 | async onConnect() { 46 | console.log(`connected to server`); 47 | } 48 | 49 | async onBrowserConnect(browser) { 50 | const result = await browser?.showMessage(`We should be good to go.`); 51 | console.log(`browser.showMessage result: ${result}`); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /demos/web/GameManager.js: -------------------------------------------------------------------------------- 1 | import { Game } from "./Game.js"; 2 | 3 | export class GameManager { 4 | constructor() { 5 | this.games = {}; 6 | this.idCounter = 1; 7 | this.gameIds = {}; 8 | } 9 | 10 | getList(client) { 11 | return Object.values(this.games).map((game) => game.getSummary(client)); 12 | } 13 | 14 | digest(id) { 15 | let digest = this.gameIds[id]; 16 | if (!digest) { 17 | digest = this.gameIds[id] = this.idCounter++; 18 | } 19 | return digest; 20 | } 21 | 22 | create(client) { 23 | let gameId = this.gameIds[client.id]; 24 | if (!gameId) gameId = this.gameIds[client.id] = this.idCounter++; 25 | this.games[gameId] = new Game(gameId); 26 | this.join(gameId, client); 27 | } 28 | 29 | join(gameId, client) { 30 | const game = this.games[gameId]; 31 | if (!game) throw new Error(`no such game id:${gameId}`); 32 | game.join(client); 33 | } 34 | 35 | play(gameId, clientId, position) { 36 | const game = this.games[gameId]; 37 | if (!game) throw new Error(`no such game id:${gameId}`); 38 | game.play(clientId, position); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /demos/web/ServerClass.js: -------------------------------------------------------------------------------- 1 | import { GameManager } from "./GameManager.js"; 2 | 3 | export class ServerClass { 4 | constructor() { 5 | console.log("server created"); 6 | this.gm = new GameManager(); 7 | 8 | this.game = { 9 | getList: (client) => { 10 | return this.gm.getList(); 11 | }, 12 | create: (client) => { 13 | this.gm.create(client); 14 | this.notifyGameList(); 15 | }, 16 | join: (client, { gameId }) => { 17 | this.gm.join(gameId, client); 18 | this.notifyGameList(); 19 | }, 20 | play: (client, { gameId, position }) => { 21 | this.gm.play(gameId, client.id, position); 22 | }, 23 | }; 24 | } 25 | 26 | async onConnect(client) { 27 | console.log(`new connection, ${this.clients.length} clients connected`); 28 | await client.admin.setId(client.id); 29 | await client.game.list({ games: this.gm.getList() }); 30 | } 31 | 32 | onDisconnect(client) { 33 | console.log(`client ${client.id} disconnected`); 34 | if (this.clients.length === 0) { 35 | console.log(`no clients connected, shutting down.`); 36 | this.quit(); 37 | } 38 | } 39 | 40 | teardown() { 41 | // FIXME: I don't like this... 42 | process.exit(0); 43 | } 44 | 45 | notifyGameList() { 46 | this.clients.forEach((client) => { 47 | const games = this.gm.getList(client); 48 | client.game.list({ games }); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /demos/web/game.js: -------------------------------------------------------------------------------- 1 | // A silly match three game implementation 2 | export class Game { 3 | constructor(gameId) { 4 | this.id = gameId; 5 | this.players = []; 6 | this.board = [...new Array(9)]; 7 | this.movesLeft = 9; 8 | } 9 | 10 | join(client) { 11 | if (this.players.length == 2) throw new Error("game full"); 12 | this.players.push(client); 13 | if (this.players.length == 2) this.start(); 14 | } 15 | 16 | start() { 17 | this.activePlayer = this.players[(Math.random() * 2) | 0].id; 18 | this.players.forEach((client) => 19 | client.game.start(this.id, this.activePlayer, this.board.join(`,`)), 20 | ); 21 | } 22 | 23 | play(clientId, position) { 24 | // lots of "is this a legal move" checks first: 25 | if (!this.activePlayer) throw new Error("game not started"); 26 | if (clientId !== this.activePlayer) throw new Error("out of turn"); 27 | if (this.won) throw new Error("game finished"); 28 | if (position < 0 || position > 8) throw new Error("illegal move"); 29 | const pid = this.players.findIndex((client) => client.id === clientId); 30 | if (this.board[position]) throw new Error("move was already played"); 31 | // IF we're all good: mark the play and inform all players. 32 | this.playMove(pid, position); 33 | } 34 | 35 | playMove(pid, position) { 36 | this.board[position] = pid + 1; 37 | this.movesLeft--; 38 | const board = this.board.join(`,`); 39 | const currentPlayer = (this.activePlayer = this.players[pid ^ 1].id); // flip between player 0 and 1 using xor 40 | this.players.forEach((client) => 41 | client.game.played(this.id, currentPlayer, board), 42 | ); 43 | 44 | this.checkGameOver(pid, position); 45 | } 46 | 47 | checkGameOver(pid, position) { 48 | // can't have a game-over until player 0 has at least three tiles claimed 49 | if (this.movesLeft > 4) return; 50 | 51 | // Do we have a draw? 52 | if (this.movesLeft === 0) { 53 | return this.players.forEach((client) => client.game.draw(this.id)); 54 | } 55 | 56 | // If not, do we have a winner? 57 | const gameWon = this.checkWinner(this.board, position); 58 | if (gameWon) { 59 | const winner = this.players[pid].id; 60 | this.players.forEach((client) => client.game.won(this.id, winner)); 61 | } 62 | } 63 | 64 | checkWinner(b, position) { 65 | switch (position) { 66 | // we don't need to check the whole board, we only need to check if the new play completes a triplet. 67 | case 0: 68 | return ( 69 | (b[0] === b[1] && b[1] === b[2]) || 70 | (b[0] === b[3] && b[3] === b[6]) || 71 | (b[0] === b[4] && b[4] === b[8]) 72 | ); 73 | case 1: 74 | return ( 75 | (b[0] === b[1] && b[1] === b[2]) || (b[1] === b[4] && b[4] === b[7]) 76 | ); 77 | case 2: 78 | return ( 79 | (b[0] === b[1] && b[1] === b[2]) || 80 | (b[2] === b[5] && b[5] === b[8]) || 81 | (b[2] === b[4] && b[4] === b[6]) 82 | ); 83 | case 3: 84 | return ( 85 | (b[3] === b[4] && b[4] === b[5]) || (b[0] === b[3] && b[3] === b[6]) 86 | ); 87 | case 4: 88 | return ( 89 | (b[3] === b[4] && b[4] === b[5]) || 90 | (b[1] === b[4] && b[4] === b[7]) || 91 | (b[0] === b[4] && b[4] === b[8]) || 92 | (b[2] === b[4] && b[4] === b[6]) 93 | ); 94 | case 5: 95 | return ( 96 | (b[3] === b[4] && b[4] === b[5]) || (b[2] === b[5] && b[5] === b[8]) 97 | ); 98 | case 6: 99 | return ( 100 | (b[6] === b[7] && b[7] === b[8]) || 101 | (b[0] === b[3] && b[3] === b[6]) || 102 | (b[2] === b[4] && b[4] === b[6]) 103 | ); 104 | case 7: 105 | return ( 106 | (b[6] === b[7] && b[7] === b[8]) || (b[1] === b[4] && b[4] === b[7]) 107 | ); 108 | case 8: 109 | return ( 110 | (b[6] === b[7] && b[7] === b[8]) || 111 | (b[2] === b[5] && b[5] === b[8]) || 112 | (b[0] === b[4] && b[4] === b[8]) 113 | ); 114 | default: 115 | return false; 116 | } 117 | } 118 | 119 | getSummary(client) { 120 | return { 121 | id: this.id, 122 | owner: this.players[0] === client, 123 | waiting: this.players.length < 2, 124 | started: !!this.activePlayer, 125 | }; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /demos/web/index.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import url from "url"; 3 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 4 | 5 | import { ClientClass } from "./ClientClass.js"; 6 | import { ServerClass } from "./ServerClass.js"; 7 | import { linkClasses } from "socketless"; 8 | const factory = linkClasses(ClientClass, ServerClass); 9 | 10 | // Set up the server: 11 | const NUMBER_OF_PLAYERS = 2; 12 | const { webServer } = factory.createServer(); 13 | webServer.listen(8000, () => { 14 | const localhost = `http://localhost`; 15 | const URL = `${localhost}:${webServer.address().port}`; 16 | console.log(`- server listening on ${URL}`); 17 | 18 | // Set up the clients: 19 | const clientURLs = []; 20 | const publicDir = path.join(__dirname, `public`); 21 | for (let player = 1; player <= NUMBER_OF_PLAYERS; player++) { 22 | const sid = Math.random().toString().substring(2); 23 | const { clientWebServer } = factory.createWebClient( 24 | `${URL}?sid=${sid}`, 25 | publicDir, 26 | ); 27 | const clientPort = 8000 + player; 28 | clientWebServer.listen(clientPort, () => { 29 | console.log( 30 | `- web client ${player} listening on ${localhost}:${clientPort}?sid=${sid}`, 31 | ); 32 | }); 33 | clientURLs.push(`http://localhost:${clientPort}?sid=${sid}`); 34 | } 35 | 36 | // And add a route handler so that when we connect to the server 37 | // with a browser, we get a list of web clients and their URLs. 38 | webServer.addRoute( 39 | `/`, 40 | // middleware: just a page request notifier 41 | (req, res, next) => { 42 | console.log(`index page requested`); 43 | next(); 44 | }, 45 | // serve HTML source for the list of connected clients 46 | (_, res) => { 47 | res.writeHead(200, { "Content-Type": `text/html` }); 48 | res.end( 49 | `
    ${clientURLs 50 | .map((url) => `
  1. ${url}
  2. `) 51 | .join(``)}
`, 52 | ); 53 | }, 54 | ); 55 | }); 56 | -------------------------------------------------------------------------------- /demos/web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Web demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /demos/web/public/page.js: -------------------------------------------------------------------------------- 1 | import { BrowserClientClass } from "./ui.js"; 2 | import(`./socketless.js${location.search}`).then((lib) => { 3 | const { createBrowserClient } = lib; 4 | createBrowserClient(BrowserClientClass); 5 | }); 6 | -------------------------------------------------------------------------------- /demos/web/public/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --quad: 4em; 3 | } 4 | 5 | .gameboard { 6 | display: flex; 7 | flex-direction: row; 8 | flex-wrap: wrap; 9 | height: calc(3 * var(--quad)); 10 | width: calc(3 * var(--quad)); 11 | background: red; 12 | opacity: 0.5; 13 | } 14 | 15 | .gameboard.winner { 16 | border: 3px solid gold; 17 | } 18 | 19 | .gameboard.draw { 20 | border: 3px solid lightskyblue; 21 | } 22 | 23 | .gameboard.loser { 24 | border: 3px solid red; 25 | } 26 | 27 | .gameboard.ourturn, 28 | .gameboard.over { 29 | opacity: 1; 30 | } 31 | 32 | .gameboard .space { 33 | display: flex; 34 | flex: 0 0 auto; 35 | background: grey; 36 | height: var(--quad); 37 | width: var(--quad); 38 | line-height: var(--quad); 39 | justify-content: center; 40 | } 41 | .gameboard .space:nth-child(2n) { 42 | background: blue; 43 | } 44 | -------------------------------------------------------------------------------- /demos/web/public/ui.js: -------------------------------------------------------------------------------- 1 | export class BrowserClientClass { 2 | init() { 3 | this.gameList = document.getElementById("gamelist"); 4 | const create = document.getElementById("create"); 5 | create.addEventListener(`click`, () => this.server.game.create()); 6 | const quit = document.getElementById("quit"); 7 | quit.addEventListener(`click`, () => { 8 | this.quit(); 9 | document.body.textContent = `You can safely close this tab now.`; 10 | }); 11 | } 12 | 13 | update(prevState) { 14 | const { state } = this; 15 | const list = this.gameList; 16 | list.innerHTML = ``; 17 | state.gameList.forEach((entry) => { 18 | const { id, owner, waiting, started } = entry; 19 | const li = document.createElement(`li`); 20 | li.textContent = id; 21 | list.appendChild(li); 22 | 23 | if (waiting) { 24 | if (!owner) { 25 | const join = document.createElement(`button`); 26 | join.textContent = `join`; 27 | join.addEventListener(`click`, (evt) => { 28 | this.server.game.join({ gameId: id }); 29 | }); 30 | li.appendChild(join); 31 | } else { 32 | li.textContent = `${li.textContent} (waiting)`; 33 | } 34 | } else if (owner && !started) { 35 | const start = document.createElement(`button`); 36 | start.textContent = `start`; 37 | start.addEventListener(`click`, (evt) => { 38 | this.server.game.start({ gameId: id }); 39 | }); 40 | li.appendChild(start); 41 | } 42 | }); 43 | 44 | const game = state.activeGame; 45 | if (game) this.drawBoard(game); 46 | } 47 | 48 | drawBoard(game) { 49 | const { gameId, currentPlayer, board, winner, draw } = game; 50 | const ourTurn = currentPlayer === this.state.id; 51 | const gameBoard = document.getElementById("board"); 52 | gameBoard.innerHTML = ``; 53 | 54 | const classes = gameBoard.classList; 55 | 56 | classes.toggle("ourturn", ourTurn); 57 | 58 | if (winner) { 59 | classes.add("over", this.state.id === winner ? "winner" : "loser"); 60 | } else if (draw) { 61 | classes.add("over", "draw"); 62 | } else { 63 | classes.remove("over", "winner", "loser"); 64 | } 65 | 66 | board.split(`,`).forEach((value, position) => { 67 | const space = document.createElement("div"); 68 | space.classList.add("space"); 69 | space.textContent = value; 70 | if (!winner && !value && ourTurn) { 71 | space.addEventListener("click", (evt) => { 72 | this.server.game.play({ gameId, position }); 73 | }); 74 | } 75 | gameBoard.appendChild(space); 76 | }); 77 | } 78 | 79 | showMessage(msg) { 80 | return confirm(msg) ? `User clicked "OK"` : `User clicked "Cancel"`; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | ## **The `socketless` library** 4 | 5 | ### exported properties: 6 | 7 | - `ALLOW_SELF_SIGNED_CERTS`, a `Symbol` used to explicitly allow HTTPs connections that are considered "unsafe" due to self-signed certificates. 8 | 9 | ### exported methods: 10 | 11 | 12 | - `createServer(ServerClass, [serverOrHttpsOptions])` yielding `{ server, webServer }` 13 | 14 | The `serverOrHttpsOptions` argument may either be an instance of a plain Node `http` or `https` server, which includes things like Express servers (as obtained from `app.listen()`), or an options object that provides the HTTPs key and cert string values, using the form `{ key: string, cert: string }`. 15 | 16 | - `createClient(ClientClass, serverURL, [ALLOW_SELF_SIGNED_CERTS])` yielding `client` 17 | 18 | The `serverURL` may be either `http://`, `https://`, `ws://` or `wss://`, http URLs are automatically converted to websocket URLs. To allow secure connections that use self-signed certificates, the optional `ALLOW_SELF_SIGNED_CERTS` must be the exported symbol listed above. 19 | 20 | - `createWebClient(ClientClass, serverURL, publicDir, [httpsOptions], [ALLOW_SELF_SIGNED_CERTS])` yielding `{ client, clientWebServer}` 21 | 22 | The `serverURL` may include an `?sid=...` query argument, in which case browsers must connect to the client's web server with that same argument. Failure to do so will result in a 404 when requesting `socketless.js`, and websocket connection requests to the client's server URL will not be honoured. 23 | 24 | The `httpsOptions` argument must be an options object that provides the HTTPs key and cert values in the form `{ key: string, cert: string }`. To allow secure connections that use self-signed certificates, the optional `ALLOW_SELF_SIGNED_CERTS` must be the exported symbol listed above. 25 | 26 | - `linkClasses(ClientClass, ServerClass)` 27 | 28 | This yields a factory object with three functions, useful for code that creates both servers and clients in the same script: 29 | 30 | - `createServer([serverOrHttpsOptions])` yielding `{ server, webServer }` 31 | 32 | This is the same as the `createServer` function that `socketless` exports, but without needing to specify the `ServerClass` again. 33 | 34 | - `createClient(serverURL, [ALLOW_SELF_SIGNED_CERTS])` yielding `client` 35 | 36 | This is the same as the `createClient` function that `socketless` exports, but with needing to specify the `ClientClass` again. 37 | 38 | - `createWebClient(serverURL, publicDir, [httpsOptions], [ALLOW_SELF_SIGNED_CERTS])` yielding `{ client, clientWebServer}` 39 | 40 | This is the same as the `createWebClient` function that `socketless` exports, but with needing to specify the `ClientClass` again. 41 | 42 | ## **Server classes** 43 | 44 | Note that the instance properties for a server will not be available until after the constructor has finished running. Also note that if a constructor implementation exists, it will be called without any arguments. 45 | 46 | ### instance properties 47 | 48 | - `this.clients`, an array of clients, with each client a local proxy to a remote, supporting the API defined in your ClientClass. 49 | 50 | ### methods 51 | 52 | - `init()`, a method that gets run immediately after construction, with all instance properties available. 53 | - `lock(object, unlock = (client)=>boolean)`, a method to lock down server property access to only those clients for which the passed unlock function returns `true`. 54 | - `quit()`, a method to close all connections and shut down the server. 55 | 56 | ### event handlers 57 | 58 | - `onError(error)`, triggers if there are websocket errors during connection negotiation. 59 | - `onConnect(client)`, triggers after a client has connected. `client` is a proxy, and will have already been added to `this.clients`. 60 | - `onDisconnect(client)`, triggers after a client has disconnected. `client` is a proxy, and will have already been removed from `this.clients`. 61 | - `onQuit()`, triggered before the server closes its web server and websocket server. 62 | - `teardown()`, triggered after the web server and websocket server have been shut down. 63 | 64 | ### Web server instances 65 | 66 | Web servers are Node http(s) servers (even when using something like Express), with the following addition 67 | 68 | - `.addRoute(relativeURL, [...middlewareHandlers], finalHandler)` adds explicit route handling for a specific URL endpoint. In this: 69 | - `[...middleWare]` is zero or more middleware handlers of the form `(req, res, next) => { ... }` where a call to `next()` will make the next middleware function on the list run after the current one completes, or if there are no more middleware functions, `finalHandler` will get called. 70 | - `finalHandler` is a function with signature `(req, res) => { ... }` and is required as last function in the route handling. 71 | - The `req` argument is a Node `http.ClientRequest` object, but with query arguments split out as `req.params`, and POST/PUT body content split out as `req.body`. Note that the body will be plain string data. 72 | - The `res` argument is a Node `http.ClientResponse` object. 73 | - `.removeRoute(relativeURL)` removes a previously added route (allowing for transient/session based URLs) 74 | - `.setAuthHandler(handlerFunction)` lets you specify a function that is of the form `async function(request) { return true or false }` and can be used to restrict access to server content based on "whatever you need" (but typically things like url arguments or cookies). 75 | 76 | ## **Client classes** 77 | 78 | As the instance properties for a client will not be available until _after_ the constructor has finished, having a constructor in the client class is strongly discouraged. If present, it will be called without arguments. Instead, if implemented, the client's `init` function will be called after construction to allow for initial setup with full access to all instance properties. 79 | 80 | ### instance properties 81 | 82 | - `this.id`, an id known to both the client and server. 83 | - `this.params`, a parameter object derived from the serverURL. 84 | - `this.server`, a local proxy for the server, supporting the API defined in your ServerClass. 85 | 86 | ### methods 87 | 88 | - `init()`, a method that gets run immediately after construction, with all instance properties available. 89 | - `disconnect()`, a method to disconnect the client from the server. 90 | - `reconnect()`, a method to reconnect the client to the server. 91 | 92 | ### event handlers 93 | 94 | - `onError(error)`, triggers if there are websocket errors during connection negotiation. 95 | - `onConnect()`, triggers after the client has connected to the server. 96 | - `onDisconnect()`, triggers after the client has disconnected from the server. 97 | 98 | ## **Web client classes** 99 | 100 | This is considered a `ClientClass`, with the additional properties and events that are only used when a client instance is created through the `createWebClient()` function. 101 | 102 | As the instance properties for a web client will not be available until _after_ the constructor has finished, having a constructor in the web client class is strongly discouraged. If present, it will be called without arguments. Instead, if implemented, the web client's `init` function will be called after construction to allow for initial setup with full access to all instance properties. 103 | 104 | ### instance properties 105 | 106 | Web client classes inherit the client instance properties, and add the following: 107 | 108 | - `this.state`, a state object that gets (one-way) synced to the browser whenever modified. 109 | - `this.browser`, a local proxy for the browser, supporting the API defined in your BrowserClientClass. 110 | 111 | ### special state properties 112 | 113 | if a web client specifies an `authenticated` property in their state, then browser connections will either be sent `{ authenticated: false}`, if that property has the value `false`, or the full state if that property has the value `true` (or has been removed). This allows clients to first verify whether a browser connection has the right permissions to even receive state information (e.g. for login-based systems you don't want the client to actually send out data except to the user whose client it is) 114 | 115 | ### methods 116 | 117 | - `init()`, a method that gets run immediately after construction, with all instance properties available. 118 | - `disconnect()`, a method to disconnect the client from the server. 119 | - `syncState()`, a method for explicitly (re)fetching the current state from the underlying client. 120 | 121 | ### event handlers 122 | 123 | Web client classes inherit the client methods, and add the following: 124 | 125 | - `onBrowserConnect()`, triggered after a browser connects. 126 | - `onBrowserDisconnect()`, triggered after a browser disconnects. 127 | - `onQuit()`, triggered before the web client closes its web server and websocket server. 128 | - `teardown()`, triggered after the web server and websocket server have been shut down. 129 | 130 | ### Web server instances 131 | 132 | The webclient web server has the same functionality as those generated through the `createServer()` factory method, details for which are listed in the Server Classes section above. 133 | 134 | ## **Browser client classes** 135 | 136 | A constructor is strongly discouraged, initialization should be handled in `init()` instead. If present, the constructor will be called without arguments. 137 | 138 | ### instance properties 139 | 140 | - `this.server`, a local proxy for the server, supporting the API defined in your ServerClass. 141 | - `this.client`, a local proxy for the underlying client, supporting the API defined in your ClientClass. 142 | - `this.socket`, the plain websocket connection to the client that the browser connected to. 143 | - `this.state`, a state object that reflects the connected web client's current state. 144 | - `this.connected`, a flag that indicates whether we're connected to our web client. 145 | - `this.disconnect()`, allows the browser to intentionally disconnect from the web client, used to intentionally trigger `.onBrowserDisconnect` at the web client. 146 | - `this.reconnect()`, allows the browser to reconnect to their web client. 147 | 148 | ### event handlers 149 | 150 | - `init()`, triggered as last step in bootstrapping the browser client. 151 | - `update(prevState)`, triggered any time the web client's state changes. 152 | -------------------------------------------------------------------------------- /docs/ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # The architecture 2 | 3 | ### 1. basic client-server 4 | 5 | The basic setup consists of: 6 | 7 | - One main server. 8 | - _If_ the server has content serving routes, browsers may connect over HTTP. 9 | - One or more clients that can connect to the server. 10 | 11 | ``` 12 | ┌─────────┬─────────────┐ ╭───────────╮ 13 | │ server <┼- web server │◁───┤ (browser) │ 14 | └────▲────┴─────△───────┘ ╰───────────╯ 15 | ║ ┊ ╭───────────────────────────────╮ 16 | ║ ┌───◇────┐ │ ─── HTTP(s) call │ 17 | ╚══════▶ client │ │ ┄┄┄ WS upgrade call over HTTP │ 18 | └────────┘ │ ═══ two-way websocket │ 19 | ╰───────────────────────────────╯ 20 | ``` 21 | 22 | ### 2. client-server with browser connections 23 | 24 | The more complex browser-connected client setup consists of: 25 | 26 | - One main server. 27 | - _If_ the server has content serving routes, browsers may connect over HTTP. 28 | - One or more clients that can connect to the server. 29 | - _If_ clients are web clients, they also run their own client-specific web server. 30 | - Any number of browsers, connected to a web client's web server. 31 | 32 | ``` 33 | ┌─────────┬─────────────┐ 34 | │ server <┼- web server ◁─────────────┐ 35 | └────▲────┴─────△───────┘ │ 36 | ║ ┊ │ 37 | ║ ┌───◇─────┬─────────────┐ │ 38 | ╚══════▶ client <┼- web server ◁─┤ 39 | └───▲─────┴─────△───────┘ │ 40 | ║ ┊ │ ╭───────────────────────────────╮ 41 | ║ ╭───◇─────╮ │ │ ─── HTTP(s) call │ 42 | ╚═══════▶ browser ├───┘ │ ┄┄┄ WS upgrade call over HTTP │ 43 | ╰─────────╯ │ ═══ two-way websocket │ 44 | ╰───────────────────────────────╯ 45 | ``` 46 | 47 | ## How it works 48 | 49 | The server is a websocket host, and can either be bootstrapped with an existing HTTP(s) server, or can build its own, in order to accept websocket connections from clients (because web sockets start life as an HTTP call with an UPGRADE instruction). 50 | 51 | Clients establish their connection using the server's URL. 52 | 53 | If clients are of the "WebClient" type, they also run their own web server, which lets browsers connect to them in order establish a state sync loop. The client's state will automatically get sent over to the browser via the web socket, so that the browser code can update the page/DOM accordingly. 54 | 55 | ### Making parties communicate 56 | 57 | Socket negotiation is automatically taken care of as part of client construction, after which the server will add a client entry into its `this.clients` array , and the client gets a `this.server` binding. Both of these are proxies for the other party, allowing bot the server and all clients to call each other's functions as if everything was local code. 58 | 59 | E.g. if the client class has a function `setName` accepting a string as argument, then the server can call this function on its first client by using `this.clients[0].setName("some name")`. Or, if the server has a function `startProcess` taking an integer argument, the client can call this using `this.server.startProcess(123)`. 60 | 61 | Functions hat return values can be awaited. For example, if the client has a function `getName`, the server could invoke that using `const clientName = await this.clients[0].getName()`. Forgetting to use `await`, or intentionally omitting it, will cause the function call to return a Promise, rather than the function call result. 62 | 63 | Note that these proxies _only_ support function calls: trying to access a property as a value property will yield a function proxy object, not the remote object's value by that name, even if it exists. To get and set values, you will need to explicitly have a (set of) function(s) in your client and/or server class(es). 64 | 65 | ### Working with server instances 66 | 67 | Server instances have access to the following pre-specified properties: 68 | 69 | - `this.clients`, an array of clients, each a socket proxy of the connected client 70 | - `this.lock()`, a method to lock down properties, with a function that determines whether clients are allowed access 71 | - `this.quit()`, a method to close all connections and shut down the server. 72 | 73 | Your server class may also implement any of the following event handlers: 74 | 75 | - `init()`, a method that gets run immediately after construction, with all instance properties available. 76 | - `onError(error)`, triggers if there are websocket errors during connection negotiation. 77 | - `onConnect(client)`, triggers after a client has connected. 78 | - `onDisconnect(client)`, triggers after a client has disconnected. 79 | - `onQuit()`, triggered before the server closes its web server and websocket server. 80 | - `teardown()`, triggered after the web server and websocket servers have been shut down. 81 | 82 | ### Working with client instances 83 | 84 | Client instances have access to the following pre-specified properties: 85 | 86 | - `this.id`, an id known to both the client and the server. 87 | - `this.params`, a parameter object derived from the serverURL. 88 | - `this.server`, a proxy of the server. 89 | - `this.browser`, a proxy of the browser, if this is a web client. Note that calls to functions on this.browser do _not_ time out, they stay waiting until the browser. 90 | - `this.state`, a state object that can be used to store client data. This object gets automatically synchronized to the browser, if this is a web client with a connected browser. 91 | - `this.disconnect()`, a method to disconnect this client from the server. 92 | - `this.reconnect()`, a method to reconnect the client to the server. 93 | 94 | Your client class may also implement any of the following event handlers: 95 | 96 | - `init()`, a method that gets run immediately after construction, with all instance properties available. 97 | - `onError(error)`, triggers if there are websocket errors during connection negotiation. 98 | - `onConnect()`, triggered after the client connects to the server. 99 | - `onBrowserConnect()`, if this is a web client, triggered after a browser connects. 100 | - `onDisconnect()`, triggered after the client gets disconnected from the server. 101 | - `onBrowserDisconnect()`, if this is a web client, triggered after a browser disconnects. 102 | - `onQuit()`, if this is a web client, triggered before the server closes its web server and websocket server. 103 | - `teardown()`, if this is a web client, triggered after the web server and websocket servers have been shut down. 104 | 105 | ### Working in the browser 106 | 107 | Browser client instances created using the browser-side `createBrowserClient` function have access to the following pre-specified properties: 108 | 109 | - `this.server`, a proxy for the main server. 110 | - `this.socket`, the _plain_ websocket connection to the client (it should almost never be necessary to interact with this property). 111 | - `this.state`, a state object that reflects the connected web client's current state. 112 | - `this.connected`, a flag that indicates whether we're connected to our web client. 113 | - `this.disconnect()`, allows the browser to intentionally disconnect from the web client, used to intentionally trigger `.onBrowserDisconnect` at the web client. 114 | - `this.reconnect()`, allows the browser to reconnect to their web client. 115 | 116 | You will also want to implement the following functions in your browser client class: 117 | 118 | - `init()`, a function that is called as part of the connection process. Any setup should be done inside `init()`, not the constructor (while you _may_ have a constructor, you will not have access to the pre-specified properties until `init` gets called). 119 | - `update(prevState)`, a function that is called any time the client's state gets reflected to the browser. 120 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # How this library is versioned 2 | 3 | Socketless _strictly_ adheres to [semver](https://semver.org)'s major.minor.patch versioning: 4 | 5 | - patch version changes indicate bug fixes and/or internal-only code changes, 6 | - minor version changes indicate new functionality that does not break backward compatibility, 7 | - major version changes indicate backward-incompatible external API changes, no matter how small. 8 | 9 | # Current version 10 | 11 | ## v6.0.0 (30 April 2025) 12 | 13 | You can now start a (web)client with an `authenticated:false` state property, which will prevent the full state from being transmitted to any connected browser. There is no predefined "unlocking" mechanism, giving you total freedom on whether you want to set that flag to `true` based on an http call, a websocket protocol, or even something completely different, but as a minimal example: 14 | 15 | ```js 16 | class ClientClass { 17 | init() { 18 | this.setState({ 19 | authenticated: false, 20 | a: 1, 21 | b: 2, 22 | c: 3, 23 | }); 24 | } 25 | async authenticate(username, password) { 26 | if (await this.server.verifyUser(username, password)) { 27 | this.setState({ 28 | authenticated: true, 29 | }); 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | This sets up a client with an initial state that includes the `authenticated` property, set to `false`, signaling that it needs browsers to authenticate before it will send any real state data. 36 | 37 | A browser class for this client can check for the `authenticated` flag, and if present _and `false`_ (because if it's not present, the client simply doesn't require authentication) then it can call the `authenticate` function, which will automatically trigger a new state update when it sets `authenticated` to true. 38 | 39 | ```js 40 | class BrowserClient { 41 | update() { 42 | const { authenticated } = this.state; 43 | if (authenticated === false) { 44 | // Note that we CANNOT use if (!authenticated) { ... } here, because 45 | // if there is no `authenticated` flag then the client simply doesn't 46 | // require any form of authentication! 47 | const username = prompt(`Please type in your username`)?.trim(); 48 | const password = prompt(`Please type in your password`)?.trim(); 49 | return this.client.authenticate(username, password); 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | Note that this is a breaking change, as it is quite likely to interfere with anyone who rolled their own auth already, using the rather obvious `authenticated` state property. 56 | 57 | # Previous versions 58 | 59 | ## v5.2.0 (27 April 2025) 60 | 61 | This version adds a `this.client` for web browser clients, to communicate with their underlying client without forwarding those calls on to the server. 62 | 63 | ## v5.1.0 (27 April 2025) 64 | 65 | Added a `setAuthHandler` to the web servers, which takes an `async` function with argument `request` that can be used to ensure that URLs only resolve for authorized users. For instance: 66 | 67 | ```js 68 | webServer.setAuthHandler(async (req) => { 69 | // only allow unrestricted access to our root URL 70 | if (req.url === `/`) return true; 71 | 72 | // As well as the login route, as long as that's a POST request: 73 | if (req.url === `/login` && req.method.toLowerCase() === `post`) return true; 74 | 75 | // Otherwise, verify that there is a auth cookie by examining the request headers: 76 | const cookies = req.headers.cookie?.split(`;`); 77 | if (!cookies) return false; 78 | 79 | const entries = cookies.map((s) => s.split("=").map((v) => v.trim())); 80 | const { authToken } = Object.fromEntries(entries); 81 | return validAuthToken(authToken); 82 | }); 83 | 84 | webServer.addRoute(`/login`, (req, res) => { 85 | let content = ``; 86 | const token = req.method.toLowerCase() === `post` && verifyLogin(req); 87 | if (token) { 88 | // If the login was deemed good, set an auth token cookie 89 | res.writeHead(302, { 90 | "Set-Cookie": `authToken=${token}` 91 | url: `${req.protocol}://${req.get('host')}/lobby`; 92 | }); 93 | content = `logged in`; 94 | } else { 95 | // If not, clear cookies and cache and let the user know 96 | res.writeHead(403, { 97 | "Clear-Site-Data": `"cache", "cookies"`, 98 | }); 99 | content = `forbidden`; 100 | } 101 | res.end(content); 102 | }) 103 | ``` 104 | 105 | This allows custom locking both for the regular server, as well as webclient servers, which means this allows the main server to set login tokens that can then be used by client webservers to verify that browser connections are from a logged in user rather than "anyone with the URL". 106 | 107 | ## v5.0.0 (26 April 2025) 108 | 109 | Reimplemented state locking on the browser side, as the diff code breaks _really hard_ when users are allowed to modify the state directly. To work with a mutable state, a special `this.getStateCopy()` function has been introduced that creates a mutable deep copy of the current state using the JS [structuredClone()](https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone) function. 110 | 111 | The code for calling client functions from the browser by using `this.server.functionName` was also improved to make sure that functionality works even when the client is not connected to a server (which should not be necessary for a pure browser<->client call). 112 | 113 | Browser code has access to two new bits of functionality: a new `this.params` has been added so that folks don't need to manually parse URL query arguments, and then type-convert them. Instead of, all query arguments are added into `this.params` with whatever type a `JSON.parse` pass can turn them into (e.g. numbers will be actual numbers, booleans will be actual booleans, JSON gets turned into objects, etc). Additionally, the `update(prevState)` function has been updated with a second argument that's called `changeFlags` internally, which is an object with with three possible values per (nested) property that got updated between `prevState` and `this.state`: 114 | 115 | - `1` if this key was added since the last update, 116 | - `2` if this key's value was replaced with a new value, and 117 | - `3` if this key was removed. 118 | 119 | With additional values for array manipulation: 120 | 121 | - `4` if this key is an array, and had an element pushed on, 122 | - `4` if this key is an array index, and this value is a newly inserted value 123 | - `5` if this key is an array index, representing an element that had its value replaced, and 124 | - `6` if this key is an array index, representing an element that got removed. 125 | 126 | so that UI code can hook into not just the current state, but also the current state _transitions_. E.g.: 127 | 128 | ```js 129 | /* 130 | The current state only tells us "that" a player drew a card, 131 | but it won't tell us anything about whether that just happened, 132 | or some other part of the state updated. 133 | 134 | So, let's check our change flags, to see if an add or replace 135 | happened, taking advantage of the fact that using "undefined" 136 | in comparisons always results in false, so we won't do the wrong 137 | thing if the value didn't change: 138 | */ 139 | if (changeFlags.game?.currentHand?.currentPlayer?.latestDraw === 1) { 140 | // Provided we wrote code that clears the "latestDraw" property when 141 | // a player stops being the current player, this is enough information 142 | // to tell us that a player just drew a card, and we should play an 143 | // audio cue for our users: 144 | playAudio(`draw-card.mp3`); 145 | 146 | /* 147 | Similarly, what if we also want to monitor the discard pile? 148 | */ 149 | if (changeFlags.game?.currentHand?.discards === 4) { 150 | // We know the discard array exists, and that it had an element 151 | // pushed onto it, so we can play the "a player discarded a card" 152 | // audio cue: 153 | playAudio(`discard-card.mp3`); 154 | } 155 | ``` 156 | 157 | Rather than having to manually compare values for every test: 158 | 159 | ```js 160 | // Is there a latestDraw, and is it different from before? 161 | const { currentPlayer: pp } = prevState.game?.currentHand ?? {}; 162 | const { currentPlayer: cp } = this.state.game?.currentHand ?? {}; 163 | if (cp.latestDraw && cp.latestDraw !== pp.latestDraw) { 164 | playAudio(`drawCard.mp3`); 165 | } 166 | 167 | // are there discards, and did the number of elements grow? 168 | const { discards: pd } = prevState.game?.currentHand ?? {}; 169 | const { discards: cd } = this.state.game?.currentHand ?? {}; 170 | if (cd && pd && cd.length > pd.length) { 171 | playAudio(`drawCard.mp3`); 172 | } 173 | ``` 174 | 175 | An associated backward compatibility breaking change will now also always call `update(prevState, changeFlags)` after calling `init()`, with a `prevState` that's an empty object, and a change flags object that reflect all leaves of the initial state (as each of those count as some form of newly added value). 176 | 177 | Related, "auto state syncing" after calls from the server to the client has been removed, as the only way to update the client's state is by calling `setState`, and automatically running `setState(this.state)` made no sense. If state updates are required, this should be explicit in the client code. 178 | 179 | Also server → client communication now also has a per-client siloed data object that can be used to sync a _server state_ to the client using JSON diffing, through the use of the `this.clients[...].syncData(stateObject, forced?)` function. The first argument is the state object that the server is working with that should be reconstructed at the client. The optional `forced` argument, when `true`, will force the sync to be performed as a regular data transfer rather than as a JSON diff (which can be useful when there is a state change that is large enough to lead to slow patch creation). 180 | 181 | In addition to the `syncData` function, client classes can now also implement the `onSyncUpdate(serverState)` handler function, which gets invoked after a data sync, to do things like "folding the siloed data into the generate client state", or doing work separately from the client's own state-related work. 182 | 183 | Finally, browser clients now have a `this.syncState()` function that lets them ask the client to resend a state trigger, which is particularly useful for things like "reloading the UI on focus without reloading the page itself", e.g. 184 | 185 | ```js 186 | ... 187 | createBrowserClient( 188 | class { 189 | async init() { 190 | addDocumentListener(`focus`, async () => { 191 | await this.syncState(); 192 | }); 193 | } 194 | ... 195 | } 196 | ``` 197 | 198 | ## v4.3.0 (13 December 2024) 199 | 200 | Fixed `this.quit()` and `this.disconnect()` not working in the browser. 201 | 202 | Added the ability to call client functions directly from the browser, for logic that does not involve the server, such as toggling client-side flags from the browser. 203 | 204 | ## v4.2.0 (5 December 2024) 205 | 206 | Added the error stack trace for remote throws, so that the caller can _actually_ see where things errored on the receiving side. 207 | 208 | ## v4.1.0 (30 November 2024) 209 | 210 | Changed the init sequence for browser clients. The code now waits to call `init()` on the web client until a socket connection has been established, _and_ the current state has been obtained from the client and locally bound. 211 | 212 | This gives code inside the `init()` function access to an up-to-date `this.state` variable, and allows web clients to immediately build the correct UI, rather than needing to first generate a "default" UI that cannot be updated to the correct view until `updateState` happens. 213 | 214 | Related, the attempt at preventing modification of `this.state` was incomplete, and a rigorous protection mechanism proved to be too much code, slowing things down, for no real payoff, so instead the partial protection mechanism was removed. If your code tries to modify it, it will modify it. This is not considered a backward compatibility breaking change, as no real code could have relied on overwriting, or manually changing, the browser state value. 215 | 216 | Additionally, the `linkClasses` shorthand function is no longer deprecated, because it was convenient enough for enough people to keep using. 217 | 218 | Consistency-wise, the spelling of `webserver` has been changed to `webServer`, while also keeping the old spelling to prevent existing code from breaking. This is not a breaking change, but note that the old spelling _will_ be removed in the next major release. 219 | 220 | ## v4.0.1 (8 January 2023) 221 | 222 | Added a `this.lock(...)` to the server that allows you to lock specific properties, with an unlock function that will be used to determine if a caller is allowed through. E.g. 223 | 224 | ```javascript 225 | class ServerClass { 226 | async init() { 227 | this.test = this.lock( 228 | // object we want to lock down 229 | { run: () => { ... }}, 230 | // "unlock" function 231 | (client) => authenticatedClients.includes(client) 232 | ); 233 | } 234 | }; 235 | ``` 236 | 237 | This breaks backwards compatibility for any server code that uses a function by the same name already. 238 | 239 | ## v3.0.0 (5 January 2023) 240 | 241 | Add a call to `server.init()` as part of the createServer process, because all the code was in place but that trigger was missing. This breaks backwards compatibility for any code that manually calls `init()` in the server class. 242 | 243 | ## v2.5.0 (19 December 2023) 244 | 245 | A throw caused by returning from a server function that's passing a `client` as first argument, but does not have a function signature with a client as first argument, now has a much more useful error message. 246 | 247 | ## v2.4.0 (25 November 2023) 248 | 249 | You can now import `createServer`, `createClient`, and `createWebClient` directly, without needing `linkClasses` (of course, `linkClasses` is still available, too). 250 | This version also fixes a bug in the web client where server throws would crash the client instead of forwarding the thrown error to the browser. 251 | 252 | ## v2.3.0 (9 November 2023) 253 | 254 | Added an `init()` to servers as well, so that there's a place where allocations can be run such that they _only_ run for servers, rather than using globals and having those run on the client, too. (since both the client class and server class need to be loaded for `linkClasses` to work). 255 | 256 | ## v2.2.0 (3 November 2023) 257 | 258 | Added `init()` to clients, to offer an alternative to the constructor with all instance properties available for use. This is particularly important for web clients where we may need a "startup state" that we cannot set in the constructor as the protected `this.state` instance property does not exist yet. 259 | 260 | ## v2.1.5 (31 October 2023) 261 | 262 | README.md update to make sure the initial example shows off everything rather than browserless client-server connections. This is important enough to merit a patch release. 263 | 264 | ## v2.1.4 (31 October 2023) 265 | 266 | bug fix when trying to connect to a server that isn't running. Previous this would throw a SyntaxError claiming that the URL provided was invalid, when the actual problem isn't that the URL is invalid, it's just not accessible. That's not an error: that's the internet. 267 | 268 | ## v2.1.3 (28 October 2023) 269 | 270 | bug fix in browser state freezing when there is no state to update. 271 | 272 | ## v2.1.2 (28 October 2023) 273 | 274 | bug fix in how the client reconnect function propagated the socket disconnect signal. 275 | 276 | ## v2.1.1 (28 October 2023) 277 | 278 | Added missing docs for the `.reconnect()` function. 279 | 280 | ## v2.1.0 (28 October 2023) 281 | 282 | Clients now have a `.reconnect()` function for reconnecting to the server, making things like "letting the client run while we restart the server" much easier. 283 | 284 | ## v2.0.1 (28 October 2023) 285 | 286 | - Fixed client crashing if the browser tried to call server functions without (or before) the web client was connected to the server. 287 | - Updated the way errors are thrown by the SocketProxy's `apply` function, so that you can actually tell where things go wrong. 288 | 289 | Previous you would get: 290 | 291 | ``` 292 | socketless.js:426 Uncaught (in promise) Error: 293 | at Object.apply (socketless.js:426:17) 294 | at async #authenticate (test.js:36:14) 295 | ``` 296 | 297 | Where Firefox would give you a few more steps, but Chrome wouldn't. Nnow you'll get: 298 | 299 | ``` 300 | test.js:37 Uncaught (in promise) CallError: Server unavailable 301 | at #authenticate (test.js:36:32) 302 | at BrowserClient.init (test.js:18:23) 303 | at createBrowserClient (socketless.js:507:27) 304 | at test.js:48:15 305 | ``` 306 | 307 | Without the line for `apply` because _you should not need to care about `socketless` internals_, and with both Firefox and Chrome reporting the full stack trace. 308 | 309 | ## v2.0.0 (25 October 2023) 310 | 311 | Locked down the sync state in the browser. This breaks backwards compatibility by no longer allowing the browser to modify its `this.state` in any way, as any modifications would throw off the json diff/patch mechanism used by state syncing. 312 | 313 | # Previous version history 314 | 315 | ## v1.0.8 (25 October 2023) 316 | 317 | Improved error reporting when trying to deep copy using JSON.parse(JSON.stringify) 318 | 319 | ## v1.0.7 (23 October 2023) 320 | 321 | Fixed a phrasing "bug" around how throws were reported across a proxied call. 322 | 323 | ## v1.0.6 (22 October 2023) 324 | 325 | Fixed a bug in how generateSocketless injects the browser/webclient string identifier. 326 | 327 | ## v1.0.5 (22 October 2023) 328 | 329 | Fixed a bug in how rfc6902 was resolved. 330 | 331 | ## v1.0.4 (22 October 2023) 332 | 333 | Removed the webclient/socketless.js build step, which was only necessary to allow the bundling that was removed in v1.0.2 334 | 335 | ## v1.0.3 (22 October 2023) 336 | 337 | Fixed a bug in upgraded-socket that caused functions to be called with the wrong `bind()` context. 338 | 339 | ## v1.0.2 (22 October 2023) 340 | 341 | removed the build step, it just interfered with debugging, and the footprint of the library with sourcemap was bigger than just including the `src` dir. 342 | 343 | ## v1.0.1 (22 October 2023) 344 | 345 | added a source map for better debugging 346 | 347 | ## v1.0.0 (21 October 2023) 348 | 349 | Full rewrite 350 | 351 | - code is now ESM 352 | - the transport mechanism is now based on Proxy objects 353 | - fewer files! 354 | - less code! 355 | - easier to maintain! (...hopefully) 356 | - the release gets compiled into a single `library.js`` file 357 | 358 | # Pre-1.0 versions 359 | 360 | ## v0.13.9 361 | 362 | Added webclient middleware support 363 | 364 | ## v0.13.8 365 | 366 | Added crash-protection 367 | 368 | ## v0.13.7 369 | 370 | Added HTTPS support 371 | 372 | ## v0.13.5 373 | 374 | Added sequence tracking to state syncing mechanism 375 | 376 | ## v0.13.3 377 | 378 | Added URL parameter parsing 379 | 380 | ## v0.13.0 (16 January 2021) 381 | 382 | Added custom routes for webclients 383 | 384 | ## v0.12.7 385 | 386 | Added automatic client id generation at the server, so that the server has a keyable value, and can communicate that back to the client if necessary. 387 | 388 | ## v0.12.5 389 | 390 | Fixed webclient syncing (sequencing was failing, and so each update led to a secondary full sync) 391 | 392 | ## v0.12.4 393 | 394 | Removed a warning while parsing an override function chain, because the behaviour was correct but the warning was nonsense. 395 | 396 | ## v0.12.3 397 | 398 | Removed morphdom from the dependency list. It had no reason to be there. 399 | 400 | ## v0.12.0 (26 February 2020) 401 | 402 | Changed the APIs and added Jest testing to make sure they actually work 403 | Updated the README.md to be a hell of a lot clearer 404 | Created a web page for https://pomax.github.io/socketless 405 | 406 | ## v0.11.8 407 | 408 | Fixed an edge case bug where direct-syncing web clients when using `$` rather than `:` as namespace separator in Client functors caused the syncing code to do everything right up to the point where it had to call the correct API function. And then called the `:` one instead of the `$` one. 409 | 410 | ## v0.11.6 411 | 412 | Stripped the demo code for the npm published version, because why bloat the library with 150KB of source that you're not going to care about if you just need this as one of the tools in your codebase toolbox? 413 | 414 | ## v0.11.5 415 | 416 | Removed the `jsonpatch` dependency, saving another 250KB of on-disk footprint. Previously, `rfc6902` did not have a dist dir with a for-browser min.js, but as of v3.0.4 it does, and so `jsonpatch` no longer has any reason to exist as dependency, or in the code. 417 | 418 | ## v0.11.2, 0.11.3 419 | 420 | Tiny tweaks 421 | 422 | ## v0.11.1 423 | 424 | Update the web client `update()` function to be called as `update(state)` purely to spare you having to issue `const state = this.state;` as first line of your update function. 425 | 426 | ## v0.11.0 (2 August 2019) 427 | 428 | Changed how client/webclient state synchronization works: with `directSync` the client instance is reflected to the web client directly. Without it (which is the default behaviour) the client's `this.state` will be reflected as the web client's `this.state`. Previously, the client's `this.state` would be reflected as the web client's state without `this.state` scoping, which was super fragile. This way, things are both more robust, and more obvious: if you're using `this.state`, you're using `this.state` both in the client and the webclient. If you're not... you're not. 429 | 430 | Also, I swapped `socket.io`, which was useful for initial prototyping, for `ws`, which is almost 4MB smaller. There's no reason to use a huge socket management library when web sockets are supported by basically everything at this point in time. 431 | 432 | ## v0.10.0 (25 July 2019) 433 | 434 | Fixed the way function names are discovered, so that `SomeClass extends ClientClass` can be used as argument to `createClientServer()`. Previously, only the class's own function names were checked, so an empty class extensions -which you'd expect to inherit all functions- was considered as "not implementing anything". So that was bad and is now fixed. 435 | 436 | ## v0.9.1 437 | 438 | Fixed incorrect dependency path resolution for generating socketless.js for web clients. 439 | 440 | ## v0.9.0 (13 July 2019) 441 | 442 | This version improves the sync mechanism for web clients, using a far more sensible sync locking mechanism: if a synchronization is already in progress, rather than just firing off a second one in parallel, queue up the _signal_ that another sync is required, but do nothing else. When a sync finishes, it checks whether that signal is active, and if so, deactivates it and performs one more sync operation. This means that if 10 sync operations are called in rapid succession, the first call starts a sync, the second call sets the signal, the third through tenth do _nothing_, and when the first sync finishes, it sees that one more sync is required. This saves socket listener allocation, processing, and time. Triple win! 443 | 444 | ## v0.8.0 (12 July 2019) 445 | 446 | This adds a web client, which is a regular client that a browser can connect in order to run a thin-client UI on top of the true client's state. It's pretty frickin sweet. It also comes with a full multiplayer mahjong game demo, because the only way I could thinkg of to properly test and debug the web client code was to sit down and write a serious piece of code with it. It's heavily code-commented, and is probably far more elaborate than you'd expect a tutorial/demonstrator to be. 447 | 448 | Good. More projects need full-blown codebases as examples for how to use them. 449 | 450 | ## v0.7.0 (28 June 2019) 451 | 452 | This adds explicit API objects back in, but as optional third argument to `generateClientServer`. It also adds in broadcasting by clients, based on clients knowing their own API and so knowing which function they want to have triggered by a broadcast. 453 | 454 | (Server broadcasting is simply calling `this.clients.forEach(c => c.namespace.fname(...)`) 455 | 456 | ## v0.6.0 (27 June 2019) 457 | 458 | This version changes the way you use the library, removing the need for a separate API object at the cost of having to namespace all your functions in your client and server classes. The `generateClientServer` function can no be called with an API object, and must be called as `generateClientServer(ClientClass, ServerClass)` instead. This will perform auto-discovery of all the API functions and their namespaces, and removes the need to pass the client and server classes when actually building clients and servers. 459 | 460 | The README.md was effectively entirely rewritten based on these new patterns and conventions. 461 | 462 | ## v0.5 (26 June 2019) and below 463 | 464 | - API-definition-based parsing with a lot of explicit passing of Client and Server classes 465 | -------------------------------------------------------------------------------- /docs/DEV.md: -------------------------------------------------------------------------------- 1 | # Developer documentation 2 | 3 | See the [ARCHITECTURE.md](ARCHITECTURE.md) document for details that won't be repeated here. 4 | 5 | ## Testing 6 | 7 | run `npm test`, which will: 8 | 9 | - lint the code using `tsc`, 10 | - if there are no linting errors, autoformats the code using `prettier`, and 11 | - runs all tests in the `./test` dir using `mocha`. 12 | 13 | ## Working on the code itself 14 | 15 | Some notes if you want to work on this code (in addition to the architecture documentation): 16 | 17 | - general proxy handling is done in the `src/upgraded-socket.js` file. 18 | - For servers, the `ws` and `webServer` properties are tacked on in the `src/index.js` file, `createServer` function. 19 | - For clients, the `params` property is tacked on in the `src/index.js` file, `createClient` function, and the `id` is established in the same function as part of the bare websocket `handshake:setid` handling. 20 | - for web clients, the `sid` authentication token is checked in the `src/index.js` file, `createWebClient` functions. Similarly, the `ws` and `webServer` properties are bound in the same function. In addition, the `syncState` call is defined there, as well. This is also where all `:response` messages get intercepted. 21 | - State syncing on the browser side is handled in `src/upgraded-socket.js`, in the `router` function, in the `if (state && receiver === BROWSER)` block. 22 | 23 | RPC calls are compared to a list of "forbidden" calls in the router function, which are pulled from the server, client, and webclient classes using their static `disallowedCalls` property, declared in `src/classes.js` and `src/webclient/classes.js`. 24 | 25 | ### Class hierarchies 26 | 27 | Server and classes are built as extensions on the user-provided class. 28 | 29 | - The `createServer` function uses a `ServerClass extends UserProvidedClass` 30 | - The `createClient` function uses a `ClientClass extends UserProvidedClass` 31 | - The `createWebClient` function uses a `WebClientClass extends ` 32 | 33 | to ensure user-implemented functions get called, the `socketless` classes defined their functions in terms of the super class: 34 | 35 | ```js 36 | async someSocketlessFunction() { 37 | super.someSocketlessFunction?.(); 38 | } 39 | ``` 40 | 41 | ### Function call routing 42 | 43 | Function calls are proxied through the `SocketProxy` class exported by `src/upgraded-socket.js`, and are resolved on step at a time using code similar to the following code block: 44 | 45 | ```js 46 | const stages = eventName.split(`:`); 47 | let response = undefined; 48 | let error = undefined; 49 | 50 | // Are we even allowed to resolve this chain? 51 | const [first] = stages; 52 | let forbidden = origin.__proto__?.constructor.disallowedCalls ?? []; 53 | if (first && forbidden.includes(first)) { 54 | error = `Illegal call: ${first} is a protected property`; 55 | } 56 | 57 | // Find the function to call: 58 | let context = origin; 59 | let callable = origin; 60 | if (!error) { 61 | try { 62 | while (stages.length) { 63 | context = callable; 64 | callable = callable[stages.shift()]; 65 | } 66 | if (receiver === `server`) payload.unshift(this[PROXY]); 67 | } catch (e) { 68 | error = e.message; 69 | } 70 | } 71 | 72 | // then call it (or capture an error) 73 | if (!error) { 74 | try { 75 | response = (await callable.bind(context)(...payload)) ?? true; 76 | } catch (e) { 77 | const chain = eventName.replaceAll(`:`, `.`); 78 | error = `Cannot call [[${receiver}]].${chain}, function is not defined.`; 79 | } 80 | } 81 | ``` 82 | 83 | That is, if we're resolving a.b.c.d() we step through this as: 84 | 85 | 1. context = a, callable = a 86 | 1. context = a, callable = b 87 | 1. context = b, callable = c 88 | 1. context = c, callable = d 89 | 90 | And then call `callable.bind(context)(...args)` so that the function gets called with the "function owner" as its call context. 91 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | There are a number of documents in this directory! And you probably know how to click links, so just head on over to... 4 | 5 | - [the general architecture docs.](ARCHITECTURE.md), or... 6 | - some ["How to" docs](HOWTO.md), or maybe... 7 | - the [API docs](API.md), or if you're super into working on the underlying code, maybe even the... 8 | - [Developer docs](DEV.md). 9 | 10 | And can I just say, It's pretty awesome that you're actually going through the documentation! [Let me know](https://github.com/Pomax/socketless/issues) if there's anything you can't find! 11 | -------------------------------------------------------------------------------- /docs/TODO.md: -------------------------------------------------------------------------------- 1 | This document mostly exists to let me track whether we're at v1 or not yet. 2 | 3 | # Basic work 4 | 5 | - [x] refactor the code from bare functions to classes 6 | - [x] implement diff-based state update for web client 7 | - [x] state messaging sequence numbers 8 | - [x] full vs. diffed state transmission 9 | - [x] disallow calling Base functions over RPC, except for the client's `syncState` and `quit`, 10 | by the browser, and `disconnect`, by the server (i.e. a server kick). 11 | - [x] figure out a way to turn "generateSocketless" into something that can be bundled. 12 | - [x] "get user input from browser" mechanism for client code when this.browser===true, after looking at what the MJ implementation actually needs/uses. 13 | 14 | # Tests 15 | 16 | - [] create new basic tests 17 | - [x] can start up constellation 18 | - [x] server + client 19 | - [x] server + webclient 20 | - [x] (server +) webclient + browser 21 | - [x] add "sid" verification tests, including a browser creating its own websocket connection rather than using socketless.js in an attempt to gain direct access 22 | - [] disallowed access 23 | - [x] client to server 24 | - [x] server to client 25 | - [x] webclient to server 26 | - [x] server to webclient 27 | - [] webclient to browser? 28 | - [] browser to webclient? 29 | - [] server types 30 | - [x] basic http 31 | - [x] basic https 32 | - [x] express server 33 | - [x] verify self-signed does not work without symbol 34 | - [] webclient state tests 35 | - [x] state 36 | - [] syncState call 37 | 38 | # Documentation 39 | 40 | - [x] document the diagram of "how things work" 41 | - [] rewrite the docs (in progress) 42 | 43 | - [x] architecture document 44 | - [x] "how to" document 45 | - [x] pure API document 46 | 47 | - [] document the places where calls get resolved. 48 | 49 | # Demos 50 | 51 | create several new demos 52 | 53 | - [x] simple client/server demo 54 | - [] simple client/server demo with auth? 55 | - [] terminal based chat client 56 | - [] multiplayer game: mahjong 57 | - [] terminal client 58 | - [] web client 59 | 60 | # Open issues 61 | 62 | - [ ] ws reconnections should be able to deaal with "finding the original client this was for". 63 | - [ ] the browser's `this.socket` feels like it should not be necessary and exposed, _or_ it should be a `this.client` and be a proxy socket? 64 | - [x] add middleware back in for the webclient? 65 | - [x] update addRoute to allow middleware? 66 | - for now I've added a setAuthHandler, but a more generic middleware solution might still be nice. 67 | 68 | - [] should clients have a `.reconnect()` so the browser can control the client's connection to the server? 69 | - [] should the browser have a .quit() that shuts down the client completely? 70 | 71 | # Useful for dev 72 | 73 | "why-is-node-running": "^2.2.2" 74 | 75 | ``` 76 | import why from "why-is-node-running"; 77 | ... 78 | why(); 79 | ``` 80 | 81 | # TRUE TODO 82 | 83 | update readme to show full server+client+browser, not just server+client 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "socketless", 3 | "version": "6.0.0", 4 | "type": "module", 5 | "description": "A framework and methodology for writing web socket RPC programs, without writing a single line of web socket or RPC code.", 6 | "main": "src/index.js", 7 | "scripts": { 8 | "demo:simple": "cd demos && npm i && npm run simple", 9 | "demo:web": "cd demos && npm i && npm run web", 10 | "prettier": "prettier --write ./**/*.js", 11 | "test": "npm run test:ast && npm run prettier && npm run test:mocha", 12 | "test:ast": "tsc ./src/index.js --checkJs --noEmit --module nodenext --skipLibCheck", 13 | "test:mocha": "mocha \"test/**/*.test.js\"" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/Pomax/socketless.git" 18 | }, 19 | "keywords": [ 20 | "client", 21 | "server", 22 | "websocket", 23 | "javascript", 24 | "framework" 25 | ], 26 | "author": "Pomax", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/Pomax/socketless/issues" 30 | }, 31 | "homepage": "https://github.com/Pomax/socketless#readme", 32 | "dependencies": { 33 | "rfc6902": "^5.1.2", 34 | "ws": "^8.18.0" 35 | }, 36 | "devDependencies": { 37 | "chai": "^5.1.2", 38 | "chai-as-promised": "^8.0.1", 39 | "esbuild": "^0.24.2", 40 | "express": "^4.21.2", 41 | "mocha": "^11.0.1", 42 | "open": "^9.1.0", 43 | "pem": "^1.14.8", 44 | "prettier": "^3.4.2", 45 | "puppeteer": "^21.3.6", 46 | "typescript": "^5.7.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/classes.js: -------------------------------------------------------------------------------- 1 | import { createSocketProxy } from "./upgraded-socket.js"; 2 | import { CLIENT, SERVER, deepCopy } from "./utils.js"; 3 | import { applyPatch } from "rfc6902"; 4 | 5 | const DEBUG = false; 6 | 7 | const STATE_SYMBOL = Symbol(`state symbol`); 8 | 9 | /** 10 | * ...docs go here... 11 | */ 12 | export function formClientClass(ClientClass) { 13 | return class ClientBase extends ClientClass { 14 | // Special data silo for JSON-diff based syncing from the server, 15 | // so that the client doesn't need to built a local data structure 16 | // that is already being maintained by the server. 17 | #server_sync_silo = {}; 18 | #server_sync_seq_num = 0; 19 | #apply_server_sync_patch(patch, seqNum) { 20 | // out-of-order update? 21 | if (seqNum !== this.#server_sync_seq_num + 1) return false; 22 | this.#server_sync_seq_num++; 23 | const results = applyPatch(this.#server_sync_silo, patch); 24 | return results.every((e) => e === null); 25 | } 26 | #set_server_sync_silo(data, seqNum) { 27 | this.#server_sync_silo = deepCopy(data); 28 | this.#server_sync_seq_num = seqNum; 29 | return true; 30 | } 31 | async __data_sync({ data, patch, seqNum = 0, forced = false }) { 32 | let result; 33 | if (forced) { 34 | result = this.#set_server_sync_silo(data, seqNum); 35 | } else { 36 | result = this.#apply_server_sync_patch(patch, seqNum); 37 | } 38 | process.nextTick(() => { 39 | this.onSyncUpdate(deepCopy(this.#server_sync_silo), forced); 40 | }); 41 | return result; 42 | } 43 | 44 | async onSyncUpdate(silo, forced) { 45 | super.onSyncUpdate?.(silo, forced); 46 | if (DEBUG) console.log(`[ClientBase] received silo update.`); 47 | } 48 | 49 | static get disallowedCalls() { 50 | // No functions in this class may be proxy-invoked 51 | const names = Object.getOwnPropertyNames(ClientBase.prototype); 52 | // (except for `disconnect` and `__data_sync`) 53 | [`constructor`, `disconnect`, `__data_sync`].forEach((name) => 54 | names.splice(names.indexOf(name), 1), 55 | ); 56 | // Nor should the following properties be accessible 57 | names.push(`server`, `state`, `params`); 58 | return names; 59 | } 60 | 61 | constructor() { 62 | super(); 63 | // disallow writing directly into state 64 | // TODO: this needs to have proxy-based deep protection. Right now it's only single level. 65 | const state = (this[STATE_SYMBOL] = {}); 66 | const readOnlyState = new Proxy(state, { 67 | get: (_, prop) => state[prop], 68 | set: () => { 69 | throw new Error( 70 | `cannot directly assign to state, use setState(update)`, 71 | ); 72 | }, 73 | }); 74 | Object.defineProperty(this, `state`, { 75 | value: readOnlyState, 76 | writable: false, 77 | configurable: false, 78 | }); 79 | } 80 | 81 | async init() { 82 | super.init?.(); 83 | if (DEBUG) console.log(`[ClientBase] running init()`); 84 | } 85 | 86 | async onError(error) { 87 | super.onError?.(error); 88 | if (DEBUG) console.log(`[ClientBase] some kind of error occurred.`); 89 | } 90 | 91 | onConnect() { 92 | super.onConnect?.(); 93 | if (DEBUG) console.log(`[ClientBase] client ${this.state.id} connected.`); 94 | } 95 | 96 | onDisconnect() { 97 | super.onDisconnect?.(); 98 | if (DEBUG) 99 | console.log(`[ClientBase] client ${this.state.id} disconnected.`); 100 | } 101 | 102 | setState(stateUpdates) { 103 | if (DEBUG) console.log(`[ClientBase] updating state`); 104 | const state = this[STATE_SYMBOL]; 105 | Object.entries(stateUpdates).forEach( 106 | ([key, value]) => (state[key] = value), 107 | ); 108 | } 109 | 110 | connectServerSocket(serverSocket) { 111 | if (DEBUG) console.log(`[ClientBase] connected to server`); 112 | this.server = createSocketProxy(serverSocket, this, CLIENT, SERVER); 113 | this.onConnect(); 114 | } 115 | 116 | disconnect() { 117 | this.server?.socket.close(); 118 | } 119 | }; 120 | } 121 | 122 | /** 123 | * ...docs go here... 124 | */ 125 | export function formServerClass(ServerClass) { 126 | return class ServerBase extends ServerClass { 127 | clients = []; 128 | ws = undefined; // websocket server instance 129 | webServer = undefined; // http(s) server instance 130 | 131 | static get disallowedCalls() { 132 | // No functions in this class may be proxy-invoked 133 | const names = Object.getOwnPropertyNames(ServerBase.prototype); 134 | names.splice(names.indexOf(`constructor`), 1); 135 | // Nor should these server-specific properties be accessible to clients 136 | names.push( 137 | `clients`, 138 | `ws`, 139 | `webServer`, 140 | // @deprecated 141 | `webserver`, 142 | ); 143 | return names; 144 | } 145 | 146 | constructor(ws, webServer) { 147 | super(); 148 | this.ws = ws; 149 | this.webServer = webServer; 150 | } 151 | 152 | async init() { 153 | super.init?.(); 154 | if (DEBUG) console.log(`[ServerBase] running init()`); 155 | } 156 | 157 | // When a client connects to the server, route it to 158 | // the server.addClient(client) function for handling. 159 | async connectClientSocket(socket) { 160 | if (DEBUG) console.log(`[ServerBase] client connecting to server...`); 161 | const client = createSocketProxy(socket, this, SERVER, CLIENT); 162 | 163 | // send the client its server id 164 | if (DEBUG) console.log(`[ServerBase] sending connection id`); 165 | 166 | client.socket.send( 167 | JSON.stringify({ 168 | name: `handshake:setid`, 169 | payload: { id: client.id }, 170 | }), 171 | ); 172 | 173 | if (DEBUG) 174 | console.log(`[ServerBase] adding client to list of known clients`); 175 | 176 | // add this client to the list 177 | this.clients.push(client); 178 | 179 | // Add client-removal handling for when the socket closes: 180 | this.addDisconnectHandling(client, socket); 181 | 182 | // And then trigger the onConnect function for subclasses to do 183 | // whatever they want to do when a client connects to the server. 184 | this.onConnect(client); 185 | } 186 | 187 | // Add client-removal handling when the socket closes: 188 | async addDisconnectHandling(client, socket) { 189 | const { clients } = this; 190 | socket.on(`close`, () => { 191 | let pos = clients.findIndex((e) => e === client); 192 | if (pos !== -1) { 193 | let e = clients.splice(pos, 1)[0]; 194 | this.onDisconnect(client); 195 | } 196 | }); 197 | } 198 | 199 | async onError(error) { 200 | super.onError?.(error); 201 | if (DEBUG) console.log(`[ServerBase] some kind of error occurred.`); 202 | } 203 | 204 | async onConnect(client) { 205 | super.onConnect?.(client); 206 | if (DEBUG) console.log(`[ServerBase] client ${client.id} connected.`); 207 | } 208 | 209 | async onDisconnect(client) { 210 | super.onDisconnect?.(client); 211 | if (DEBUG) console.log(`[ServerBase] client ${client.id} disconnected.`); 212 | } 213 | 214 | async quit() { 215 | await this.onQuit(); 216 | this.clients.forEach((client) => client.disconnect()); 217 | this.ws.close(); 218 | this.webServer.closeAllConnections(); 219 | this.webServer.close(() => this.teardown()); 220 | } 221 | 222 | async onQuit() { 223 | super.onQuit?.(); 224 | if (DEBUG) console.log(`[ServerBase] told to quit.`); 225 | } 226 | 227 | async teardown() { 228 | super.teardown?.(); 229 | if (DEBUG) console.log(`[ServerBase] post-quit teardown.`); 230 | } 231 | }; 232 | } 233 | -------------------------------------------------------------------------------- /src/generate-socketless.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script creates a browser library that gets served up 3 | * by the webclient's route-handler when the browser asks for 4 | * 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/webclient/auth/index.js: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from "./socketless.js"; 2 | 3 | class BrowserClient { 4 | init() { 5 | const props = Object.keys(this.state); 6 | if (props.length > 1) { 7 | this.client.fail(`too many props: ${props}`); 8 | } 9 | } 10 | update() { 11 | if (this.state.authenticated === true) { 12 | const { a, b, c } = this.state; 13 | this.client.setResult(a, b, c); 14 | } 15 | 16 | if (this.state.authenticated === false) { 17 | this.client.authenticate(`user`, `password`, `12345`); 18 | } 19 | } 20 | } 21 | 22 | createBrowserClient(BrowserClient); 23 | -------------------------------------------------------------------------------- /test/webclient/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | test 5 | 6 | 7 | 8 | quit 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/webclient/basic/index.js: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from "./socketless.js"; 2 | createBrowserClient(class {}); 3 | -------------------------------------------------------------------------------- /test/webclient/dedicated/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | test 5 | 6 | 7 | 8 | quit 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/webclient/dedicated/index.js: -------------------------------------------------------------------------------- 1 | import(`./socketless.js${location.search}`).then((lib) => { 2 | const { createBrowserClient } = lib; 3 | 4 | const create = (tag, classString, parent) => { 5 | const e = document.createElement(tag); 6 | e.setAttribute(`class`, classString); 7 | parent.appendChild(e); 8 | return e; 9 | }; 10 | 11 | class BrowserClientClass { 12 | async init() { 13 | this.idField = create(`p`, `idfield`, document.body); 14 | this.testField = create(`p`, `testfield`, document.body); 15 | const result = await this.server.test(1, 2, 3); 16 | if (result !== `321`) { 17 | throw new Error(`Incorrect result received in the browser`); 18 | } 19 | window.test = this; 20 | } 21 | 22 | async update(prevState) { 23 | this.render(); 24 | } 25 | 26 | render() { 27 | const { id, randomValue } = this.state; 28 | this.idField.textContent = id; 29 | this.testField.textContent = randomValue; 30 | } 31 | } 32 | 33 | createBrowserClient(BrowserClientClass); 34 | }); 35 | -------------------------------------------------------------------------------- /test/webclient/params/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | test 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/webclient/params/index.js: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from "./socketless.js"; 2 | import { targets } from "./targets.js"; 3 | 4 | function compare(name, v1, v2) { 5 | const t1 = typeof v1; 6 | const t2 = typeof v2; 7 | if (t1 !== t2) { 8 | return `type mismatch: ${t1} !== ${t2}`; 9 | } else if (name === `object`) { 10 | if (JSON.stringify(v1) !== JSON.stringify(v2)) { 11 | return `mismatch in object`; 12 | } 13 | } else if (v1 !== v2) { 14 | return `value mismatch (${JSON.stringify(v1)} !== ${JSON.stringify(v2)})`; 15 | } 16 | } 17 | 18 | class BrowserClient { 19 | async init() { 20 | if (!this.params) { 21 | return this.server.fail(`no params found`); 22 | } 23 | 24 | for (const [name, value] of Object.entries(targets)) { 25 | if (this.params[name] === undefined) { 26 | return this.server.fail(`browser did not receive param "${name}"`); 27 | } 28 | 29 | const err = compare(name, this.params[name], value); 30 | 31 | if (err) { 32 | return this.server.fail(`error for param "${name}": ${err}`); 33 | } 34 | } 35 | 36 | this.server.pass(); 37 | } 38 | } 39 | 40 | createBrowserClient(BrowserClient); 41 | -------------------------------------------------------------------------------- /test/webclient/params/targets.js: -------------------------------------------------------------------------------- 1 | export const targets = { 2 | number: 1, 3 | boolean: true, 4 | string: "test", 5 | object: { a: {} }, 6 | }; 7 | -------------------------------------------------------------------------------- /test/webclient/pass-through/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | test 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/webclient/pass-through/index.js: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from "./socketless.js"; 2 | 3 | class BrowserClient { 4 | runPassThroughTest(text) { 5 | this.server.passthroughReceived(); 6 | } 7 | } 8 | 9 | createBrowserClient(BrowserClient); 10 | -------------------------------------------------------------------------------- /test/webclient/standalone/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | test 5 | 6 | 7 | 8 | quit 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/webclient/standalone/index.js: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from "./socketless.js"; 2 | 3 | class BrowserClient { 4 | async init() { 5 | try { 6 | await this.server.nonexistent(); 7 | } catch (e) { 8 | this.quit(); 9 | } 10 | } 11 | } 12 | 13 | createBrowserClient(BrowserClient); 14 | -------------------------------------------------------------------------------- /test/webclient/stateful/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | test 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/webclient/stateful/index.js: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from "./socketless.js"; 2 | 3 | let cfIteration = 1; 4 | 5 | class BrowserClient { 6 | init() { 7 | this.current = this.state.v || 0; 8 | } 9 | 10 | update(prevState, changeFlags) { 11 | if (changeFlags) { 12 | if (!changeFlags.a.e) { 13 | throw new Error(`missing diff flag for "a.e"`); 14 | } 15 | 16 | // does adding work? 17 | if (cfIteration === 1 && changeFlags.a.e !== 1) { 18 | throw new Error(`wrong flag value for add (${changeFlags.a.e})`); 19 | } 20 | 21 | if (cfIteration > 1 && changeFlags.a.e !== 4) { 22 | throw new Error(`wrong flag value for array add (${changeFlags.a.e})`); 23 | } 24 | 25 | // does removal work? 26 | if (cfIteration === 2 && changeFlags.a.b.c !== 3) { 27 | throw new Error(`wrong flag value for remove (${changeFlags.a.b.c})`); 28 | } 29 | 30 | if (!changeFlags.a.b.d) { 31 | throw new Error(`missing diff flag for "a.b.d"`); 32 | } 33 | 34 | // does replacement work? 35 | if (cfIteration >= 2 && changeFlags.a.b.d !== 2) { 36 | throw new Error(`wrong flag value for replace (${changeFlags.a.b.d})`); 37 | } 38 | 39 | if (!changeFlags.v) { 40 | throw new Error(`missing diff flag for "v"`); 41 | } 42 | 43 | cfIteration++; 44 | } 45 | 46 | if (this.state.v === this.current + 1) { 47 | this.current++; 48 | if (this.current === 5) { 49 | this.disconnect(); 50 | document.body.classList.add(`done`); 51 | } 52 | } 53 | } 54 | } 55 | 56 | createBrowserClient(BrowserClient); 57 | -------------------------------------------------------------------------------- /test/webclient/statemod/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | test 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/webclient/statemod/index.js: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from "./socketless.js"; 2 | 3 | class BrowserClient { 4 | init() { 5 | // check tests for initial state object 6 | this.runTests(); 7 | this.server.updateValue(); 8 | } 9 | 10 | update(prevState, changeFlags) { 11 | const { state } = this; 12 | 13 | if (changeFlags.a && state.a.b.c !== 1) { 14 | throw new Error(`did not receive a.b.c?`); 15 | } 16 | 17 | // recheck tests for updated state object 18 | this.runTests(); 19 | this.quit(); 20 | } 21 | 22 | runTests() { 23 | let msg; 24 | 25 | // Reassigning this.state should be impossible 26 | msg = `should not have been able to reassign this.state`; 27 | try { 28 | this.state = {}; 29 | throw new Error(msg); 30 | } catch (e) { 31 | if (e.message === msg) throw e; 32 | // if this is not our own message, all is well. 33 | } 34 | 35 | // Assigning properties in this.state should be impossible 36 | msg = `should not have been able to add .a to this.state`; 37 | try { 38 | this.state.a = 12345; 39 | throw new Error(msg); 40 | } catch (e) { 41 | if (e.message === msg) throw e; 42 | // if this is not our own message, all is well. 43 | } 44 | 45 | // This should be perfectly allowed: 46 | const state = this.getStateCopy(); 47 | state.a = `test`; 48 | } 49 | } 50 | 51 | createBrowserClient(BrowserClient); 52 | -------------------------------------------------------------------------------- /test/webclient/webclient.test.js: -------------------------------------------------------------------------------- 1 | import { WebSocket } from "ws"; 2 | import { 3 | linkClasses, 4 | createWebClient, 5 | ALLOW_SELF_SIGNED_CERTS, 6 | } from "../../src/index.js"; 7 | 8 | import puppeteer from "puppeteer"; 9 | import url from "url"; 10 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 11 | 12 | // self-signed certificate code for HTTPS: 13 | import pem from "pem"; 14 | const httpsOptions = await new Promise((resolve, reject) => { 15 | pem.createCertificate( 16 | { days: 1, selfSigned: true }, 17 | function (e, { clientKey: key, certificate: cert }) { 18 | if (e) return reject(e); 19 | resolve({ key, cert }); 20 | }, 21 | ); 22 | }); 23 | 24 | async function getPage(browser, onError) { 25 | const page = await browser.newPage(); 26 | page.on("console", (msg) => console.log(`[browser log: ${msg.text()}]`)); 27 | page.on("pageerror", (msg) => { 28 | console.log(`[browser error]`, msg); 29 | onError?.(new Error(msg)); 30 | }); 31 | return page; 32 | } 33 | 34 | /** 35 | * ... 36 | * @param {*} done 37 | * @param {*} getError 38 | * @returns 39 | */ 40 | function getClasses(done, getError) { 41 | class ClientClass { 42 | async onConnect() { 43 | this.interval = setInterval( 44 | () => 45 | this.setState({ 46 | randomValue: Math.random(), 47 | }), 48 | 3000, 49 | ); 50 | } 51 | onQuit() { 52 | clearInterval(this.interval); 53 | delete this.interval; 54 | } 55 | } 56 | 57 | class ServerClass { 58 | async onDisconnect(client) { 59 | if (this.clients.length === 0) { 60 | this.quit(); 61 | } 62 | } 63 | async teardown() { 64 | done(getError?.()); 65 | } 66 | async test(client, a, b, c) { 67 | return [c, b, a].join(``); 68 | } 69 | } 70 | return { ClientClass, ServerClass }; 71 | } 72 | 73 | describe("web client tests", () => { 74 | it("should run on a basic http setup", (done) => { 75 | let error; 76 | const { ClientClass, ServerClass } = getClasses(done, () => error); 77 | const factory = linkClasses(ClientClass, ServerClass); 78 | const { webServer } = factory.createServer(); 79 | webServer.listen(0, () => { 80 | const PORT = webServer.address().port; 81 | const serverURL = `http://localhost:${PORT}`; 82 | const { client, clientWebServer } = factory.createWebClient( 83 | serverURL, 84 | `${__dirname}/dedicated`, 85 | ); 86 | 87 | clientWebServer.addRoute(`/quit`, (req, res) => { 88 | res.end("client disconnected"); 89 | // Not sure why we need a timeout here, but if 90 | // we don't the browser get uppity on MacOS... 91 | setTimeout(() => client.quit(), 25); 92 | }); 93 | 94 | clientWebServer.listen(0, async () => { 95 | const clientURL = `http://localhost:${clientWebServer.address().port}`; 96 | const browser = await puppeteer.launch({ headless: `new` }); 97 | const page = await getPage(browser, (msg) => (error = msg)); 98 | await page.goto(clientURL); 99 | await page.waitForSelector(`.testfield`); 100 | await page.click(`#quit`); 101 | await browser.close(); 102 | }); 103 | }); 104 | }); 105 | 106 | it("should run on a https for the server, but basic http for the web client", (done) => { 107 | let error; 108 | const { ClientClass, ServerClass } = getClasses(done, () => error); 109 | const factory = linkClasses(ClientClass, ServerClass); 110 | const { webServer } = factory.createServer(httpsOptions); 111 | webServer.listen(0, () => { 112 | const PORT = webServer.address().port; 113 | const serverURL = `https://localhost:${PORT}`; 114 | const { client, clientWebServer } = factory.createWebClient( 115 | serverURL, 116 | `${__dirname}/dedicated`, 117 | false, 118 | ALLOW_SELF_SIGNED_CERTS, 119 | ); 120 | 121 | clientWebServer.addRoute(`/quit`, (req, res) => { 122 | res.end("client disconnected"); 123 | // Not sure why we need a timeout here, but if 124 | // we don't the browser get uppity on MacOS... 125 | setTimeout(() => client.quit(), 25); 126 | }); 127 | 128 | clientWebServer.listen(0, async () => { 129 | const url = `http://localhost:${clientWebServer.address().port}`; 130 | const browser = await puppeteer.launch({ headless: `new` }); 131 | const page = await getPage(browser, (msg) => (error = msg)); 132 | await page.goto(url); 133 | await page.waitForSelector(`.testfield`); 134 | await page.click(`#quit`); 135 | await browser.close(); 136 | }); 137 | }); 138 | }); 139 | 140 | it("should run on https for both the server and the webclient", (done) => { 141 | let error; 142 | const { ClientClass, ServerClass } = getClasses(done, () => error); 143 | const factory = linkClasses(ClientClass, ServerClass); 144 | const { webServer } = factory.createServer(httpsOptions); 145 | webServer.listen(0, () => { 146 | const PORT = webServer.address().port; 147 | const url = `https://localhost:${PORT}`; 148 | const { client, clientWebServer } = factory.createWebClient( 149 | url, 150 | `${__dirname}/dedicated`, 151 | httpsOptions, 152 | ALLOW_SELF_SIGNED_CERTS, 153 | ); 154 | 155 | clientWebServer.addRoute(`/quit`, (req, res) => { 156 | res.end("client disconnected"); 157 | // Not sure why we need a timeout here, but if 158 | // we don't the browser get uppity on MacOS... 159 | setTimeout(() => client.quit(), 25); 160 | }); 161 | 162 | clientWebServer.listen(0, async () => { 163 | const browser = await puppeteer.launch({ 164 | headless: `new`, 165 | ignoreHTTPSErrors: true, 166 | }); 167 | const page = await getPage(browser, (msg) => (error = msg)); 168 | await page.goto(`https://localhost:${clientWebServer.address().port}`); 169 | await page.waitForSelector(`.testfield`); 170 | await page.click(`#quit`); 171 | await browser.close(); 172 | }); 173 | }); 174 | }); 175 | 176 | it("should reject socketless connections on SID mismatch", (done) => { 177 | let error = `connection was allowed through`; 178 | const { ClientClass, ServerClass } = getClasses(done, () => error); 179 | const factory = linkClasses(ClientClass, ServerClass); 180 | const { webServer } = factory.createServer(); 181 | webServer.listen(0, () => { 182 | const sid = "testing"; 183 | const PORT = webServer.address().port; 184 | const serverURL = `http://localhost:${PORT}`; 185 | const { client, clientWebServer } = factory.createWebClient( 186 | `${serverURL}?sid=${sid}`, 187 | `${__dirname}/dedicated`, 188 | ); 189 | 190 | clientWebServer.listen(0, async () => { 191 | const clientURL = `http://localhost:${clientWebServer.address().port}`; 192 | const browser = await puppeteer.launch({ headless: `new` }); 193 | const page = await getPage(browser); 194 | page.on("console", (msg) => { 195 | msg = msg.text(); 196 | if ( 197 | msg === 198 | `Failed to load resource: the server responded with a status of 404 (Not Found)` 199 | ) { 200 | error = undefined; 201 | } 202 | }); 203 | await page.goto(clientURL); 204 | await new Promise((resolve) => setTimeout(resolve, 100)); 205 | await browser.close(); 206 | client.quit(); 207 | }); 208 | }); 209 | }); 210 | 211 | it("should honour socketless connections on SID match", (done) => { 212 | let error; 213 | const { ClientClass, ServerClass } = getClasses(done, () => error); 214 | const factory = linkClasses(ClientClass, ServerClass); 215 | const { webServer } = factory.createServer(); 216 | webServer.listen(0, () => { 217 | const sid = "testing"; 218 | const PORT = webServer.address().port; 219 | const serverURL = `http://localhost:${PORT}`; 220 | const { client, clientWebServer } = factory.createWebClient( 221 | `${serverURL}?sid=${sid}`, 222 | `${__dirname}/dedicated`, 223 | ); 224 | 225 | clientWebServer.addRoute(`/quit`, (req, res) => { 226 | res.end("client disconnected"); 227 | // Not sure why we need a timeout here, but if 228 | // we don't the browser get uppity on MacOS... 229 | setTimeout(() => client.quit(), 25); 230 | }); 231 | 232 | clientWebServer.listen(0, async () => { 233 | const clientURL = `http://localhost:${clientWebServer.address().port}`; 234 | const browser = await puppeteer.launch({ headless: `new` }); 235 | const page = await getPage(browser, (msg) => (error = msg)); 236 | await page.goto(`${clientURL}?sid=${sid}`); 237 | await page.waitForSelector(`.testfield`); 238 | await page.click(`#quit`); 239 | await browser.close(); 240 | }); 241 | }); 242 | }); 243 | 244 | it("should reject bare websocket connection on SID mismatch", (done) => { 245 | let error = `connection was allowed through`; 246 | const { ClientClass, ServerClass } = getClasses(done, () => error); 247 | const factory = linkClasses(ClientClass, ServerClass); 248 | const { webServer } = factory.createServer(); 249 | 250 | webServer.listen(0, () => { 251 | const sid = "testing"; 252 | const PORT = webServer.address().port; 253 | const serverURL = `http://localhost:${PORT}`; 254 | const { client, clientWebServer } = factory.createWebClient( 255 | `${serverURL}?sid=${sid}`, 256 | `${__dirname}/dedicated`, 257 | ); 258 | 259 | clientWebServer.listen(0, async () => { 260 | const clientURL = `http://localhost:${clientWebServer.address().port}`; 261 | 262 | setTimeout(() => { 263 | const ws = new WebSocket(clientURL); 264 | ws.on(`error`, (err) => { 265 | error = undefined; 266 | client.quit(); 267 | }); 268 | }, 100); 269 | }); 270 | }); 271 | }); 272 | 273 | it("should allow bare websocket connection if SID matches", (done) => { 274 | let error; 275 | const { ClientClass, ServerClass } = getClasses(done, () => error); 276 | const factory = linkClasses(ClientClass, ServerClass); 277 | const { webServer } = factory.createServer(); 278 | webServer.listen(0, () => { 279 | const sid = "testing"; 280 | const PORT = webServer.address().port; 281 | const serverURL = `http://localhost:${PORT}`; 282 | const { client, clientWebServer } = factory.createWebClient( 283 | `${serverURL}?sid=${sid}`, 284 | `${__dirname}/dedicated`, 285 | ); 286 | 287 | clientWebServer.listen(0, async () => { 288 | const clientURL = `http://localhost:${clientWebServer.address().port}`; 289 | const ws = new WebSocket(`${clientURL}?sid=${sid}`); 290 | const readyState = await new Promise((resolve) => 291 | setTimeout(() => resolve(ws.readyState), 500), 292 | ); 293 | if (readyState !== 1) { 294 | error = `websocket connection should have been allowed through`; 295 | } 296 | ws.close(); 297 | client.quit(); 298 | }); 299 | }); 300 | }); 301 | 302 | it("should sync state between client and browser", (done) => { 303 | let error = `state did not sync correctly`; 304 | 305 | // Note that this test is a bit doubling up, as we already 306 | // test the changeFlags functionality in core.test.js, so we 307 | // know what the changeFlags object should look like given 308 | // the changes in state. However, we do want to make sure 309 | // that the transport mechanism ends up sending the correct 310 | // data, so the doubling up makes sense. 311 | 312 | const list = [1, 2, 3, 4, 5]; 313 | 314 | class ServerClass { 315 | onDisconnect() { 316 | if (this.clients.length === 0) { 317 | this.quit(); 318 | } 319 | } 320 | teardown() { 321 | done(error); 322 | } 323 | } 324 | 325 | let arr = []; 326 | 327 | class ClientClass { 328 | onBrowserConnect() { 329 | const run = () => { 330 | const v = list.shift(); 331 | arr.push(v); 332 | this.setState({ 333 | a: { 334 | b: { 335 | // remove "c" at some point 336 | c: arr.length < 3 ? "test" : undefined, 337 | // update "d" every iteration 338 | d: Math.random(), 339 | }, 340 | // test for a growing array 341 | e: arr, 342 | }, 343 | v, 344 | }); 345 | if (v) setTimeout(run, 50); 346 | }; 347 | run(); 348 | } 349 | } 350 | 351 | const factory = linkClasses(ClientClass, ServerClass); 352 | const { webServer } = factory.createServer(); 353 | 354 | webServer.listen(0, () => { 355 | const PORT = webServer.address().port; 356 | const serverURL = `http://localhost:${PORT}`; 357 | const { client, clientWebServer } = factory.createWebClient( 358 | serverURL, 359 | `${__dirname}/stateful`, 360 | ); 361 | 362 | clientWebServer.listen(0, async () => { 363 | const clientURL = `http://localhost:${clientWebServer.address().port}`; 364 | const browser = await puppeteer.launch({ headless: `new` }); 365 | 366 | const page = await getPage(browser, async (msg) => { 367 | await browser.close(); 368 | error = msg; 369 | client.quit(); 370 | }); 371 | 372 | await page.goto(clientURL); 373 | await page.waitForSelector(`body.done`); 374 | await browser.close(); 375 | error = ``; 376 | client.quit(); 377 | }); 378 | }); 379 | }); 380 | 381 | it("should allow browser connection without server", (done) => { 382 | let browser; 383 | 384 | const { clientWebServer } = createWebClient( 385 | class { 386 | async teardown() { 387 | await browser.close(); 388 | done(); 389 | } 390 | }, 391 | `http://localhost:8000`, 392 | `${__dirname}/standalone`, 393 | ); 394 | 395 | clientWebServer.listen(0, async () => { 396 | const clientURL = `http://localhost:${clientWebServer.address().port}`; 397 | browser = await puppeteer.launch({ headless: `new` }); 398 | const page = await getPage(browser); 399 | await page.goto(clientURL); 400 | }); 401 | }); 402 | 403 | it("should lock the browser state to prevent modification", (done) => { 404 | let browser; 405 | let error = undefined; 406 | 407 | const { client, clientWebServer } = createWebClient( 408 | class { 409 | async teardown() { 410 | await browser.close(); 411 | done(error); 412 | } 413 | async updateValue() { 414 | this.setState({ a: { b: { c: 1 } } }); 415 | } 416 | }, 417 | `http://localhost:8000`, 418 | `${__dirname}/statemod`, 419 | ); 420 | 421 | clientWebServer.listen(0, async () => { 422 | const clientURL = `http://localhost:${clientWebServer.address().port}`; 423 | browser = await puppeteer.launch({ headless: `new` }); 424 | const page = await getPage(browser, (msg) => { 425 | error = msg; 426 | }); 427 | await page.goto(clientURL); 428 | }); 429 | }); 430 | 431 | it("should parse query args in the browser", () => { 432 | return new Promise(async (resolve, reject) => { 433 | let browser; 434 | let error = `test did not run`; 435 | 436 | const { client, clientWebServer } = createWebClient( 437 | class { 438 | async pass() { 439 | error = undefined; 440 | this.quit(); 441 | } 442 | async fail(reason) { 443 | error = reason; 444 | this.quit(); 445 | } 446 | async teardown() { 447 | await browser.close(); 448 | if (error) reject(new Error(error)); 449 | else resolve(); 450 | } 451 | }, 452 | `http://localhost:8000`, 453 | `${__dirname}/params`, 454 | ); 455 | 456 | const targetsFile = import.meta.url.replace( 457 | `webclient.test.js`, 458 | `params/targets.js`, 459 | ); 460 | 461 | const { targets } = await import(targetsFile); 462 | 463 | const query = Object.entries(targets) 464 | .map(([key, value]) => { 465 | return `${key}=${JSON.stringify(value)}`; 466 | }) 467 | .join(`&`); 468 | 469 | clientWebServer.listen(0, async () => { 470 | const clientURL = `http://localhost:${ 471 | clientWebServer.address().port 472 | }?${query}`; 473 | browser = await puppeteer.launch({ headless: `new` }); 474 | const page = await getPage(browser); 475 | await page.goto(clientURL); 476 | }); 477 | }); 478 | }); 479 | 480 | it("client with auth requirement should not send state until authed", (done) => { 481 | let browser; 482 | let error = `authentication did not succeed`; 483 | 484 | // Note that this test is a bit doubling up, as we already 485 | // test the changeFlags functionality in core.test.js, so we 486 | // know what the changeFlags object should look like given 487 | // the changes in state. However, we do want to make sure 488 | // that the transport mechanism ends up sending the correct 489 | // data, so the doubling up makes sense. 490 | 491 | class ServerClass { 492 | onDisconnect() { 493 | if (this.clients.length === 0) { 494 | this.quit(); 495 | } 496 | } 497 | teardown() { 498 | done(error); 499 | } 500 | } 501 | 502 | class ClientClass { 503 | init() { 504 | this.setState({ authenticated: false, a: 1, b: 2, c: 3 }); 505 | } 506 | authenticate(user, password, token) { 507 | if (user === `user` && password === `password` && token === `12345`) { 508 | this.setState({ authenticated: true }); 509 | } 510 | } 511 | async setResult(A, B, C) { 512 | const { a, b, c } = this.state; 513 | if (a === A && b === B && c === C) { 514 | await browser.close(); 515 | error = ``; 516 | this.quit(); 517 | } 518 | } 519 | async fail(reason) { 520 | await browser.close(); 521 | error = reason; 522 | this.quit(); 523 | } 524 | } 525 | 526 | const factory = linkClasses(ClientClass, ServerClass); 527 | const { webServer } = factory.createServer(); 528 | 529 | webServer.listen(0, () => { 530 | const PORT = webServer.address().port; 531 | const serverURL = `http://localhost:${PORT}`; 532 | const { clientWebServer } = factory.createWebClient( 533 | serverURL, 534 | `${__dirname}/auth`, 535 | ); 536 | 537 | clientWebServer.listen(0, async () => { 538 | const clientURL = `http://localhost:${clientWebServer.address().port}`; 539 | browser = await puppeteer.launch({ headless: `new` }); 540 | const page = await getPage(browser); 541 | await page.goto(clientURL); 542 | }); 543 | }); 544 | }); 545 | }); 546 | --------------------------------------------------------------------------------