├── .editorconfig ├── .github └── workflows │ ├── api.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── base-node ├── index.d.ts ├── index.js └── index.test.ts ├── client-node ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── connect ├── index.js └── index.test.ts ├── debug ├── index.js └── index.test.ts ├── each-store-check ├── index.d.ts └── index.js ├── error ├── index.js └── index.test.ts ├── eslint.config.js ├── headers ├── index.js └── index.test.ts ├── index.d.ts ├── index.js ├── is-first-older ├── index.d.ts ├── index.js └── index.test.ts ├── local-pair ├── index.d.ts ├── index.js └── index.test.ts ├── log ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── logux-error ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── memory-store ├── index.d.ts ├── index.js └── index.test.ts ├── package.json ├── parse-id ├── index.d.ts ├── index.js └── index.test.ts ├── ping ├── index.js └── index.test.ts ├── pnpm-lock.yaml ├── reconnect ├── index.d.ts ├── index.js └── index.test.ts ├── server-connection ├── index.d.ts ├── index.js └── index.test.ts ├── server-node ├── index.d.ts ├── index.js └── index.test.ts ├── sync ├── index.js └── index.test.ts ├── test-log ├── index.d.ts └── index.js ├── test-pair ├── index.d.ts ├── index.js └── index.test.ts ├── test-time ├── index.d.ts ├── index.js └── index.test.ts ├── tsconfig.json ├── validate └── index.js └── ws-connection ├── index.d.ts ├── index.js ├── index.test.ts └── integration.test.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/api.yml: -------------------------------------------------------------------------------- 1 | name: Update API 2 | on: 3 | create: 4 | tags: 5 | - "*.*.*" 6 | jobs: 7 | api: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Start logux.org re-build 11 | run: | 12 | curl -XPOST -u "${{ secrets.DEPLOY_USER }}:${{ secrets.DEPLOY_TOKEN }}" -H "Accept: application/vnd.github.everest-preview+json" -H "Content-Type: application/json" https://api.github.com/repos/logux/logux.org/dispatches --data '{"event_type": "deploy"}' 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | permissions: 7 | contents: write 8 | jobs: 9 | release: 10 | name: Release On Tag 11 | if: startsWith(github.ref, 'refs/tags/') 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout the repository 15 | uses: actions/checkout@v4 16 | - name: Extract the changelog 17 | id: changelog 18 | run: | 19 | TAG_NAME=${GITHUB_REF/refs\/tags\//} 20 | READ_SECTION=false 21 | CHANGELOG="" 22 | while IFS= read -r line; do 23 | if [[ "$line" =~ ^#+\ +(.*) ]]; then 24 | if [[ "${BASH_REMATCH[1]}" == "$TAG_NAME" ]]; then 25 | READ_SECTION=true 26 | elif [[ "$READ_SECTION" == true ]]; then 27 | break 28 | fi 29 | elif [[ "$READ_SECTION" == true ]]; then 30 | CHANGELOG+="$line"$'\n' 31 | fi 32 | done < "CHANGELOG.md" 33 | CHANGELOG=$(echo "$CHANGELOG" | awk '/./ {$1=$1;print}') 34 | echo "changelog_content<> $GITHUB_OUTPUT 35 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 36 | echo "EOF" >> $GITHUB_OUTPUT 37 | - name: Create the release 38 | if: steps.changelog.outputs.changelog_content != '' 39 | uses: softprops/action-gh-release@v2 40 | with: 41 | name: ${{ github.ref_name }} 42 | body: '${{ steps.changelog.outputs.changelog_content }}' 43 | draft: false 44 | prerelease: false 45 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - next 7 | pull_request: 8 | permissions: 9 | contents: read 10 | jobs: 11 | full: 12 | name: Node.js Latest Full 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout the repository 16 | uses: actions/checkout@v4 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v4 19 | with: 20 | version: 10 21 | - name: Install Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 23 25 | cache: pnpm 26 | - name: Install dependencies 27 | run: pnpm install --ignore-scripts 28 | - name: Run tests 29 | run: pnpm test 30 | short: 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | node-version: 35 | - 20 36 | - 18 37 | name: Node.js ${{ matrix.node-version }} Quick 38 | steps: 39 | - name: Checkout the repository 40 | uses: actions/checkout@v4 41 | - name: Install pnpm 42 | uses: pnpm/action-setup@v4 43 | with: 44 | version: 10 45 | - name: Install Node.js ${{ matrix.node-version }} 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: ${{ matrix.node-version }} 49 | cache: pnpm 50 | - name: Install dependencies 51 | run: pnpm install --ignore-scripts 52 | - name: Run unit tests 53 | run: pnpm bnt 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/*.test.ts 2 | **/types.ts 3 | **/errors.ts 4 | tsconfig.json 5 | coverage/ 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## 0.9 L·L 5 | * Removed Node.js 16 support. 6 | * Replaced `inMap` and `inFilter` with `onReceive`. 7 | * Replaced `outMap` and `outFilter` to `onSend`. 8 | * Fixed synchronization. 9 | * Added `Criteria` and `ReadonlyListener` types to exports (by @nichoth). 10 | 11 | ## 0.8.5 12 | * Fixed frozen pages support (by @erictheswift). 13 | 14 | ## 0.8.4 15 | * Fixed docs. 16 | 17 | ## 0.8.3 18 | * Fixed disconnecting `TestPair` during connection process. 19 | 20 | ## 0.8.2 21 | * Fixed `undefined` options (by @erictheswift). 22 | 23 | ## 0.8.1 24 | * Removed Node.js dependencies to simplify bundling. 25 | 26 | ## 0.8 Ա 27 | * Dropped Node.js 12 support. 28 | * Add string-based time to `isFirstOlder()`. 29 | * Fixed action syncing order (by Tyler Han). 30 | * Fixed behavior on disconnection in the middle of syncing (by Tyler Han). 31 | 32 | ## 0.7.3 33 | * Increased ping timeout to solve Chrome background tab issue. 34 | 35 | ## 0.7.2 36 | * Marked `meta` as read-only in non-`preadd` callbacks. 37 | 38 | ## 0.7.1 39 | * Fixed re-connection delay to avoid backoff better. 40 | 41 | ## 0.7 क 42 | * Moved project to ESM-only type. Applications must use ESM too. 43 | * Dropped Node.js 10 support. 44 | * Added `Log#type()`. 45 | * Added `index` to log entries (by Eduard Aksamitov). 46 | * Added `TestLog#keepActions()`. 47 | * Allowed to specify action type in `Log#add()` call. 48 | * Made `destroy` as mandatory method of `Connection`. 49 | * Fixed types performance by replacing `type` to `interface`. 50 | 51 | ## 0.6.2 52 | * Allow to call `MemoryStore#get()` without options (by Eduard Aksamitov). 53 | 54 | ## 0.6.1 55 | * Fix counters comparison in `isFirstOlder`. 56 | 57 | ## 0.6 ᐁ 58 | * Use WebSocket Protocol version 4. 59 | * Remove `reasons: string` support. It must be always an array. 60 | * Add `parseId()` helper. 61 | * Add headers (by Ivan Menshykov). 62 | * Add `MemoryStore#entries`. 63 | * Allow to pass `undefined` to `isFirstOlder()`. 64 | * Return unbind function from `Node#catch`. 65 | * Rename `WsConnection#WS` to `WsConnection#Class`. 66 | * Rename `Store` type to `LogStore`. 67 | * Fix WebSocket connectivity. 68 | * Improve types (by Nikolay Govorov). 69 | 70 | ## 0.5.3 71 | * Fix types. 72 | 73 | ## 0.5.2 74 | * Fix `Reconnect` at `changeUser` from Logux Client. 75 | 76 | ## 0.5.1 77 | * Fix protocol version. 78 | 79 | ## 0.5 ö 80 | * Use WebSocket Protocol version 3. 81 | * Change `auth` callback signature. 82 | * Rename `credentials` option to `token`. 83 | * User ID must be always a string. 84 | * Add support for dynamic tokens. 85 | 86 | ## 0.4.2 87 | * Fix types. 88 | 89 | ## 0.4.1 90 | * Fix private API for Logux Server. 91 | 92 | ## 0.4 ñ 93 | * Add ES modules support. 94 | * Add TypeScript definitions. 95 | * Move API docs from JSDoc to TypeDoc. 96 | * Mark package as side effect free. 97 | 98 | ## 0.3.5 99 | * Fix actions double sending to the server. 100 | 101 | ## 0.3.4 102 | * Fix React Native and React Server-Side Rendering support (by Can Rau). 103 | 104 | ## 0.3.3 105 | * Fix JSDoc. 106 | 107 | ## 0.3.2 108 | * Fix read-only meta keys. 109 | 110 | ## 0.3.1 111 | * Fix using old `added` in `sync` message. 112 | 113 | ## 0.3 Ω 114 | * Rename project from `logux-core` to `@logux/core`. 115 | * Remove Node.js 6 and 8 support. 116 | * Merge with `logux-sync`. 117 | * Merge with `logux-store-tests`. 118 | * Use sting-based `meta.id`. 119 | * Rename `BaseSync`, `ClientSync`, `ServerSync` to `*Node`. 120 | * Rename `SyncError` to `LoguxError`. 121 | * Remove `missed-auth` error. 122 | * Rename `BrowserConnection` to `WsConnection`. 123 | * Run input map before filter. 124 | * Add `Store#clean()` (by Arthur Kushka). 125 | * Add `criteria.id` to `Store#removeReason`. 126 | * Add `TestTime#lastId`. 127 | * Add `TestLog#entries` and `TestLog#actions`. 128 | * Use more events for `Reconnect`. 129 | * Do not throw on `wrong-subprotocol`, `wrong-protocol`, and `timeout`. 130 | * Allow to send debug before authentication. 131 | * Move all Logux docs to singe place. 132 | 133 | ## 0.2.2 134 | * Allow to set `meta.keepLast` in `preadd` event listener. 135 | 136 | ## 0.2.1 137 | * Fix removing action with different `time` from memory store. 138 | 139 | ## 0.2 Ѣ 140 | * Rename `meta.created` to `meta.id`. 141 | * Rename `event` event to `add`. 142 | * Use reasons of life API to clean log. 143 | * Return new action `meta` in `Log#add`. 144 | * Deny tab symbol in Node ID. 145 | * Add `preadd` event. 146 | * Add `TestTime`. 147 | * Add `keepLast` option to `Log#add` (by Igor Deryabin). 148 | * Add `meta.time` for `fixTime` feature. 149 | * Add `isFirstOlder()` helper. 150 | * Add `changeMeta`, `removeReason` and `byId` to store. 151 | * Add `getLastAdded`, `getLastSynced` and `setLastSynced` method to store. 152 | * Fix leap second problem. 153 | * Move store tests to separated project (by Konstantin Mamaev). 154 | * Fix docs (by Grigoriy Beziuk, Andrew Romanov and Alexey Gaziev). 155 | 156 | ## 0.1 𐤀 157 | * Initial release. 158 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2016 Andrey Sitnik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logux Core [![Cult Of Martians][cult-img]][cult] 2 | 3 | 5 | 6 | Logux is a new way to connect client and server. Instead of sending 7 | HTTP requests (e.g., AJAX and GraphQL) it synchronizes log of operations 8 | between client, server, and other clients. 9 | 10 | * **[Guide, recipes, and API](https://logux.org/)** 11 | * **[Issues](https://github.com/logux/logux/issues)** 12 | and **[roadmap](https://github.com/orgs/logux/projects/1)** 13 | * **[Projects](https://logux.org/guide/architecture/parts/)** 14 | inside Logux ecosystem 15 | 16 | This repository contains Logux core components for JavaScript: 17 | 18 | * `Log` to store node’s actions. 19 | * `MemoryStore` to store log in the memory. 20 | * `BaseNode`, `ClientNode`, and `ServerNode` to synchronize actions 21 | from Log with other node. 22 | * `isFirstOlder` to compare creation time of different actions. 23 | * `WsConnection`, `Reconnect`, and `ServerConnection` to connect nodes 24 | via WebSocket. 25 | * `TestLog`, `TestPair`, `TestTime`, and `eachStoreCheck` 26 | to test Logux application. 27 | 28 | 29 | Sponsored by Evil Martians 31 | 32 | 33 | [cult-img]: http://cultofmartians.com/assets/badges/badge.svg 34 | [cult]: http://cultofmartians.com/done.html 35 | 36 | 37 | ## Install 38 | 39 | ```sh 40 | npm install @logux/core 41 | ``` 42 | 43 | 44 | ## Usage 45 | 46 | See [documentation] for Logux API. 47 | 48 | ```js 49 | import { ClientNode, TestTime, TestLog, TestPair } from '@logux/core' 50 | 51 | let time = new TestTime() 52 | let pair = new TestPair() 53 | let node = new ClientNode('client:test', time.nextLog(), pair.left) 54 | ``` 55 | 56 | ```js 57 | import { isFirstOlder } from '@logux/core' 58 | 59 | let lastRename 60 | log.type('RENAME', (action, meta) => { 61 | if (isFirstOlder(lastRename, meta)) { 62 | changeName(action.name) 63 | lastRename = meta 64 | } 65 | }) 66 | ``` 67 | 68 | [documentation]: https://logux.org/web-api/ 69 | -------------------------------------------------------------------------------- /base-node/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Unsubscribe } from 'nanoevents' 2 | 3 | import type { Action, AnyAction, Log, Meta } from '../log/index.js' 4 | import type { LoguxError, LoguxErrorOptions } from '../logux-error/index.js' 5 | 6 | interface Authenticator { 7 | (nodeId: string, token: string, headers: {} | Headers): Promise 8 | } 9 | 10 | export interface ActionFilter { 11 | (action: Action, meta: Meta): 12 | | [Action, Meta] 13 | | false 14 | | Promise<[Action, Meta] | false> 15 | } 16 | 17 | interface EmptyHeaders { 18 | [key: string]: undefined 19 | } 20 | 21 | export interface TokenGenerator { 22 | (): Promise | string 23 | } 24 | 25 | export type NodeState = 26 | | 'connecting' 27 | | 'disconnected' 28 | | 'sending' 29 | | 'synchronized' 30 | 31 | export interface CompressedMeta { 32 | id: [number, string, number] | number 33 | time: number 34 | } 35 | 36 | export type Message = 37 | | ['connect', number, string, number, object?] 38 | | ['connected', number, string, [number, number], object?] 39 | | ['debug', 'error', string] 40 | | ['error', keyof LoguxErrorOptions, any?] 41 | | ['headers', object] 42 | | ['ping', number] 43 | | ['pong', number] 44 | | ['sync', number, ...(AnyAction | CompressedMeta)[]] 45 | | ['synced', number] 46 | 47 | /** 48 | * Abstract interface for connection to synchronize logs over it. 49 | * For example, WebSocket or Loopback. 50 | */ 51 | export abstract class Connection { 52 | /** 53 | * Is connection is enabled. 54 | */ 55 | connected: boolean 56 | 57 | /** 58 | * Disconnect and unbind all even listeners. 59 | */ 60 | destroy: () => void 61 | 62 | /** 63 | * Start connection. Connection should be in disconnected state 64 | * from the beginning and start connection only on this method call. 65 | * 66 | * This method could be called again if connection moved 67 | * to disconnected state. 68 | * 69 | * @returns Promise until connection will be established. 70 | */ 71 | connect(): Promise 72 | 73 | /** 74 | * Finish current connection. 75 | * 76 | * @param reason Disconnection reason. 77 | */ 78 | disconnect(reason?: 'destroy' | 'error' | 'timeout'): void 79 | on(event: 'disconnect', listener: (reason: string) => void): Unsubscribe 80 | on(event: 'error', listener: (error: Error) => void): Unsubscribe 81 | 82 | /** 83 | * Subscribe for connection events. It implements nanoevents API. 84 | * Supported events: 85 | * 86 | * * `connecting`: connection establishing was started. 87 | * * `connect`: connection was established by any side. 88 | * * `disconnect`: connection was closed by any side. 89 | * * `message`: message was receive from remote node. 90 | * * `error`: error during connection, sending or receiving. 91 | * 92 | * @param event Event name. 93 | * @param listener Event listener. 94 | * @returns Unbind listener from event. 95 | */ 96 | on( 97 | event: 'connect' | 'connecting' | 'disconnect', 98 | listener: () => void 99 | ): Unsubscribe 100 | 101 | on(event: 'message', listener: (msg: Message) => void): Unsubscribe 102 | 103 | /** 104 | * Send message to connection. 105 | * 106 | * @param message The message to be sent. 107 | */ 108 | send(message: Message): void 109 | } 110 | 111 | export interface NodeOptions { 112 | /** 113 | * Function to check client credentials. 114 | */ 115 | auth?: Authenticator 116 | 117 | /** 118 | * Detect difference between client and server and fix time 119 | * in synchronized actions. 120 | */ 121 | fixTime?: boolean 122 | 123 | /** 124 | * Function to filter or change actions coming from remote node’s 125 | * before put it to current log. 126 | * 127 | * ```js 128 | * async nReceive(action, meta) { 129 | * if (checkMeta(meta)) { 130 | * await [action, cleanMeta(meta)] 131 | * } else { 132 | * return false 133 | * } 134 | * } 135 | * ``` 136 | */ 137 | onReceive?: ActionFilter 138 | 139 | /** 140 | * Function to filter or change actions before sending to remote node’s. 141 | * 142 | * ```js 143 | * async onSend(action, meta) { 144 | * if (meta.sync) { 145 | * return [action, cleanMeta(meta)] 146 | * } else { 147 | * return false 148 | * } 149 | * } 150 | * ``` 151 | */ 152 | onSend?: ActionFilter 153 | 154 | /** 155 | * Milliseconds since last message to test connection by sending ping. 156 | */ 157 | ping?: number 158 | 159 | /** 160 | * Application subprotocol version in SemVer format. 161 | */ 162 | subprotocol?: string 163 | 164 | /** 165 | * Timeout in milliseconds to wait answer before disconnect. 166 | */ 167 | timeout?: number 168 | 169 | /** 170 | * Client credentials. For example, access token. 171 | */ 172 | token?: string | TokenGenerator 173 | } 174 | 175 | /** 176 | * Base methods for synchronization nodes. Client and server nodes 177 | * are based on this module. 178 | */ 179 | export class BaseNode< 180 | Headers extends object = {}, 181 | NodeLog extends Log = Log 182 | > { 183 | /** 184 | * Did we finish remote node authentication. 185 | */ 186 | authenticated: boolean 187 | 188 | /** 189 | * Is synchronization in process. 190 | * 191 | * ```js 192 | * node.on('disconnect', () => { 193 | * node.connected //=> false 194 | * }) 195 | */ 196 | connected: boolean 197 | 198 | /** 199 | * Connection used to communicate to remote node. 200 | */ 201 | connection: Connection 202 | 203 | /** 204 | * Promise for node data initial loadiging. 205 | */ 206 | initializing: Promise 207 | 208 | /** 209 | * Latest remote node’s log `added` time, which was successfully 210 | * synchronized. It will be saves in log store. 211 | */ 212 | lastReceived: number 213 | 214 | /** 215 | * Latest current log `added` time, which was successfully synchronized. 216 | * It will be saves in log store. 217 | */ 218 | lastSent: number 219 | 220 | /** 221 | * Unique current machine name. 222 | * 223 | * ```js 224 | * console.log(node.localNodeId + ' is started') 225 | * ``` 226 | */ 227 | localNodeId: string 228 | 229 | /** 230 | * Used Logux protocol. 231 | * 232 | * ```js 233 | * if (tool.node.localProtocol !== 1) { 234 | * throw new Error('Unsupported Logux protocol') 235 | * } 236 | * ``` 237 | */ 238 | localProtocol: number 239 | 240 | /** 241 | * Log for synchronization. 242 | */ 243 | log: NodeLog 244 | 245 | /** 246 | * Minimum version of Logux protocol, which is supported. 247 | * 248 | * ```js 249 | * console.log(`You need Logux protocol ${node.minProtocol} or higher`) 250 | * ``` 251 | */ 252 | minProtocol: number 253 | 254 | /** 255 | * Synchronization options. 256 | */ 257 | options: NodeOptions 258 | 259 | /** 260 | * Headers set by remote node. 261 | * By default, it is an empty object. 262 | * 263 | * ```js 264 | * let message = I18N_ERRORS[node.remoteHeaders.language || 'en'] 265 | * node.log.add({ type: 'error', message }) 266 | * ``` 267 | */ 268 | remoteHeaders: EmptyHeaders | Headers 269 | 270 | /** 271 | * Unique name of remote machine. 272 | * It is undefined until nodes handshake. 273 | * 274 | * ```js 275 | * console.log('Connected to ' + node.remoteNodeId) 276 | * ``` 277 | */ 278 | remoteNodeId: string | undefined 279 | 280 | /** 281 | * Remote node Logux protocol. 282 | * It is undefined until nodes handshake. 283 | * 284 | * ```js 285 | * if (node.remoteProtocol >= 5) { 286 | * useNewAPI() 287 | * } else { 288 | * useOldAPI() 289 | * } 290 | * ``` 291 | */ 292 | remoteProtocol: number | undefined 293 | 294 | /** 295 | * Remote node’s application subprotocol version in SemVer format. 296 | * 297 | * It is undefined until nodes handshake. If remote node will not send 298 | * on handshake its subprotocol, it will be set to `0.0.0`. 299 | * 300 | * ```js 301 | * if (semver.satisfies(node.remoteSubprotocol, '>= 5.0.0') { 302 | * useNewAPI() 303 | * } else { 304 | * useOldAPI() 305 | * } 306 | * ``` 307 | */ 308 | remoteSubprotocol: string | undefined 309 | 310 | /** 311 | * Current synchronization state. 312 | * 313 | * * `disconnected`: no connection. 314 | * * `connecting`: connection was started and we wait for node answer. 315 | * * `sending`: new actions was sent, waiting for answer. 316 | * * `synchronized`: all actions was synchronized and we keep connection. 317 | * 318 | * ```js 319 | * node.on('state', () => { 320 | * if (node.state === 'sending') { 321 | * console.log('Do not close browser') 322 | * } 323 | * }) 324 | * ``` 325 | */ 326 | state: NodeState 327 | 328 | /** 329 | * Time difference between nodes. 330 | */ 331 | timeFix: number 332 | 333 | /** 334 | * @param nodeId Unique current machine name. 335 | * @param log Logux log instance to be synchronized. 336 | * @param connection Connection to remote node. 337 | * @param options Synchronization options. 338 | */ 339 | constructor( 340 | nodeId: string, 341 | log: NodeLog, 342 | connection: Connection, 343 | options?: NodeOptions 344 | ) 345 | 346 | /** 347 | * Disable throwing a error on error message and create error listener. 348 | * 349 | * ```js 350 | * node.catch(error => { 351 | * console.error(error) 352 | * }) 353 | * ``` 354 | * 355 | * @param listener The error listener. 356 | * @returns Unbind listener from event. 357 | */ 358 | catch(listener: (error: LoguxError) => void): Unsubscribe 359 | 360 | /** 361 | * Shut down the connection and unsubscribe from log events. 362 | * 363 | * ```js 364 | * connection.on('disconnect', () => { 365 | * server.destroy() 366 | * }) 367 | * ``` 368 | */ 369 | destroy(): void 370 | 371 | on(event: 'headers', listener: (headers: Headers) => void): Unsubscribe 372 | 373 | on( 374 | event: 'clientError' | 'error', 375 | listener: (error: LoguxError) => void 376 | ): Unsubscribe 377 | 378 | /** 379 | * Subscribe for synchronization events. It implements nanoevents API. 380 | * Supported events: 381 | * 382 | * * `state`: synchronization state was changed. 383 | * * `connect`: custom check before node authentication. You can throw 384 | * a {@link LoguxError} to send error to remote node. 385 | * * `error`: synchronization error was raised. 386 | * * `clientError`: when error was sent to remote node. 387 | * * `debug`: when debug information received from remote node. 388 | * * `headers`: headers was receive from remote node. 389 | * 390 | * ```js 391 | * node.on('clientError', error => { 392 | * logError(error) 393 | * }) 394 | * ``` 395 | * 396 | * @param event Event name. 397 | * @param listener The listener function. 398 | * @returns Unbind listener from event. 399 | */ 400 | on( 401 | event: 'connect' | 'debug' | 'headers' | 'state', 402 | listener: () => void 403 | ): Unsubscribe 404 | 405 | on( 406 | event: 'debug', 407 | listener: (type: 'error', data: string) => void 408 | ): Unsubscribe 409 | 410 | /** 411 | * Set headers for current node. 412 | * 413 | * ```js 414 | * if (navigator) { 415 | * node.setLocalHeaders({ language: navigator.language }) 416 | * } 417 | * node.connection.connect() 418 | * ``` 419 | * 420 | * @param headers The data object will be set as headers for current node. 421 | */ 422 | setLocalHeaders(headers: Headers): void 423 | 424 | /** 425 | * Return Promise until sync will have specific state. 426 | * 427 | * If current state is correct, method will return resolved Promise. 428 | * 429 | * ```js 430 | * await node.waitFor('synchronized') 431 | * console.log('Everything is synchronized') 432 | * ``` 433 | * 434 | * @param state The expected synchronization state value. 435 | * @returns Promise until specific state. 436 | */ 437 | waitFor(state: NodeState): Promise 438 | } 439 | -------------------------------------------------------------------------------- /base-node/index.js: -------------------------------------------------------------------------------- 1 | import { createNanoEvents } from 'nanoevents' 2 | 3 | import { 4 | connectedMessage, 5 | connectMessage, 6 | sendConnect, 7 | sendConnected 8 | } from '../connect/index.js' 9 | import { debugMessage, sendDebug } from '../debug/index.js' 10 | import { errorMessage, sendError } from '../error/index.js' 11 | import { headersMessage, sendHeaders } from '../headers/index.js' 12 | import { LoguxError } from '../logux-error/index.js' 13 | import { pingMessage, pongMessage, sendPing } from '../ping/index.js' 14 | import { 15 | sendSync, 16 | sendSynced, 17 | syncedMessage, 18 | syncMessage 19 | } from '../sync/index.js' 20 | 21 | const NOT_TO_THROW = { 22 | 'timeout': true, 23 | 'wrong-protocol': true, 24 | 'wrong-subprotocol': true 25 | } 26 | 27 | const BEFORE_AUTH = ['connect', 'connected', 'error', 'debug', 'headers'] 28 | 29 | function syncEvent(node, action, meta, added) { 30 | if (typeof added === 'undefined') { 31 | let lastAdded = node.lastAddedCache 32 | added = lastAdded > node.lastSent ? lastAdded : node.lastSent 33 | } 34 | node.sendSync(added, [[action, meta]]) 35 | } 36 | 37 | export class BaseNode { 38 | constructor(nodeId, log, connection, options = {}) { 39 | this.remoteNodeId = undefined 40 | this.remoteProtocol = undefined 41 | this.remoteSubprotocol = undefined 42 | 43 | this.minProtocol = 3 44 | this.localProtocol = 4 45 | this.localNodeId = nodeId 46 | 47 | this.log = log 48 | this.connection = connection 49 | this.options = options 50 | 51 | if (this.options.ping && !this.options.timeout) { 52 | throw new Error('You must set timeout option to use ping') 53 | } 54 | 55 | this.connected = false 56 | this.authenticated = false 57 | this.unauthenticated = [] 58 | 59 | this.timeFix = 0 60 | this.syncing = 0 61 | this.received = {} 62 | 63 | this.lastSent = 0 64 | this.lastReceived = 0 65 | 66 | this.state = 'disconnected' 67 | 68 | this.emitter = createNanoEvents() 69 | this.timeouts = [] 70 | this.throwsError = true 71 | 72 | this.unbind = [ 73 | log.on('add', (action, meta) => { 74 | this.onAdd(action, meta) 75 | }), 76 | connection.on('connecting', () => { 77 | this.onConnecting() 78 | }), 79 | connection.on('connect', () => { 80 | this.onConnect() 81 | }), 82 | connection.on('message', message => { 83 | this.onMessage(message) 84 | }), 85 | connection.on('error', error => { 86 | if (error.message === 'Wrong message format') { 87 | this.sendError(new LoguxError('wrong-format', error.received)) 88 | this.connection.disconnect('error') 89 | } else { 90 | this.error(error) 91 | } 92 | }), 93 | connection.on('disconnect', () => { 94 | this.onDisconnect() 95 | }) 96 | ] 97 | 98 | this.initialized = false 99 | this.lastAddedCache = 0 100 | this.initializing = this.initialize() 101 | this.localHeaders = {} 102 | this.remoteHeaders = {} 103 | } 104 | 105 | catch(listener) { 106 | this.throwsError = false 107 | let unbind = this.on('error', listener) 108 | return () => { 109 | this.throwsError = true 110 | unbind() 111 | } 112 | } 113 | 114 | delayPing() { 115 | if (!this.options.ping) return 116 | if (this.pingTimeout) clearTimeout(this.pingTimeout) 117 | 118 | this.pingTimeout = setTimeout(() => { 119 | if (this.connected && this.authenticated) this.sendPing() 120 | }, this.options.ping) 121 | } 122 | 123 | destroy() { 124 | if (this.connection.destroy) { 125 | this.connection.destroy() 126 | } else if (this.connected) { 127 | this.connection.disconnect('destroy') 128 | } 129 | for (let i of this.unbind) i() 130 | clearTimeout(this.pingTimeout) 131 | this.endTimeout() 132 | } 133 | 134 | duilianMessage(line) { 135 | if (DUILIANS[line]) { 136 | this.send(['duilian', DUILIANS[line]]) 137 | } 138 | } 139 | 140 | endTimeout() { 141 | if (this.timeouts.length > 0) { 142 | clearTimeout(this.timeouts.shift()) 143 | } 144 | } 145 | 146 | error(err) { 147 | this.emitter.emit('error', err) 148 | this.connection.disconnect('error') 149 | if (this.throwsError) { 150 | throw err 151 | } 152 | } 153 | 154 | async initialize() { 155 | let [synced, added] = await Promise.all([ 156 | this.log.store.getLastSynced(), 157 | this.log.store.getLastAdded() 158 | ]) 159 | this.initialized = true 160 | this.lastSent = synced.sent 161 | this.lastReceived = synced.received 162 | this.lastAddedCache = added 163 | if (this.connection.connected) this.onConnect() 164 | } 165 | 166 | now() { 167 | return Date.now() 168 | } 169 | 170 | on(event, listener) { 171 | return this.emitter.on(event, listener) 172 | } 173 | 174 | async onAdd(action, meta) { 175 | if (!this.authenticated) return 176 | if (this.lastAddedCache < meta.added) { 177 | this.lastAddedCache = meta.added 178 | } 179 | 180 | if (this.received && this.received[meta.id]) { 181 | delete this.received[meta.id] 182 | return 183 | } 184 | 185 | if (this.options.onSend) { 186 | try { 187 | let added = meta.added 188 | let result = await this.options.onSend(action, meta) 189 | if (result) { 190 | syncEvent(this, result[0], result[1], added) 191 | } 192 | } catch (e) { 193 | this.error(e) 194 | } 195 | } else { 196 | syncEvent(this, action, meta, meta.added) 197 | } 198 | } 199 | 200 | onConnect() { 201 | this.delayPing() 202 | this.connected = true 203 | } 204 | 205 | onConnecting() { 206 | this.setState('connecting') 207 | } 208 | 209 | onDisconnect() { 210 | while (this.timeouts.length > 0) { 211 | this.endTimeout() 212 | } 213 | if (this.pingTimeout) clearTimeout(this.pingTimeout) 214 | this.authenticated = false 215 | this.connected = false 216 | this.syncing = 0 217 | this.setState('disconnected') 218 | } 219 | 220 | onMessage(msg) { 221 | this.delayPing() 222 | let name = msg[0] 223 | 224 | if (!this.authenticated && !BEFORE_AUTH.includes(name)) { 225 | this.unauthenticated.push(msg) 226 | return 227 | } 228 | 229 | this[name + 'Message'](...msg.slice(1)) 230 | } 231 | 232 | send(msg) { 233 | if (!this.connected) return 234 | this.delayPing() 235 | try { 236 | this.connection.send(msg) 237 | } catch (e) { 238 | this.error(e) 239 | } 240 | } 241 | 242 | sendDuilian() { 243 | this.send(['duilian', Object.keys(DUILIANS)[0]]) 244 | } 245 | 246 | setLastReceived(value) { 247 | if (this.lastReceived < value) this.lastReceived = value 248 | this.log.store.setLastSynced({ received: value }) 249 | } 250 | 251 | setLastSent(value) { 252 | if (this.lastSent < value) { 253 | this.lastSent = value 254 | this.log.store.setLastSynced({ sent: value }) 255 | } 256 | } 257 | 258 | setLocalHeaders(headers) { 259 | this.localHeaders = headers 260 | if (this.connected) { 261 | this.sendHeaders(headers) 262 | } 263 | } 264 | 265 | setState(state) { 266 | if (this.state !== state) { 267 | this.state = state 268 | this.emitter.emit('state') 269 | } 270 | } 271 | 272 | startTimeout() { 273 | if (!this.options.timeout) return 274 | 275 | let ms = this.options.timeout 276 | let timeout = setTimeout(() => { 277 | if (this.connected) this.connection.disconnect('timeout') 278 | this.syncError('timeout', ms) 279 | }, ms) 280 | 281 | this.timeouts.push(timeout) 282 | } 283 | 284 | syncError(type, options, received) { 285 | let err = new LoguxError(type, options, received) 286 | this.emitter.emit('error', err) 287 | if (!NOT_TO_THROW[type] && this.throwsError) { 288 | throw err 289 | } 290 | } 291 | 292 | syncFilter() { 293 | return true 294 | } 295 | 296 | async syncSince(lastSynced) { 297 | let data = await this.syncSinceQuery(lastSynced) 298 | if (!this.connected) return 299 | if (data.entries.length > 0) { 300 | this.sendSync(data.added, data.entries) 301 | } else { 302 | this.setState('synchronized') 303 | } 304 | } 305 | 306 | async syncSinceQuery(lastSynced) { 307 | let promises = [] 308 | let maxAdded = 0 309 | await this.log.each({ order: 'added' }, (action, meta) => { 310 | if (meta.added <= lastSynced) return false 311 | if (!this.syncFilter(action, meta)) return undefined 312 | if (this.options.onSend) { 313 | promises.push( 314 | (async () => { 315 | try { 316 | let result = await this.options.onSend(action, meta) 317 | if (result) { 318 | if (meta.added > maxAdded) { 319 | maxAdded = meta.added 320 | } 321 | } 322 | return result 323 | } catch (e) { 324 | this.error(e) 325 | return false 326 | } 327 | })() 328 | ) 329 | } else { 330 | if (meta.added > maxAdded) { 331 | maxAdded = meta.added 332 | } 333 | promises.push(Promise.resolve([action, meta])) 334 | } 335 | return true 336 | }) 337 | 338 | let entries = await Promise.all(promises) 339 | return { 340 | added: maxAdded, 341 | entries: entries.filter(entry => entry !== false) 342 | } 343 | } 344 | 345 | waitFor(state) { 346 | if (this.state === state) { 347 | return Promise.resolve() 348 | } 349 | return new Promise(resolve => { 350 | let unbind = this.on('state', () => { 351 | if (this.state === state) { 352 | unbind() 353 | resolve() 354 | } 355 | }) 356 | }) 357 | } 358 | } 359 | 360 | BaseNode.prototype.sendConnect = sendConnect 361 | BaseNode.prototype.sendConnected = sendConnected 362 | BaseNode.prototype.connectMessage = connectMessage 363 | BaseNode.prototype.connectedMessage = connectedMessage 364 | 365 | BaseNode.prototype.sendSync = sendSync 366 | BaseNode.prototype.sendSynced = sendSynced 367 | BaseNode.prototype.syncMessage = syncMessage 368 | BaseNode.prototype.syncedMessage = syncedMessage 369 | 370 | BaseNode.prototype.sendPing = sendPing 371 | BaseNode.prototype.pingMessage = pingMessage 372 | BaseNode.prototype.pongMessage = pongMessage 373 | 374 | BaseNode.prototype.sendDebug = sendDebug 375 | BaseNode.prototype.debugMessage = debugMessage 376 | 377 | BaseNode.prototype.sendError = sendError 378 | BaseNode.prototype.errorMessage = errorMessage 379 | 380 | BaseNode.prototype.sendHeaders = sendHeaders 381 | BaseNode.prototype.headersMessage = headersMessage 382 | 383 | const DUILIANS = { 384 | 金木水火土: '板城烧锅酒' 385 | } 386 | -------------------------------------------------------------------------------- /base-node/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { spyOn } from 'nanospy' 3 | import { deepStrictEqual, doesNotThrow, equal, ok, throws } from 'node:assert' 4 | import { test } from 'node:test' 5 | 6 | import { 7 | BaseNode, 8 | type NodeOptions, 9 | type NodeState, 10 | type TestLog, 11 | TestPair, 12 | TestTime 13 | } from '../index.js' 14 | 15 | function createNode( 16 | opts?: NodeOptions, 17 | pair = new TestPair() 18 | ): BaseNode<{}, TestLog> { 19 | let log = TestTime.getLog() 20 | log.on('preadd', (action, meta) => { 21 | meta.reasons = ['test'] 22 | }) 23 | return new BaseNode('client', log, pair.left, opts) 24 | } 25 | 26 | async function createTest(): Promise { 27 | let pair = new TestPair() 28 | let node = createNode({}, pair) 29 | pair.leftNode = node 30 | await pair.left.connect() 31 | return pair 32 | } 33 | 34 | function privateMethods(obj: object): any { 35 | return obj 36 | } 37 | 38 | function emit(obj: any, event: string, ...args: any): void { 39 | obj.emitter.emit(event, ...args) 40 | } 41 | 42 | function listeners(obj: any): number { 43 | let count = 0 44 | for (let i in obj.emitter.events) { 45 | count += obj.emitter.events[i]?.length ?? 0 46 | } 47 | return count 48 | } 49 | 50 | test('saves all arguments', () => { 51 | let log = TestTime.getLog() 52 | let pair = new TestPair() 53 | let options = {} 54 | let node = new BaseNode('client', log, pair.left, options) 55 | 56 | equal(node.localNodeId, 'client') 57 | equal(node.log, log) 58 | equal(node.connection, pair.left) 59 | equal(node.options, options) 60 | }) 61 | 62 | test('allows to miss options', () => { 63 | deepStrictEqual(createNode().options, {}) 64 | }) 65 | 66 | test('has protocol version', () => { 67 | let node = createNode() 68 | equal(typeof node.localProtocol, 'number') 69 | equal(typeof node.minProtocol, 'number') 70 | ok(node.localProtocol >= node.minProtocol) 71 | }) 72 | 73 | test('unbind all listeners on destroy', () => { 74 | let pair = new TestPair() 75 | let conListenersBefore = listeners(pair.left) 76 | let node = new BaseNode('client', TestTime.getLog(), pair.left) 77 | 78 | ok(listeners(node.log) > 0) 79 | ok(listeners(pair.left) > conListenersBefore) 80 | 81 | node.destroy() 82 | equal(listeners(node.log), 0) 83 | equal(listeners(pair.left), conListenersBefore) 84 | }) 85 | 86 | test('destroys connection on destroy', () => { 87 | let node = createNode() 88 | node.connection.destroy = () => {} 89 | let disconnect = spyOn(node.connection, 'disconnect') 90 | let destroy = spyOn(node.connection, 'destroy') 91 | 92 | node.destroy() 93 | deepStrictEqual(disconnect.calls, []) 94 | equal(destroy.callCount, 1) 95 | }) 96 | 97 | test('disconnects on destroy', async () => { 98 | let node = createNode() 99 | await node.connection.connect() 100 | node.destroy() 101 | equal(node.connection.connected, false) 102 | }) 103 | 104 | test('does not throw error on send to disconnected connection', () => { 105 | let node = createNode() 106 | doesNotThrow(() => { 107 | privateMethods(node).sendDuilian() 108 | }) 109 | }) 110 | 111 | test('sends messages to connection', async () => { 112 | let pair = await createTest() 113 | privateMethods(pair.leftNode).send(['test']) 114 | await pair.wait() 115 | deepStrictEqual(pair.leftSent, [['test']]) 116 | }) 117 | 118 | test('has connection state', async () => { 119 | let node = createNode() 120 | equal(node.connected, false) 121 | await node.connection.connect() 122 | equal(node.connected, true) 123 | node.connection.disconnect() 124 | equal(node.connected, false) 125 | }) 126 | 127 | test('has state', async () => { 128 | let pair = new TestPair() 129 | let node = createNode({}, pair) 130 | 131 | let states: NodeState[] = [] 132 | node.on('state', () => { 133 | states.push(node.state) 134 | }) 135 | 136 | equal(node.state, 'disconnected') 137 | await node.connection.connect() 138 | privateMethods(node).sendConnect() 139 | pair.right.send(['connected', node.localProtocol, 'server', [0, 0]]) 140 | await node.waitFor('synchronized') 141 | equal(node.state, 'synchronized') 142 | await node.log.add({ type: 'a' }) 143 | equal(node.state, 'sending') 144 | pair.right.send(['synced', 1]) 145 | await node.waitFor('synchronized') 146 | equal(node.state, 'synchronized') 147 | node.connection.disconnect() 148 | equal(node.state, 'disconnected') 149 | await node.log.add({ type: 'b' }) 150 | equal(node.state, 'disconnected') 151 | emit(node.connection, 'connecting') 152 | equal(node.state, 'connecting') 153 | await node.connection.connect() 154 | privateMethods(node).sendConnect() 155 | pair.right.send(['connected', node.localProtocol, 'server', [0, 0]]) 156 | await node.waitFor('sending') 157 | equal(node.state, 'sending') 158 | pair.right.send(['synced', 2]) 159 | await node.waitFor('synchronized') 160 | equal(node.state, 'synchronized') 161 | await node.log.add({ type: 'c' }) 162 | node.connection.disconnect() 163 | deepStrictEqual(states, [ 164 | 'connecting', 165 | 'synchronized', 166 | 'sending', 167 | 'synchronized', 168 | 'disconnected', 169 | 'connecting', 170 | 'sending', 171 | 'synchronized', 172 | 'sending', 173 | 'disconnected' 174 | ]) 175 | }) 176 | 177 | test('does not wait for state change is current state is correct', async () => { 178 | await createNode().waitFor('disconnected') 179 | }) 180 | 181 | test('loads lastSent, lastReceived and lastAdded from store', async () => { 182 | let log = TestTime.getLog() 183 | let pair = new TestPair() 184 | let node 185 | 186 | log.store.setLastSynced({ received: 2, sent: 1 }) 187 | await log.add({ type: 'a' }, { reasons: ['test'] }) 188 | node = new BaseNode('client', log, pair.left) 189 | await node.initializing 190 | equal(privateMethods(node).lastAddedCache, 1) 191 | equal(node.lastSent, 1) 192 | equal(node.lastReceived, 2) 193 | }) 194 | 195 | test('does not override smaller lastSent', async () => { 196 | let node = createNode() 197 | privateMethods(node).setLastSent(2) 198 | privateMethods(node).setLastSent(1) 199 | equal(privateMethods(node.log.store).lastSent, 2) 200 | }) 201 | 202 | test('has separated timeouts', async () => { 203 | let node = createNode({ timeout: 100 }) 204 | 205 | let error: Error | undefined 206 | node.catch(e => { 207 | error = e 208 | }) 209 | 210 | privateMethods(node).startTimeout() 211 | await delay(60) 212 | privateMethods(node).startTimeout() 213 | await delay(60) 214 | if (typeof error === 'undefined') throw new Error('Error was no sent') 215 | ok(error.message.includes('timeout')) 216 | }) 217 | 218 | test('stops timeouts on disconnect', async () => { 219 | let node = createNode({ timeout: 10 }) 220 | 221 | let error 222 | node.catch(e => { 223 | error = e 224 | }) 225 | 226 | privateMethods(node).startTimeout() 227 | privateMethods(node).startTimeout() 228 | privateMethods(node).onDisconnect() 229 | 230 | await delay(50) 231 | privateMethods(node).startTimeout() 232 | equal(error, undefined) 233 | }) 234 | 235 | test('accepts already connected connection', async () => { 236 | let pair = new TestPair() 237 | let node 238 | await pair.left.connect() 239 | node = new BaseNode('client', TestTime.getLog(), pair.left) 240 | await node.initializing 241 | equal(node.connected, true) 242 | }) 243 | 244 | test('receives errors from connection', async () => { 245 | let pair = await createTest() 246 | let emitted 247 | pair.leftNode.catch(e => { 248 | emitted = e 249 | }) 250 | 251 | let error = new Error('test') 252 | emit(pair.left, 'error', error) 253 | 254 | equal(pair.leftNode.connected, false) 255 | deepStrictEqual(pair.leftEvents, [['connect'], ['disconnect', 'error']]) 256 | equal(emitted, error) 257 | }) 258 | 259 | test('cancels error catching', async () => { 260 | let pair = await createTest() 261 | let emitted 262 | let unbind = pair.leftNode.catch((e: Error) => { 263 | emitted = e 264 | }) 265 | 266 | unbind() 267 | let error = new Error('test') 268 | let catched 269 | try { 270 | emit(pair.left, 'error', error) 271 | } catch (e) { 272 | catched = e 273 | } 274 | equal(emitted, undefined) 275 | equal(catched, error) 276 | }) 277 | 278 | test('does not fall on sync without connection', async () => { 279 | await privateMethods(createNode()).syncSince(0) 280 | }) 281 | 282 | test('receives format errors from connection', async () => { 283 | let pair = await createTest() 284 | let error = new Error('Wrong message format') 285 | privateMethods(error).received = 'options' 286 | emit(pair.left, 'error', error) 287 | await pair.wait() 288 | equal(pair.leftNode.connected, false) 289 | deepStrictEqual(pair.leftEvents, [['connect'], ['disconnect', 'error']]) 290 | deepStrictEqual(pair.leftSent, [['error', 'wrong-format', 'options']]) 291 | }) 292 | 293 | test('throws error by default', async () => { 294 | let error = new Error('test') 295 | let pair = await createTest() 296 | pair.leftNode.connection.send = () => { 297 | throw error 298 | } 299 | throws(() => { 300 | privateMethods(pair.leftNode).send(['ping', 0]) 301 | }, error) 302 | }) 303 | 304 | test('disconnect on the error during send', async () => { 305 | let error = new Error('test') 306 | let errors: Error[] = [] 307 | let pair = await createTest() 308 | pair.leftNode.catch(e => { 309 | errors.push(e) 310 | }) 311 | pair.leftNode.connection.send = () => { 312 | throw error 313 | } 314 | privateMethods(pair.leftNode).send(['ping', 0]) 315 | await delay(1) 316 | equal(pair.leftNode.connected, false) 317 | deepStrictEqual(errors, [error]) 318 | }) 319 | -------------------------------------------------------------------------------- /client-node/errors.ts: -------------------------------------------------------------------------------- 1 | import { Log, Meta, MemoryStore, ClientNode, LocalPair } from '../index.js' 2 | 3 | type MyMeta = Meta & { 4 | extra: number 5 | } 6 | 7 | type Headers = { 8 | lang: string 9 | } 10 | 11 | let log = new Log({ nodeId: 'client', store: new MemoryStore() }) 12 | let pair = new LocalPair() 13 | 14 | let client = new ClientNode>('client', log, pair.left) 15 | 16 | // THROWS 'locale' does not exist in type 'Headers' 17 | client.setLocalHeaders({ locale: 'ru' }) 18 | 19 | // THROWS 'string' is not assignable to type 'number' 20 | client.log.add({ type: 'A' }, { extra: '1' }) 21 | 22 | function sayHi(lang: string) { 23 | console.log('Hi, ' + lang) 24 | } 25 | 26 | // THROWS 'string | undefined' is not assignable to parameter of type 'string'. 27 | sayHi(client.remoteHeaders.lang) 28 | // THROWS Property 'locale' does not exist on type 'Headers'. 29 | client.on('headers', ({ locale }) => { 30 | sayHi(locale) 31 | }) 32 | -------------------------------------------------------------------------------- /client-node/index.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseNode } from '../base-node/index.js' 2 | import type { Log, Meta } from '../log/index.js' 3 | 4 | /** 5 | * Client node in synchronization pair. 6 | * 7 | * Instead of server node, it initializes synchronization 8 | * and sends connect message. 9 | * 10 | * ```js 11 | * import { ClientNode } from '@logux/core' 12 | * const connection = new BrowserConnection(url) 13 | * const node = new ClientNode(nodeId, log, connection) 14 | * ``` 15 | */ 16 | export class ClientNode< 17 | Headers extends object = {}, 18 | NodeLog extends Log = Log 19 | > extends BaseNode {} 20 | -------------------------------------------------------------------------------- /client-node/index.js: -------------------------------------------------------------------------------- 1 | import { BaseNode } from '../base-node/index.js' 2 | 3 | const DEFAULT_OPTIONS = { 4 | fixTime: true, 5 | ping: 10000, 6 | timeout: 70000 7 | } 8 | 9 | export class ClientNode extends BaseNode { 10 | constructor(nodeId, log, connection, options = {}) { 11 | super(nodeId, log, connection, { 12 | ...options, 13 | fixTime: options.fixTime ?? DEFAULT_OPTIONS.fixTime, 14 | ping: options.ping ?? DEFAULT_OPTIONS.ping, 15 | timeout: options.timeout ?? DEFAULT_OPTIONS.timeout 16 | }) 17 | } 18 | 19 | onConnect() { 20 | if (!this.connected) { 21 | this.connected = true 22 | this.initializing = this.initializing.then(() => { 23 | if (this.connected) this.sendConnect() 24 | }) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client-node/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { spyOn } from 'nanospy' 3 | import { deepStrictEqual, equal } from 'node:assert' 4 | import { afterEach, test } from 'node:test' 5 | 6 | import { ClientNode, TestPair, TestTime } from '../index.js' 7 | 8 | let node: ClientNode 9 | afterEach(() => { 10 | node.destroy() 11 | }) 12 | 13 | function privateMethods(obj: object): any { 14 | return obj 15 | } 16 | 17 | test('connects first', async () => { 18 | let pair = new TestPair() 19 | node = new ClientNode('client', TestTime.getLog(), pair.left) 20 | let sendConnect = spyOn(privateMethods(node), 'sendConnect') 21 | await pair.left.connect() 22 | equal(sendConnect.callCount, 1) 23 | }) 24 | 25 | test('saves last added from ping', async () => { 26 | let log = TestTime.getLog() 27 | let pair = new TestPair() 28 | node = new ClientNode('client', log, pair.left, { fixTime: false }) 29 | await pair.left.connect() 30 | pair.right.send(['connected', node.localProtocol, 'server', [0, 0]]) 31 | await pair.wait() 32 | equal(node.lastReceived, 0) 33 | pair.right.send(['ping', 1]) 34 | await pair.wait('right') 35 | equal(node.lastReceived, 1) 36 | privateMethods(node).sendPing() 37 | pair.right.send(['pong', 2]) 38 | await pair.wait('left') 39 | equal(node.lastReceived, 2) 40 | }) 41 | 42 | test('does not connect before initializing', async () => { 43 | let log = TestTime.getLog() 44 | 45 | let returnLastAdded: (added: number) => void = () => { 46 | throw new Error('getLastAdded was not called') 47 | } 48 | log.store.getLastAdded = () => { 49 | return new Promise(resolve => { 50 | returnLastAdded = resolve 51 | }) 52 | } 53 | 54 | let pair = new TestPair() 55 | node = new ClientNode('client', log, pair.left, { fixTime: false }) 56 | 57 | await pair.left.connect() 58 | await delay(10) 59 | deepStrictEqual(pair.leftSent, []) 60 | returnLastAdded(10) 61 | await delay(10) 62 | deepStrictEqual(pair.leftSent, [['connect', node.localProtocol, 'client', 0]]) 63 | }) 64 | -------------------------------------------------------------------------------- /client-node/types.ts: -------------------------------------------------------------------------------- 1 | import { ClientNode, LocalPair, Log, MemoryStore, type Meta } from '../index.js' 2 | 3 | type MyMeta = { 4 | extra: number 5 | } & Meta 6 | 7 | type Headers = { 8 | lang: string 9 | } 10 | 11 | let log = new Log({ nodeId: 'client', store: new MemoryStore() }) 12 | let pair = new LocalPair() 13 | 14 | let client = new ClientNode>('client', log, pair.left) 15 | 16 | client.setLocalHeaders({ lang: 'ru' }) 17 | 18 | client.log.add({ type: 'A' }, { extra: 1 }) 19 | 20 | function sayHi(lang: string): void { 21 | console.log('Hi, ' + lang) 22 | } 23 | 24 | sayHi(client.remoteHeaders.lang ?? 'en') 25 | client.on('headers', ({ lang }) => { 26 | sayHi(lang) 27 | }) 28 | -------------------------------------------------------------------------------- /connect/index.js: -------------------------------------------------------------------------------- 1 | import { LoguxError } from '../logux-error/index.js' 2 | 3 | async function auth(node, nodeId, token, callback) { 4 | if (!node.options.auth) { 5 | node.authenticated = true 6 | callback() 7 | return 8 | } 9 | 10 | try { 11 | let access = await node.options.auth(nodeId, token, node.remoteHeaders) 12 | if (access) { 13 | node.authenticated = true 14 | callback() 15 | for (let i = 0; i < node.unauthenticated.length; i++) { 16 | node.onMessage(node.unauthenticated[i]) 17 | } 18 | node.unauthenticated = [] 19 | } else { 20 | node.sendError(new LoguxError('wrong-credentials')) 21 | node.destroy() 22 | } 23 | } catch (e) { 24 | if (e.name === 'LoguxError') { 25 | node.sendError(e) 26 | node.destroy() 27 | } else { 28 | node.error(e) 29 | } 30 | } 31 | } 32 | 33 | function checkProtocol(node, ver) { 34 | node.remoteProtocol = ver 35 | 36 | if (ver >= node.minProtocol) { 37 | return true 38 | } else { 39 | node.sendError( 40 | new LoguxError('wrong-protocol', { 41 | supported: node.minProtocol, 42 | used: ver 43 | }) 44 | ) 45 | node.destroy() 46 | return false 47 | } 48 | } 49 | 50 | function emitEvent(node) { 51 | try { 52 | node.emitter.emit('connect') 53 | } catch (e) { 54 | if (e.name === 'LoguxError') { 55 | node.sendError(e) 56 | return false 57 | } else { 58 | throw e 59 | } 60 | } 61 | return true 62 | } 63 | 64 | export async function sendConnect() { 65 | let message = [ 66 | 'connect', 67 | this.localProtocol, 68 | this.localNodeId, 69 | this.lastReceived 70 | ] 71 | 72 | let options = {} 73 | if (this.options.token) { 74 | if (typeof this.options.token === 'function') { 75 | options.token = await this.options.token() 76 | } else { 77 | options.token = this.options.token 78 | } 79 | } 80 | if (this.options.subprotocol) { 81 | options.subprotocol = this.options.subprotocol 82 | } 83 | if (Object.keys(options).length > 0) message.push(options) 84 | 85 | if (this.options.fixTime) this.connectSended = this.now() 86 | 87 | if (Object.keys(this.localHeaders).length > 0) { 88 | this.sendHeaders(this.localHeaders) 89 | } 90 | 91 | this.startTimeout() 92 | this.send(message) 93 | } 94 | 95 | export async function sendConnected(start, end) { 96 | let message = [ 97 | 'connected', 98 | this.localProtocol, 99 | this.localNodeId, 100 | [start, end] 101 | ] 102 | 103 | let options = {} 104 | if (this.options.token) { 105 | if (typeof this.options.token === 'function') { 106 | options.token = await this.options.token() 107 | } else { 108 | options.token = this.options.token 109 | } 110 | } 111 | if (this.options.subprotocol) { 112 | options.subprotocol = this.options.subprotocol 113 | } 114 | if (Object.keys(options).length > 0) message.push(options) 115 | 116 | if (Object.keys(this.localHeaders).length > 0) { 117 | this.sendHeaders(this.localHeaders) 118 | } 119 | 120 | this.send(message) 121 | } 122 | 123 | export function connectMessage(ver, nodeId, synced, options) { 124 | let start = this.now() 125 | if (!options) options = {} 126 | 127 | this.remoteNodeId = nodeId 128 | if (!checkProtocol(this, ver)) return 129 | 130 | this.remoteSubprotocol = options.subprotocol || '0.0.0' 131 | 132 | if (!emitEvent(this)) { 133 | this.destroy() 134 | return 135 | } 136 | 137 | auth(this, nodeId, options.token, () => { 138 | this.baseTime = this.now() 139 | this.sendConnected(start, this.baseTime) 140 | this.syncSince(synced) 141 | }) 142 | } 143 | 144 | export function connectedMessage(ver, nodeId, time, options) { 145 | if (!options) options = {} 146 | 147 | this.endTimeout() 148 | this.remoteNodeId = nodeId 149 | if (!checkProtocol(this, ver)) return 150 | 151 | this.baseTime = time[1] 152 | 153 | if (this.options.fixTime) { 154 | let now = this.now() 155 | let authTime = time[1] - time[0] 156 | let roundTrip = now - this.connectSended - authTime 157 | this.timeFix = Math.floor(this.connectSended - time[0] + roundTrip / 2) 158 | } 159 | 160 | this.remoteSubprotocol = options.subprotocol || '0.0.0' 161 | 162 | if (!emitEvent(this)) { 163 | this.destroy() 164 | return 165 | } 166 | 167 | auth(this, nodeId, options.token, () => { 168 | this.syncSince(this.lastSent) 169 | }) 170 | } 171 | -------------------------------------------------------------------------------- /connect/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { deepStrictEqual, equal, ok, throws } from 'node:assert' 3 | import { afterEach, test } from 'node:test' 4 | 5 | import { 6 | BaseNode, 7 | ClientNode, 8 | LoguxError, 9 | ServerNode, 10 | TestPair, 11 | TestTime 12 | } from '../index.js' 13 | 14 | let fakeNode = new BaseNode('id', TestTime.getLog(), new TestPair().left) 15 | const PROTOCOL = fakeNode.localProtocol 16 | 17 | let pair: TestPair 18 | afterEach(() => { 19 | pair.leftNode.destroy() 20 | pair.rightNode.destroy() 21 | }) 22 | 23 | function privateMethods(obj: object): any { 24 | return obj 25 | } 26 | 27 | function createTest(): TestPair { 28 | let time = new TestTime() 29 | let p = new TestPair() 30 | 31 | p.leftNode = new ClientNode('client', time.nextLog(), p.left) 32 | p.rightNode = new ServerNode('server', time.nextLog(), p.right) 33 | 34 | let current = 0 35 | privateMethods(p.leftNode).now = () => { 36 | current += 1 37 | return current 38 | } 39 | privateMethods(p.rightNode).now = privateMethods(p.leftNode).now 40 | 41 | p.leftNode.catch(() => true) 42 | p.rightNode.catch(() => true) 43 | 44 | return p 45 | } 46 | 47 | test('sends protocol version and name in connect message', async () => { 48 | pair = createTest() 49 | await pair.left.connect() 50 | await pair.wait() 51 | deepStrictEqual(pair.leftSent, [['connect', PROTOCOL, 'client', 0]]) 52 | }) 53 | 54 | test('answers with protocol version and name in connected message', async () => { 55 | pair = createTest() 56 | await pair.left.connect() 57 | await pair.wait('left') 58 | deepStrictEqual(pair.rightSent, [['connected', PROTOCOL, 'server', [2, 3]]]) 59 | }) 60 | 61 | test('checks client protocol version', async () => { 62 | pair = createTest() 63 | pair.leftNode.localProtocol = 1 64 | pair.rightNode.minProtocol = 2 65 | 66 | await pair.left.connect() 67 | await pair.wait('left') 68 | deepStrictEqual(pair.rightSent, [ 69 | ['error', 'wrong-protocol', { supported: 2, used: 1 }] 70 | ]) 71 | equal(pair.rightNode.connected, false) 72 | }) 73 | 74 | test('checks server protocol version', async () => { 75 | pair = createTest() 76 | pair.leftNode.minProtocol = 2 77 | pair.rightNode.localProtocol = 1 78 | 79 | await pair.left.connect() 80 | await pair.wait('left') 81 | await pair.wait('right') 82 | deepStrictEqual(pair.leftSent, [ 83 | ['connect', PROTOCOL, 'client', 0], 84 | ['error', 'wrong-protocol', { supported: 2, used: 1 }] 85 | ]) 86 | equal(pair.left.connected, false) 87 | }) 88 | 89 | test('checks types in connect message', async () => { 90 | let wrongs = [ 91 | ['connect', []], 92 | ['connect', PROTOCOL, 'client', 0, 'abc'], 93 | ['connected', []], 94 | ['connected', PROTOCOL, 'client', [0]], 95 | ['connected', PROTOCOL, 'client', [0, 0], 1], 96 | ['connected', PROTOCOL, 'client', [0, 0], {}, 1] 97 | ] 98 | await Promise.all( 99 | wrongs.map(async msg => { 100 | let log = TestTime.getLog() 101 | let p = new TestPair() 102 | let node = new ServerNode('server', log, p.left) 103 | await p.left.connect() 104 | // @ts-expect-error 105 | p.right.send(msg) 106 | await p.wait('right') 107 | equal(node.connected, false) 108 | deepStrictEqual(p.leftSent, [ 109 | ['error', 'wrong-format', JSON.stringify(msg)] 110 | ]) 111 | }) 112 | ) 113 | }) 114 | 115 | test('saves other node name', async () => { 116 | pair = createTest() 117 | pair.left.connect() 118 | await pair.leftNode.waitFor('synchronized') 119 | equal(pair.leftNode.remoteNodeId, 'server') 120 | equal(pair.rightNode.remoteNodeId, 'client') 121 | }) 122 | 123 | test('saves other client protocol', async () => { 124 | pair = createTest() 125 | pair.leftNode.minProtocol = 1 126 | pair.leftNode.localProtocol = 1 127 | pair.rightNode.minProtocol = 1 128 | pair.rightNode.localProtocol = 2 129 | 130 | pair.left.connect() 131 | await pair.leftNode.waitFor('synchronized') 132 | equal(pair.leftNode.remoteProtocol, 2) 133 | equal(pair.rightNode.remoteProtocol, 1) 134 | }) 135 | 136 | test('saves other client subprotocol', async () => { 137 | pair = createTest() 138 | pair.leftNode.options.subprotocol = '1.0.0' 139 | pair.rightNode.options.subprotocol = '1.1.0' 140 | 141 | pair.left.connect() 142 | await pair.leftNode.waitFor('synchronized') 143 | equal(pair.leftNode.remoteSubprotocol, '1.1.0') 144 | equal(pair.rightNode.remoteSubprotocol, '1.0.0') 145 | }) 146 | 147 | test('has default subprotocol', async () => { 148 | pair = createTest() 149 | pair.left.connect() 150 | await pair.leftNode.waitFor('synchronized') 151 | equal(pair.rightNode.remoteSubprotocol, '0.0.0') 152 | }) 153 | 154 | test('checks subprotocol version', async () => { 155 | pair = createTest() 156 | pair.leftNode.options.subprotocol = '1.0.0' 157 | pair.rightNode.on('connect', () => { 158 | throw new LoguxError('wrong-subprotocol', { 159 | supported: '2.x', 160 | used: pair.rightNode.remoteSubprotocol ?? 'NO REMOTE' 161 | }) 162 | }) 163 | 164 | await pair.left.connect() 165 | await pair.wait('left') 166 | deepStrictEqual(pair.rightSent, [ 167 | ['error', 'wrong-subprotocol', { supported: '2.x', used: '1.0.0' }] 168 | ]) 169 | equal(pair.rightNode.connected, false) 170 | }) 171 | 172 | test('checks subprotocol version in client', async () => { 173 | pair = createTest() 174 | pair.rightNode.options.subprotocol = '1.0.0' 175 | pair.leftNode.on('connect', () => { 176 | throw new LoguxError('wrong-subprotocol', { 177 | supported: '2.x', 178 | used: pair.leftNode.remoteSubprotocol ?? 'NO REMOTE' 179 | }) 180 | }) 181 | 182 | await pair.left.connect() 183 | await pair.wait('right') 184 | await pair.wait('right') 185 | deepStrictEqual(pair.leftSent, [ 186 | ['connect', PROTOCOL, 'client', 0], 187 | ['error', 'wrong-subprotocol', { supported: '2.x', used: '1.0.0' }] 188 | ]) 189 | equal(pair.leftNode.connected, false) 190 | }) 191 | 192 | test('throws regular errors during connect event', () => { 193 | pair = createTest() 194 | 195 | let error = new Error('test') 196 | pair.leftNode.on('connect', () => { 197 | throw error 198 | }) 199 | 200 | throws(() => { 201 | privateMethods(pair.leftNode).connectMessage(PROTOCOL, 'client', 0) 202 | }, error) 203 | }) 204 | 205 | test('sends credentials in connect', async () => { 206 | pair = createTest() 207 | pair.leftNode.options = { token: '1' } 208 | 209 | pair.left.connect() 210 | await pair.leftNode.waitFor('synchronized') 211 | deepStrictEqual(pair.leftSent, [ 212 | ['connect', PROTOCOL, 'client', 0, { token: '1' }] 213 | ]) 214 | }) 215 | 216 | test('generates credentials in connect', async () => { 217 | pair = createTest() 218 | pair.leftNode.options = { token: () => Promise.resolve('1') } 219 | 220 | pair.left.connect() 221 | await pair.leftNode.waitFor('synchronized') 222 | deepStrictEqual(pair.leftSent, [ 223 | ['connect', PROTOCOL, 'client', 0, { token: '1' }] 224 | ]) 225 | }) 226 | 227 | test('sends credentials in connected', async () => { 228 | pair = createTest() 229 | pair.rightNode.options = { token: '1' } 230 | 231 | pair.left.connect() 232 | await pair.leftNode.waitFor('synchronized') 233 | deepStrictEqual(pair.rightSent, [ 234 | ['connected', PROTOCOL, 'server', [2, 3], { token: '1' }] 235 | ]) 236 | }) 237 | 238 | test('generates credentials in connected', async () => { 239 | pair = createTest() 240 | pair.rightNode.options = { token: () => Promise.resolve('1') } 241 | 242 | pair.left.connect() 243 | await pair.leftNode.waitFor('synchronized') 244 | deepStrictEqual(pair.rightSent, [ 245 | ['connected', PROTOCOL, 'server', [2, 3], { token: '1' }] 246 | ]) 247 | }) 248 | 249 | test('denies access for wrong users', async () => { 250 | pair = createTest() 251 | pair.rightNode.options = { 252 | async auth() { 253 | return false 254 | } 255 | } 256 | 257 | await pair.left.connect() 258 | await pair.wait('left') 259 | deepStrictEqual(pair.rightSent, [['error', 'wrong-credentials']]) 260 | equal(pair.rightNode.connected, false) 261 | }) 262 | 263 | test('denies access to wrong server', async () => { 264 | pair = createTest() 265 | pair.leftNode.options = { 266 | async auth() { 267 | return false 268 | } 269 | } 270 | 271 | await pair.left.connect() 272 | await pair.wait('right') 273 | await pair.wait('right') 274 | deepStrictEqual(pair.leftSent, [ 275 | ['connect', PROTOCOL, 'client', 0], 276 | ['error', 'wrong-credentials'] 277 | ]) 278 | equal(pair.leftNode.connected, false) 279 | }) 280 | 281 | test('allows access for right users', async () => { 282 | pair = createTest() 283 | pair.leftNode.options = { token: 'a' } 284 | pair.rightNode.options = { 285 | async auth(nodeId, token) { 286 | await delay(10) 287 | return token === 'a' && nodeId === 'client' 288 | } 289 | } 290 | 291 | await pair.left.connect() 292 | privateMethods(pair.leftNode).sendDuilian(0) 293 | await delay(50) 294 | deepStrictEqual(pair.rightSent[0], ['connected', PROTOCOL, 'server', [1, 2]]) 295 | }) 296 | 297 | test('has default timeFix', async () => { 298 | pair = createTest() 299 | pair.left.connect() 300 | await pair.leftNode.waitFor('synchronized') 301 | equal(pair.leftNode.timeFix, 0) 302 | }) 303 | 304 | test('calculates time difference', async () => { 305 | pair = createTest() 306 | let clientTime = [10000, 10000 + 1000 + 100 + 1] 307 | privateMethods(pair.leftNode).now = () => clientTime.shift() 308 | let serverTime = [0 + 50, 0 + 50 + 1000] 309 | privateMethods(pair.rightNode).now = () => serverTime.shift() 310 | 311 | pair.leftNode.options.fixTime = true 312 | pair.left.connect() 313 | await pair.leftNode.waitFor('synchronized') 314 | equal(privateMethods(pair.leftNode).baseTime, 1050) 315 | equal(privateMethods(pair.rightNode).baseTime, 1050) 316 | equal(pair.leftNode.timeFix, 10000) 317 | }) 318 | 319 | test('uses timeout between connect and connected', async () => { 320 | let log = TestTime.getLog() 321 | let p = new TestPair() 322 | let client = new ClientNode('client', log, p.left, { timeout: 100 }) 323 | 324 | let error: Error | undefined 325 | client.catch(err => { 326 | error = err 327 | }) 328 | 329 | await p.left.connect() 330 | await delay(101) 331 | if (typeof error === 'undefined') throw new Error('Error was not thrown') 332 | equal(error.name, 'LoguxError') 333 | ok(!error.message.includes('received')) 334 | ok(error.message.includes('timeout')) 335 | }) 336 | 337 | test('catches authentication errors', async () => { 338 | pair = createTest() 339 | let errors: Error[] = [] 340 | pair.rightNode.catch(e => { 341 | errors.push(e) 342 | }) 343 | 344 | let error = new Error() 345 | pair.rightNode.options = { 346 | async auth() { 347 | throw error 348 | } 349 | } 350 | 351 | await pair.left.connect() 352 | await pair.wait('right') 353 | await delay(1) 354 | deepStrictEqual(errors, [error]) 355 | deepStrictEqual(pair.rightSent, []) 356 | equal(pair.rightNode.connected, false) 357 | }) 358 | 359 | test('sends authentication errors', async () => { 360 | pair = createTest() 361 | pair.rightNode.options = { 362 | async auth() { 363 | throw new LoguxError('bruteforce') 364 | } 365 | } 366 | 367 | await pair.left.connect() 368 | await pair.wait('right') 369 | await pair.wait('left') 370 | deepStrictEqual(pair.rightSent, [['error', 'bruteforce']]) 371 | equal(pair.rightNode.connected, false) 372 | }) 373 | 374 | test('sends headers before connect message (if headers is set)', async () => { 375 | pair = createTest() 376 | pair.leftNode.setLocalHeaders({ env: 'development' }) 377 | await pair.left.connect() 378 | await delay(101) 379 | deepStrictEqual(pair.leftSent, [ 380 | ['headers', { env: 'development' }], 381 | ['connect', PROTOCOL, 'client', 0] 382 | ]) 383 | }) 384 | 385 | test('answers with headers before connected message', async () => { 386 | pair = createTest() 387 | pair.rightNode.setLocalHeaders({ env: 'development' }) 388 | await pair.left.connect() 389 | await delay(101) 390 | deepStrictEqual(pair.rightSent, [ 391 | ['headers', { env: 'development' }], 392 | ['connected', PROTOCOL, 'server', [2, 3]] 393 | ]) 394 | }) 395 | 396 | test('sends headers if connection is active', async () => { 397 | pair = createTest() 398 | await pair.left.connect() 399 | await pair.wait() 400 | deepStrictEqual(pair.leftSent, [['connect', PROTOCOL, 'client', 0]]) 401 | 402 | pair.leftNode.setLocalHeaders({ env: 'development' }) 403 | await delay(101) 404 | deepStrictEqual(pair.leftSent, [ 405 | ['connect', PROTOCOL, 'client', 0], 406 | ['headers', { env: 'development' }] 407 | ]) 408 | }) 409 | 410 | test('saves remote headers', async () => { 411 | pair = createTest() 412 | pair.leftNode.setLocalHeaders({ env: 'development' }) 413 | await pair.left.connect() 414 | await delay(101) 415 | deepStrictEqual(pair.rightNode.remoteHeaders, { env: 'development' }) 416 | }) 417 | 418 | test('allows access only with headers', async () => { 419 | pair = createTest() 420 | 421 | let authHeaders: object | undefined 422 | pair.leftNode.options = { token: 'a' } 423 | pair.rightNode.options = { 424 | async auth(nodeId, token, headers) { 425 | authHeaders = headers 426 | return true 427 | } 428 | } 429 | 430 | pair.leftNode.setLocalHeaders({ env: 'development' }) 431 | await pair.left.connect() 432 | await delay(101) 433 | 434 | deepStrictEqual(authHeaders, { env: 'development' }) 435 | }) 436 | -------------------------------------------------------------------------------- /debug/index.js: -------------------------------------------------------------------------------- 1 | export function sendDebug(type, data) { 2 | this.send(['debug', type, data]) 3 | } 4 | 5 | export function debugMessage(type, data) { 6 | this.emitter.emit('debug', type, data) 7 | } 8 | -------------------------------------------------------------------------------- /debug/index.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, equal } from 'node:assert' 2 | import { afterEach, test } from 'node:test' 3 | 4 | import { ServerNode, type TestLog, TestPair, TestTime } from '../index.js' 5 | 6 | let node: ServerNode<{}, TestLog> 7 | 8 | async function createTest(): Promise { 9 | let pair = new TestPair() 10 | node = new ServerNode('server', TestTime.getLog(), pair.left) 11 | pair.leftNode = node 12 | await pair.left.connect() 13 | return pair 14 | } 15 | 16 | afterEach(() => { 17 | node.destroy() 18 | }) 19 | 20 | function privateMethods(obj: object): any { 21 | return obj 22 | } 23 | 24 | test('sends debug messages', async () => { 25 | let pair = await createTest() 26 | privateMethods(pair.leftNode).sendDebug('testType', 'testData') 27 | await pair.wait('right') 28 | deepStrictEqual(pair.leftSent, [['debug', 'testType', 'testData']]) 29 | }) 30 | 31 | test('emits a debug on debug error messages', () => { 32 | let pair = new TestPair() 33 | node = new ServerNode('server', TestTime.getLog(), pair.left) 34 | 35 | let debugs: [string, string][] = [] 36 | node.on('debug', (type, data) => { 37 | debugs.push([type, data]) 38 | }) 39 | 40 | privateMethods(node).onMessage(['debug', 'error', 'testData']) 41 | 42 | deepStrictEqual(debugs, [['error', 'testData']]) 43 | }) 44 | 45 | test('checks types', async () => { 46 | let wrongs = [ 47 | ['debug'], 48 | ['debug', 0], 49 | ['debug', []], 50 | ['debug', {}, 'abc'], 51 | ['debug', 'error', 0], 52 | ['debug', 'error', []], 53 | ['debug', 'error', {}] 54 | ] 55 | await Promise.all( 56 | wrongs.map(async msg => { 57 | let pair = await createTest() 58 | // @ts-expect-error 59 | pair.right.send(msg) 60 | await pair.wait('right') 61 | equal(pair.leftNode.connected, false) 62 | deepStrictEqual(pair.leftSent, [ 63 | ['error', 'wrong-format', JSON.stringify(msg)] 64 | ]) 65 | }) 66 | ) 67 | }) 68 | -------------------------------------------------------------------------------- /each-store-check/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { LogStore } from '../log/index.js' 2 | 3 | /** 4 | * Pass all common tests for Logux store to callback. 5 | * 6 | * ```js 7 | * import { eachStoreCheck } from '@logux/core' 8 | * 9 | * eachStoreCheck((desc, creator) => { 10 | * it(desc, creator(() => new CustomStore())) 11 | * }) 12 | * ``` 13 | * 14 | * @param test Callback to create tests in your test framework. 15 | */ 16 | export function eachStoreCheck( 17 | test: ( 18 | name: string, 19 | testCreator: (storeCreator: () => LogStore) => () => void 20 | ) => void 21 | ): void 22 | -------------------------------------------------------------------------------- /error/index.js: -------------------------------------------------------------------------------- 1 | export function sendError(error) { 2 | let message = ['error', error.type] 3 | if (typeof error.options !== 'undefined') message.push(error.options) 4 | this.send(message) 5 | 6 | this.emitter.emit('clientError', error) 7 | } 8 | 9 | export function errorMessage(type, options) { 10 | this.syncError(type, options, true) 11 | } 12 | -------------------------------------------------------------------------------- /error/index.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, equal, throws } from 'node:assert' 2 | import { afterEach, test } from 'node:test' 3 | 4 | import { 5 | LoguxError, 6 | ServerNode, 7 | type TestLog, 8 | TestPair, 9 | TestTime 10 | } from '../index.js' 11 | 12 | let node: ServerNode<{}, TestLog> 13 | 14 | afterEach(() => { 15 | node.destroy() 16 | }) 17 | 18 | function privateMethods(obj: object): any { 19 | return obj 20 | } 21 | 22 | function createNode(): ServerNode<{}, TestLog> { 23 | let pair = new TestPair() 24 | return new ServerNode('server', TestTime.getLog(), pair.left) 25 | } 26 | 27 | async function createTest(): Promise { 28 | let pair = new TestPair() 29 | node = new ServerNode('server', TestTime.getLog(), pair.left) 30 | pair.leftNode = node 31 | await pair.left.connect() 32 | return pair 33 | } 34 | 35 | test('sends error on wrong message format', async () => { 36 | let wrongs = [1, { hi: 1 }, [], [1]] 37 | await Promise.all( 38 | wrongs.map(async msg => { 39 | let pair = await createTest() 40 | // @ts-expect-error 41 | pair.right.send(msg) 42 | await pair.wait('right') 43 | equal(pair.left.connected, false) 44 | deepStrictEqual(pair.leftSent, [ 45 | ['error', 'wrong-format', JSON.stringify(msg)] 46 | ]) 47 | }) 48 | ) 49 | }) 50 | 51 | test('sends error on wrong error parameters', async () => { 52 | let wrongs = [['error'], ['error', 1], ['error', {}]] 53 | await Promise.all( 54 | wrongs.map(async msg => { 55 | let pair = await createTest() 56 | // @ts-expect-error 57 | pair.right.send(msg) 58 | await pair.wait('right') 59 | equal(pair.left.connected, false) 60 | deepStrictEqual(pair.leftSent, [ 61 | ['error', 'wrong-format', JSON.stringify(msg)] 62 | ]) 63 | }) 64 | ) 65 | }) 66 | 67 | test('sends error on unknown message type', async () => { 68 | let pair = await createTest() 69 | // @ts-expect-error 70 | pair.right.send(['test']) 71 | await pair.wait('right') 72 | equal(pair.left.connected, false) 73 | deepStrictEqual(pair.leftSent, [['error', 'unknown-message', 'test']]) 74 | }) 75 | 76 | test('throws a error on error message by default', () => { 77 | node = createNode() 78 | throws(() => { 79 | privateMethods(node).onMessage(['error', 'wrong-format', '1']) 80 | }, new LoguxError('wrong-format', '1', true)) 81 | }) 82 | 83 | test('does not throw errors which are not relevant to code', () => { 84 | node = createNode() 85 | privateMethods(node).onMessage(['error', 'timeout', '1']) 86 | privateMethods(node).onMessage(['error', 'wrong-protocol', {}]) 87 | privateMethods(node).onMessage(['error', 'wrong-subprotocol', {}]) 88 | }) 89 | 90 | test('disables throwing a error on listener', () => { 91 | node = createNode() 92 | 93 | let errors: Error[] = [] 94 | node.catch(error => { 95 | errors.push(error) 96 | }) 97 | 98 | privateMethods(node).onMessage(['error', 'wrong-format', '2']) 99 | deepStrictEqual(errors, [new LoguxError('wrong-format', '2', true)]) 100 | }) 101 | 102 | test('emits a event on error sending', async () => { 103 | let pair = await createTest() 104 | let errors: Error[] = [] 105 | pair.leftNode.on('clientError', err => { 106 | errors.push(err) 107 | }) 108 | 109 | let error = new LoguxError('timeout', 10) 110 | privateMethods(pair.leftNode).sendError(error) 111 | deepStrictEqual(errors, [error]) 112 | }) 113 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import loguxTsConfig from '@logux/eslint-config/ts' 2 | 3 | /** @type {import('eslint').Linter.FlatConfig[]} */ 4 | export default [ 5 | { 6 | ignores: ['**/errors.ts', 'coverage'] 7 | }, 8 | ...loguxTsConfig, 9 | { 10 | languageOptions: { 11 | globals: { 12 | WebSocket: 'readonly' 13 | } 14 | }, 15 | rules: { 16 | '@typescript-eslint/no-explicit-any': 'off', 17 | 'n/no-unsupported-features/node-builtins': [ 18 | 'error', 19 | { 20 | ignores: [ 21 | 'navigator', 22 | 'WebSocket', 23 | 'test', 24 | 'test.afterEach', 25 | 'test.beforeEach' 26 | ] 27 | } 28 | ], 29 | 'no-invalid-this': 'off' 30 | } 31 | }, 32 | { 33 | files: ['server-connection/*.ts', 'ws-connection/*.ts'], 34 | rules: { 35 | 'import/order': 'off' 36 | } 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /headers/index.js: -------------------------------------------------------------------------------- 1 | export function sendHeaders(data) { 2 | this.send(['headers', data]) 3 | } 4 | 5 | export function headersMessage(data) { 6 | this.remoteHeaders = data 7 | this.emitter.emit('headers', data) 8 | } 9 | -------------------------------------------------------------------------------- /headers/index.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, equal } from 'node:assert' 2 | import { afterEach, test } from 'node:test' 3 | 4 | import { ServerNode, type TestLog, TestPair, TestTime } from '../index.js' 5 | 6 | let node: ServerNode<{}, TestLog> 7 | 8 | afterEach(() => { 9 | node.destroy() 10 | }) 11 | 12 | function privateMethods(obj: object): any { 13 | return obj 14 | } 15 | 16 | async function createTestPair(): Promise { 17 | let pair = new TestPair() 18 | node = new ServerNode<{}, TestLog>('server', TestTime.getLog(), pair.left) 19 | pair.leftNode = node 20 | await pair.left.connect() 21 | 22 | return pair 23 | } 24 | 25 | test('emits a headers on header messages', async () => { 26 | let pair = await createTestPair() 27 | 28 | let headers = {} 29 | pair.leftNode.on('headers', data => { 30 | headers = data 31 | }) 32 | 33 | privateMethods(pair.leftNode).onMessage(['headers', { test: 'test' }]) 34 | 35 | deepStrictEqual(headers, { test: 'test' }) 36 | }) 37 | 38 | test('checks types', async () => { 39 | let wrongs = [ 40 | ['headers'], 41 | ['headers', true], 42 | ['headers', 0], 43 | ['headers', []], 44 | ['headers', 'abc'], 45 | ['headers', {}, 'abc'] 46 | ] 47 | await Promise.all( 48 | wrongs.map(async msg => { 49 | let pair = await createTestPair() 50 | // @ts-expect-error 51 | pair.right.send(msg) 52 | await pair.wait('right') 53 | equal(pair.leftNode.connected, false) 54 | deepStrictEqual(pair.leftSent, [ 55 | ['error', 'wrong-format', JSON.stringify(msg)] 56 | ]) 57 | pair.leftNode.destroy() 58 | }) 59 | ) 60 | }) 61 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ActionFilter, 3 | BaseNode, 4 | CompressedMeta, 5 | Connection, 6 | Message, 7 | NodeOptions, 8 | NodeState, 9 | TokenGenerator 10 | } from './base-node/index.js' 11 | export { ClientNode } from './client-node/index.js' 12 | export { eachStoreCheck } from './each-store-check/index.js' 13 | export { isFirstOlder } from './is-first-older/index.js' 14 | export { LocalPair } from './local-pair/index.js' 15 | export { 16 | Action, 17 | actionEvents, 18 | AnyAction, 19 | Criteria, 20 | ID, 21 | Log, 22 | LogPage, 23 | LogStore, 24 | Meta, 25 | ReadonlyListener 26 | } from './log/index.js' 27 | export { LoguxError, LoguxErrorOptions } from './logux-error/index.js' 28 | export { MemoryStore } from './memory-store/index.js' 29 | export { parseId } from './parse-id/index.js' 30 | export { Reconnect } from './reconnect/index.js' 31 | export { ServerConnection } from './server-connection/index.js' 32 | export { ServerNode } from './server-node/index.js' 33 | export { TestLog } from './test-log/index.js' 34 | export { TestPair } from './test-pair/index.js' 35 | export { TestTime } from './test-time/index.js' 36 | export { WsConnection } from './ws-connection/index.js' 37 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { BaseNode } from './base-node/index.js' 2 | export { ClientNode } from './client-node/index.js' 3 | export { eachStoreCheck } from './each-store-check/index.js' 4 | export { isFirstOlder } from './is-first-older/index.js' 5 | export { LocalPair } from './local-pair/index.js' 6 | export { actionEvents, Log } from './log/index.js' 7 | export { LoguxError } from './logux-error/index.js' 8 | export { MemoryStore } from './memory-store/index.js' 9 | export { parseId } from './parse-id/index.js' 10 | export { Reconnect } from './reconnect/index.js' 11 | export { ServerConnection } from './server-connection/index.js' 12 | export { ServerNode } from './server-node/index.js' 13 | export { TestPair } from './test-pair/index.js' 14 | export { TestTime } from './test-time/index.js' 15 | export { WsConnection } from './ws-connection/index.js' 16 | -------------------------------------------------------------------------------- /is-first-older/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Meta } from '../index.js' 2 | 3 | /** 4 | * Compare time, when log entries were created. 5 | * 6 | * It uses `meta.time` and `meta.id` to detect entries order. 7 | * 8 | * ```js 9 | * import { isFirstOlder } from '@logux/core' 10 | * if (isFirstOlder(lastBeep, meta) { 11 | * beep(action) 12 | * lastBeep = meta 13 | * } 14 | * ``` 15 | * 16 | * @param firstMeta Some action’s metadata. 17 | * @param secondMeta Other action’s metadata. 18 | */ 19 | export function isFirstOlder( 20 | firstMeta: Meta | string | undefined, 21 | secondMeta: Meta | string | undefined 22 | ): boolean 23 | -------------------------------------------------------------------------------- /is-first-older/index.js: -------------------------------------------------------------------------------- 1 | export function isFirstOlder(firstMeta, secondMeta) { 2 | if (firstMeta && !secondMeta) { 3 | return false 4 | } else if (!firstMeta && secondMeta) { 5 | return true 6 | } 7 | 8 | if (typeof firstMeta === 'string') { 9 | firstMeta = { id: firstMeta, time: parseInt(firstMeta) } 10 | } 11 | if (typeof secondMeta === 'string') { 12 | secondMeta = { id: secondMeta, time: parseInt(secondMeta) } 13 | } 14 | 15 | if (firstMeta.time > secondMeta.time) { 16 | return false 17 | } else if (firstMeta.time < secondMeta.time) { 18 | return true 19 | } 20 | 21 | let first = firstMeta.id.split(' ') 22 | let second = secondMeta.id.split(' ') 23 | 24 | let firstNode = first[1] 25 | let secondNode = second[1] 26 | if (firstNode > secondNode) { 27 | return false 28 | } else if (firstNode < secondNode) { 29 | return true 30 | } 31 | 32 | let firstCounter = parseInt(first[2]) 33 | let secondCounter = parseInt(second[2]) 34 | if (firstCounter > secondCounter) { 35 | return false 36 | } else if (firstCounter < secondCounter) { 37 | return true 38 | } 39 | 40 | let firstNodeTime = parseInt(first[0]) 41 | let secondNodeTime = parseInt(second[0]) 42 | if (firstNodeTime > secondNodeTime) { 43 | return false 44 | } else if (firstNodeTime < secondNodeTime) { 45 | return true 46 | } 47 | 48 | return false 49 | } 50 | -------------------------------------------------------------------------------- /is-first-older/index.test.ts: -------------------------------------------------------------------------------- 1 | import { equal } from 'node:assert' 2 | import { test } from 'node:test' 3 | 4 | import { isFirstOlder, type Meta } from '../index.js' 5 | 6 | function createMeta(id: string, time: number): Meta { 7 | return { added: 1, id, reasons: [], time } 8 | } 9 | 10 | test('compares entries by time', () => { 11 | let a = createMeta('10 a 0', 2) 12 | let b = createMeta('1 a 0', 1) 13 | equal(isFirstOlder(a, b), false) 14 | equal(isFirstOlder(b, a), true) 15 | equal(isFirstOlder('10 a 0', '1 a 0'), false) 16 | equal(isFirstOlder('1 a 0', '10 a 0'), true) 17 | }) 18 | 19 | test('compares entries by real time', () => { 20 | let a = createMeta('1 a 0', 2) 21 | let b = createMeta('1 a 0', 1) 22 | equal(isFirstOlder(a, b), false) 23 | equal(isFirstOlder(b, a), true) 24 | }) 25 | 26 | test('compares entries by other ID parts', () => { 27 | let a = createMeta('1 a 9', 1) 28 | let b = createMeta('1 a 10', 1) 29 | equal(isFirstOlder(a, b), true) 30 | equal(isFirstOlder(b, a), false) 31 | equal(isFirstOlder('1 a 9', '1 a 10'), true) 32 | equal(isFirstOlder('1 a 10', '1 a 9'), false) 33 | }) 34 | 35 | test('compares entries by other ID parts with priority', () => { 36 | let a = createMeta('1 b 1', 1) 37 | let b = createMeta('1 a 2', 1) 38 | equal(isFirstOlder(a, b), false) 39 | equal(isFirstOlder(b, a), true) 40 | equal(isFirstOlder('1 b 1', '1 a 1'), false) 41 | equal(isFirstOlder('1 a 1', '1 b 1'), true) 42 | }) 43 | 44 | test('compares entries with same time', () => { 45 | let a = createMeta('2 a 0', 1) 46 | let b = createMeta('1 a 0', 1) 47 | equal(isFirstOlder(a, b), false) 48 | equal(isFirstOlder(b, a), true) 49 | }) 50 | 51 | test('returns false for same entry', () => { 52 | let a = createMeta('1 b 1', 1) 53 | equal(isFirstOlder(a, a), false) 54 | }) 55 | 56 | test('orders entries with different node ID length', () => { 57 | let a = createMeta('1 11 1', 1) 58 | let b = createMeta('1 1 2', 1) 59 | equal(isFirstOlder(a, b), false) 60 | equal(isFirstOlder(b, a), true) 61 | }) 62 | 63 | test('works with undefined in one meta', () => { 64 | let a = createMeta('1 a 0', 1) 65 | equal(isFirstOlder(a, undefined), false) 66 | equal(isFirstOlder(undefined, a), true) 67 | }) 68 | -------------------------------------------------------------------------------- /local-pair/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from '../base-node/index.js' 2 | 3 | export class LocalConnection extends Connection { 4 | other(): LocalConnection 5 | } 6 | 7 | /** 8 | * Two paired loopback connections. 9 | * 10 | * ```js 11 | * import { LocalPair, ClientNode, ServerNode } from '@logux/core' 12 | * const pair = new LocalPair() 13 | * const client = new ClientNode('client', log1, pair.left) 14 | * const server = new ServerNode('server', log2, pair.right) 15 | * ``` 16 | */ 17 | export class LocalPair { 18 | /** 19 | * Delay for connection and send events to emulate real connection latency. 20 | */ 21 | delay: number 22 | 23 | /** 24 | * First connection. Will be connected to `right` one after `connect()`. 25 | * 26 | * ```js 27 | * new ClientNode('client, log1, pair.left) 28 | * ``` 29 | */ 30 | left: LocalConnection 31 | 32 | /** 33 | * Second connection. Will be connected to `right` one after `connect()`. 34 | * 35 | * ```js 36 | * new ServerNode('server, log2, pair.right) 37 | * ``` 38 | */ 39 | right: LocalConnection 40 | 41 | /** 42 | * @param delay Delay for connection and send events. Default is `1`. 43 | */ 44 | constructor(delay?: number) 45 | } 46 | -------------------------------------------------------------------------------- /local-pair/index.js: -------------------------------------------------------------------------------- 1 | import { createNanoEvents } from 'nanoevents' 2 | 3 | class LocalConnection { 4 | constructor(pair, type) { 5 | this.connecting = false 6 | this.connected = false 7 | this.emitter = createNanoEvents() 8 | this.type = type 9 | this.pair = pair 10 | } 11 | 12 | connect() { 13 | if (this.connected) { 14 | throw new Error('Connection already established') 15 | } else { 16 | this.connecting = true 17 | this.emitter.emit('connecting') 18 | return new Promise(resolve => { 19 | setTimeout(() => { 20 | if (!this.connecting) { 21 | resolve() 22 | return 23 | } 24 | this.other().connected = true 25 | this.connected = true 26 | this.connecting = false 27 | this.other().emitter.emit('connect') 28 | this.emitter.emit('connect') 29 | resolve() 30 | }, this.pair.delay) 31 | }) 32 | } 33 | } 34 | 35 | disconnect(reason) { 36 | if (this.connecting) { 37 | this.connecting = false 38 | this.emitter.emit('disconnect', reason) 39 | return Promise.resolve() 40 | } else if (this.connected) { 41 | this.connected = false 42 | this.emitter.emit('disconnect', reason) 43 | return new Promise(resolve => { 44 | setTimeout(() => { 45 | this.other().connected = false 46 | this.other().emitter.emit('disconnect') 47 | resolve() 48 | }, 1) 49 | }) 50 | } else { 51 | throw new Error('Connection already finished') 52 | } 53 | } 54 | 55 | on(event, listener) { 56 | return this.emitter.on(event, listener) 57 | } 58 | 59 | other() { 60 | if (this.type === 'left') { 61 | return this.pair.right 62 | } else { 63 | return this.pair.left 64 | } 65 | } 66 | 67 | send(message) { 68 | if (this.connected) { 69 | setTimeout(() => { 70 | this.other().emitter.emit('message', message) 71 | }, this.pair.delay) 72 | } else { 73 | throw new Error('Connection should be started before sending a message') 74 | } 75 | } 76 | } 77 | 78 | export class LocalPair { 79 | constructor(delay = 1) { 80 | this.delay = delay 81 | this.left = new LocalConnection(this, 'left') 82 | this.right = new LocalConnection(this, 'right') 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /local-pair/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { deepStrictEqual, equal, ok, throws } from 'node:assert' 3 | import { test } from 'node:test' 4 | 5 | import { type Connection, LocalPair, type Message } from '../index.js' 6 | 7 | type Event = 8 | | ['connect'] 9 | | ['connecting'] 10 | | ['disconnect', string] 11 | | ['message', Message] 12 | 13 | function track(tracker: Tracker, connection: Connection): Event[] { 14 | let events: Event[] = [] 15 | connection.on('connecting', () => { 16 | events.push(['connecting']) 17 | tracker.waiting?.() 18 | }) 19 | connection.on('connect', () => { 20 | events.push(['connect']) 21 | tracker.waiting?.() 22 | }) 23 | connection.on('disconnect', reason => { 24 | events.push(['disconnect', reason]) 25 | tracker.waiting?.() 26 | }) 27 | connection.on('message', msg => { 28 | events.push(['message', msg]) 29 | tracker.waiting?.() 30 | }) 31 | return events 32 | } 33 | 34 | class Tracker { 35 | left: Event[] 36 | 37 | pair: LocalPair 38 | 39 | right: Event[] 40 | 41 | waiting?: () => void 42 | 43 | constructor(delayMs?: number) { 44 | this.pair = new LocalPair(delayMs) 45 | this.left = track(this, this.pair.left) 46 | this.right = track(this, this.pair.right) 47 | } 48 | 49 | wait(): Promise { 50 | return new Promise(resolve => { 51 | this.waiting = () => { 52 | delete this.waiting 53 | resolve() 54 | } 55 | }) 56 | } 57 | } 58 | 59 | test('has right link between connections', () => { 60 | let pair = new LocalPair() 61 | equal(pair.left.other(), pair.right) 62 | equal(pair.right.other(), pair.left) 63 | }) 64 | 65 | test('throws a error on disconnection in disconnected state', () => { 66 | let pair = new LocalPair() 67 | throws(() => { 68 | pair.left.disconnect() 69 | }, /already finished/) 70 | }) 71 | 72 | test('throws a error on message in disconnected state', () => { 73 | let pair = new LocalPair() 74 | throws(() => { 75 | pair.left.send(['ping', 1]) 76 | }, /started before sending/) 77 | }) 78 | 79 | test('throws a error on connection in connected state', async () => { 80 | let pair = new LocalPair() 81 | await pair.left.connect() 82 | throws(() => { 83 | pair.left.connect() 84 | }, /already established/) 85 | }) 86 | 87 | test('sends a connect event', async () => { 88 | let tracker = new Tracker() 89 | deepStrictEqual(tracker.left, []) 90 | 91 | let connecting = tracker.pair.left.connect() 92 | deepStrictEqual(tracker.left, [['connecting']]) 93 | deepStrictEqual(tracker.right, []) 94 | 95 | await connecting 96 | deepStrictEqual(tracker.left, [['connecting'], ['connect']]) 97 | deepStrictEqual(tracker.right, [['connect']]) 98 | }) 99 | 100 | test('sends a disconnect event', async () => { 101 | let tracker = new Tracker() 102 | await tracker.pair.left.connect() 103 | tracker.pair.right.disconnect('error') 104 | deepStrictEqual(tracker.left, [['connecting'], ['connect']]) 105 | deepStrictEqual(tracker.right, [['connect'], ['disconnect', 'error']]) 106 | await tracker.wait() 107 | deepStrictEqual(tracker.left, [ 108 | ['connecting'], 109 | ['connect'], 110 | ['disconnect', undefined] 111 | ]) 112 | deepStrictEqual(tracker.right, [['connect'], ['disconnect', 'error']]) 113 | }) 114 | 115 | test('sends a message event', async () => { 116 | let tracker = new Tracker() 117 | await tracker.pair.left.connect() 118 | tracker.pair.left.send(['ping', 1]) 119 | deepStrictEqual(tracker.right, [['connect']]) 120 | await tracker.wait() 121 | deepStrictEqual(tracker.left, [['connecting'], ['connect']]) 122 | deepStrictEqual(tracker.right, [['connect'], ['message', ['ping', 1]]]) 123 | }) 124 | 125 | test('emulates delay', async () => { 126 | let tracker = new Tracker(50) 127 | equal(tracker.pair.delay, 50) 128 | 129 | let prevTime = Date.now() 130 | await tracker.pair.left.connect() 131 | ok(Date.now() - prevTime >= 48) 132 | 133 | prevTime = Date.now() 134 | tracker.pair.left.send(['ping', 1]) 135 | await tracker.wait() 136 | ok(Date.now() - prevTime >= 48) 137 | }) 138 | 139 | test('allows disconnect during connecting', async () => { 140 | let tracker = new Tracker(10) 141 | tracker.pair.left.connect() 142 | tracker.pair.left.disconnect() 143 | await delay(50) 144 | deepStrictEqual(tracker.left, [['connecting'], ['disconnect', undefined]]) 145 | deepStrictEqual(tracker.right, []) 146 | }) 147 | -------------------------------------------------------------------------------- /log/errors.ts: -------------------------------------------------------------------------------- 1 | import { Log, MemoryStore, Action } from '../index.js' 2 | 3 | let log = new Log({ nodeId: 'test1', store: new MemoryStore() }) 4 | 5 | // THROWS 'name' does not exist in type 'Action' 6 | log.add({ name: 'Kate' }) 7 | 8 | // THROWS 'number' is not assignable to type 'string' 9 | log.add({ type: 'user/add', name: 'Kate' }, { id: 1 }) 10 | 11 | type RenameAction = Action & { 12 | type: 'rename' 13 | name: string 14 | } 15 | 16 | // THROWS '"rename2"' is not assignable to parameter of type '"rename"' 17 | log.type('rename2', action => { 18 | document.title = action.name 19 | }) 20 | 21 | log.type('rename', action => { 22 | // THROWS 'fullName' does not exist on type 'RenameAction' 23 | document.title = action.fullName 24 | }) 25 | -------------------------------------------------------------------------------- /log/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Emitter, Unsubscribe } from 'nanoevents' 2 | 3 | /** 4 | * Action unique ID accross all nodes. 5 | * 6 | * ```js 7 | * "1564508138460 380:R7BNGAP5:px3-J3oc 0" 8 | * ``` 9 | */ 10 | export type ID = string 11 | 12 | interface PreaddListener { 13 | (action: ListenerAction, meta: LogMeta): void 14 | } 15 | 16 | export interface ReadonlyListener< 17 | ListenerAction extends Action, 18 | LogMeta extends Meta 19 | > { 20 | (action: ListenerAction, meta: LogMeta): void 21 | } 22 | 23 | interface ActionIterator { 24 | (action: Action, meta: Readonly): boolean | void 25 | } 26 | 27 | export function actionEvents( 28 | emitter: Emitter, 29 | event: 'add' | 'clean' | 'preadd', 30 | action: Action, 31 | meta: Meta 32 | ): void 33 | 34 | export interface Meta { 35 | [extra: string]: any 36 | 37 | /** 38 | * Sequence number of action in current log. Log fills it. 39 | */ 40 | added: number 41 | 42 | /** 43 | * Action unique ID. Log sets it automatically. 44 | */ 45 | id: ID 46 | 47 | /** 48 | * Indexes for action quick extraction. 49 | */ 50 | indexes?: string[] 51 | 52 | /** 53 | * Set value to `reasons` and this reason from old action. 54 | */ 55 | keepLast?: string 56 | 57 | /** 58 | * Why action should be kept in log. Action without reasons will be removed. 59 | */ 60 | reasons: string[] 61 | 62 | /** 63 | * Application subprotocol version in SemVer format. 64 | */ 65 | subprotocol?: string 66 | 67 | /** 68 | * Action created time in current node time. Milliseconds since UNIX epoch. 69 | */ 70 | time: number 71 | } 72 | 73 | export interface Action { 74 | /** 75 | * Action type name. 76 | */ 77 | type: string 78 | } 79 | 80 | export interface AnyAction { 81 | [extra: string]: any 82 | type: string 83 | } 84 | 85 | export interface Criteria { 86 | /** 87 | * Remove reason only for action with `id`. 88 | */ 89 | id?: ID 90 | 91 | /** 92 | * Remove reason only for actions with lower `added`. 93 | */ 94 | maxAdded?: number 95 | 96 | /** 97 | * Remove reason only for actions with bigger `added`. 98 | */ 99 | minAdded?: number 100 | 101 | /** 102 | * Remove reason only older than specific action. 103 | */ 104 | olderThan?: Meta 105 | 106 | /** 107 | * Remove reason only younger than specific action. 108 | */ 109 | youngerThan?: Meta 110 | } 111 | 112 | interface LastSynced { 113 | /** 114 | * The `added` value of latest received event. 115 | */ 116 | received: number 117 | 118 | /** 119 | * The `added` value of latest sent event. 120 | */ 121 | sent: number 122 | } 123 | 124 | export interface LogPage { 125 | /** 126 | * Pagination page. 127 | */ 128 | entries: [Action, Meta][] 129 | 130 | /** 131 | * Next page loader. 132 | */ 133 | next?(): Promise 134 | } 135 | 136 | interface GetOptions { 137 | /** 138 | * Get entries with a custom index. 139 | */ 140 | index?: string 141 | 142 | /** 143 | * Sort entries by created time or when they was added to current log. 144 | */ 145 | order?: 'added' | 'created' 146 | } 147 | 148 | /** 149 | * Every Store class should provide 8 standard methods. 150 | */ 151 | export abstract class LogStore { 152 | /** 153 | * Add action to store. Action always will have `type` property. 154 | * 155 | * @param action The action to add. 156 | * @param meta Action’s metadata. 157 | * @returns Promise with `meta` for new action or `false` if action with 158 | * same `meta.id` was already in store. 159 | */ 160 | add(action: AnyAction, meta: Meta): Promise 161 | 162 | /** 163 | * Return action by action ID. 164 | * 165 | * @param id Action ID. 166 | * @returns Promise with array of action and metadata. 167 | */ 168 | byId(id: ID): Promise<[Action, Meta] | [null, null]> 169 | 170 | /** 171 | * Change action metadata. 172 | * 173 | * @param id Action ID. 174 | * @param diff Object with values to change in action metadata. 175 | * @returns Promise with `true` if metadata was changed or `false` 176 | * on unknown ID. 177 | */ 178 | changeMeta(id: ID, diff: Partial): Promise 179 | 180 | /** 181 | * Remove all data from the store. 182 | * 183 | * @returns Promise when cleaning will be finished. 184 | */ 185 | clean(): Promise 186 | 187 | /** 188 | * Return a Promise with first page. Page object has `entries` property 189 | * with part of actions and `next` property with function to load next page. 190 | * If it was a last page, `next` property should be empty. 191 | * 192 | * This tricky API is used, because log could be very big. So we need 193 | * pagination to keep them in memory. 194 | * 195 | * @param opts Query options. 196 | * @returns Promise with first page. 197 | */ 198 | get(opts?: GetOptions): Promise 199 | 200 | /** 201 | * Return biggest `added` number in store. 202 | * All actions in this log have less or same `added` time. 203 | * 204 | * @returns Promise with biggest `added` number. 205 | */ 206 | getLastAdded(): Promise 207 | 208 | /** 209 | * Get `added` values for latest synchronized received/sent events. 210 | * 211 | * @returns Promise with `added` values 212 | */ 213 | getLastSynced(): Promise 214 | 215 | /** 216 | * Remove action from store. 217 | * 218 | * @param id Action ID. 219 | * @returns Promise with entry if action was in store. 220 | */ 221 | remove(id: ID): Promise<[Action, Meta] | false> 222 | 223 | /** 224 | * Remove reason from action’s metadata and remove actions without reasons. 225 | * 226 | * @param reason The reason name. 227 | * @param criteria Criteria to select action for reason removing. 228 | * @param callback Callback for every removed action. 229 | * @returns Promise when cleaning will be finished. 230 | */ 231 | removeReason( 232 | reason: string, 233 | criteria: Criteria, 234 | callback: ReadonlyListener 235 | ): Promise 236 | 237 | /** 238 | * Set `added` value for latest synchronized received or/and sent events. 239 | * @param values Object with latest sent or received values. 240 | * @returns Promise when values will be saved to store. 241 | */ 242 | setLastSynced(values: Partial): Promise 243 | } 244 | 245 | interface LogOptions { 246 | /** 247 | * Unique current machine name. 248 | */ 249 | nodeId: string 250 | 251 | /** 252 | * Store for log. 253 | */ 254 | store: Store 255 | } 256 | 257 | /** 258 | * Stores actions with time marks. Log is main idea in Logux. 259 | * In most end-user tools you will work with log and should know log API. 260 | * 261 | * ```js 262 | * import Log from '@logux/core' 263 | * const log = new Log({ 264 | * store: new MemoryStore(), 265 | * nodeId: 'client:134' 266 | * }) 267 | * 268 | * log.on('add', beeper) 269 | * log.add({ type: 'beep' }) 270 | * ``` 271 | */ 272 | export class Log< 273 | LogMeta extends Meta = Meta, 274 | Store extends LogStore = LogStore 275 | > { 276 | /** 277 | * Unique node ID. It is used in action IDs. 278 | */ 279 | nodeId: string 280 | 281 | /** 282 | * Log store. 283 | */ 284 | store: Store 285 | 286 | /** 287 | * @param opts Log options. 288 | */ 289 | constructor(opts: LogOptions) 290 | 291 | /** 292 | * 293 | * Add action to log. 294 | * 295 | * It will set `id`, `time` (if they was missed) and `added` property 296 | * to `meta` and call all listeners. 297 | * 298 | * ```js 299 | * removeButton.addEventListener('click', () => { 300 | * log.add({ type: 'users:remove', user: id }) 301 | * }) 302 | * ``` 303 | * 304 | * @param action The new action. 305 | * @param meta Open structure for action metadata. 306 | * @returns Promise with `meta` if action was added to log or `false` 307 | * if action was already in log. 308 | */ 309 | add( 310 | action: NewAction, 311 | meta?: Partial 312 | ): Promise 313 | 314 | /** 315 | * Does log already has action with this ID. 316 | * 317 | * ```js 318 | * if (action.type === 'logux/undo') { 319 | * const [undidAction, undidMeta] = await log.byId(action.id) 320 | * log.changeMeta(meta.id, { reasons: undidMeta.reasons }) 321 | * } 322 | * ``` 323 | * 324 | * @param id Action ID. 325 | * @returns Promise with array of action and metadata. 326 | */ 327 | byId(id: ID): Promise<[Action, LogMeta] | [null, null]> 328 | /** 329 | * Change action metadata. You will remove action by setting `reasons: []`. 330 | * 331 | * ```js 332 | * await process(action) 333 | * log.changeMeta(action, { status: 'processed' }) 334 | * ``` 335 | * 336 | * @param id Action ID. 337 | * @param diff Object with values to change in action metadata. 338 | * @returns Promise with `true` if metadata was changed or `false` 339 | * on unknown ID. 340 | */ 341 | changeMeta(id: ID, diff: Partial): Promise 342 | 343 | /** 344 | * @param opts Iterator options. 345 | * @param callback Function will be executed on every action. 346 | */ 347 | each(opts: GetOptions, callback: ActionIterator): Promise 348 | /** 349 | * Iterates through all actions, from last to first. 350 | * 351 | * Return false from callback if you want to stop iteration. 352 | * 353 | * ```js 354 | * log.each((action, meta) => { 355 | * if (compareTime(meta.id, lastBeep) <= 0) { 356 | * return false; 357 | * } else if (action.type === 'beep') { 358 | * beep() 359 | * lastBeep = meta.id 360 | * return false; 361 | * } 362 | * }) 363 | * ``` 364 | * 365 | * @param callback Function will be executed on every action. 366 | * @returns When iteration will be finished by iterator or end of actions. 367 | */ 368 | each(callback: ActionIterator): Promise 369 | 370 | each(callback: ActionIterator): Promise 371 | 372 | /** 373 | * Generate next unique action ID. 374 | * 375 | * ```js 376 | * const id = log.generateId() 377 | * ``` 378 | * 379 | * @returns Unique ID for action. 380 | */ 381 | generateId(): ID 382 | 383 | /** 384 | * Subscribe for log events. It implements nanoevents API. Supported events: 385 | * 386 | * * `preadd`: when somebody try to add action to log. 387 | * It fires before ID check. The best place to add reason. 388 | * * `add`: when new action was added to log. 389 | * * `clean`: when action was cleaned from store. 390 | * 391 | * Note, that `Log#type()` will work faster than `on` event with `if`. 392 | * 393 | * ```js 394 | * log.on('preadd', (action, meta) => { 395 | * if (action.type === 'beep') { 396 | * meta.reasons.push('test') 397 | * } 398 | * }) 399 | * ``` 400 | * 401 | * @param event The event name. 402 | * @param listener The listener function. 403 | * @returns Unbind listener from event. 404 | */ 405 | on( 406 | event: 'add' | 'clean', 407 | listener: ReadonlyListener 408 | ): Unsubscribe 409 | on(event: 'preadd', listener: PreaddListener): Unsubscribe 410 | 411 | /** 412 | * Remove reason tag from action’s metadata and remove actions without reason 413 | * from log. 414 | * 415 | * ```js 416 | * onSync(lastSent) { 417 | * log.removeReason('unsynchronized', { maxAdded: lastSent }) 418 | * } 419 | * ``` 420 | * 421 | * @param reason The reason name. 422 | * @param criteria Criteria to select action for reason removing. 423 | * @returns Promise when cleaning will be finished. 424 | */ 425 | removeReason(reason: string, criteria?: Criteria): Promise 426 | 427 | /** 428 | * Add listener for adding action with specific type. 429 | * Works faster than `on('add', cb)` with `if`. 430 | * 431 | * Setting `opts.id` will filter events ponly from actions with specific 432 | * `action.id`. 433 | * 434 | * ```js 435 | * const unbind = log.type('beep', (action, meta) => { 436 | * beep() 437 | * }) 438 | * function disableBeeps () { 439 | * unbind() 440 | * } 441 | * ``` 442 | * 443 | * @param type Action’s type. 444 | * @param listener The listener function. 445 | * @param event 446 | * @returns Unbind listener from event. 447 | */ 448 | type< 449 | NewAction extends Action = Action, 450 | Type extends string = NewAction['type'] 451 | >( 452 | type: Type, 453 | listener: ReadonlyListener, 454 | opts?: { event?: 'add' | 'clean'; id?: string } 455 | ): Unsubscribe 456 | 457 | type< 458 | NewAction extends Action = Action, 459 | Type extends string = NewAction['type'] 460 | >( 461 | type: Type, 462 | listener: PreaddListener, 463 | opts: { event: 'preadd'; id?: string } 464 | ): Unsubscribe 465 | } 466 | -------------------------------------------------------------------------------- /log/index.js: -------------------------------------------------------------------------------- 1 | import { createNanoEvents } from 'nanoevents' 2 | 3 | export function actionEvents(emitter, event, action, meta) { 4 | if (action.id) { 5 | emitter.emit(`${event}-${action.type}-${action.id}`, action, meta) 6 | } 7 | emitter.emit(`${event}-${action.type}-`, action, meta) 8 | emitter.emit(event, action, meta) 9 | } 10 | 11 | export class Log { 12 | constructor(opts = {}) { 13 | if (process.env.NODE_ENV !== 'production') { 14 | if (typeof opts.nodeId === 'undefined') { 15 | throw new Error('Expected node ID') 16 | } 17 | if (typeof opts.store !== 'object') { 18 | throw new Error('Expected store') 19 | } 20 | if (opts.nodeId.includes(' ')) { 21 | throw new Error('Space is prohibited in node ID') 22 | } 23 | } 24 | 25 | this.nodeId = opts.nodeId 26 | 27 | this.lastTime = 0 28 | this.sequence = 0 29 | 30 | this.store = opts.store 31 | 32 | this.emitter = createNanoEvents() 33 | } 34 | 35 | async add(action, meta = {}) { 36 | if (typeof action.type === 'undefined') { 37 | throw new Error('Expected "type" in action') 38 | } 39 | 40 | let newId = false 41 | if (typeof meta.id === 'undefined') { 42 | newId = true 43 | meta.id = this.generateId() 44 | } 45 | 46 | if (typeof meta.time === 'undefined') { 47 | meta.time = parseInt(meta.id) 48 | } 49 | 50 | if (typeof meta.reasons === 'undefined') { 51 | meta.reasons = [] 52 | } 53 | 54 | if (process.env.NODE_ENV !== 'production') { 55 | if (!Array.isArray(meta.reasons)) { 56 | throw new Error('Expected "reasons" to be an array of strings') 57 | } 58 | 59 | for (let reason of meta.reasons) { 60 | if (typeof reason !== 'string') { 61 | throw new Error('Expected "reasons" to be an array of strings') 62 | } 63 | } 64 | 65 | if (typeof meta.indexes !== 'undefined') { 66 | if (!Array.isArray(meta.indexes)) { 67 | throw new Error('Expected "indexes" to be an array of strings') 68 | } 69 | 70 | for (let index of meta.indexes) { 71 | if (typeof index !== 'string') { 72 | throw new Error('Expected "indexes" to be an array of strings') 73 | } 74 | } 75 | } 76 | } 77 | 78 | actionEvents(this.emitter, 'preadd', action, meta) 79 | 80 | if (meta.keepLast) { 81 | this.removeReason(meta.keepLast, { olderThan: meta }) 82 | meta.reasons.push(meta.keepLast) 83 | } 84 | 85 | if (meta.reasons.length === 0 && newId) { 86 | actionEvents(this.emitter, 'add', action, meta) 87 | actionEvents(this.emitter, 'clean', action, meta) 88 | return meta 89 | } else if (meta.reasons.length === 0) { 90 | let [action2] = await this.store.byId(meta.id) 91 | if (action2) { 92 | return false 93 | } else { 94 | actionEvents(this.emitter, 'add', action, meta) 95 | actionEvents(this.emitter, 'clean', action, meta) 96 | return meta 97 | } 98 | } else { 99 | let addedMeta = await this.store.add(action, meta) 100 | if (addedMeta === false) { 101 | return false 102 | } else { 103 | actionEvents(this.emitter, 'add', action, meta) 104 | return addedMeta 105 | } 106 | } 107 | } 108 | 109 | byId(id) { 110 | return this.store.byId(id) 111 | } 112 | 113 | async changeMeta(id, diff) { 114 | for (let k in diff) { 115 | if ( 116 | k === 'id' || 117 | k === 'added' || 118 | k === 'time' || 119 | k === 'subprotocol' || 120 | k === 'indexes' 121 | ) { 122 | throw new Error('Meta "' + k + '" is read-only') 123 | } 124 | } 125 | 126 | if (diff.reasons && diff.reasons.length === 0) { 127 | let entry = await this.store.remove(id) 128 | if (entry) { 129 | for (let k in diff) entry[1][k] = diff[k] 130 | actionEvents(this.emitter, 'clean', entry[0], entry[1]) 131 | } 132 | return !!entry 133 | } else { 134 | return this.store.changeMeta(id, diff) 135 | } 136 | } 137 | 138 | each(opts, callback) { 139 | if (!callback) { 140 | callback = opts 141 | opts = { order: 'created' } 142 | } 143 | 144 | let store = this.store 145 | return new Promise(resolve => { 146 | async function nextPage(get) { 147 | let page = await get() 148 | let result 149 | for (let i = page.entries.length - 1; i >= 0; i--) { 150 | let entry = page.entries[i] 151 | result = callback(entry[0], entry[1]) 152 | if (result === false) break 153 | } 154 | 155 | if (result === false || !page.next) { 156 | resolve() 157 | } else { 158 | nextPage(page.next) 159 | } 160 | } 161 | 162 | nextPage(store.get.bind(store, opts)) 163 | }) 164 | } 165 | 166 | generateId() { 167 | let now = Date.now() 168 | if (now <= this.lastTime) { 169 | now = this.lastTime 170 | this.sequence += 1 171 | } else { 172 | this.lastTime = now 173 | this.sequence = 0 174 | } 175 | return now + ' ' + this.nodeId + ' ' + this.sequence 176 | } 177 | 178 | on(event, listener) { 179 | return this.emitter.on(event, listener) 180 | } 181 | 182 | removeReason(reason, criteria = {}) { 183 | return this.store.removeReason(reason, criteria, (action, meta) => { 184 | actionEvents(this.emitter, 'clean', action, meta) 185 | }) 186 | } 187 | 188 | type(type, listener, opts = {}) { 189 | let event = opts.event || 'add' 190 | let id = opts.id || '' 191 | return this.emitter.on(`${event}-${type}-${id}`, listener) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /log/types.ts: -------------------------------------------------------------------------------- 1 | import { type Action, Log, MemoryStore } from '../index.js' 2 | 3 | let log = new Log({ nodeId: 'test1', store: new MemoryStore() }) 4 | 5 | log.add({ name: 'Kate', type: 'user/add' }) 6 | 7 | log.add({ name: 'Kate', type: 'user/add' }, { extra: 1 }) 8 | 9 | type RenameAction = { 10 | name: string 11 | type: 'rename' 12 | } & Action 13 | 14 | log.type('rename', action => { 15 | document.title = action.name 16 | }) 17 | 18 | log.type('rename', action => { 19 | console.log(action) 20 | }) 21 | -------------------------------------------------------------------------------- /logux-error/errors.ts: -------------------------------------------------------------------------------- 1 | import { LoguxError } from '../index.js' 2 | 3 | // THROWS 'number' is not assignable to parameter of type 'void' 4 | new LoguxError('bruteforce', 10) 5 | // THROWS 'a' does not exist in type 'Versions' 6 | new LoguxError('wrong-protocol', { a: 1 }) 7 | -------------------------------------------------------------------------------- /logux-error/index.d.ts: -------------------------------------------------------------------------------- 1 | interface Versions { 2 | supported: string 3 | used: string 4 | } 5 | 6 | export interface LoguxErrorOptions { 7 | 'bruteforce': void 8 | 'timeout': number 9 | 'unknown-message': string 10 | 'wrong-credentials': void 11 | 'wrong-format': string 12 | 'wrong-protocol': Versions 13 | 'wrong-subprotocol': Versions 14 | } 15 | 16 | /** 17 | * Logux error in logs synchronization. 18 | * 19 | * ```js 20 | * if (error.name === 'LoguxError') { 21 | * console.log('Server throws: ' + error.description) 22 | * } 23 | * ``` 24 | */ 25 | export class LoguxError< 26 | ErrorType extends keyof LoguxErrorOptions = keyof LoguxErrorOptions 27 | > extends Error { 28 | /** 29 | * Human-readable error description. 30 | * 31 | * ```js 32 | * console.log('Server throws: ' + error.description) 33 | * ``` 34 | */ 35 | description: string 36 | 37 | /** 38 | * Full text of error to print in debug message. 39 | */ 40 | message: string 41 | 42 | /** 43 | * Always equal to `LoguxError`. The best way to check error class. 44 | * 45 | * ```js 46 | * if (error.name === 'LoguxError') { 47 | * ``` 48 | */ 49 | name: 'LoguxError' 50 | 51 | /** 52 | * Error options depends on error type. 53 | * 54 | * ```js 55 | * if (error.type === 'timeout') { 56 | * console.error('A timeout was reached (' + error.options + ' ms)') 57 | * } 58 | * ``` 59 | */ 60 | options: LoguxErrorOptions[ErrorType] 61 | 62 | /** 63 | * Was error received from remote client. 64 | */ 65 | received: boolean 66 | 67 | /** 68 | * Calls which cause the error. 69 | */ 70 | stack: string 71 | 72 | /** 73 | * The error code. 74 | * 75 | * ```js 76 | * if (error.type === 'timeout') { 77 | * fixNetwork() 78 | * } 79 | * ``` 80 | */ 81 | type: ErrorType 82 | 83 | /** 84 | * @param type The error code. 85 | * @param options The error option. 86 | * @param received Was error received from remote node. 87 | */ 88 | constructor( 89 | type: ErrorType, 90 | options?: LoguxErrorOptions[ErrorType], 91 | received?: boolean 92 | ) 93 | 94 | /** 95 | * Return a error description by it code. 96 | * 97 | * @param type The error code. 98 | * @param options The errors options depends on error code. 99 | * 100 | * ```js 101 | * errorMessage(msg) { 102 | * console.log(LoguxError.describe(msg[1], msg[2])) 103 | * } 104 | * ``` 105 | */ 106 | static description( 107 | type: Type, 108 | options?: LoguxErrorOptions[Type] 109 | ): string 110 | } 111 | -------------------------------------------------------------------------------- /logux-error/index.js: -------------------------------------------------------------------------------- 1 | export class LoguxError extends Error { 2 | constructor(type, options, received) { 3 | super(type) 4 | this.name = 'LoguxError' 5 | this.type = type 6 | this.options = options 7 | this.description = LoguxError.describe(type, options) 8 | this.received = !!received 9 | 10 | if (received) { 11 | this.message = 'Logux received ' + this.type + ' error' 12 | if (this.description !== this.type) { 13 | this.message += ' (' + this.description + ')' 14 | } 15 | } else { 16 | this.message = this.description 17 | } 18 | 19 | if (Error.captureStackTrace) { 20 | Error.captureStackTrace(this, LoguxError) 21 | } 22 | } 23 | 24 | static describe(type, options) { 25 | if (type === 'timeout') { 26 | return 'A timeout was reached (' + options + ' ms)' 27 | } else if (type === 'wrong-format') { 28 | return 'Wrong message format in ' + options 29 | } else if (type === 'unknown-message') { 30 | return 'Unknown message `' + options + '` type' 31 | } else if (type === 'bruteforce') { 32 | return 'Too many wrong authentication attempts' 33 | } else if (type === 'wrong-protocol') { 34 | return ( 35 | `Logux supports protocols only from version ${options.supported}` + 36 | `, but you use ${options.used}` 37 | ) 38 | } else if (type === 'wrong-subprotocol') { 39 | return ( 40 | `Only ${options.supported} application subprotocols are ` + 41 | `supported, but you use ${options.used}` 42 | ) 43 | } else if (type === 'wrong-credentials') { 44 | return 'Wrong credentials' 45 | } else { 46 | return type 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /logux-error/index.test.ts: -------------------------------------------------------------------------------- 1 | import { equal, ok } from 'node:assert' 2 | import { test } from 'node:test' 3 | 4 | import { LoguxError, type LoguxErrorOptions } from '../index.js' 5 | 6 | function catchError( 7 | type: T, 8 | opts?: LoguxErrorOptions[T], 9 | received?: boolean 10 | ): LoguxError { 11 | let error: LoguxError 12 | try { 13 | throw new LoguxError(type, opts, received) 14 | } catch (e) { 15 | error = e as LoguxError 16 | } 17 | return error 18 | } 19 | 20 | test('does not crash if captureStackTrace does not exist', () => { 21 | let captureStackTrace = global.Error.captureStackTrace 22 | // @ts-expect-error 23 | delete global.Error.captureStackTrace 24 | catchError('wrong-credentials') 25 | global.Error.captureStackTrace = captureStackTrace 26 | }) 27 | 28 | test('has stack trace', () => { 29 | let error = catchError('wrong-credentials') 30 | ok(error.stack.includes('index.test.ts')) 31 | }) 32 | 33 | test('has class name', () => { 34 | let error = catchError('wrong-credentials') 35 | equal(error.name, 'LoguxError') 36 | }) 37 | 38 | test('has error description', () => { 39 | let error = catchError('wrong-credentials') 40 | equal(error.description, 'Wrong credentials') 41 | }) 42 | 43 | test('has received', () => { 44 | let own = catchError('timeout', 10) 45 | equal(own.received, false) 46 | let received = catchError('timeout', 10, true) 47 | equal(received.received, true) 48 | }) 49 | 50 | test('stringifies', () => { 51 | let error = catchError('timeout', 10, true) 52 | ok( 53 | String(error).includes( 54 | 'LoguxError: Logux received timeout error (A timeout was reached (10 ms))' 55 | ) 56 | ) 57 | }) 58 | 59 | test('stringifies local unknown error', () => { 60 | let error = catchError('timeout', 10) 61 | ok(error.toString().includes('LoguxError: A timeout was reached (10 ms)')) 62 | }) 63 | 64 | test('stringifies bruteforce error', () => { 65 | ok( 66 | catchError('bruteforce') 67 | .toString() 68 | .includes('LoguxError: Too many wrong authentication attempts') 69 | ) 70 | }) 71 | 72 | test('stringifies subprotocol error', () => { 73 | let error = catchError( 74 | 'wrong-subprotocol', 75 | { 76 | supported: '2.x', 77 | used: '1.0' 78 | }, 79 | true 80 | ) 81 | ok( 82 | error 83 | .toString() 84 | .includes( 85 | 'LoguxError: Logux received wrong-subprotocol error ' + 86 | '(Only 2.x application subprotocols are supported, but you use 1.0)' 87 | ) 88 | ) 89 | }) 90 | 91 | test('returns description by error type', () => { 92 | ok( 93 | catchError('wrong-format', '{}') 94 | .toString() 95 | .includes('LoguxError: Wrong message format in {}') 96 | ) 97 | }) 98 | 99 | test('returns description by unknown type', () => { 100 | // @ts-expect-error 101 | ok(catchError('unknown').toString().includes('LoguxError: unknown')) 102 | }) 103 | -------------------------------------------------------------------------------- /logux-error/types.ts: -------------------------------------------------------------------------------- 1 | import { LoguxError } from '../index.js' 2 | 3 | new LoguxError('timeout', 10, true) 4 | new LoguxError('wrong-protocol', { supported: '1.1.0', used: '1.0.0' }) 5 | new LoguxError('bruteforce') 6 | -------------------------------------------------------------------------------- /memory-store/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Action, Meta } from '../log/index.js'; 2 | import { LogStore } from '../log/index.js' 3 | 4 | /** 5 | * Simple memory-based log store. 6 | * 7 | * It is good for tests, but not for server or client usage, 8 | * because it store all data in memory and will lose log on exit. 9 | * 10 | * ```js 11 | * import { MemoryStore } from '@logux/core' 12 | * 13 | * var log = new Log({ 14 | * nodeId: 'server', 15 | * store: new MemoryStore() 16 | * }) 17 | * ``` 18 | */ 19 | export class MemoryStore extends LogStore { 20 | /** 21 | * Actions in the store. 22 | */ 23 | entries: [Action, Meta][] 24 | } 25 | -------------------------------------------------------------------------------- /memory-store/index.js: -------------------------------------------------------------------------------- 1 | import { isFirstOlder } from '../is-first-older/index.js' 2 | 3 | function checkIndex(store, index) { 4 | if (!store.indexes[index]) { 5 | store.indexes[index] = { added: [], entries: [] } 6 | } 7 | } 8 | 9 | function forEachIndex(meta, cb) { 10 | let indexes = meta.indexes 11 | if (isDefined(indexes) && indexes.length > 0) { 12 | for (let index of indexes) { 13 | cb(index) 14 | } 15 | } 16 | } 17 | 18 | function insert(store, entry) { 19 | store.lastAdded += 1 20 | entry[1].added = store.lastAdded 21 | store.added.push(entry) 22 | forEachIndex(entry[1], index => { 23 | checkIndex(store, index) 24 | store.indexes[index].added.push(entry) 25 | }) 26 | return Promise.resolve(entry[1]) 27 | } 28 | 29 | function eject(store, meta) { 30 | let added = meta.added 31 | let start = 0 32 | let end = store.added.length - 1 33 | while (start <= end) { 34 | let middle = (end + start) >> 1 35 | let otherAdded = store.added[middle][1].added 36 | if (otherAdded < added) { 37 | start = middle + 1 38 | } else if (otherAdded > added) { 39 | end = middle - 1 40 | } else { 41 | store.added.splice(middle, 1) 42 | break 43 | } 44 | } 45 | } 46 | 47 | function find(list, id) { 48 | for (let i = list.length - 1; i >= 0; i--) { 49 | if (id === list[i][1].id) { 50 | return i 51 | } 52 | } 53 | return -1 54 | } 55 | 56 | function isDefined(value) { 57 | return typeof value !== 'undefined' 58 | } 59 | 60 | export class MemoryStore { 61 | constructor() { 62 | this.entries = [] 63 | this.added = [] 64 | this.indexes = {} 65 | this.lastReceived = 0 66 | this.lastAdded = 0 67 | this.lastSent = 0 68 | } 69 | 70 | async add(action, meta) { 71 | let entry = [action, meta] 72 | let id = meta.id 73 | 74 | let list = this.entries 75 | for (let i = 0; i < list.length; i++) { 76 | let [, otherMeta] = list[i] 77 | if (id === otherMeta.id) { 78 | return false 79 | } else if (!isFirstOlder(otherMeta, meta)) { 80 | forEachIndex(meta, index => { 81 | checkIndex(this, index) 82 | let indexList = this.indexes[index].entries 83 | let j = indexList.findIndex(item => !isFirstOlder(item[1], meta)) 84 | indexList.splice(j, 0, entry) 85 | }) 86 | list.splice(i, 0, entry) 87 | return insert(this, entry) 88 | } 89 | } 90 | 91 | forEachIndex(meta, index => { 92 | checkIndex(this, index) 93 | this.indexes[index].entries.push(entry) 94 | }) 95 | list.push(entry) 96 | return insert(this, entry) 97 | } 98 | 99 | async byId(id) { 100 | let created = find(this.entries, id) 101 | if (created === -1) { 102 | return [null, null] 103 | } else { 104 | let [action, meta] = this.entries[created] 105 | return [action, meta] 106 | } 107 | } 108 | 109 | async changeMeta(id, diff) { 110 | let index = find(this.entries, id) 111 | if (index === -1) { 112 | return false 113 | } else { 114 | let meta = this.entries[index][1] 115 | for (let key in diff) meta[key] = diff[key] 116 | return true 117 | } 118 | } 119 | 120 | async clean() { 121 | this.entries = [] 122 | this.added = [] 123 | this.indexes = {} 124 | this.lastReceived = 0 125 | this.lastAdded = 0 126 | this.lastSent = 0 127 | } 128 | 129 | async get(opts = {}) { 130 | let index = opts.index 131 | let store = this 132 | let entries 133 | if (index) { 134 | store = this.indexes[index] || { added: [], entries: [] } 135 | } 136 | if (opts.order === 'created') { 137 | entries = store.entries 138 | } else { 139 | entries = store.added 140 | } 141 | return { entries: entries.slice(0) } 142 | } 143 | 144 | async getLastAdded() { 145 | return this.lastAdded 146 | } 147 | 148 | async getLastSynced() { 149 | return { 150 | received: this.lastReceived, 151 | sent: this.lastSent 152 | } 153 | } 154 | 155 | async remove(id, created) { 156 | if (typeof created === 'undefined') { 157 | created = find(this.entries, id) 158 | if (created === -1) return Promise.resolve(false) 159 | } 160 | 161 | let entry = [this.entries[created][0], this.entries[created][1]] 162 | forEachIndex(entry[1], index => { 163 | let entries = this.indexes[index].entries 164 | let indexed = find(entries, id) 165 | if (indexed !== -1) entries.splice(indexed, 1) 166 | }) 167 | this.entries.splice(created, 1) 168 | 169 | forEachIndex(entry[1], index => { 170 | eject(this.indexes[index], entry[1]) 171 | }) 172 | eject(this, entry[1]) 173 | 174 | return entry 175 | } 176 | 177 | async removeReason(reason, criteria, callback) { 178 | let removed = [] 179 | 180 | if (criteria.id) { 181 | let index = find(this.entries, criteria.id) 182 | if (index !== -1) { 183 | let meta = this.entries[index][1] 184 | let reasonPos = meta.reasons.indexOf(reason) 185 | if (reasonPos !== -1) { 186 | meta.reasons.splice(reasonPos, 1) 187 | if (meta.reasons.length === 0) { 188 | callback(this.entries[index][0], meta) 189 | this.remove(criteria.id) 190 | } 191 | } 192 | } 193 | } else { 194 | this.entries = this.entries.filter(([action, meta]) => { 195 | let c = criteria 196 | 197 | let reasonPos = meta.reasons.indexOf(reason) 198 | if (reasonPos === -1) { 199 | return true 200 | } 201 | if (isDefined(c.olderThan) && !isFirstOlder(meta, c.olderThan)) { 202 | return true 203 | } 204 | if (isDefined(c.youngerThan) && !isFirstOlder(c.youngerThan, meta)) { 205 | return true 206 | } 207 | if (isDefined(c.minAdded) && meta.added < c.minAdded) { 208 | return true 209 | } 210 | if (isDefined(c.maxAdded) && meta.added > c.maxAdded) { 211 | return true 212 | } 213 | 214 | meta.reasons.splice(reasonPos, 1) 215 | if (meta.reasons.length === 0) { 216 | callback(action, meta) 217 | removed.push(meta) 218 | return false 219 | } else { 220 | return true 221 | } 222 | }) 223 | 224 | let removedAdded = removed.map(m => m.added) 225 | let removing = i => !removedAdded.includes(i[1].added) 226 | this.added = this.added.filter(removing) 227 | 228 | for (let meta of removed) { 229 | forEachIndex(meta, i => { 230 | this.indexes[i].entries = this.indexes[i].entries.filter(removing) 231 | this.indexes[i].added = this.indexes[i].added.filter(removing) 232 | }) 233 | } 234 | } 235 | } 236 | 237 | async setLastSynced(values) { 238 | if (typeof values.sent !== 'undefined') { 239 | this.lastSent = values.sent 240 | } 241 | if (typeof values.received !== 'undefined') { 242 | this.lastReceived = values.received 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /memory-store/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | 3 | import { eachStoreCheck, MemoryStore } from '../index.js' 4 | 5 | eachStoreCheck((desc, creator) => { 6 | test( 7 | `${desc}`, 8 | creator(() => new MemoryStore()) 9 | ) 10 | }) 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@logux/core", 3 | "version": "0.9.0", 4 | "description": "Logux core components", 5 | "keywords": [ 6 | "logux", 7 | "core", 8 | "connection", 9 | "websocket", 10 | "crdt", 11 | "event sourcing", 12 | "distributed systems" 13 | ], 14 | "scripts": { 15 | "test:coverage": "c8 pnpm bnt", 16 | "test:lint": "eslint .", 17 | "test:types": "check-dts", 18 | "test": "pnpm run /^test:/" 19 | }, 20 | "author": "Andrey Sitnik ", 21 | "license": "MIT", 22 | "homepage": "https://logux.org/", 23 | "repository": "logux/core", 24 | "sideEffects": false, 25 | "type": "module", 26 | "types": "./index.d.ts", 27 | "exports": { 28 | ".": "./index.js", 29 | "./package.json": "./package.json" 30 | }, 31 | "engines": { 32 | "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 33 | }, 34 | "dependencies": { 35 | "nanoevents": "^9.0.0" 36 | }, 37 | "devDependencies": { 38 | "@logux/eslint-config": "^53.3.0", 39 | "@types/ws": "^8.5.12", 40 | "better-node-test": "^0.5.1", 41 | "c8": "^10.1.2", 42 | "check-dts": "^0.8.0", 43 | "clean-publish": "^5.0.0", 44 | "eslint": "^9.8.0", 45 | "nanodelay": "^2.0.2", 46 | "nanospy": "^1.0.0", 47 | "tsx": "^4.16.5", 48 | "typescript": "^5.5.4", 49 | "ws": "^8.18.0" 50 | }, 51 | "prettier": { 52 | "arrowParens": "avoid", 53 | "jsxSingleQuote": false, 54 | "quoteProps": "consistent", 55 | "semi": false, 56 | "singleQuote": true, 57 | "trailingComma": "none" 58 | }, 59 | "c8": { 60 | "exclude": [ 61 | "**/*.test.*", 62 | "test/*" 63 | ], 64 | "lines": 100, 65 | "check-coverage": true, 66 | "reporter": [ 67 | "text", 68 | "lcov" 69 | ], 70 | "skip-full": true, 71 | "clean": true 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /parse-id/index.d.ts: -------------------------------------------------------------------------------- 1 | interface IDComponents { 2 | clientId: string 3 | nodeId: string 4 | userId: string | undefined 5 | } 6 | 7 | /** 8 | * Parse `meta.id` or Node ID into component: user ID, client ID, node ID. 9 | * 10 | * ```js 11 | * import { parseId } from '@logux/core' 12 | * const { userId, clientId } = parseId(meta.id) 13 | * ``` 14 | * 15 | * @param id Action or Node ID 16 | */ 17 | export function parseId(id: string): IDComponents 18 | -------------------------------------------------------------------------------- /parse-id/index.js: -------------------------------------------------------------------------------- 1 | export function parseId(nodeId) { 2 | if (nodeId.includes(' ')) nodeId = nodeId.split(' ')[1] 3 | let parts = nodeId.split(':') 4 | if (parts.length === 1) { 5 | return { clientId: nodeId, nodeId, userId: undefined } 6 | } else { 7 | let userId = parts[0] 8 | return { clientId: parts[0] + ':' + parts[1], nodeId, userId } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /parse-id/index.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual } from 'node:assert' 2 | import { test } from 'node:test' 3 | 4 | import { parseId } from '../index.js' 5 | 6 | test('parses node ID', () => { 7 | deepStrictEqual(parseId('10:client:uuid'), { 8 | clientId: '10:client', 9 | nodeId: '10:client:uuid', 10 | userId: '10' 11 | }) 12 | }) 13 | 14 | test('parses action ID', () => { 15 | deepStrictEqual(parseId('1 10:client:uuid 0'), { 16 | clientId: '10:client', 17 | nodeId: '10:client:uuid', 18 | userId: '10' 19 | }) 20 | }) 21 | 22 | test('parses node ID without client', () => { 23 | deepStrictEqual(parseId('10:uuid'), { 24 | clientId: '10:uuid', 25 | nodeId: '10:uuid', 26 | userId: '10' 27 | }) 28 | }) 29 | 30 | test('parses node ID without client and user', () => { 31 | deepStrictEqual(parseId('uuid'), { 32 | clientId: 'uuid', 33 | nodeId: 'uuid', 34 | userId: undefined 35 | }) 36 | }) 37 | 38 | test('parses node ID with false user', () => { 39 | deepStrictEqual(parseId('false:client:uuid'), { 40 | clientId: 'false:client', 41 | nodeId: 'false:client:uuid', 42 | userId: 'false' 43 | }) 44 | }) 45 | 46 | test('parses node ID with multiple colon', () => { 47 | deepStrictEqual(parseId('10:client:uuid:more'), { 48 | clientId: '10:client', 49 | nodeId: '10:client:uuid:more', 50 | userId: '10' 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /ping/index.js: -------------------------------------------------------------------------------- 1 | export function sendPing() { 2 | this.startTimeout() 3 | this.send(['ping', this.lastAddedCache]) 4 | if (this.pingTimeout) clearTimeout(this.pingTimeout) 5 | } 6 | 7 | export function pingMessage(synced) { 8 | this.setLastReceived(synced) 9 | if (this.connected && this.authenticated) { 10 | this.send(['pong', this.lastAddedCache]) 11 | } 12 | } 13 | 14 | export function pongMessage(synced) { 15 | this.setLastReceived(synced) 16 | this.endTimeout() 17 | } 18 | -------------------------------------------------------------------------------- /ping/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { deepStrictEqual, equal, ok, throws } from 'node:assert' 3 | import { afterEach, test } from 'node:test' 4 | 5 | import { 6 | type BaseNode, 7 | ClientNode, 8 | type NodeOptions, 9 | ServerNode, 10 | type TestLog, 11 | TestPair, 12 | TestTime 13 | } from '../index.js' 14 | 15 | let node: BaseNode<{}, TestLog> | undefined 16 | 17 | afterEach(() => { 18 | node?.destroy() 19 | }) 20 | 21 | function privateMethods(obj: object): any { 22 | return obj 23 | } 24 | 25 | async function createTest(opts: NodeOptions): Promise { 26 | let log = TestTime.getLog() 27 | let pair = new TestPair() 28 | await log.add({ type: 'test' }, { reasons: ['test'] }) 29 | privateMethods(log.store).lastSent = 1 30 | node = new ClientNode('client', log, pair.left, opts) 31 | pair.leftNode = node 32 | await pair.left.connect() 33 | await pair.wait() 34 | let protocol = pair.leftNode.localProtocol 35 | pair.right.send(['connected', protocol, 'server', [0, 0]]) 36 | pair.clear() 37 | return pair 38 | } 39 | 40 | test('throws on ping and no timeout options', () => { 41 | let pair = new TestPair() 42 | let log = TestTime.getLog() 43 | throws(() => { 44 | new ClientNode('client', log, pair.left, { ping: 1000, timeout: 0 }) 45 | }, /set timeout option/) 46 | }) 47 | 48 | test('answers pong on ping', async () => { 49 | let pair = await createTest({ fixTime: false }) 50 | pair.right.send(['ping', 1]) 51 | await pair.wait('right') 52 | deepStrictEqual(pair.leftSent, [['pong', 1]]) 53 | }) 54 | 55 | test('sends ping on idle connection', async () => { 56 | let error: Error | undefined 57 | let pair = await createTest({ 58 | fixTime: false, 59 | ping: 300, 60 | timeout: 100 61 | }) 62 | pair.leftNode.catch(err => { 63 | error = err 64 | }) 65 | await delay(250) 66 | privateMethods(pair.right).send(['duilian', '']) 67 | await delay(250) 68 | privateMethods(pair.leftNode).send(['duilian', '']) 69 | await delay(250) 70 | equal(typeof error, 'undefined') 71 | deepStrictEqual(pair.leftSent, [['duilian', '']]) 72 | await delay(100) 73 | equal(typeof error, 'undefined') 74 | deepStrictEqual(pair.leftSent, [ 75 | ['duilian', ''], 76 | ['ping', 1] 77 | ]) 78 | pair.right.send(['pong', 1]) 79 | await delay(250) 80 | equal(typeof error, 'undefined') 81 | deepStrictEqual(pair.leftSent, [ 82 | ['duilian', ''], 83 | ['ping', 1] 84 | ]) 85 | await delay(100) 86 | equal(typeof error, 'undefined') 87 | deepStrictEqual(pair.leftSent, [ 88 | ['duilian', ''], 89 | ['ping', 1], 90 | ['ping', 1] 91 | ]) 92 | await delay(250) 93 | if (typeof error === 'undefined') throw new Error('Error was not sent') 94 | ok(error.message.includes('timeout')) 95 | deepStrictEqual(pair.leftSent, [ 96 | ['duilian', ''], 97 | ['ping', 1], 98 | ['ping', 1] 99 | ]) 100 | deepStrictEqual(pair.leftEvents[3], ['disconnect', 'timeout']) 101 | }) 102 | 103 | test('does not ping before authentication', async () => { 104 | let log = TestTime.getLog() 105 | let pair = new TestPair() 106 | pair.leftNode = new ClientNode('client', log, pair.left, { 107 | fixTime: false, 108 | ping: 100, 109 | timeout: 300 110 | }) 111 | pair.leftNode.catch(() => true) 112 | await pair.left.connect() 113 | await pair.wait() 114 | pair.clear() 115 | await delay(250) 116 | deepStrictEqual(pair.leftSent, []) 117 | }) 118 | 119 | test('sends only one ping if timeout is bigger than ping', async () => { 120 | let pair = await createTest({ 121 | fixTime: false, 122 | ping: 100, 123 | timeout: 300 124 | }) 125 | await delay(250) 126 | deepStrictEqual(pair.leftSent, [['ping', 1]]) 127 | }) 128 | 129 | test('do not try clear timeout if it does not set', async () => { 130 | let pair = await createTest({ 131 | ping: undefined 132 | }) 133 | await delay(250) 134 | privateMethods(pair.leftNode).sendPing() 135 | deepStrictEqual(pair.leftSent, []) 136 | }) 137 | 138 | test('do not send ping if not connected', async () => { 139 | let pair = await createTest({ fixTime: false }) 140 | pair.right.send(['ping', 1]) 141 | pair.left.disconnect() 142 | await pair.wait('right') 143 | deepStrictEqual(pair.leftSent, []) 144 | }) 145 | 146 | test('checks types', async () => { 147 | let wrongs = [ 148 | ['ping'], 149 | ['ping', 'abc'], 150 | ['ping', []], 151 | ['pong'], 152 | ['pong', 'abc'], 153 | ['pong', {}] 154 | ] 155 | await Promise.all( 156 | wrongs.map(async msg => { 157 | let pair = new TestPair() 158 | let log = TestTime.getLog() 159 | pair.leftNode = new ServerNode('server', log, pair.left) 160 | await pair.left.connect() 161 | // @ts-expect-error 162 | pair.right.send(msg) 163 | await pair.wait('right') 164 | equal(pair.leftNode.connected, false) 165 | deepStrictEqual(pair.leftSent, [ 166 | ['error', 'wrong-format', JSON.stringify(msg)] 167 | ]) 168 | }) 169 | ) 170 | }) 171 | -------------------------------------------------------------------------------- /reconnect/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from '../base-node/index.js' 2 | 3 | interface ReconnectOptions { 4 | /** 5 | * Maximum reconnecting attempts. 6 | */ 7 | attempts?: number 8 | 9 | /** 10 | * Maximum delay between re-connecting. 11 | */ 12 | maxDelay?: number 13 | 14 | /** 15 | * Minimum delay between re-connecting. 16 | */ 17 | minDelay?: number 18 | } 19 | 20 | /** 21 | * Wrapper for Connection for re-connecting it on every disconnect. 22 | * 23 | * ```js 24 | * import { ClientNode, Reconnect } from '@logux/core' 25 | * const recon = new Reconnect(connection) 26 | * new ClientNode(nodeId, log, recon, options) 27 | * ``` 28 | */ 29 | export class Reconnect extends Connection { 30 | /** 31 | * Fails attempts since the last connected state. 32 | */ 33 | attempts: number 34 | 35 | /** 36 | * Are we in the middle of connecting. 37 | */ 38 | connecting: boolean 39 | 40 | /** 41 | * Wrapped connection. 42 | */ 43 | connection: Connection 44 | 45 | /** 46 | * Unbind all listeners and disconnect. Use it if you will not need 47 | * this class anymore. 48 | */ 49 | destroy: () => void 50 | 51 | /** 52 | * Re-connection options. 53 | */ 54 | options: ReconnectOptions 55 | 56 | /** 57 | * Should we re-connect connection on next connection break. 58 | * Next `connect` call will set to `true`. 59 | * 60 | * ```js 61 | * function lastTry () { 62 | * recon.reconnecting = false 63 | * } 64 | * ``` 65 | */ 66 | reconnecting: boolean 67 | 68 | /** 69 | * @param connection The connection to be re-connectable. 70 | * @param options Re-connection options. 71 | */ 72 | constructor(connection: Connection, options?: ReconnectOptions) 73 | } 74 | -------------------------------------------------------------------------------- /reconnect/index.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_OPTIONS = { 2 | attempts: Infinity, 3 | maxDelay: 5000, 4 | minDelay: 1000 5 | } 6 | 7 | const FATAL_ERRORS = [ 8 | 'wrong-protocol', 9 | 'wrong-subprotocol', 10 | 'wrong-credentials' 11 | ] 12 | 13 | export class Reconnect { 14 | constructor(connection, { 15 | attempts = DEFAULT_OPTIONS.attempts, 16 | maxDelay = DEFAULT_OPTIONS.maxDelay, 17 | minDelay = DEFAULT_OPTIONS.minDelay 18 | } = {}) { 19 | this.connection = connection 20 | this.options = { attempts, maxDelay, minDelay } 21 | 22 | this.reconnecting = connection.connected 23 | this.beforeFreeze = null 24 | this.connecting = false 25 | this.attempts = 0 26 | 27 | this.unbind = [ 28 | this.connection.on('message', msg => { 29 | if (msg[0] === 'error' && FATAL_ERRORS.includes(msg[1])) { 30 | this.reconnecting = false 31 | } 32 | }), 33 | this.connection.on('connecting', () => { 34 | this.connecting = true 35 | }), 36 | this.connection.on('connect', () => { 37 | this.attempts = 0 38 | this.connecting = false 39 | }), 40 | this.connection.on('disconnect', () => { 41 | this.connecting = false 42 | if (this.reconnecting) this.reconnect() 43 | }), 44 | () => { 45 | clearTimeout(this.timer) 46 | } 47 | ] 48 | 49 | let visibility = () => { 50 | if (this.reconnecting && !this.connected && !this.connecting) { 51 | if (typeof document !== 'undefined' && !document.hidden) this.connect() 52 | } 53 | } 54 | let connect = () => { 55 | if (this.reconnecting && !this.connected && !this.connecting) { 56 | if (navigator.onLine) this.connect() 57 | } 58 | } 59 | let resume = () => { 60 | if (this.beforeFreeze !== null) { 61 | this.reconnecting = this.beforeFreeze 62 | this.beforeFreeze = null 63 | } 64 | connect() 65 | } 66 | let freeze = () => { 67 | if (this.beforeFreeze === null) { 68 | this.beforeFreeze = this.reconnecting 69 | this.reconnecting = false 70 | } 71 | this.disconnect('freeze') 72 | } 73 | if ( 74 | typeof document !== 'undefined' && 75 | typeof window !== 'undefined' && 76 | document.addEventListener && 77 | window.addEventListener 78 | ) { 79 | document.addEventListener('visibilitychange', visibility, false) 80 | window.addEventListener('focus', connect, false) 81 | window.addEventListener('online', connect, false) 82 | window.addEventListener('resume', resume, false) 83 | window.addEventListener('freeze', freeze, false) 84 | this.unbind.push(() => { 85 | document.removeEventListener('visibilitychange', visibility, false) 86 | window.removeEventListener('focus', connect, false) 87 | window.removeEventListener('online', connect, false) 88 | window.removeEventListener('resume', resume, false) 89 | window.removeEventListener('freeze', freeze, false) 90 | }) 91 | } 92 | } 93 | 94 | connect() { 95 | this.attempts += 1 96 | this.reconnecting = true 97 | return this.connection.connect() 98 | } 99 | 100 | destroy() { 101 | for (let i of this.unbind) i() 102 | this.disconnect('destroy') 103 | } 104 | 105 | disconnect(reason) { 106 | if (reason !== 'timeout' && reason !== 'error' && reason !== 'freeze') { 107 | this.reconnecting = false 108 | } 109 | return this.connection.disconnect(reason) 110 | } 111 | 112 | nextDelay() { 113 | let base = this.options.minDelay * 2 ** this.attempts 114 | let rand = Math.random() 115 | let deviation = rand * 0.5 * base 116 | if (Math.floor(rand * 10) === 1) deviation = -deviation 117 | return Math.min(base + deviation, this.options.maxDelay) || 0 118 | } 119 | 120 | on(...args) { 121 | return this.connection.on(...args) 122 | } 123 | 124 | reconnect() { 125 | if (this.attempts > this.options.attempts - 1) { 126 | this.reconnecting = false 127 | this.attempts = 0 128 | return 129 | } 130 | 131 | let delay = this.nextDelay() 132 | this.timer = setTimeout(() => { 133 | if (this.reconnecting && !this.connecting && !this.connected) { 134 | this.connect() 135 | } 136 | }, delay) 137 | } 138 | 139 | send(...args) { 140 | return this.connection.send(...args) 141 | } 142 | 143 | get connected() { 144 | return this.connection.connected 145 | } 146 | 147 | get emitter() { 148 | return this.connection.emitter 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /reconnect/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { restoreAll, spyOn } from 'nanospy' 3 | import { deepStrictEqual, equal, notEqual, ok } from 'node:assert' 4 | import { afterEach, beforeEach, test } from 'node:test' 5 | 6 | import { type Message, Reconnect, TestPair } from '../index.js' 7 | 8 | let listeners: { [key: string]: () => void } = {} 9 | const listenerMethods = { 10 | addEventListener(name: string, callback: () => void): void { 11 | listeners[name] = callback 12 | }, 13 | removeEventListener(name: string, callback: () => void): void { 14 | if (listeners[name] === callback) { 15 | delete listeners[name] 16 | } 17 | } 18 | } 19 | 20 | function setHidden(value: boolean): void { 21 | // @ts-expect-error 22 | global.document.hidden = value 23 | listeners.visibilitychange() 24 | } 25 | 26 | function setOnLine(value: boolean, event: 'online' | 'resume'): void { 27 | // @ts-expect-error 28 | global.navigator.onLine = value 29 | listeners[event]() 30 | } 31 | 32 | function privateMethods(obj: object): any { 33 | return obj 34 | } 35 | 36 | beforeEach(() => { 37 | listeners = {} 38 | // @ts-expect-error 39 | global.window = { 40 | ...listenerMethods 41 | } 42 | // @ts-expect-error 43 | global.document = { 44 | hidden: false, 45 | ...listenerMethods 46 | } 47 | Object.defineProperty(global, 'navigator', { 48 | configurable: true, 49 | value: { 50 | onLine: true 51 | } 52 | }) 53 | }) 54 | 55 | afterEach(() => { 56 | restoreAll() 57 | }) 58 | 59 | test('saves connection and options', () => { 60 | let pair = new TestPair() 61 | let recon = new Reconnect(pair.left, { attempts: 1 }) 62 | equal(recon.connection, pair.left) 63 | equal(recon.options.attempts, 1) 64 | }) 65 | 66 | test('uses default options', () => { 67 | let pair = new TestPair() 68 | let recon = new Reconnect(pair.left) 69 | equal(typeof recon.options.minDelay, 'number') 70 | }) 71 | 72 | test('enables reconnecting on connect', () => { 73 | let pair = new TestPair() 74 | let recon = new Reconnect(pair.left) 75 | equal(recon.reconnecting, false) 76 | 77 | recon.connect() 78 | equal(recon.reconnecting, true) 79 | }) 80 | 81 | test('enables reconnecting if connection was already connected', async () => { 82 | let pair = new TestPair() 83 | await pair.left.connect() 84 | let recon = new Reconnect(pair.left) 85 | equal(recon.reconnecting, true) 86 | }) 87 | 88 | test('disables reconnecting on destroy and empty disconnect', async () => { 89 | let pair = new TestPair() 90 | let recon = new Reconnect(pair.left) 91 | 92 | await recon.connect() 93 | recon.disconnect('destroy') 94 | equal(recon.reconnecting, false) 95 | deepStrictEqual(pair.leftEvents, [['connect'], ['disconnect', 'destroy']]) 96 | await recon.connect() 97 | recon.disconnect() 98 | equal(recon.reconnecting, false) 99 | }) 100 | 101 | test('reconnects on timeout and error disconnect', async () => { 102 | let pair = new TestPair() 103 | await pair.left.connect() 104 | let recon = new Reconnect(pair.left) 105 | 106 | recon.disconnect('timeout') 107 | equal(recon.reconnecting, true) 108 | await pair.left.connect() 109 | 110 | recon.disconnect('error') 111 | equal(recon.reconnecting, true) 112 | }) 113 | 114 | test('proxies connection methods', () => { 115 | let sent: Message[] = [] 116 | let con = { 117 | async connect() { 118 | this.connected = true 119 | }, 120 | connected: false, 121 | destroy() {}, 122 | disconnect() { 123 | this.connected = false 124 | }, 125 | emitter: {}, 126 | on() { 127 | return () => {} 128 | }, 129 | send(msg: Message) { 130 | sent.push(msg) 131 | } 132 | } 133 | let recon = new Reconnect(con) 134 | equal(recon.connected, false) 135 | equal(privateMethods(recon).emitter, con.emitter) 136 | 137 | recon.connect() 138 | equal(recon.connected, true) 139 | 140 | recon.send(['ping', 1]) 141 | deepStrictEqual(sent, [['ping', 1]]) 142 | 143 | recon.disconnect() 144 | equal(recon.connected, false) 145 | }) 146 | 147 | test('proxies connection events', async () => { 148 | let pair = new TestPair() 149 | let recon = new Reconnect(pair.left) 150 | 151 | let received: Message[] = [] 152 | let unbind = recon.on('message', msg => { 153 | received.push(msg) 154 | }) 155 | 156 | await recon.connect() 157 | pair.right.send(['ping', 1]) 158 | await pair.wait() 159 | pair.right.send(['ping', 2]) 160 | await pair.wait() 161 | unbind() 162 | pair.right.send(['ping', 3]) 163 | await pair.wait() 164 | deepStrictEqual(received, [ 165 | ['ping', 1], 166 | ['ping', 2] 167 | ]) 168 | }) 169 | 170 | test('disables reconnection on protocol error', async () => { 171 | let pair = new TestPair() 172 | let recon = new Reconnect(pair.left) 173 | await recon.connect() 174 | pair.right.send(['error', 'wrong-protocol']) 175 | pair.right.disconnect() 176 | await pair.wait() 177 | equal(recon.reconnecting, false) 178 | }) 179 | 180 | test('disables reconnection on authentication error', async () => { 181 | let pair = new TestPair() 182 | let recon = new Reconnect(pair.left) 183 | await recon.connect() 184 | pair.right.send(['error', 'wrong-credentials']) 185 | pair.right.disconnect() 186 | await pair.wait() 187 | equal(recon.reconnecting, false) 188 | }) 189 | 190 | test('disables reconnection on subprotocol error', async () => { 191 | let pair = new TestPair() 192 | let recon = new Reconnect(pair.left) 193 | await recon.connect() 194 | pair.right.send(['error', 'wrong-subprotocol']) 195 | pair.right.disconnect() 196 | await pair.wait() 197 | equal(recon.reconnecting, false) 198 | }) 199 | 200 | test('disconnects and unbind listeners on destroy', async () => { 201 | let pair = new TestPair() 202 | let origin = privateMethods(pair.left).emitter.events.connect.length 203 | 204 | let recon = new Reconnect(pair.left) 205 | notEqual(privateMethods(pair.left).emitter.events.connect.length, origin) 206 | 207 | await recon.connect() 208 | recon.destroy() 209 | await pair.wait() 210 | equal(privateMethods(pair.left).emitter.events.connect.length, origin) 211 | equal(pair.right.connected, false) 212 | }) 213 | 214 | test('reconnects automatically with delay', async () => { 215 | let pair = new TestPair() 216 | let recon = new Reconnect(pair.left, { maxDelay: 50, minDelay: 50 }) 217 | await recon.connect() 218 | pair.right.disconnect() 219 | await pair.wait() 220 | equal(pair.right.connected, false) 221 | await delay(70) 222 | equal(pair.right.connected, true) 223 | }) 224 | 225 | test('allows to disable reconnecting', async () => { 226 | let pair = new TestPair() 227 | let recon = new Reconnect(pair.left) 228 | await recon.connect() 229 | recon.reconnecting = false 230 | pair.right.disconnect() 231 | await pair.wait() 232 | await delay(1) 233 | equal(pair.right.connected, false) 234 | }) 235 | 236 | test('has maximum reconnection attempts', async () => { 237 | let pair = new TestPair() 238 | let connects = 0 239 | pair.left.connect = () => { 240 | connects += 1 241 | privateMethods(pair.left).emitter.emit('disconnect') 242 | return Promise.resolve() 243 | } 244 | 245 | let recon = new Reconnect(pair.left, { 246 | attempts: 3, 247 | maxDelay: 0, 248 | minDelay: 0 249 | }) 250 | 251 | recon.connect() 252 | 253 | await delay(10) 254 | equal(recon.reconnecting, false) 255 | equal(connects, 3) 256 | }) 257 | 258 | test('tracks connecting state', () => { 259 | let pair = new TestPair() 260 | let recon = new Reconnect(pair.left, { 261 | maxDelay: 5000, 262 | minDelay: 1000 263 | }) 264 | 265 | equal(recon.connecting, false) 266 | 267 | privateMethods(pair.left).emitter.emit('connecting') 268 | equal(recon.connecting, true) 269 | 270 | privateMethods(pair.left).emitter.emit('disconnect') 271 | equal(recon.connecting, false) 272 | 273 | privateMethods(pair.left).emitter.emit('connecting') 274 | privateMethods(pair.left).emitter.emit('connect') 275 | equal(recon.connecting, false) 276 | }) 277 | 278 | test('has dynamic delay', () => { 279 | let pair = new TestPair() 280 | let recon = new Reconnect(pair.left, { 281 | maxDelay: 5000, 282 | minDelay: 1000 283 | }) 284 | 285 | function attemptsIsAround(attempt: number, ms: number): void { 286 | recon.attempts = attempt 287 | let time = privateMethods(recon).nextDelay() 288 | ok(Math.abs(time - ms) < 1000) 289 | } 290 | 291 | attemptsIsAround(0, 1000) 292 | attemptsIsAround(1, 2200) 293 | attemptsIsAround(2, 4500) 294 | attemptsIsAround(3, 5000) 295 | 296 | function attemptsequal(attempt: number, ms: number): void { 297 | recon.attempts = attempt 298 | let time = privateMethods(recon).nextDelay() 299 | equal(time, ms) 300 | } 301 | 302 | for (let i = 4; i < 100; i++) { 303 | attemptsequal(i, 5000) 304 | } 305 | }) 306 | 307 | test('listens for window events', async () => { 308 | let pair = new TestPair() 309 | let recon = new Reconnect(pair.left, { maxDelay: 0 }) 310 | 311 | await recon.connect() 312 | pair.right.disconnect() 313 | await pair.wait() 314 | equal(recon.connected, false) 315 | 316 | setHidden(true) 317 | listeners.visibilitychange() 318 | equal(recon.connecting, false) 319 | 320 | setHidden(false) 321 | await pair.wait() 322 | equal(recon.connected, true) 323 | 324 | listeners.freeze() 325 | await delay(10) 326 | 327 | equal(recon.connecting, false) 328 | equal(recon.connected, false) 329 | 330 | setOnLine(false, 'resume') 331 | equal(recon.connecting, false) 332 | 333 | setOnLine(true, 'resume') 334 | await delay(10) 335 | equal(recon.connected, true) 336 | pair.right.disconnect() 337 | await pair.wait() 338 | equal(recon.connected, false) 339 | 340 | setOnLine(true, 'online') 341 | await pair.wait() 342 | equal(recon.connected, true) 343 | 344 | recon.destroy() 345 | deepStrictEqual(Object.keys(listeners), []) 346 | }) 347 | 348 | test('does connect on online if client was not connected', async () => { 349 | let pair = new TestPair() 350 | new Reconnect(pair.left) 351 | 352 | let connect = spyOn(Reconnect.prototype, 'connect') 353 | 354 | listeners.visibilitychange() 355 | equal(connect.callCount, 0) 356 | 357 | listeners.online() 358 | equal(connect.callCount, 0) 359 | }) 360 | -------------------------------------------------------------------------------- /server-connection/index.d.ts: -------------------------------------------------------------------------------- 1 | import WebSocket = require('ws') 2 | 3 | import { Connection } from '../base-node/index.js' 4 | 5 | /** 6 | * Logux connection for server WebSocket. 7 | * 8 | * ```js 9 | * import { ServerConnection } from '@logux/core' 10 | * import { Server } from 'ws' 11 | * 12 | * wss.on('connection', function connection(ws) { 13 | * const connection = new ServerConnection(ws) 14 | * const node = new ServerNode('server', log, connection, opts) 15 | * }) 16 | * ``` 17 | */ 18 | export class ServerConnection extends Connection { 19 | /** 20 | * WebSocket connection instance 21 | */ 22 | ws: WebSocket 23 | 24 | /** 25 | * @param ws WebSocket connection instance 26 | */ 27 | constructor(ws: WebSocket) 28 | } 29 | -------------------------------------------------------------------------------- /server-connection/index.js: -------------------------------------------------------------------------------- 1 | import { WsConnection } from '../ws-connection/index.js' 2 | 3 | export class ServerConnection extends WsConnection { 4 | constructor(ws) { 5 | super(undefined, true) 6 | this.connected = true 7 | this.init(ws) 8 | } 9 | 10 | connect() { 11 | throw new Error( 12 | 'ServerConnection accepts already connected WebSocket ' + 13 | 'instance and could not reconnect it' 14 | ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server-connection/index.test.ts: -------------------------------------------------------------------------------- 1 | import { restoreAll, spyOn } from 'nanospy' 2 | import { deepStrictEqual, equal, throws } from 'node:assert' 3 | import { afterEach, test } from 'node:test' 4 | import WebSocket from 'ws' 5 | 6 | import { type Message, ServerConnection } from '../index.js' 7 | 8 | function privateMethods(obj: object): any { 9 | return obj 10 | } 11 | 12 | function prepareWs(): WebSocket { 13 | let ws = new WebSocket('ws://example.com/') 14 | privateMethods(ws)._readyState = ws.OPEN 15 | return ws 16 | } 17 | 18 | afterEach(() => { 19 | restoreAll() 20 | }) 21 | 22 | test('throws on connect method call', () => { 23 | let connection = new ServerConnection(prepareWs()) 24 | throws(() => { 25 | connection.connect() 26 | }, /reconnect/) 27 | }) 28 | 29 | test('emits connection states', () => { 30 | let connection = new ServerConnection(prepareWs()) 31 | 32 | let states: string[] = [] 33 | connection.on('disconnect', () => { 34 | states.push('disconnect') 35 | }) 36 | 37 | deepStrictEqual(states, []) 38 | equal(connection.connected, true) 39 | 40 | connection.ws.emit('close', 500, 'message') 41 | deepStrictEqual(states, ['disconnect']) 42 | equal(connection.connected, false) 43 | }) 44 | 45 | test('emits error on wrong format', () => { 46 | let connection = new ServerConnection(prepareWs()) 47 | let error: Error | undefined 48 | connection.on('error', err => { 49 | error = err 50 | }) 51 | 52 | connection.ws.emit('message', '{') 53 | if (typeof error === 'undefined') throw new Error('Error was no set') 54 | equal(error.message, 'Wrong message format') 55 | equal(privateMethods(error).received, '{') 56 | }) 57 | 58 | test('closes WebSocket', () => { 59 | let ws = prepareWs() 60 | let close = spyOn(ws, 'close', () => { 61 | ws.emit('close') 62 | }) 63 | let connection = new ServerConnection(ws) 64 | 65 | connection.disconnect() 66 | equal(close.callCount, 1) 67 | equal(connection.connected, false) 68 | }) 69 | 70 | test('receives messages', () => { 71 | let connection = new ServerConnection(prepareWs()) 72 | 73 | let received: Message[] = [] 74 | connection.on('message', msg => { 75 | received.push(msg) 76 | }) 77 | 78 | connection.ws.emit('message', '["ping",1]') 79 | deepStrictEqual(received, [['ping', 1]]) 80 | }) 81 | 82 | test('sends messages', () => { 83 | let sent: string[] = [] 84 | let ws = prepareWs() 85 | ws.send = (msg: string) => { 86 | sent.push(msg) 87 | } 88 | let connection = new ServerConnection(ws) 89 | 90 | connection.send(['ping', 1]) 91 | deepStrictEqual(sent, ['["ping",1]']) 92 | }) 93 | 94 | test('does not send to closed socket', () => { 95 | let sent: string[] = [] 96 | let ws = prepareWs() 97 | ws.send = (msg: string) => { 98 | sent.push(msg) 99 | } 100 | 101 | let connection = new ServerConnection(ws) 102 | 103 | let errors: string[] = [] 104 | connection.on('error', e => { 105 | errors.push(e.message) 106 | }) 107 | 108 | privateMethods(connection.ws)._readyState = 2 109 | 110 | connection.send(['ping', 1]) 111 | deepStrictEqual(sent, []) 112 | deepStrictEqual(errors, ['WS was closed']) 113 | }) 114 | -------------------------------------------------------------------------------- /server-node/index.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseNode } from '../base-node/index.js' 2 | import type { Log, Meta } from '../log/index.js' 3 | 4 | /** 5 | * Server node in synchronization pair. 6 | * 7 | * Instead of client node, it doesn’t initialize synchronization 8 | * and destroy itself on disconnect. 9 | * 10 | * ```js 11 | * import { ServerNode } from '@logux/core' 12 | * startServer(ws => { 13 | * const connection = new ServerConnection(ws) 14 | * const node = new ServerNode('server' + id, log, connection) 15 | * }) 16 | * ``` 17 | */ 18 | export class ServerNode< 19 | Headers extends object = {}, 20 | NodeLog extends Log = Log 21 | > extends BaseNode {} 22 | -------------------------------------------------------------------------------- /server-node/index.js: -------------------------------------------------------------------------------- 1 | import { BaseNode } from '../base-node/index.js' 2 | import { validate } from '../validate/index.js' 3 | 4 | const DEFAULT_OPTIONS = { 5 | ping: 20000, 6 | timeout: 70000 7 | } 8 | 9 | export class ServerNode extends BaseNode { 10 | constructor(nodeId, log, connection, options = {}) { 11 | super(nodeId, log, connection, { 12 | ...options, 13 | ping: options.ping ?? DEFAULT_OPTIONS.ping, 14 | timeout: options.timeout ?? DEFAULT_OPTIONS.timeout 15 | }) 16 | 17 | if (this.options.fixTime) { 18 | throw new Error( 19 | 'Logux Server could not fix time. Set opts.fixTime for Client node.' 20 | ) 21 | } 22 | 23 | this.state = 'connecting' 24 | } 25 | 26 | async connectMessage(...args) { 27 | await this.initializing 28 | super.connectMessage(...args) 29 | this.endTimeout() 30 | } 31 | 32 | async initialize() { 33 | let added = await this.log.store.getLastAdded() 34 | this.initialized = true 35 | this.lastAddedCache = added 36 | if (this.connection.connected) this.onConnect() 37 | } 38 | 39 | onConnect() { 40 | if (this.initialized) { 41 | super.onConnect() 42 | this.startTimeout() 43 | } 44 | } 45 | 46 | onDisconnect() { 47 | super.onDisconnect() 48 | this.destroy() 49 | } 50 | 51 | onMessage(msg) { 52 | if (validate(this, msg)) { 53 | super.onMessage(msg) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /server-node/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { spyOn } from 'nanospy' 3 | import { deepStrictEqual, equal, ok, throws } from 'node:assert' 4 | import { afterEach, test } from 'node:test' 5 | 6 | import { ServerNode, TestPair, TestTime } from '../index.js' 7 | 8 | let node: ServerNode 9 | afterEach(() => { 10 | node.destroy() 11 | }) 12 | 13 | function privateMethods(obj: object): any { 14 | return obj 15 | } 16 | 17 | test('has connecting state from the beginning', () => { 18 | let pair = new TestPair() 19 | pair.right.connect() 20 | node = new ServerNode('server', TestTime.getLog(), pair.left) 21 | equal(node.state, 'connecting') 22 | }) 23 | 24 | test('destroys on disconnect', async () => { 25 | let pair = new TestPair() 26 | node = new ServerNode('server', TestTime.getLog(), pair.left) 27 | let destroy = spyOn(node, 'destroy') 28 | await pair.left.connect() 29 | pair.left.disconnect() 30 | equal(destroy.callCount, 1) 31 | }) 32 | 33 | test('destroys on connect timeout', async () => { 34 | let log = TestTime.getLog() 35 | let pair = new TestPair() 36 | node = new ServerNode('server', log, pair.left, { timeout: 200 }) 37 | 38 | let error: Error | undefined 39 | node.catch(err => { 40 | error = err 41 | }) 42 | 43 | let destroy = spyOn(node, 'destroy') 44 | await pair.left.connect() 45 | equal(destroy.callCount, 0) 46 | await delay(200) 47 | if (typeof error === 'undefined') throw new Error('Error was not sent') 48 | ok(error.message.includes('timeout')) 49 | equal(destroy.callCount, 1) 50 | }) 51 | 52 | test('throws on fixTime option', () => { 53 | let log = TestTime.getLog() 54 | let pair = new TestPair() 55 | throws(() => { 56 | new ServerNode('a', log, pair.left, { fixTime: true }) 57 | }, /fixTime/) 58 | }) 59 | 60 | test('loads only last added from store', async () => { 61 | let log = TestTime.getLog() 62 | let pair = new TestPair() 63 | log.store.setLastSynced({ received: 2, sent: 1 }) 64 | await log.add({ type: 'a' }, { reasons: ['test'] }) 65 | node = new ServerNode('server', log, pair.left) 66 | await node.initializing 67 | equal(privateMethods(node).lastAddedCache, 1) 68 | equal(node.lastSent, 0) 69 | equal(node.lastReceived, 0) 70 | }) 71 | 72 | test('supports connection before initializing', async () => { 73 | let log = TestTime.getLog() 74 | 75 | let returnLastAdded: (added: number) => void = () => { 76 | throw new Error('getLastAdded was not called') 77 | } 78 | log.store.getLastAdded = () => 79 | new Promise(resolve => { 80 | returnLastAdded = resolve 81 | }) 82 | 83 | let pair = new TestPair() 84 | node = new ServerNode('server', log, pair.left, { ping: 50, timeout: 50 }) 85 | 86 | await pair.right.connect() 87 | pair.right.send(['connect', node.localProtocol, 'client', 0]) 88 | await delay(70) 89 | deepStrictEqual(pair.leftSent, []) 90 | returnLastAdded(10) 91 | await delay(70) 92 | equal(node.connected, true) 93 | equal(pair.leftSent.length, 2) 94 | deepStrictEqual(pair.leftSent[0][0], 'connected') 95 | deepStrictEqual(pair.leftSent[1], ['ping', 10]) 96 | }) 97 | -------------------------------------------------------------------------------- /sync/index.js: -------------------------------------------------------------------------------- 1 | export function sendSync(added, entries) { 2 | this.startTimeout() 3 | 4 | let data = [] 5 | for (let [action, originMeta] of entries) { 6 | let meta = {} 7 | for (let key in originMeta) { 8 | if (key === 'id') { 9 | meta.id = originMeta.id.split(' ') 10 | } else if (key !== 'added') { 11 | meta[key] = originMeta[key] 12 | } 13 | } 14 | 15 | if (this.timeFix) meta.time -= this.timeFix 16 | meta.id[0] = parseInt(meta.id[0]) - this.baseTime 17 | meta.id[2] = parseInt(meta.id[2]) 18 | meta.time -= this.baseTime 19 | 20 | if (meta.id[1] === this.localNodeId) { 21 | if (meta.id[2] === 0) { 22 | meta.id = meta.id[0] 23 | } else { 24 | meta.id = [meta.id[0], meta.id[2]] 25 | } 26 | } 27 | 28 | data.unshift(action, meta) 29 | } 30 | 31 | this.syncing += 1 32 | this.setState('sending') 33 | this.send(['sync', added].concat(data)) 34 | } 35 | 36 | export function sendSynced(added) { 37 | this.send(['synced', added]) 38 | } 39 | 40 | export async function syncMessage(added, ...data) { 41 | for (let i = 0; i < data.length - 1; i += 2) { 42 | let action = data[i] 43 | let meta = data[i + 1] 44 | 45 | if (typeof meta.id === 'number') { 46 | meta.id = meta.id + this.baseTime + ' ' + this.remoteNodeId + ' ' + 0 47 | } else { 48 | meta.id[0] = meta.id[0] + this.baseTime 49 | if (meta.id.length === 2) { 50 | meta.id = meta.id[0] + ' ' + this.remoteNodeId + ' ' + meta.id[1] 51 | } else { 52 | meta.id = meta.id.join(' ') 53 | } 54 | } 55 | 56 | meta.time = meta.time + this.baseTime 57 | if (this.timeFix) meta.time = meta.time + this.timeFix 58 | 59 | if (this.options.onReceive) { 60 | runOnReceiveInParallel(this, action, meta) 61 | } else { 62 | add(this, action, meta) 63 | } 64 | } 65 | 66 | this.setLastReceived(added) 67 | this.sendSynced(added) 68 | } 69 | 70 | async function runOnReceiveInParallel(node, action, meta) { 71 | try { 72 | let result = await node.options.onReceive(action, meta) 73 | if (result) { 74 | add(node, result[0], result[1]) 75 | } 76 | } catch (e) { 77 | node.error(e) 78 | } 79 | } 80 | 81 | function add(node, action, meta) { 82 | if (node.received) node.received[meta.id] = true 83 | return node.log.add(action, meta) 84 | } 85 | 86 | export function syncedMessage(synced) { 87 | this.endTimeout() 88 | this.setLastSent(synced) 89 | if (this.syncing > 0) this.syncing -= 1 90 | if (this.syncing === 0) { 91 | this.setState('synchronized') 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /sync/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { deepStrictEqual, equal } from 'node:assert' 3 | import { afterEach, test } from 'node:test' 4 | 5 | import { ClientNode, ServerNode, TestPair, TestTime } from '../index.js' 6 | 7 | let destroyable: TestPair 8 | 9 | afterEach(() => { 10 | destroyable.leftNode.destroy() 11 | destroyable.rightNode.destroy() 12 | }) 13 | 14 | function privateMethods(obj: object): any { 15 | return obj 16 | } 17 | 18 | function createPair(): TestPair { 19 | let time = new TestTime() 20 | let log1 = time.nextLog() 21 | let log2 = time.nextLog() 22 | let pair = new TestPair() 23 | 24 | destroyable = pair 25 | 26 | log1.on('preadd', (action, meta) => { 27 | meta.reasons = ['t'] 28 | }) 29 | log2.on('preadd', (action, meta) => { 30 | meta.reasons = ['t'] 31 | }) 32 | 33 | pair.leftNode = new ClientNode('client', log1, pair.left, { fixTime: false }) 34 | pair.rightNode = new ServerNode('server', log2, pair.right) 35 | 36 | return pair 37 | } 38 | 39 | async function createTest( 40 | before?: (testPair: TestPair) => void 41 | ): Promise { 42 | let pair = createPair() 43 | before?.(pair) 44 | pair.left.connect() 45 | await pair.leftNode.waitFor('synchronized') 46 | pair.clear() 47 | privateMethods(pair.leftNode).baseTime = 0 48 | privateMethods(pair.rightNode).baseTime = 0 49 | return pair 50 | } 51 | 52 | test('sends sync messages', async () => { 53 | let actionA = { type: 'a' } 54 | let actionB = { type: 'b' } 55 | let pair = await createTest() 56 | pair.leftNode.log.add(actionA) 57 | await pair.wait('left') 58 | deepStrictEqual(pair.leftSent, [ 59 | ['sync', 1, actionA, { id: [1, 'test1', 0], reasons: ['t'], time: 1 }] 60 | ]) 61 | deepStrictEqual(pair.rightSent, [['synced', 1]]) 62 | 63 | pair.rightNode.log.add(actionB) 64 | await pair.wait('right') 65 | deepStrictEqual(pair.leftSent, [ 66 | ['sync', 1, actionA, { id: [1, 'test1', 0], reasons: ['t'], time: 1 }], 67 | ['synced', 2] 68 | ]) 69 | deepStrictEqual(pair.rightSent, [ 70 | ['synced', 1], 71 | ['sync', 2, actionB, { id: [2, 'test2', 0], reasons: ['t'], time: 2 }] 72 | ]) 73 | }) 74 | 75 | test('uses last added on non-added action', async () => { 76 | let pair = await createTest() 77 | pair.leftNode.log.on('preadd', (action, meta) => { 78 | meta.reasons = [] 79 | }) 80 | pair.leftNode.log.add({ type: 'a' }) 81 | await pair.wait('left') 82 | deepStrictEqual(pair.leftSent, [ 83 | ['sync', 0, { type: 'a' }, { id: [1, 'test1', 0], reasons: [], time: 1 }] 84 | ]) 85 | }) 86 | 87 | test('checks sync types', async () => { 88 | let wrongs = [ 89 | ['sync'], 90 | ['sync', 0, { type: 'a' }], 91 | ['sync', 0, { type: 'a' }, []], 92 | ['sync', 0, { type: 'a' }, {}], 93 | ['sync', 0, { type: 'a' }, { id: 0 }], 94 | ['sync', 0, { type: 'a' }, { time: 0 }], 95 | ['sync', 0, { type: 'a' }, { id: 0, time: '0' }], 96 | ['sync', 0, { type: 'a' }, { id: [0], time: 0 }], 97 | ['sync', 0, { type: 'a' }, { id: [0, 'node'], time: 0 }], 98 | ['sync', 0, { type: 'a' }, { id: '1 node 0', time: 0 }], 99 | ['sync', 0, { type: 'a' }, { id: [1, 'node', 1, '0'], time: 0 }], 100 | ['sync', 0, {}, { id: 0, time: 0 }], 101 | ['synced'], 102 | ['synced', 'abc'] 103 | ] 104 | await Promise.all( 105 | wrongs.map(async msg => { 106 | let pair = await createTest() 107 | pair.leftNode.catch(() => true) 108 | // @ts-expect-error 109 | pair.leftNode.send(msg) 110 | await pair.wait('left') 111 | equal(pair.rightNode.connected, false) 112 | deepStrictEqual(pair.rightSent, [ 113 | ['error', 'wrong-format', JSON.stringify(msg)] 114 | ]) 115 | }) 116 | ) 117 | }) 118 | 119 | test('synchronizes actions', async () => { 120 | let pair = await createTest() 121 | pair.leftNode.log.add({ type: 'a' }) 122 | await pair.wait('left') 123 | deepStrictEqual(pair.leftNode.log.actions(), [{ type: 'a' }]) 124 | deepStrictEqual(pair.leftNode.log.actions(), pair.rightNode.log.actions()) 125 | pair.rightNode.log.add({ type: 'b' }) 126 | await pair.wait('right') 127 | deepStrictEqual(pair.leftNode.log.actions(), [{ type: 'a' }, { type: 'b' }]) 128 | deepStrictEqual(pair.leftNode.log.actions(), pair.rightNode.log.actions()) 129 | }) 130 | 131 | test('remembers synced added', async () => { 132 | let pair = await createTest() 133 | equal(pair.leftNode.lastSent, 0) 134 | equal(pair.leftNode.lastReceived, 0) 135 | pair.leftNode.log.add({ type: 'a' }) 136 | await pair.wait('left') 137 | equal(pair.leftNode.lastSent, 1) 138 | equal(pair.leftNode.lastReceived, 0) 139 | pair.rightNode.log.add({ type: 'b' }) 140 | await pair.wait('right') 141 | equal(pair.leftNode.lastSent, 1) 142 | equal(pair.leftNode.lastReceived, 2) 143 | equal(privateMethods(pair.leftNode.log.store).lastSent, 1) 144 | equal(privateMethods(pair.leftNode.log.store).lastReceived, 2) 145 | }) 146 | 147 | test('filters output actions', async () => { 148 | let pair = await createTest(async created => { 149 | created.leftNode.options.onSend = async (action, meta) => { 150 | equal(typeof meta.id, 'string') 151 | equal(typeof meta.time, 'number') 152 | equal(typeof meta.added, 'number') 153 | if (action.type === 'b') { 154 | return [action, meta] 155 | } else { 156 | return false 157 | } 158 | } 159 | await Promise.all([ 160 | created.leftNode.log.add({ type: 'a' }), 161 | created.leftNode.log.add({ type: 'b' }) 162 | ]) 163 | }) 164 | deepStrictEqual(pair.rightNode.log.actions(), [{ type: 'b' }]) 165 | await Promise.all([ 166 | pair.leftNode.log.add({ type: 'a' }), 167 | pair.leftNode.log.add({ type: 'b' }) 168 | ]) 169 | await pair.leftNode.waitFor('synchronized') 170 | deepStrictEqual(pair.rightNode.log.actions(), [{ type: 'b' }, { type: 'b' }]) 171 | }) 172 | 173 | test('maps output actions', async () => { 174 | let pair = await createTest() 175 | pair.leftNode.options.onSend = async (action, meta) => { 176 | equal(typeof meta.id, 'string') 177 | equal(typeof meta.time, 'number') 178 | equal(typeof meta.added, 'number') 179 | return [{ type: action.type + '1' }, meta] 180 | } 181 | pair.leftNode.log.add({ type: 'a' }) 182 | await pair.wait('left') 183 | deepStrictEqual(pair.leftNode.log.actions(), [{ type: 'a' }]) 184 | deepStrictEqual(pair.rightNode.log.actions(), [{ type: 'a1' }]) 185 | }) 186 | 187 | test('filters input actions', async () => { 188 | let pair = await createTest(created => { 189 | created.rightNode.options.onReceive = async (action, meta) => { 190 | equal(typeof meta.id, 'string') 191 | equal(typeof meta.time, 'number') 192 | if (action.type !== 'c') { 193 | return [action, meta] 194 | } else { 195 | return false 196 | } 197 | } 198 | created.leftNode.log.add({ type: 'a' }) 199 | created.leftNode.log.add({ type: 'b' }) 200 | created.leftNode.log.add({ type: 'c' }) 201 | }) 202 | deepStrictEqual(pair.leftNode.log.actions(), [ 203 | { type: 'a' }, 204 | { type: 'b' }, 205 | { type: 'c' } 206 | ]) 207 | deepStrictEqual(pair.rightNode.log.actions(), [{ type: 'a' }, { type: 'b' }]) 208 | }) 209 | 210 | test('maps input actions', async () => { 211 | let pair = await createTest() 212 | pair.rightNode.options.onReceive = async (action, meta) => { 213 | equal(typeof meta.id, 'string') 214 | equal(typeof meta.time, 'number') 215 | return [{ type: action.type + '1' }, meta] 216 | } 217 | pair.leftNode.log.add({ type: 'a' }) 218 | await pair.wait('left') 219 | deepStrictEqual(pair.leftNode.log.actions(), [{ type: 'a' }]) 220 | deepStrictEqual(pair.rightNode.log.actions(), [{ type: 'a1' }]) 221 | }) 222 | 223 | test('handles error in onReceive', async () => { 224 | let error = new Error('test') 225 | let catched: Error[] = [] 226 | 227 | let pair = await createTest() 228 | pair.rightNode.options.onReceive = () => { 229 | throw error 230 | } 231 | pair.rightNode.catch(e => { 232 | catched.push(e) 233 | }) 234 | pair.leftNode.log.add({ type: 'a' }) 235 | 236 | await delay(50) 237 | deepStrictEqual(catched, [error]) 238 | }) 239 | 240 | test('reports errors during initial output filter', async () => { 241 | let error = new Error('test') 242 | let catched: Error[] = [] 243 | let pair = createPair() 244 | pair.rightNode.log.add({ type: 'a' }) 245 | pair.rightNode.catch(e => { 246 | catched.push(e) 247 | }) 248 | pair.rightNode.options.onSend = async () => { 249 | throw error 250 | } 251 | pair.left.connect() 252 | await delay(50) 253 | deepStrictEqual(catched, [error]) 254 | }) 255 | 256 | test('reports errors during output filter', async () => { 257 | let error = new Error('test') 258 | let catched: Error[] = [] 259 | let pair = await createTest(created => { 260 | created.rightNode.catch(e => { 261 | catched.push(e) 262 | }) 263 | created.rightNode.options.onSend = async () => { 264 | throw error 265 | } 266 | }) 267 | pair.rightNode.log.add({ type: 'a' }) 268 | await delay(50) 269 | deepStrictEqual(catched, [error]) 270 | }) 271 | 272 | test('compresses time', async () => { 273 | let pair = await createTest() 274 | privateMethods(pair.leftNode).baseTime = 100 275 | privateMethods(pair.rightNode).baseTime = 100 276 | await pair.leftNode.log.add({ type: 'a' }, { id: '1 test1 0', time: 1 }) 277 | await pair.leftNode.waitFor('synchronized') 278 | deepStrictEqual(pair.leftSent, [ 279 | [ 280 | 'sync', 281 | 1, 282 | { type: 'a' }, 283 | { id: [-99, 'test1', 0], reasons: ['t'], time: -99 } 284 | ] 285 | ]) 286 | deepStrictEqual(pair.rightNode.log.entries(), [ 287 | [{ type: 'a' }, { added: 1, id: '1 test1 0', reasons: ['t'], time: 1 }] 288 | ]) 289 | }) 290 | 291 | test('compresses IDs', async () => { 292 | let pair = await createTest() 293 | await Promise.all([ 294 | pair.leftNode.log.add({ type: 'a' }, { id: '1 client 0', time: 1 }), 295 | pair.leftNode.log.add({ type: 'a' }, { id: '1 client 1', time: 1 }), 296 | pair.leftNode.log.add({ type: 'a' }, { id: '1 o 0', time: 1 }) 297 | ]) 298 | await pair.leftNode.waitFor('synchronized') 299 | deepStrictEqual(pair.leftSent, [ 300 | ['sync', 1, { type: 'a' }, { id: 1, reasons: ['t'], time: 1 }], 301 | ['sync', 2, { type: 'a' }, { id: [1, 1], reasons: ['t'], time: 1 }], 302 | ['sync', 3, { type: 'a' }, { id: [1, 'o', 0], reasons: ['t'], time: 1 }] 303 | ]) 304 | deepStrictEqual(pair.rightNode.log.entries(), [ 305 | [{ type: 'a' }, { added: 1, id: '1 client 0', reasons: ['t'], time: 1 }], 306 | [{ type: 'a' }, { added: 2, id: '1 client 1', reasons: ['t'], time: 1 }], 307 | [{ type: 'a' }, { added: 3, id: '1 o 0', reasons: ['t'], time: 1 }] 308 | ]) 309 | }) 310 | 311 | test('synchronizes any meta fields', async () => { 312 | let a = { type: 'a' } 313 | let pair = await createTest() 314 | await pair.leftNode.log.add(a, { id: '1 test1 0', one: 1, time: 1 }) 315 | await pair.leftNode.waitFor('synchronized') 316 | deepStrictEqual(pair.leftSent, [ 317 | ['sync', 1, a, { id: [1, 'test1', 0], one: 1, reasons: ['t'], time: 1 }] 318 | ]) 319 | deepStrictEqual(pair.rightNode.log.entries(), [ 320 | [a, { added: 1, id: '1 test1 0', one: 1, reasons: ['t'], time: 1 }] 321 | ]) 322 | }) 323 | 324 | test('fixes created time', async () => { 325 | let pair = await createTest() 326 | pair.leftNode.timeFix = 10 327 | await Promise.all([ 328 | pair.leftNode.log.add({ type: 'a' }, { id: '11 test1 0', time: 11 }), 329 | pair.rightNode.log.add({ type: 'b' }, { id: '2 test2 0', time: 2 }) 330 | ]) 331 | await pair.leftNode.waitFor('synchronized') 332 | deepStrictEqual(pair.leftNode.log.entries(), [ 333 | [{ type: 'a' }, { added: 1, id: '11 test1 0', reasons: ['t'], time: 11 }], 334 | [{ type: 'b' }, { added: 2, id: '2 test2 0', reasons: ['t'], time: 12 }] 335 | ]) 336 | deepStrictEqual(pair.rightNode.log.entries(), [ 337 | [{ type: 'a' }, { added: 2, id: '11 test1 0', reasons: ['t'], time: 1 }], 338 | [{ type: 'b' }, { added: 1, id: '2 test2 0', reasons: ['t'], time: 2 }] 339 | ]) 340 | }) 341 | 342 | test('supports multiple actions in sync', async () => { 343 | let pair = await createTest() 344 | privateMethods(pair.rightNode).sendSync(2, [ 345 | [{ type: 'b' }, { added: 2, id: '2 test2 0', time: 2 }], 346 | [{ type: 'a' }, { added: 1, id: '1 test2 0', time: 1 }] 347 | ]) 348 | await pair.wait('right') 349 | equal(pair.leftNode.lastReceived, 2) 350 | deepStrictEqual(pair.leftNode.log.entries(), [ 351 | [{ type: 'a' }, { added: 1, id: '1 test2 0', reasons: ['t'], time: 1 }], 352 | [{ type: 'b' }, { added: 2, id: '2 test2 0', reasons: ['t'], time: 2 }] 353 | ]) 354 | }) 355 | 356 | test('starts and ends timeout', async () => { 357 | let pair = await createTest() 358 | privateMethods(pair.leftNode).sendSync(1, [ 359 | [{ type: 'a' }, { added: 1, id: '1 test2 0', time: 1 }] 360 | ]) 361 | privateMethods(pair.leftNode).sendSync(2, [ 362 | [{ type: 'a' }, { added: 1, id: '2 test2 0', time: 2 }] 363 | ]) 364 | equal(privateMethods(pair.leftNode).timeouts.length, 2) 365 | 366 | privateMethods(pair.leftNode).syncedMessage(1) 367 | equal(privateMethods(pair.leftNode).timeouts.length, 1) 368 | 369 | privateMethods(pair.leftNode).syncedMessage(2) 370 | equal(privateMethods(pair.leftNode).timeouts.length, 0) 371 | }) 372 | 373 | test('should nothing happend if syncedMessage of empty syncing', async () => { 374 | let pair = await createTest() 375 | equal(privateMethods(pair.leftNode).timeouts.length, 0) 376 | 377 | privateMethods(pair.leftNode).syncedMessage(1) 378 | equal(privateMethods(pair.leftNode).timeouts.length, 0) 379 | }) 380 | 381 | test('uses always latest added', async () => { 382 | let pair = await createTest() 383 | pair.leftNode.log.on('preadd', (action, meta) => { 384 | meta.reasons = action.type === 'a' ? ['t'] : [] 385 | }) 386 | privateMethods(pair.rightNode).send = () => {} 387 | pair.leftNode.log.add({ type: 'a' }) 388 | await delay(1) 389 | pair.leftNode.log.add({ type: 'b' }) 390 | await delay(1) 391 | equal(pair.leftSent[1][1], 1) 392 | }) 393 | 394 | test('changes multiple actions in map', async () => { 395 | let pair = await createTest(created => { 396 | created.leftNode.options.onSend = async (action, meta) => { 397 | return [{ type: action.type.toUpperCase() }, meta] 398 | } 399 | created.leftNode.log.add({ type: 'a' }) 400 | created.leftNode.log.add({ type: 'b' }) 401 | }) 402 | await pair.leftNode.waitFor('synchronized') 403 | equal(pair.rightNode.lastReceived, 2) 404 | deepStrictEqual(pair.rightNode.log.actions(), [{ type: 'A' }, { type: 'B' }]) 405 | }) 406 | 407 | test('synchronizes actions on connect', async () => { 408 | let added: string[] = [] 409 | let pair = await createTest() 410 | pair.leftNode.log.on('add', action => { 411 | added.push(action.type) 412 | }) 413 | await Promise.all([ 414 | pair.leftNode.log.add({ type: 'a' }), 415 | pair.rightNode.log.add({ type: 'b' }) 416 | ]) 417 | await pair.leftNode.waitFor('synchronized') 418 | pair.left.disconnect() 419 | await pair.wait('right') 420 | equal(pair.leftNode.lastSent, 1) 421 | equal(pair.leftNode.lastReceived, 1) 422 | await Promise.all([ 423 | pair.leftNode.log.add({ type: 'c' }), 424 | pair.leftNode.log.add({ type: 'd' }), 425 | pair.rightNode.log.add({ type: 'e' }), 426 | pair.rightNode.log.add({ type: 'f' }) 427 | ]) 428 | await pair.left.connect() 429 | pair.rightNode = new ServerNode('server2', pair.rightNode.log, pair.right) 430 | await pair.leftNode.waitFor('synchronized') 431 | deepStrictEqual(pair.leftNode.log.actions(), [ 432 | { type: 'a' }, 433 | { type: 'b' }, 434 | { type: 'c' }, 435 | { type: 'd' }, 436 | { type: 'e' }, 437 | { type: 'f' } 438 | ]) 439 | deepStrictEqual(pair.leftNode.log.actions(), pair.rightNode.log.actions()) 440 | deepStrictEqual(added, ['a', 'b', 'c', 'd', 'e', 'f']) 441 | }) 442 | -------------------------------------------------------------------------------- /test-log/index.d.ts: -------------------------------------------------------------------------------- 1 | import { type Action, Log, type Meta } from '../log/index.js' 2 | 3 | /** 4 | * Log to be used in tests. It already has memory store, node ID, 5 | * and special test timer. 6 | * 7 | * Use {@link TestTime} to create test log. 8 | * 9 | * ```js 10 | * import { TestTime } from '@logux/core' 11 | * 12 | * it('tests log', () => { 13 | * const log = TestTime.getLog() 14 | * }) 15 | * 16 | * it('tests 2 logs', () => { 17 | * const time = new TestTime() 18 | * const log1 = time.nextLog() 19 | * const log2 = time.nextLog() 20 | * }) 21 | * ``` 22 | */ 23 | export class TestLog extends Log { 24 | /** 25 | * Return all action (without metadata) inside log, sorted by created time. 26 | * 27 | * This shortcut works only with {@link MemoryStore}. 28 | * 29 | * ```js 30 | * expect(log.action).toEqual([ 31 | * { type: 'A' } 32 | * ]) 33 | * ``` 34 | */ 35 | actions(): Action[] 36 | 37 | /** 38 | * Return all entries (with metadata) inside log, sorted by created time. 39 | * 40 | * This shortcut works only with {@link MemoryStore}. 41 | * 42 | * ```js 43 | * expect(log.action).toEqual([ 44 | * [{ type: 'A' }, { id: '1 test1 0', time: 1, added: 1, reasons: ['t'] }] 45 | * ]) 46 | * ``` 47 | */ 48 | entries(): [Action, Meta][] 49 | 50 | /** 51 | * Keep actions without `meta.reasons` in the log by setting `test` reason 52 | * during adding to the log. 53 | * 54 | * ```js 55 | * log.keepActions() 56 | * log.add({ type: 'test' }) 57 | * log.actions() //=> [{ type: 'test' }] 58 | * ``` 59 | */ 60 | keepActions(): void 61 | } 62 | -------------------------------------------------------------------------------- /test-log/index.js: -------------------------------------------------------------------------------- 1 | import { Log } from '../log/index.js' 2 | import { MemoryStore } from '../memory-store/index.js' 3 | 4 | export class TestLog extends Log { 5 | constructor(time, id, opts = {}) { 6 | if (!opts.store) opts.store = new MemoryStore() 7 | if (typeof opts.nodeId === 'undefined') { 8 | opts.nodeId = 'test' + id 9 | } 10 | 11 | super(opts) 12 | this.time = time 13 | } 14 | 15 | actions() { 16 | return this.entries().map(i => i[0]) 17 | } 18 | 19 | entries() { 20 | return this.store.entries 21 | } 22 | 23 | generateId() { 24 | this.time.lastTime += 1 25 | return this.time.lastTime + ' ' + this.nodeId + ' 0' 26 | } 27 | 28 | keepActions() { 29 | this.on('preadd', (action, meta) => { 30 | meta.reasons.push('test') 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test-pair/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { BaseNode, Message } from '../base-node/index.js' 2 | import { LocalPair } from '../local-pair/index.js' 3 | import type { TestLog } from '../test-log/index.js' 4 | 5 | /** 6 | * Two paired loopback connections with events tracking 7 | * to be used in Logux tests. 8 | * 9 | * ```js 10 | * import { TestPair } from '@logux/core' 11 | * it('tracks events', async () => { 12 | * const pair = new TestPair() 13 | * const client = new ClientNode(pair.right) 14 | * await pair.left.connect() 15 | * expect(pair.leftEvents).toEqual('connect') 16 | * await pair.left.send(msg) 17 | * expect(pair.leftSent).toEqual([msg]) 18 | * }) 19 | * ``` 20 | */ 21 | export class TestPair extends LocalPair { 22 | /** 23 | * Emitted events from `left` connection. 24 | * 25 | * ```js 26 | * await pair.left.connect() 27 | * pair.leftEvents //=> [['connect']] 28 | * ``` 29 | */ 30 | leftEvents: string[][] 31 | 32 | /** 33 | * Node instance used in this test, connected with `left`. 34 | * 35 | * ```js 36 | * function createTest () { 37 | * test = new TestPair() 38 | * test.leftNode = ClientNode('client', log, test.left) 39 | * return test 40 | * } 41 | * ``` 42 | */ 43 | leftNode: BaseNode<{}, TestLog> 44 | 45 | /** 46 | * Sent messages from `left` connection. 47 | * 48 | * ```js 49 | * await pair.left.send(msg) 50 | * pair.leftSent //=> [msg] 51 | * ``` 52 | */ 53 | leftSent: Message[] 54 | 55 | /** 56 | * Emitted events from `right` connection. 57 | * 58 | * ```js 59 | * await pair.right.connect() 60 | * pair.rightEvents //=> [['connect']] 61 | * ``` 62 | */ 63 | rightEvents: string[][] 64 | 65 | /** 66 | * Node instance used in this test, connected with `right`. 67 | * 68 | * ```js 69 | * function createTest () { 70 | * test = new TestPair() 71 | * test.rightNode = ServerNode('client', log, test.right) 72 | * return test 73 | * } 74 | * ``` 75 | */ 76 | rightNode: BaseNode<{}, TestLog> 77 | 78 | /** 79 | * Sent messages from `right` connection. 80 | * 81 | * ```js 82 | * await pair.right.send(msg) 83 | * pair.rightSent //=> [msg] 84 | * ``` 85 | */ 86 | rightSent: Message[] 87 | 88 | /** 89 | * Clear all connections events and messages to test only last events. 90 | * 91 | * ```js 92 | * await client.connection.connect() 93 | * pair.clear() // Remove all connecting messages 94 | * await client.log.add({ type: 'a' }) 95 | * expect(pair.leftSent).toEqual([ 96 | * ['sync', …] 97 | * ]) 98 | * ``` 99 | */ 100 | clear(): void 101 | 102 | /** 103 | * Return Promise until next event. 104 | * 105 | * ```js 106 | * pair.left.send(['test']) 107 | * await pair.wait('left') 108 | * pair.leftSend //=> [['test']] 109 | * ``` 110 | * 111 | * @param receiver Wait for specific receiver event. 112 | * @returns Promise until next event. 113 | */ 114 | wait(receiver?: 'left' | 'right'): Promise 115 | } 116 | -------------------------------------------------------------------------------- /test-pair/index.js: -------------------------------------------------------------------------------- 1 | import { LocalPair } from '../local-pair/index.js' 2 | 3 | function clone(obj) { 4 | if (Array.isArray(obj)) { 5 | return obj.map(i => clone(i)) 6 | } else if (typeof obj === 'object') { 7 | let cloned = {} 8 | for (let i in obj) { 9 | cloned[i] = clone(obj[i]) 10 | } 11 | return cloned 12 | } else { 13 | return obj 14 | } 15 | } 16 | 17 | export class TestPair extends LocalPair { 18 | constructor(delay) { 19 | super(delay) 20 | 21 | this.leftNode = undefined 22 | this.rightNode = undefined 23 | this.clear() 24 | 25 | this.unbind = [ 26 | this.left.on('connect', () => { 27 | this.leftEvents.push(['connect']) 28 | if (this.waiting) this.waiting('left') 29 | }), 30 | this.right.on('connect', () => { 31 | this.rightEvents.push(['connect']) 32 | if (this.waiting) this.waiting('right') 33 | }), 34 | 35 | this.left.on('message', msg => { 36 | let cloned = clone(msg) 37 | this.rightSent.push(cloned) 38 | this.leftEvents.push(['message', cloned]) 39 | if (this.waiting) this.waiting('left') 40 | }), 41 | this.right.on('message', msg => { 42 | let cloned = clone(msg) 43 | this.leftSent.push(cloned) 44 | this.rightEvents.push(['message', cloned]) 45 | if (this.waiting) this.waiting('right') 46 | }), 47 | 48 | this.left.on('disconnect', reason => { 49 | this.leftEvents.push(['disconnect', reason]) 50 | if (this.waiting) this.waiting('left') 51 | }), 52 | this.right.on('disconnect', () => { 53 | this.rightEvents.push(['disconnect']) 54 | if (this.waiting) this.waiting('right') 55 | }) 56 | ] 57 | } 58 | 59 | clear() { 60 | this.leftSent = [] 61 | this.rightSent = [] 62 | this.leftEvents = [] 63 | this.rightEvents = [] 64 | } 65 | 66 | wait(receiver) { 67 | return new Promise(resolve => { 68 | this.waiting = from => { 69 | if (!receiver || from === receiver) { 70 | this.waiting = false 71 | resolve(this) 72 | } 73 | } 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /test-pair/index.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, equal } from 'node:assert' 2 | import { test } from 'node:test' 3 | 4 | import type { Message } from '../index.js' 5 | import { TestPair } from '../index.js' 6 | 7 | test('tracks events', async () => { 8 | let pair = new TestPair() 9 | deepStrictEqual(pair.leftEvents, []) 10 | deepStrictEqual(pair.rightEvents, []) 11 | 12 | pair.left.connect() 13 | await pair.wait() 14 | deepStrictEqual(pair.leftEvents, [['connect']]) 15 | deepStrictEqual(pair.rightEvents, [['connect']]) 16 | 17 | pair.left.send(['ping', 1]) 18 | deepStrictEqual(pair.rightEvents, [['connect']]) 19 | 20 | await pair.wait() 21 | deepStrictEqual(pair.rightEvents, [['connect'], ['message', ['ping', 1]]]) 22 | 23 | pair.left.disconnect('timeout') 24 | deepStrictEqual(pair.leftEvents, [['connect'], ['disconnect', 'timeout']]) 25 | deepStrictEqual(pair.rightEvents, [['connect'], ['message', ['ping', 1]]]) 26 | await pair.wait() 27 | deepStrictEqual(pair.rightEvents, [ 28 | ['connect'], 29 | ['message', ['ping', 1]], 30 | ['disconnect'] 31 | ]) 32 | 33 | pair.right.connect() 34 | await pair.wait() 35 | deepStrictEqual(pair.rightEvents, [ 36 | ['connect'], 37 | ['message', ['ping', 1]], 38 | ['disconnect'], 39 | ['connect'] 40 | ]) 41 | }) 42 | 43 | test('tracks messages', async () => { 44 | let pair = new TestPair() 45 | await pair.left.connect() 46 | pair.right.send(['ping', 1]) 47 | deepStrictEqual(pair.rightSent, []) 48 | deepStrictEqual(pair.leftSent, []) 49 | await pair.wait() 50 | deepStrictEqual(pair.rightSent, [['ping', 1]]) 51 | pair.left.send(['pong', 1]) 52 | deepStrictEqual(pair.leftSent, []) 53 | await pair.wait() 54 | deepStrictEqual(pair.leftSent, [['pong', 1]]) 55 | deepStrictEqual(pair.rightSent, [['ping', 1]]) 56 | }) 57 | 58 | test('clears tracked data', async () => { 59 | let pair = new TestPair() 60 | await pair.left.connect() 61 | pair.left.send(['ping', 1]) 62 | await pair.wait() 63 | pair.clear() 64 | deepStrictEqual(pair.leftSent, []) 65 | deepStrictEqual(pair.rightSent, []) 66 | deepStrictEqual(pair.leftEvents, []) 67 | deepStrictEqual(pair.rightEvents, []) 68 | }) 69 | 70 | test('clones messages', async () => { 71 | let pair = new TestPair() 72 | let msg: Message = ['ping', 1] 73 | await pair.left.connect() 74 | pair.left.send(msg) 75 | await pair.wait() 76 | msg[1] = 2 77 | deepStrictEqual(pair.leftSent, [['ping', 1]]) 78 | deepStrictEqual(pair.rightEvents, [['connect'], ['message', ['ping', 1]]]) 79 | }) 80 | 81 | test('returns self in wait()', async () => { 82 | let pair = new TestPair() 83 | await pair.left.connect() 84 | pair.left.send(['ping', 1]) 85 | let testPair = await pair.wait() 86 | equal(testPair, pair) 87 | }) 88 | 89 | test('filters events in wait()', async () => { 90 | let pair = new TestPair() 91 | await pair.left.connect() 92 | pair.left.send(['ping', 1]) 93 | setTimeout(() => { 94 | pair.right.send(['pong', 1]) 95 | }, 1) 96 | await pair.wait() 97 | deepStrictEqual(pair.rightSent, []) 98 | await pair.wait() 99 | deepStrictEqual(pair.rightSent, [['pong', 1]]) 100 | pair.left.send(['ping', 2]) 101 | setTimeout(() => { 102 | pair.right.send(['pong', 2]) 103 | }, 1) 104 | await pair.wait('left') 105 | deepStrictEqual(pair.rightSent, [ 106 | ['pong', 1], 107 | ['pong', 2] 108 | ]) 109 | }) 110 | 111 | test('passes delay', () => { 112 | let pair = new TestPair(50) 113 | equal(pair.delay, 50) 114 | }) 115 | -------------------------------------------------------------------------------- /test-time/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { LogStore } from '../log/index.js' 2 | import type { TestLog } from '../test-log/index.js' 3 | 4 | interface TestLogOptions { 5 | /** 6 | * Unique log name. 7 | */ 8 | nodeId?: string 9 | /** 10 | * Store for log. Will use {@link MemoryStore} by default. 11 | */ 12 | store?: LogStore 13 | } 14 | 15 | /** 16 | * Creates special logs for test purposes. 17 | * 18 | * Real logs use real time in actions ID, 19 | * so log content will be different on every test execution. 20 | * 21 | * To fix it Logux has special logs for tests with simple sequence timer. 22 | * All logs from one test should share same time. This is why you should 23 | * use log creator to share time between all logs in one test. 24 | * 25 | * ```js 26 | * import { TestTime } from '@logux/core' 27 | * 28 | * it('tests log', () => { 29 | * const log = TestTime.getLog() 30 | * }) 31 | * 32 | * it('tests 2 logs', () => { 33 | * const time = new TestTime() 34 | * const log1 = time.nextLog() 35 | * const log2 = time.nextLog() 36 | * }) 37 | * ``` 38 | */ 39 | export class TestTime { 40 | /** 41 | * Last letd number in log’s `nodeId`. 42 | */ 43 | lastId: number 44 | 45 | constructor() 46 | 47 | /** 48 | * Shortcut to create time and generate single log. 49 | * Use it only if you need one log in test. 50 | * 51 | * ```js 52 | * it('tests log', () => { 53 | * const log = TestTime.getLog() 54 | * }) 55 | * ``` 56 | * 57 | * @param opts Log options. 58 | */ 59 | static getLog(opts?: TestLogOptions): TestLog 60 | 61 | /** 62 | * Return next test log in same time. 63 | * 64 | * ```js 65 | * it('tests 2 logs', () => { 66 | * const time = new TestTime() 67 | * const log1 = time.nextLog() 68 | * const log2 = time.nextLog() 69 | * }) 70 | * ``` 71 | * 72 | * @param opts Log options. 73 | */ 74 | nextLog(opts?: TestLogOptions): TestLog 75 | } 76 | -------------------------------------------------------------------------------- /test-time/index.js: -------------------------------------------------------------------------------- 1 | import { TestLog } from '../test-log/index.js' 2 | 3 | export class TestTime { 4 | constructor() { 5 | this.lastId = 0 6 | this.lastTime = 0 7 | } 8 | 9 | static getLog(opts) { 10 | let time = new TestTime() 11 | return time.nextLog(opts) 12 | } 13 | 14 | nextLog(opts) { 15 | this.lastId += 1 16 | return new TestLog(this, this.lastId, opts) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test-time/index.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, equal, ok } from 'node:assert' 2 | import { test } from 'node:test' 3 | 4 | import { MemoryStore, TestTime } from '../index.js' 5 | 6 | test('creates test log', () => { 7 | let log = TestTime.getLog() 8 | equal(log.nodeId, 'test1') 9 | ok(log.store instanceof MemoryStore) 10 | }) 11 | 12 | test('creates test log with specific parameters', () => { 13 | let store = new MemoryStore() 14 | let log = TestTime.getLog({ nodeId: 'other', store }) 15 | equal(log.nodeId, 'other') 16 | equal(log.store, store) 17 | }) 18 | 19 | test('uses special ID generator in test log', async () => { 20 | let log = TestTime.getLog() 21 | await Promise.all([ 22 | log.add({ type: 'a' }, { reasons: ['test'] }), 23 | log.add({ type: 'b' }, { reasons: ['test'] }) 24 | ]) 25 | deepStrictEqual(log.entries(), [ 26 | [{ type: 'a' }, { added: 1, id: '1 test1 0', reasons: ['test'], time: 1 }], 27 | [{ type: 'b' }, { added: 2, id: '2 test1 0', reasons: ['test'], time: 2 }] 28 | ]) 29 | }) 30 | 31 | test('creates test logs with same time', async () => { 32 | let time = new TestTime() 33 | let log1 = time.nextLog() 34 | let log2 = time.nextLog() 35 | 36 | equal(log1.nodeId, 'test1') 37 | equal(log2.nodeId, 'test2') 38 | 39 | await Promise.all([ 40 | log1.add({ type: 'a' }, { reasons: ['test'] }), 41 | log2.add({ type: 'b' }, { reasons: ['test'] }) 42 | ]) 43 | deepStrictEqual(log1.entries(), [ 44 | [{ type: 'a' }, { added: 1, id: '1 test1 0', reasons: ['test'], time: 1 }] 45 | ]) 46 | deepStrictEqual(log2.entries(), [ 47 | [{ type: 'b' }, { added: 1, id: '2 test2 0', reasons: ['test'], time: 2 }] 48 | ]) 49 | }) 50 | 51 | test('creates log with test shortcuts', () => { 52 | let log = TestTime.getLog() 53 | log.add({ type: 'A' }, { reasons: ['t'] }) 54 | deepStrictEqual(log.actions(), [{ type: 'A' }]) 55 | deepStrictEqual(log.entries(), [ 56 | [{ type: 'A' }, { added: 1, id: '1 test1 0', reasons: ['t'], time: 1 }] 57 | ]) 58 | }) 59 | 60 | test('keeps actions on request', async () => { 61 | let log = TestTime.getLog() 62 | 63 | await log.add({ type: 'a' }) 64 | deepStrictEqual(log.actions(), []) 65 | 66 | log.keepActions() 67 | await log.add({ type: 'b' }) 68 | deepStrictEqual(log.actions(), [{ type: 'b' }]) 69 | }) 70 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "allowJs": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "exclude": [ 13 | "**/errors.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /validate/index.js: -------------------------------------------------------------------------------- 1 | import { LoguxError } from '../logux-error/index.js' 2 | 3 | function isNumber(value) { 4 | return typeof value === 'number' 5 | } 6 | 7 | function isString(value) { 8 | return typeof value === 'string' 9 | } 10 | 11 | function isObject(value) { 12 | return typeof value === 'object' && typeof value.length !== 'number' 13 | } 14 | 15 | function isArray(value) { 16 | return Array.isArray(value) 17 | } 18 | 19 | function isTwoNumbers(value) { 20 | return ( 21 | isArray(value) && 22 | value.length === 2 && 23 | isNumber(value[0]) && 24 | isNumber(value[1]) 25 | ) 26 | } 27 | 28 | function isID(value) { 29 | return ( 30 | isArray(value) && 31 | value.length === 3 && 32 | isNumber(value[0]) && 33 | isString(value[1]) && 34 | isNumber(value[2]) 35 | ) 36 | } 37 | 38 | function isMeta(value) { 39 | return ( 40 | isObject(value) && 41 | isNumber(value.time) && 42 | (isNumber(value.id) || isTwoNumbers(value.id) || isID(value.id)) 43 | ) 44 | } 45 | 46 | let validators = { 47 | connect(msg) { 48 | return ( 49 | isNumber(msg[1]) && 50 | isString(msg[2]) && 51 | isNumber(msg[3]) && 52 | (msg.length === 4 || (msg.length === 5 && isObject(msg[4]))) 53 | ) 54 | }, 55 | 56 | connected(msg) { 57 | return ( 58 | isNumber(msg[1]) && 59 | isString(msg[2]) && 60 | isTwoNumbers(msg[3]) && 61 | (msg.length === 4 || (msg.length === 5 && isObject(msg[4]))) 62 | ) 63 | }, 64 | 65 | debug(msg) { 66 | return ( 67 | msg.length === 3 && 68 | isString(msg[1]) && 69 | msg[1] === 'error' && 70 | isString(msg[2]) 71 | ) 72 | }, 73 | 74 | duilian(msg) { 75 | return msg.length === 2 && isString(msg[1]) 76 | }, 77 | 78 | error(msg) { 79 | return (msg.length === 2 || msg.length === 3) && isString(msg[1]) 80 | }, 81 | 82 | headers(msg) { 83 | return msg.length === 2 && isObject(msg[1]) 84 | }, 85 | 86 | ping(msg) { 87 | return msg.length === 2 && isNumber(msg[1]) 88 | }, 89 | 90 | pong(msg) { 91 | return msg.length === 2 && isNumber(msg[1]) 92 | }, 93 | 94 | sync(msg) { 95 | if (!isNumber(msg[1])) return false 96 | if (msg.length % 2 !== 0) return false 97 | 98 | for (let i = 2; i < msg.length; i++) { 99 | if (i % 2 === 0) { 100 | if (!isObject(msg[i]) || !isString(msg[i].type)) return false 101 | } else if (!isMeta(msg[i])) { 102 | return false 103 | } 104 | } 105 | 106 | return true 107 | }, 108 | 109 | synced(msg) { 110 | return msg.length === 2 && isNumber(msg[1]) 111 | } 112 | } 113 | 114 | function wrongFormat(node, msg) { 115 | node.sendError(new LoguxError('wrong-format', JSON.stringify(msg))) 116 | node.connection.disconnect('error') 117 | return false 118 | } 119 | 120 | export function validate(node, msg) { 121 | if (!isArray(msg)) return wrongFormat(node, msg) 122 | 123 | let name = msg[0] 124 | if (!isString(name)) return wrongFormat(node, msg) 125 | 126 | let validator = validators[name] 127 | if (!validator || !node[name + 'Message']) { 128 | node.sendError(new LoguxError('unknown-message', name)) 129 | node.connection.disconnect('error') 130 | return false 131 | } 132 | 133 | if (!validator(msg)) { 134 | return wrongFormat(node, msg) 135 | } else { 136 | return true 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /ws-connection/index.d.ts: -------------------------------------------------------------------------------- 1 | import WebSocket = require('ws') 2 | 3 | import { Connection } from '../base-node/index.js' 4 | 5 | /** 6 | * Logux connection for browser WebSocket. 7 | * 8 | * ```js 9 | * import { WsConnection } from '@logux/core' 10 | * 11 | * const connection = new WsConnection('wss://logux.example.com/') 12 | * const node = new ClientNode(nodeId, log, connection, opts) 13 | * ``` 14 | */ 15 | export class WsConnection extends Connection { 16 | /** 17 | * WebSocket instance. 18 | */ 19 | ws?: WS 20 | 21 | /** 22 | * @param url WebSocket server URL. 23 | * @param WS WebSocket class if you want change implementation. 24 | * @param opts Extra option for WebSocket constructor. 25 | */ 26 | constructor(url: string, Class?: any, opts?: any) 27 | } 28 | -------------------------------------------------------------------------------- /ws-connection/index.js: -------------------------------------------------------------------------------- 1 | import { createNanoEvents } from 'nanoevents' 2 | 3 | export class WsConnection { 4 | constructor(url, Class, opts) { 5 | this.connected = false 6 | this.emitter = createNanoEvents() 7 | if (Class) { 8 | this.Class = Class 9 | } else if (typeof WebSocket !== 'undefined') { 10 | this.Class = WebSocket 11 | } else { 12 | /* c8 ignore next 2 */ 13 | throw new Error('No WebSocket support') 14 | } 15 | this.url = url 16 | this.opts = opts 17 | } 18 | 19 | connect() { 20 | if (this.ws) return Promise.resolve() 21 | 22 | this.emitter.emit('connecting') 23 | this.init(new this.Class(this.url, undefined, this.opts)) 24 | 25 | return new Promise(resolve => { 26 | this.ws.onopen = () => { 27 | this.connected = true 28 | this.emitter.emit('connect') 29 | resolve() 30 | } 31 | }) 32 | } 33 | 34 | disconnect() { 35 | if (this.ws) { 36 | this.ws.onclose = undefined 37 | this.ws.close() 38 | this.onclose() 39 | } 40 | } 41 | 42 | error(message) { 43 | let err = new Error('Wrong message format') 44 | err.received = message 45 | this.emitter.emit('error', err) 46 | } 47 | 48 | init(ws) { 49 | ws.onerror = event => { 50 | this.emitter.emit('error', event.error || new Error('WS Error')) 51 | } 52 | 53 | ws.onclose = () => { 54 | this.onclose() 55 | } 56 | 57 | ws.onmessage = event => { 58 | let data 59 | try { 60 | data = JSON.parse(event.data) 61 | } catch { 62 | this.error(event.data) 63 | return 64 | } 65 | this.emitter.emit('message', data) 66 | } 67 | 68 | this.ws = ws 69 | } 70 | 71 | on(event, listener) { 72 | return this.emitter.on(event, listener) 73 | } 74 | 75 | onclose() { 76 | if (this.ws) { 77 | this.connected = false 78 | this.emitter.emit('disconnect') 79 | this.ws = undefined 80 | } 81 | } 82 | 83 | send(message) { 84 | if (this.ws && this.ws.readyState === this.ws.OPEN) { 85 | this.ws.send(JSON.stringify(message)) 86 | } else { 87 | this.emitter.emit('error', new Error('WS was closed')) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /ws-connection/index.test.ts: -------------------------------------------------------------------------------- 1 | import { restoreAll, spyOn } from 'nanospy' 2 | import { deepStrictEqual, equal } from 'node:assert' 3 | import { afterEach, test } from 'node:test' 4 | import type WebSocket from 'ws' 5 | 6 | import { type Message, WsConnection } from '../index.js' 7 | 8 | class FakeWebSocket { 9 | onclose?: () => void 10 | 11 | onerror?: (event: object) => void 12 | 13 | onmessage?: (event: object) => void 14 | 15 | onopen?: () => void 16 | 17 | opts: object 18 | 19 | readyState?: number 20 | 21 | sent: string[] 22 | 23 | constructor(url: string, protocols: string, opts: object) { 24 | this.opts = opts 25 | this.sent = [] 26 | setTimeout(() => { 27 | this.onopen?.() 28 | }, 1) 29 | } 30 | 31 | close(): void { 32 | this.emit('close') 33 | } 34 | 35 | emit(name: string, data?: Error | string): void { 36 | if (name === 'open') { 37 | if (typeof this.onopen === 'undefined') { 38 | throw new Error(`No ${name} event listener`) 39 | } else { 40 | this.onopen() 41 | } 42 | } else if (name === 'message') { 43 | if (typeof this.onmessage === 'undefined') { 44 | throw new Error(`No ${name} event listener`) 45 | } else { 46 | this.onmessage({ data }) 47 | } 48 | } else if (name === 'error') { 49 | if (typeof this.onerror === 'undefined') { 50 | throw new Error(`No ${name} event listener`) 51 | } else { 52 | this.onerror({ error: data }) 53 | } 54 | } else if (name === 'close') { 55 | if (typeof this.onclose !== 'undefined') { 56 | this.onclose() 57 | } 58 | } 59 | } 60 | 61 | send(msg: string): void { 62 | this.sent.push(msg) 63 | } 64 | } 65 | 66 | function privateMethods(obj: object): any { 67 | return obj 68 | } 69 | 70 | function setWebSocket(ws: object | undefined): void { 71 | // @ts-expect-error 72 | global.WebSocket = ws 73 | } 74 | 75 | afterEach(() => { 76 | restoreAll() 77 | setWebSocket(undefined) 78 | }) 79 | 80 | function emit( 81 | ws: undefined | WebSocket, 82 | name: string, 83 | data?: Error | string 84 | ): void { 85 | if (typeof ws === 'undefined') { 86 | throw new Error('WebSocket was not created') 87 | } 88 | ws.emit(name, data) 89 | } 90 | 91 | test('emits error on wrong format', async () => { 92 | setWebSocket(FakeWebSocket) 93 | let connection = new WsConnection('ws://localhost') 94 | let error: Error | undefined 95 | connection.on('error', err => { 96 | error = err 97 | }) 98 | 99 | await connection.connect() 100 | 101 | emit(connection.ws, 'message', '{') 102 | if (typeof error === 'undefined') throw new Error('Error was not sent') 103 | equal(error.message, 'Wrong message format') 104 | equal(privateMethods(error).received, '{') 105 | }) 106 | 107 | test('emits error on error', async () => { 108 | setWebSocket(FakeWebSocket) 109 | let connection = new WsConnection('ws://localhost') 110 | let error: Error | undefined 111 | connection.on('error', err => { 112 | error = err 113 | }) 114 | 115 | await connection.connect() 116 | 117 | emit(connection.ws, 'error', new Error('test')) 118 | if (typeof error === 'undefined') throw new Error('Error was not sent') 119 | equal(error.message, 'test') 120 | emit(connection.ws, 'error') 121 | equal(error.message, 'WS Error') 122 | }) 123 | 124 | test('emits connection states', async () => { 125 | setWebSocket(FakeWebSocket) 126 | let connection = new WsConnection('ws://localhost') 127 | 128 | let states: string[] = [] 129 | connection.on('connecting', () => { 130 | states.push('connecting') 131 | }) 132 | connection.on('connect', () => { 133 | states.push('connect') 134 | }) 135 | connection.on('disconnect', () => { 136 | states.push('disconnect') 137 | }) 138 | 139 | deepStrictEqual(states, []) 140 | equal(connection.connected, false) 141 | 142 | let connecting = connection.connect() 143 | 144 | deepStrictEqual(states, ['connecting']) 145 | equal(connection.connected, false) 146 | 147 | await connecting 148 | deepStrictEqual(states, ['connecting', 'connect']) 149 | equal(connection.connected, true) 150 | 151 | emit(connection.ws, 'close') 152 | deepStrictEqual(states, ['connecting', 'connect', 'disconnect']) 153 | equal(connection.connected, false) 154 | 155 | connection.connect() 156 | emit(connection.ws, 'close') 157 | deepStrictEqual(states, [ 158 | 'connecting', 159 | 'connect', 160 | 'disconnect', 161 | 'connecting', 162 | 'disconnect' 163 | ]) 164 | equal(connection.connected, false) 165 | }) 166 | 167 | test('closes WebSocket', async () => { 168 | setWebSocket(FakeWebSocket) 169 | let connection = new WsConnection('ws://localhost') 170 | 171 | await connection.connect() 172 | if (typeof connection.ws === 'undefined') { 173 | throw new Error('WebSocket was not created') 174 | } 175 | 176 | let ws = connection.ws 177 | let close = spyOn(ws, 'close') 178 | 179 | connection.disconnect() 180 | equal(close.callCount, 1) 181 | equal(connection.connected, false) 182 | }) 183 | 184 | test('close WebSocket 2 times', async () => { 185 | setWebSocket(FakeWebSocket) 186 | let connection = new WsConnection('ws://localhost') 187 | 188 | await connection.connect() 189 | if (typeof connection.ws === 'undefined') { 190 | throw new Error('WebSocket was not created') 191 | } 192 | 193 | let ws = connection.ws 194 | let close = spyOn(ws, 'close') 195 | 196 | connection.disconnect() 197 | connection.disconnect() 198 | equal(close.callCount, 1) 199 | equal(connection.connected, false) 200 | }) 201 | 202 | test('receives messages', async () => { 203 | setWebSocket(FakeWebSocket) 204 | let connection = new WsConnection('ws://localhost') 205 | 206 | let received: Message[] = [] 207 | connection.on('message', msg => { 208 | received.push(msg) 209 | }) 210 | 211 | await connection.connect() 212 | 213 | emit(connection.ws, 'message', '["ping",1]') 214 | deepStrictEqual(received, [['ping', 1]]) 215 | }) 216 | 217 | test('sends messages', async () => { 218 | setWebSocket(FakeWebSocket) 219 | let connection = new WsConnection('ws://localhost') 220 | 221 | await connection.connect() 222 | if (typeof connection.ws === 'undefined') { 223 | throw new Error('WebSocket was not created') 224 | } 225 | 226 | connection.send(['ping', 1]) 227 | deepStrictEqual(connection.ws.sent, ['["ping",1]']) 228 | }) 229 | 230 | test('uses custom WebSocket implementation', async () => { 231 | let connection = new WsConnection( 232 | 'ws://localhost', 233 | FakeWebSocket 234 | ) 235 | 236 | await connection.connect() 237 | if (typeof connection.ws === 'undefined') { 238 | throw new Error('WebSocket was not created') 239 | } 240 | 241 | connection.send(['ping', 1]) 242 | deepStrictEqual(connection.ws.sent, ['["ping",1]']) 243 | }) 244 | 245 | test('passes extra option for WebSocket', async () => { 246 | let connection = new WsConnection( 247 | 'ws://localhost', 248 | FakeWebSocket, 249 | { a: 1 } 250 | ) 251 | await connection.connect() 252 | if (typeof connection.ws === 'undefined') { 253 | throw new Error('WebSocket was not created') 254 | } 255 | 256 | deepStrictEqual(connection.ws.opts, { a: 1 }) 257 | }) 258 | 259 | test('does not send to closed socket', async () => { 260 | setWebSocket(FakeWebSocket) 261 | let connection = new WsConnection('ws://localhost') 262 | 263 | let errors: string[] = [] 264 | connection.on('error', e => { 265 | errors.push(e.message) 266 | }) 267 | 268 | await connection.connect() 269 | if (typeof connection.ws === 'undefined') { 270 | throw new Error('WebSocket was not created') 271 | } 272 | 273 | connection.ws.readyState = 2 274 | connection.send(['ping', 1]) 275 | deepStrictEqual(errors, ['WS was closed']) 276 | }) 277 | 278 | test('ignores double connect call', async () => { 279 | setWebSocket(FakeWebSocket) 280 | let connection = new WsConnection('ws://localhost') 281 | 282 | let connected = 0 283 | connection.on('connecting', () => { 284 | connected += 1 285 | }) 286 | 287 | await connection.connect() 288 | await connection.connect() 289 | 290 | equal(connection.connected, true) 291 | equal(connected, 1) 292 | }) 293 | -------------------------------------------------------------------------------- /ws-connection/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { deepStrictEqual, equal } from 'node:assert' 3 | import { afterEach, test } from 'node:test' 4 | import WebSocket, { WebSocketServer } from 'ws' 5 | 6 | import { type Message, ServerConnection, WsConnection } from '../index.js' 7 | 8 | let wss: WebSocket.Server 9 | afterEach(() => { 10 | wss.close() 11 | }) 12 | 13 | function connect( 14 | server: WebSocket.Server, 15 | client: WsConnection 16 | ): Promise { 17 | return new Promise(resolve => { 18 | server.on('connection', ws => { 19 | let connection = new ServerConnection(ws) 20 | resolve(connection) 21 | }) 22 | client.connect() 23 | }) 24 | } 25 | 26 | test('works in real protocol', async () => { 27 | wss = new WebSocketServer({ port: 8081 }) 28 | 29 | let client = new WsConnection('ws://0.0.0.0:8081', WebSocket) 30 | let clientReceived: Message[] = [] 31 | client.on('message', msg => { 32 | clientReceived.push(msg) 33 | }) 34 | 35 | let server = await connect(wss, client) 36 | 37 | let serverReceived: Message[] = [] 38 | server.on('message', msg => { 39 | serverReceived.push(msg) 40 | }) 41 | 42 | await delay(100) 43 | equal(server.connected, true) 44 | equal(client.connected, true) 45 | 46 | client.send(['ping', 1]) 47 | await delay(100) 48 | deepStrictEqual(serverReceived, [['ping', 1]]) 49 | 50 | server.send(['pong', 1]) 51 | await delay(100) 52 | deepStrictEqual(clientReceived, [['pong', 1]]) 53 | 54 | server.disconnect() 55 | await delay(100) 56 | equal(server.connected, false) 57 | equal(client.connected, false) 58 | }) 59 | --------------------------------------------------------------------------------