├── .editorconfig ├── .github └── workflows │ ├── api.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── attention ├── index.d.ts ├── index.js └── index.test.ts ├── badge ├── index.d.ts ├── index.js ├── index.test.ts └── styles │ ├── error.svg │ ├── index.d.ts │ ├── index.js │ ├── offline.svg │ ├── refresh.svg │ └── success.svg ├── client ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── confirm ├── index.d.ts ├── index.js └── index.test.ts ├── create-auth ├── index.d.ts ├── index.js └── index.test.ts ├── create-client-store ├── index.d.ts ├── index.js └── index.test.ts ├── create-filter ├── index.d.ts ├── index.js └── index.test.ts ├── cross-tab-client ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── encrypt-actions ├── index.d.ts ├── index.js └── index.test.ts ├── eslint.config.js ├── favicon ├── index.d.ts ├── index.js └── index.test.ts ├── index.d.ts ├── index.js ├── indexed-store ├── global.d.ts ├── index.d.ts ├── index.js └── index.test.ts ├── log ├── __snapshots__ │ └── index.test.ts.snap ├── index.d.ts ├── index.js └── index.test.ts ├── logux-undo-error ├── index.d.ts └── index.js ├── package.json ├── pnpm-lock.yaml ├── preact ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── prepare-for-test ├── index.d.ts ├── index.js └── index.test.ts ├── react ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── request ├── index.d.ts ├── index.js └── index.test.ts ├── status ├── index.d.ts ├── index.js └── index.test.ts ├── sync-map-template ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── test-client ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.ts ├── test-server ├── index.d.ts └── index.js ├── test ├── demo │ ├── error.png │ ├── index.html │ ├── index.js │ ├── normal.png │ └── offline.png └── local-storage.js ├── track ├── index.d.ts ├── index.js └── index.test.ts ├── tsconfig.json ├── vite.config.ts └── vue ├── errors.ts ├── index.d.ts ├── index.js ├── index.test.ts └── types.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 | permissions: {} 7 | jobs: 8 | api: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Start logux.org re-build 12 | run: | 13 | 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"}' 14 | -------------------------------------------------------------------------------- /.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@v1 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: 9 21 | - name: Install Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 22 25 | cache: pnpm 26 | - name: Install dependencies 27 | run: pnpm install --ignore-scripts 28 | - name: Run tests 29 | run: pnpm test 30 | 31 | short: 32 | runs-on: ubuntu-latest 33 | strategy: 34 | matrix: 35 | node-version: 36 | - 20 37 | - 18 38 | name: Node.js ${{ matrix.node-version }} Quick 39 | steps: 40 | - name: Checkout the repository 41 | uses: actions/checkout@v4 42 | - name: Install pnpm 43 | uses: pnpm/action-setup@v4 44 | with: 45 | version: 9 46 | - name: Install Node.js ${{ matrix.node-version }} 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | cache: pnpm 51 | - name: Install dependencies 52 | run: pnpm install --ignore-scripts 53 | - name: Run unit tests 54 | run: pnpm vitest run 55 | 56 | deploy: 57 | name: Demo Deploy 58 | runs-on: ubuntu-latest 59 | if: github.ref == 'refs/heads/main' 60 | needs: 61 | - full 62 | permissions: 63 | contents: write 64 | steps: 65 | - name: Checkout the repository 66 | uses: actions/checkout@v4 67 | with: 68 | persist-credentials: false 69 | - name: Install pnpm 70 | uses: pnpm/action-setup@v4 71 | with: 72 | version: 9 73 | - name: Install Node.js 74 | uses: actions/setup-node@v4 75 | with: 76 | node-version: 22 77 | cache: pnpm 78 | - name: Install dependencies 79 | run: pnpm install --ignore-scripts 80 | - name: Build 81 | run: pnpm build 82 | - name: Deploy 83 | uses: JamesIves/github-pages-deploy-action@v4 84 | with: 85 | folder: test/demo/dist 86 | branch: gh-pages 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | coverage/ 4 | 5 | test/demo/dist/ 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | coverage/ 3 | **/types.ts 4 | **/errors.ts 5 | **/*.test.* 6 | tsconfig.json 7 | vite.config.ts 8 | 9 | global.d.ts 10 | __snapshots__ 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## 0.21.1 5 | * Fixed types. 6 | 7 | ## 0.21.0 8 | * Added `loadValue()`. 9 | * Added `ensureLoaded()` helper for tests. 10 | * Added `connect` argument to `Client#start`. 11 | * Added key support in `encryptActions` (by @nichoth). 12 | * Added Nano Stores 0.10 support. 13 | * Added `SyncMapStore#deleted`. 14 | * Added `LoadedValue` type to exports. 15 | * Fixed support of local `SyncMap`. 16 | * Removed Node.js 16 support. 17 | 18 | ## 0.20.1 19 | * Fixed double subscription on `createFilter` (by Eduard Aksamitov). 20 | 21 | ## 0.20 22 | * Moved to Nano Stores 0.9. 23 | * Refactored Vue composables to improve performance (by Eduard Aksamitov). 24 | * Fixed loss of unsubscribes on changing arguments in Vue (by Eduard Aksamitov). 25 | * Fixed errors handler in Vue (by Eduard Aksamitov). 26 | 27 | ## 0.19 28 | * Removed Node.js 14 support. 29 | * Moved to Nano Stores 0.8. 30 | * Faster leader election with Web Lock API. 31 | 32 | ## 0.18.4 33 | * Fixed types (by Slava Mostovoy). 34 | 35 | ## 0.18.3 36 | * Fixed Nano Stores peer dependency. 37 | 38 | ## 0.18.2 39 | * Fixed `subscribe.since` for single offline `SyncMap` (by Nikita Galaiko). 40 | * Moved to Nano Stores 0.7. 41 | 42 | ## 0.18.1 43 | * Fixed `subscribe.since` for offline `SyncMap` (by Nikita Galaiko). 44 | 45 | ## 0.18 46 | * Moved to Logux Core 0.8. 47 | 48 | ## 0.17 49 | * Moved to Nano Stores 0.6. 50 | 51 | ## 0.16 52 | * Removed Node.js 12 support. 53 | * Add `logux/unsubscribe` processing in offline. 54 | * Fixed race condition on resubscribe on `sending` state (by Tyler Han). 55 | 56 | ## 0.15.3 57 | * Fixed React types (by Wojciech Grzebieniowski). 58 | 59 | ## 0.15.2 60 | * Cleaned peer dependencies (by Yuri Mikhin). 61 | 62 | ## 0.15.1 63 | * Fixed value object mutation in `createSyncMap` and `buildSyncMap`. 64 | * Fixed `ErrorsContext` export. 65 | 66 | ## 0.15 67 | * Added `Client#type(actionCreator, cb)` support. 68 | * Added color highlight for new action and `logux/undo` events in `log()`. 69 | 70 | ## 0.14.7 71 | * Moved to new default version of Vue 3 (by Eduard Aksamitov). 72 | 73 | ## 0.14.6 74 | * Fixed missing store updates in React and Preact (by Aleksandr Slepchenkov). 75 | 76 | ## 0.14.5 77 | * Fixed Vue `useFilter()` working with reactive filter (by Eduard Aksamitov). 78 | * Improved Vue `useFilter()` performance (by Eduard Aksamitov). 79 | * Improved Vue `useSync()` performance (by Eduard Aksamitov). 80 | 81 | ## 0.14.4 82 | * Fixed logger for clean events (by Aleksandr Slepchenkov). 83 | * Updated `@nanostores/vue`. 84 | 85 | ## 0.14.3 86 | * Updated `@nanostores/vue`. 87 | 88 | ## 0.14.2 89 | * Moved to Nano Stores Vue 0.2 (by Eduard Aksamitov). 90 | 91 | ## 0.14.1 92 | * Reduced package size. 93 | 94 | ## 0.14 95 | * Renamed `defineSyncMap()` to `syncMapTemplate()`. 96 | * Moved to Nano Stores 0.5. 97 | * Added `AuthStore#loading` (by Eduard Aksamitov). 98 | 99 | ## 0.13.3 100 | * Replaced `colorette` with `nanocolors`. 101 | 102 | ## 0.13.2 103 | * Throw an error on missed client in `useClient()` (by Aleksandr Slepchenkov). 104 | 105 | ## 0.13.1 106 | * Fixed Preact export (by Phil Schaffarzyk). 107 | 108 | ## 0.13 109 | * Moved to Nano Stores 0.4. 110 | * Added Nano Stores effects to `SyncMap` and `Filter`. 111 | 112 | ## 0.12.1 113 | * Moved to React own batching. 114 | * Fixed `useAuth()`. 115 | 116 | ## 0.12 117 | * Moved from `@logux/state` to `nanostores`. 118 | * Added Preact support. 119 | * Added `filter` support to `TestServer` (by Aleksandr Slepchenkov). 120 | * Fixed docs (by Eduard Aksamitov). 121 | 122 | ## 0.11.1 123 | * Added `badge/styles` export (by @TrentWest7190). 124 | 125 | ## 0.11 126 | * Added `@logux/state` peer dependency. 127 | * Added `SyncMap`. 128 | * Added `Filter`. 129 | * Added `prepareForTest` for `SyncMap`. 130 | * Added React helpers for `SyncMap`. 131 | * Added Vue.js helpers for `SyncMap` (by Eduard Aksamitov). 132 | * Fixed `TestServer` error text (by Aleksandr Slepchenkov). 133 | 134 | ## 0.10 135 | * Moved project to ESM-only type. Applications must use ESM too. 136 | * Dropped Node.js 10 support. 137 | * Added `Client#sync()`. 138 | * Added `track()`. 139 | * Added `Client#type()`. 140 | * Added action encryption. 141 | * Added `request()`. 142 | * Added `TestServer#sendAll()`. 143 | * Added `logux/subscribed` support to `log()`. 144 | * Fixed `IndexedStore` with `Promise`. 145 | * Fixed `TextClient#sent` by adding small delay. 146 | * Fixed types performance by replacing `type` to `interface`. 147 | 148 | ## 0.9.3 149 | * Fix `changeUser()` for cross-tab client case (by Sergey Korolev). 150 | 151 | ## 0.9.2 152 | * Fix log messages (by Eduard Aksamitov). 153 | * Replace `chalk` with `colorette`. 154 | 155 | ## 0.9.1 156 | * Improve log messages for subscribing. 157 | 158 | ## 0.9 159 | * Use Logux Core 0.6 and WebSocket Protocol 4. 160 | * Add `CrossTabClient#waitFor()`. 161 | * Add `user` event. 162 | * Improve `log()` output with collapsed groups. 163 | * Show offline in the badge from the beginning. 164 | * Fix `Client#changeUser()`. 165 | 166 | ## 0.8.5 167 | * Remove `global` from `IndexedStore` (by Neville Franks). 168 | 169 | ## 0.8.4 170 | * Fix `log()` for `logux/unsubscribe` actions. 171 | 172 | ## 0.8.3 173 | * Update log’s node ID on `Client#changeUser()` call. 174 | 175 | ## 0.8.2 176 | * Remove support of old servers. 177 | 178 | ## 0.8.1 179 | * Fix node ID generation in `Client#changeUser()`. 180 | 181 | ## 0.8 182 | * Use Logux Core 0.5 and WebSocket Protocol 3. 183 | * Rename `credentials` option to `token`. 184 | * User ID must be always a string without `:`. 185 | * Token must be a string. 186 | * Add `Client#changeUser()` method. 187 | * Add support for dynamic tokens. 188 | 189 | ## 0.7 190 | * Add ES modules support. 191 | * Add TypeScript definitions. 192 | * Move API docs from JSDoc to TypeDoc. 193 | * Mark package as side effect free. 194 | 195 | ## 0.6 196 | * Do not synchronize event cleaning between tabs. 197 | * Ask to update page receiving bigger `subprocol` from another tab. 198 | * Disable cross-tab communication on `localStorage` error. 199 | * Fix falling on empty `userId` (by @Abdubek). 200 | 201 | ## 0.5.2 202 | * Fix React Native and React Server-Side Rendering support (by Can Rau). 203 | 204 | ## 0.5.1 205 | * Fix compatibility with Logux Server 0.4. 206 | 207 | ## 0.5 208 | * Rename `Client#id` to `Client#tabId`. 209 | * Trim re-send meta keys (`users`, `channels`) during synchronization. 210 | 211 | ## 0.4 212 | * Add `Client#on` method to unify API with `CrossTabClient`. 213 | * Add `preadd` event as alias to `client.log.on('preadd', cb)`. 214 | * Improve docs (by Paul Chavard). 215 | 216 | ## 0.3.4 217 | * Fix multiple resubscriptions. 218 | 219 | ## 0.3.3 220 | * Fix log message for `logux/processed` or `logux/undo`. 221 | 222 | ## 0.3.2 223 | * Keep `sync: true` actions in the log until `logux/processed` or `logux/undo`. 224 | * Add resubscribe action only after origin action was processed. 225 | 226 | ## 0.3.1 227 | * Update dependencies. 228 | 229 | ## 0.3 230 | * Rename project from `logux-client` to `@logux/client`. 231 | * Merge with `logux-status`. 232 | * Use `MemoryStore` by default. 233 | * Use Logux Core 0.3. 234 | * Wait for `logux/processed` before switching to `synchronized` state. 235 | * Add `Client#clientId`. 236 | * Add `action.since` to `logux/subscribe` action. 237 | * Add `ignoreActions` to `log()`. 238 | * Combine add/clean messages in `log()`. 239 | * Remove Promise fix for old Firefox. 240 | * Track subscription counts. 241 | * Clean up code (by Dimitri Nicolas). 242 | 243 | ## 0.2.10 244 | * Fix cross-tab `add` event with `MemoryStore`. 245 | 246 | ## 0.2.9 247 | * Fix `MemoryStore` support in cross-tab client. 248 | 249 | ## 0.2.8 250 | * Fix subscription after offline. 251 | 252 | ## 0.2.7 253 | * Allow to work with missed `extra.lastSynced`. 254 | 255 | ## 0.2.6 256 | * Fix `logux/unsubscribe` detection. 257 | * Subscribe again only in `connected` state. 258 | * Allow to have `:` in user ID. 259 | * Allow to use client without `window`. 260 | 261 | ## 0.2.5 262 | * Fix follower tab’s actions synchronization. 263 | 264 | ## 0.2.4 265 | * Fix `Promise` implementation in `IndexedStore`. 266 | 267 | ## 0.2.3 268 | * Fix `IndexedStore` in Firefox. 269 | 270 | ## 0.2.2 271 | * Fix subscription to same channel twice. 272 | 273 | ## 0.2.1 274 | * Sort correctly actions with same `time`. 275 | * Fix race condition between uniqueness check and add. 276 | 277 | ## 0.2 278 | * Use Logux Protocol 2. 279 | * Use Logux Core 0.2 and Logux Sync 0.2. 280 | * Send actions with `meta.sync` only. 281 | * Add `logux/subscribe` and `logux/unsubscribe` support. 282 | * Replace `localStorage` to `IndexedDB` in the store (by Alexey Gaziev). 283 | * Add mandatory `userId` option. 284 | * Use Nano ID for node ID. 285 | * Add cross-tab communication with leader tab election. 286 | * Add `meta.tab` support. 287 | * Add `debug` message support (by Roman Fursov). 288 | * Add production non-secure protocol warning (by Hanna Stoliar). 289 | * Add `Add Client#clean` method. 290 | * Set `meta.subprotocol`. 291 | * Move store tests to separated project (by Konstantin Mamaev). 292 | * Fix docs (by Grigoriy Beziuk and Vladimir Dementyev). 293 | * Clean up code (by Evgeny Rodionov). 294 | 295 | ## 0.1 296 | * Initial release. 297 | -------------------------------------------------------------------------------- /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 Client [![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 base components to build web client: 17 | 18 | * `CrossTabClient` and `Client` to create web client for Logux. 19 | * `IndexedStore` to store Logux log in `IndexedDB`. 20 | * `badge()` widget to show Logux synchronization status in UI. 21 | * `status()` to write own UI to show Logux synchronization status in UI. 22 | * `attention()`, `confirm()`, `favicon()` to improve UX in Logux web app. 23 | * `log()` to print Logux synchronization status to browser DevTools. 24 | 25 | Check **[demo page]** for widget UI. 26 | 27 | [demo page]: https://logux.github.io/client/ 28 | [cult-img]: http://cultofmartians.com/assets/badges/badge.svg 29 | [cult]: http://cultofmartians.com/done.html 30 | 31 | --- 32 | 33 | Made at Evil Martians, product consulting for developer tools. 34 | 35 | --- 36 | 37 | 38 | ## Install 39 | 40 | ```sh 41 | npm install @logux/core @logux/client nanostores 42 | ``` 43 | 44 | 45 | ## Usage 46 | 47 | See [documentation] for Logux API. 48 | 49 | ```js 50 | import { CrossTabClient, badge, badgeEn, log } from '@logux/client' 51 | import { badgeStyles } from '@logux/client/badge/styles' 52 | 53 | let userId = document.querySelector('meta[name=user]').content 54 | let token = document.querySelector('meta[name=token]').content 55 | 56 | const client = new CrossTabClient({ 57 | subprotocol: '1.0.0', 58 | server: 'wss://example.com:1337', 59 | userId, 60 | token 61 | }) 62 | 63 | badge(client, { messages: badgeEn, styles: badgeStyles }) 64 | log(client) 65 | 66 | client.start() 67 | ``` 68 | 69 | [documentation]: https://github.com/logux/logux 70 | -------------------------------------------------------------------------------- /attention/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '../client/index.js' 2 | 3 | /** 4 | * Highlight tabs on synchronization errors. 5 | * 6 | * ```js 7 | * import { attention } from '@logux/client' 8 | * attention(client) 9 | * ``` 10 | * 11 | * @param client Observed Client instance. 12 | * @returns Unbind listener. 13 | */ 14 | export function attention(client: Client): () => void 15 | -------------------------------------------------------------------------------- /attention/index.js: -------------------------------------------------------------------------------- 1 | export function attention(client) { 2 | let doc = document 3 | let originTitle = false 4 | let unbind = [] 5 | let timeout = false 6 | 7 | let restoreTitle = () => { 8 | if (originTitle) { 9 | doc.title = originTitle 10 | originTitle = false 11 | } 12 | } 13 | 14 | let blink = () => { 15 | if (doc.hidden && !originTitle) { 16 | originTitle = doc.title 17 | doc.title = '* ' + doc.title 18 | } else { 19 | restoreTitle() 20 | } 21 | 22 | if (doc.hidden) timeout = setTimeout(blink, 1000) 23 | } 24 | 25 | let tabListener = () => { 26 | if (!doc.hidden && timeout) { 27 | timeout = clearTimeout(timeout) 28 | restoreTitle() 29 | } 30 | } 31 | 32 | if (doc && typeof doc.hidden !== 'undefined') { 33 | unbind.push( 34 | client.node.on('error', error => { 35 | if (error.type !== 'timeout' && !timeout) { 36 | blink() 37 | } 38 | }) 39 | ) 40 | 41 | unbind.push( 42 | client.on('add', action => { 43 | if (action.type === 'logux/undo' && action.reason && !timeout) { 44 | blink() 45 | } 46 | }) 47 | ) 48 | 49 | document.addEventListener('visibilitychange', tabListener, false) 50 | unbind.push(() => { 51 | document.removeEventListener('visibilitychange', tabListener, false) 52 | }) 53 | } 54 | 55 | return () => { 56 | for (let i of unbind) i() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /attention/index.test.ts: -------------------------------------------------------------------------------- 1 | import { LoguxError, TestPair } from '@logux/core' 2 | import { spyOn } from 'nanospy' 3 | import { afterEach, expect, it } from 'vitest' 4 | 5 | import { attention, CrossTabClient } from '../index.js' 6 | 7 | let nextHidden: boolean | undefined 8 | Object.defineProperty(document, 'hidden', { 9 | get() { 10 | if (typeof nextHidden !== 'undefined') { 11 | let value = nextHidden 12 | nextHidden = undefined 13 | return value 14 | } else { 15 | return true 16 | } 17 | } 18 | }) 19 | 20 | function emit(obj: any, event: string, ...args: any[]): void { 21 | obj.emitter.emit(event, ...args) 22 | } 23 | 24 | async function createClient(): Promise { 25 | document.title = 'title' 26 | 27 | let pair = new TestPair() 28 | let client = new CrossTabClient({ 29 | server: pair.left, 30 | subprotocol: '1.0.0', 31 | userId: '10' 32 | }) 33 | 34 | client.node.catch(() => {}) 35 | client.role = 'leader' 36 | 37 | await pair.left.connect() 38 | return client 39 | } 40 | 41 | let originAdd = document.addEventListener 42 | afterEach(() => { 43 | document.addEventListener = originAdd 44 | }) 45 | 46 | it('receives errors', async () => { 47 | let client = await createClient() 48 | attention(client) 49 | emit(client.node, 'error', new Error('test')) 50 | expect(document.title).toBe('* title') 51 | }) 52 | 53 | it('receives undo', async () => { 54 | let client = await createClient() 55 | attention(client) 56 | client.log.add({ reason: 'error', type: 'logux/undo' }) 57 | expect(document.title).toBe('* title') 58 | }) 59 | 60 | it('returns unbind function', async () => { 61 | let removeEventListener = spyOn(document, 'removeEventListener') 62 | let client = await createClient() 63 | let unbind = attention(client) 64 | unbind() 65 | expect(removeEventListener.callCount).toEqual(1) 66 | }) 67 | 68 | it('allows to miss timeout error', async () => { 69 | let client = await createClient() 70 | attention(client) 71 | emit(client.node, 'error', new LoguxError('timeout')) 72 | expect(document.title).toBe('title') 73 | }) 74 | 75 | it('sets old title when user open a tab', async () => { 76 | let listener: (() => void) | undefined 77 | document.addEventListener = (name: string, callback: any) => { 78 | expect(name).toBe('visibilitychange') 79 | listener = callback 80 | } 81 | 82 | let client = await createClient() 83 | attention(client) 84 | 85 | emit(client.node, 'error', new Error('test')) 86 | expect(document.title).toBe('* title') 87 | 88 | nextHidden = false 89 | if (typeof listener === 'undefined') throw new Error('lister was not set') 90 | listener() 91 | expect(document.title).toBe('title') 92 | }) 93 | 94 | it('does not double title changes', async () => { 95 | let client = await createClient() 96 | attention(client) 97 | 98 | emit(client.node, 'error', new Error('test')) 99 | emit(client.node, 'error', new Error('test')) 100 | expect(document.title).toBe('* title') 101 | }) 102 | 103 | it('does not change title of visible tab', async () => { 104 | let client = await createClient() 105 | attention(client) 106 | 107 | nextHidden = false 108 | emit(client.node, 'error', new Error('test')) 109 | expect(document.title).toBe('title') 110 | }) 111 | -------------------------------------------------------------------------------- /badge/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '../client/index.js' 2 | 3 | export interface BadgeMessages { 4 | denied: string 5 | disconnected: string 6 | error: string 7 | protocolError: string 8 | sending: string 9 | syncError: string 10 | synchronized: string 11 | wait: string 12 | } 13 | 14 | export interface BadgeStyles { 15 | base: object 16 | connecting: object 17 | disconnected: object 18 | error: object 19 | icon: { 20 | disconnected: string 21 | error: string 22 | protocolError: string 23 | sending: string 24 | synchronized: string 25 | wait: string 26 | } 27 | protocolError: object 28 | sending: object 29 | synchronized: object 30 | text: object 31 | wait: object 32 | } 33 | 34 | interface BadgeOptions { 35 | /** 36 | * Synchronized state duration. Default is `3000`. 37 | */ 38 | duration?: number 39 | 40 | /** 41 | * Widget text for different states. 42 | */ 43 | messages: BadgeMessages 44 | 45 | /** 46 | * Widget position. Default is `bottom-right`. 47 | */ 48 | position?: 49 | | 'bottom-center' 50 | | 'bottom-left' 51 | | 'bottom-right' 52 | | 'middle-center' 53 | | 'middle-left' 54 | | 'middle-right' 55 | | 'top-center' 56 | | 'top-left' 57 | | 'top-right' 58 | 59 | /** 60 | * Inline styles for different states. 61 | */ 62 | styles: BadgeStyles 63 | } 64 | 65 | /** 66 | * Display Logux widget in browser. 67 | * 68 | * ```js 69 | * import { badge, badgeEn } from '@logux/client' 70 | * import { badgeStyles } from '@logux/client/badge/styles' 71 | * 72 | * badge(client, { 73 | * messages: badgeEn, 74 | * styles: { 75 | * ...badgeStyles, 76 | * synchronized: { backgroundColor: 'green' } 77 | * }, 78 | * position: 'top-left' 79 | * }) 80 | * ``` 81 | * 82 | * @param client Observed Client instance. 83 | * @param opts Widget settings. 84 | * @returns Unbind badge listener and remove widget from DOM. 85 | */ 86 | export function badge(client: Client, opts: BadgeOptions): () => void 87 | 88 | /** 89 | * Russian translation for widget. 90 | */ 91 | export const badgeRu: BadgeMessages 92 | 93 | /** 94 | * English translation for widget. 95 | */ 96 | export const badgeEn: BadgeMessages 97 | -------------------------------------------------------------------------------- /badge/index.js: -------------------------------------------------------------------------------- 1 | import { status } from '../status/index.js' 2 | 3 | function injectStyles(element, styles) { 4 | for (let i in styles) { 5 | element.style[i] = styles[i] 6 | } 7 | } 8 | 9 | function setPosition(element, position) { 10 | let style = element.style 11 | if (position === 'middle-center' || position === 'center-middle') { 12 | style.top = '50%' 13 | style.left = '50%' 14 | style.transform = 'translate(-50%, -50%)' 15 | } else { 16 | position.split('-').forEach(pos => { 17 | if (pos === 'middle') { 18 | style.top = '50%' 19 | style.transform = 'translateY(-50%)' 20 | } else if (pos === 'center') { 21 | style.left = '50%' 22 | style.transform = 'translateX(-50%)' 23 | } else { 24 | style[pos] = '0' 25 | } 26 | }) 27 | } 28 | } 29 | 30 | const RESET = { 31 | boxSizing: 'content-box', 32 | fontStyle: 'normal', 33 | fontVariant: 'normal', 34 | fontWeight: 'normal', 35 | letterSpacing: 'normal', 36 | lineHeight: 'auto', 37 | textIndent: '0', 38 | textTransform: 'none', 39 | visibility: 'visible', 40 | wordSpacing: 'normal' 41 | } 42 | 43 | export function badge(client, opts) { 44 | let messages = opts.messages 45 | let position = opts.position || 'bottom-right' 46 | let styles = opts.styles 47 | 48 | let widget = document.createElement('div') 49 | let text = document.createElement('span') 50 | 51 | widget.setAttribute('role', 'alert') 52 | 53 | injectStyles(widget, RESET) 54 | injectStyles(widget, styles.base) 55 | injectStyles(text, styles.text) 56 | setPosition(widget, position) 57 | 58 | let show = (style, msg) => { 59 | text.innerHTML = msg 60 | injectStyles(widget, style) 61 | widget.style.display = 'block' 62 | } 63 | 64 | let hide = () => { 65 | widget.style.display = 'none' 66 | } 67 | 68 | let unbind = status( 69 | client, 70 | state => { 71 | if (state === 'sendingAfterWait' || state === 'connectingAfterWait') { 72 | show(styles.sending, messages.sending) 73 | } else if (state === 'synchronizedAfterWait') { 74 | show(styles.synchronized, messages.synchronized) 75 | } else if (state === 'synchronized') { 76 | hide(widget) 77 | } else if (state === 'disconnected') { 78 | show(styles.disconnected, messages.disconnected) 79 | } else if (state === 'wait') { 80 | show(styles.wait, messages.wait) 81 | } else if (state === 'protocolError') { 82 | show(styles.protocolError, messages.protocolError) 83 | } else if (state === 'syncError') { 84 | show(styles.error, messages.syncError) 85 | } else if (state === 'error') { 86 | show(styles.error, messages.error) 87 | } else if (state === 'denied') { 88 | show(styles.error, messages.denied) 89 | } 90 | }, 91 | opts 92 | ) 93 | 94 | widget.appendChild(text) 95 | document.body.appendChild(widget) 96 | 97 | return () => { 98 | unbind() 99 | document.body.removeChild(widget) 100 | } 101 | } 102 | 103 | export let badgeRu = { 104 | denied: 'Нет прав
Ваши действия отменены', 105 | disconnected: 'Нет интернета', 106 | error: 'Ошибка на сервере
Ваши действия отменены', 107 | protocolError: 'Сохранение не работает
Обновите страницу', 108 | sending: 'Сохраняю ваши данные', 109 | syncError: 'Ошибка на сервере
Ваши данные не сохранены', 110 | synchronized: 'Ваши данные сохранены', 111 | wait: 'Нет интернета
Ваши данные не сохранены' 112 | } 113 | 114 | export let badgeEn = { 115 | denied: 'You have no access
You changes was reverted', 116 | disconnected: 'No Internet connection', 117 | error: 'Server error
You changes was reverted', 118 | protocolError: 'Saving is not working
Refresh the page', 119 | sending: 'Data saving', 120 | syncError: 'Server error
Your data has not been saved', 121 | synchronized: 'Your data has been saved', 122 | wait: 'No Internet connection
Your data has not been saved' 123 | } 124 | -------------------------------------------------------------------------------- /badge/index.test.ts: -------------------------------------------------------------------------------- 1 | import { LoguxError, type TestLog, TestPair, TestTime } from '@logux/core' 2 | import { delay } from 'nanodelay' 3 | import { afterEach, expect, it } from 'vitest' 4 | 5 | import { badge, badgeEn, type ClientMeta, CrossTabClient } from '../index.js' 6 | import type { BadgeOptions } from './index.js' 7 | import { badgeStyles } from './styles/index.js' 8 | 9 | function badgeNode(): HTMLElement | null { 10 | return document.querySelector('div')! 11 | } 12 | 13 | function badgeChildStyle(): any { 14 | let node = badgeNode() 15 | if (node !== null) { 16 | let child = node.children[0] 17 | if (child instanceof HTMLElement) { 18 | return child.style 19 | } 20 | } 21 | return {} 22 | } 23 | 24 | function badgeStyle(): any { 25 | let node = badgeNode() 26 | return node !== null ? node.style : {} 27 | } 28 | 29 | function getBadgeMessage(): string { 30 | return badgeNode()?.children[0].innerHTML ?? 'NO BADGE' 31 | } 32 | 33 | function setState(node: any, state: string): void { 34 | node.setState(state) 35 | } 36 | 37 | function emit(obj: any, event: string, ...args: any[]): void { 38 | obj.emitter.emit(event, ...args) 39 | } 40 | 41 | async function createTest(override?: Partial): Promise { 42 | let pair = new TestPair() 43 | let client = new CrossTabClient<{}, TestLog>({ 44 | server: pair.left, 45 | subprotocol: '1.0.0', 46 | time: new TestTime(), 47 | userId: '1' 48 | }) 49 | 50 | client.role = 'leader' 51 | client.node.catch(() => true) 52 | 53 | pair.leftNode = client.node 54 | 55 | pair.leftNode.catch(() => true) 56 | await pair.left.connect() 57 | let opts: BadgeOptions = { messages: badgeEn, styles: badgeStyles } 58 | Object.assign(opts, override) 59 | badge(client, opts) 60 | return pair 61 | } 62 | 63 | afterEach(() => { 64 | let node = badgeNode() 65 | if (node !== null) document.body.removeChild(node) 66 | }) 67 | 68 | it('injects base widget styles', async () => { 69 | await createTest() 70 | expect(badgeStyle().position).toBe('fixed') 71 | expect(badgeChildStyle().verticalAlign).toBe('middle') 72 | }) 73 | 74 | it('shows synchronized state', async () => { 75 | let test = await createTest({ duration: 10 }) 76 | 77 | test.leftNode.connected = true 78 | setState(test.leftNode, 'synchronized') 79 | expect(badgeStyle().display).toBe('none') 80 | 81 | test.leftNode.connected = false 82 | setState(test.leftNode, 'disconnected') 83 | await test.leftNode.log.add({ type: 'A' }, { reasons: ['t'], sync: true }) 84 | 85 | setState(test.leftNode, 'connecting') 86 | test.leftNode.connected = true 87 | setState(test.leftNode, 'sending') 88 | setState(test.leftNode, 'synchronized') 89 | expect(badgeStyle().display).toBe('block') 90 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/refresh.svg")') 91 | test.leftNode.log.add({ id: '1 1:1:1 0', type: 'logux/processed' }) 92 | await delay(1) 93 | 94 | expect(getBadgeMessage()).toEqual(badgeEn.synchronized) 95 | await delay(10) 96 | 97 | expect(badgeStyle().display).toBe('none') 98 | }) 99 | 100 | it('shows disconnected state', async () => { 101 | let test = await createTest() 102 | test.leftNode.connected = true 103 | setState(test.leftNode, 'connected') 104 | test.leftNode.connected = false 105 | setState(test.leftNode, 'disconnected') 106 | expect(badgeStyle().display).toBe('block') 107 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/offline.svg")') 108 | expect(getBadgeMessage()).toEqual(badgeEn.disconnected) 109 | }) 110 | 111 | it('shows wait state', async () => { 112 | let test = await createTest() 113 | test.leftNode.connected = false 114 | setState(test.leftNode, 'disconnected') 115 | setState(test.leftNode, 'wait') 116 | await test.leftNode.log.add({ type: 'A' }, { reasons: ['t'], sync: true }) 117 | expect(badgeStyle().display).toBe('block') 118 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/offline.svg")') 119 | expect(getBadgeMessage()).toEqual(badgeEn.wait) 120 | }) 121 | 122 | it('shows sending state', async () => { 123 | let test = await createTest() 124 | 125 | test.leftNode.connected = false 126 | setState(test.leftNode, 'disconnected') 127 | setState(test.leftNode, 'connecting') 128 | expect(getBadgeMessage()).toEqual(badgeEn.disconnected) 129 | 130 | test.leftNode.connected = false 131 | setState(test.leftNode, 'wait') 132 | await test.leftNode.log.add({ type: 'A' }, { reasons: ['t'], sync: true }) 133 | 134 | setState(test.leftNode, 'connecting') 135 | expect(badgeStyle().display).toBe('block') 136 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/offline.svg")') 137 | expect(getBadgeMessage()).toEqual(badgeEn.wait) 138 | await delay(105) 139 | 140 | expect(getBadgeMessage()).toEqual(badgeEn.sending) 141 | setState(test.leftNode, 'sending') 142 | expect(getBadgeMessage()).toEqual(badgeEn.sending) 143 | }) 144 | 145 | it('shows error', async () => { 146 | let test = await createTest() 147 | emit(test.leftNode, 'error', { type: 'any error' }) 148 | expect(badgeStyle().display).toBe('block') 149 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/error.svg")') 150 | expect(getBadgeMessage()).toEqual(badgeEn.syncError) 151 | }) 152 | 153 | it('shows server errors', async () => { 154 | let test = await createTest() 155 | let protocol = new LoguxError('wrong-protocol', { 156 | supported: '1.0.0', 157 | used: '0.1.0' 158 | }) 159 | emit(test.leftNode, 'error', protocol) 160 | expect(badgeStyle().display).toBe('block') 161 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/refresh.svg")') 162 | expect(getBadgeMessage()).toEqual(badgeEn.protocolError) 163 | 164 | let subprotocol = new LoguxError('wrong-subprotocol', { 165 | supported: '1.0.0', 166 | used: '0.1.0' 167 | }) 168 | emit(test.leftNode, 'error', subprotocol) 169 | expect(badgeStyle().display).toBe('block') 170 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/refresh.svg")') 171 | expect(getBadgeMessage()).toEqual(badgeEn.protocolError) 172 | }) 173 | 174 | it('shows client error', async () => { 175 | let test = await createTest() 176 | let error = new LoguxError('timeout', 10, true) 177 | emit(test.leftNode, 'clientError', error) 178 | 179 | expect(badgeStyle().display).toBe('block') 180 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/error.svg")') 181 | expect(getBadgeMessage()).toEqual(badgeEn.syncError) 182 | }) 183 | 184 | it('shows error undo actions', async () => { 185 | let test = await createTest() 186 | test.leftNode.log.add({ reason: 'error', type: 'logux/undo' }) 187 | expect(badgeStyle().display).toBe('block') 188 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/error.svg")') 189 | expect(getBadgeMessage()).toEqual(badgeEn.error) 190 | }) 191 | 192 | it('shows denied undo actions', async () => { 193 | let test = await createTest() 194 | test.leftNode.log.add({ reason: 'denied', type: 'logux/undo' }) 195 | expect(badgeStyle().display).toBe('block') 196 | expect(badgeStyle().backgroundImage).toBe('url("/badge/styles/error.svg")') 197 | expect(getBadgeMessage()).toEqual(badgeEn.denied) 198 | }) 199 | 200 | it('supports bottom and left side of position setting', async () => { 201 | await createTest({ position: 'bottom-left' }) 202 | expect(badgeStyle().bottom).toBe('0px') 203 | expect(badgeStyle().left).toBe('0px') 204 | }) 205 | 206 | it('supports middle and right side of position setting', async () => { 207 | await createTest({ position: 'middle-right' }) 208 | expect(badgeStyle().top).toBe('50%') 209 | expect(badgeStyle().right).toBe('0px') 210 | expect(badgeStyle().transform).toBe('translateY(-50%)') 211 | }) 212 | 213 | it('supports bottom and center side of position setting', async () => { 214 | await createTest({ position: 'bottom-center' }) 215 | expect(badgeStyle().bottom).toBe('0px') 216 | expect(badgeStyle().left).toBe('50%') 217 | expect(badgeStyle().transform).toBe('translateX(-50%)') 218 | }) 219 | 220 | it('supports middle-center position setting', async () => { 221 | await createTest({ position: 'middle-center' }) 222 | expect(badgeStyle().top).toBe('50%') 223 | expect(badgeStyle().left).toBe('50%') 224 | expect(badgeStyle().transform).toBe('translate(-50%, -50%)') 225 | }) 226 | 227 | it('removes badge from DOM', () => { 228 | let pair = new TestPair() 229 | let client = new CrossTabClient({ 230 | server: pair.left, 231 | subprotocol: '1.0.0', 232 | time: new TestTime(), 233 | userId: '10' 234 | }) 235 | 236 | let opts = { messages: badgeEn, styles: badgeStyles } 237 | let unbind = badge(client, opts) 238 | 239 | unbind() 240 | 241 | expect(badgeNode()).toBeNull() 242 | emit(client.node, 'error', { type: 'wrong-protocol' }) 243 | }) 244 | -------------------------------------------------------------------------------- /badge/styles/error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /badge/styles/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { BadgeStyles } from '../index.js' 2 | 3 | export const badgeStyles: BadgeStyles 4 | -------------------------------------------------------------------------------- /badge/styles/index.js: -------------------------------------------------------------------------------- 1 | import error from './error.svg' 2 | import offline from './offline.svg' 3 | import refresh from './refresh.svg' 4 | import success from './success.svg' 5 | 6 | export let badgeStyles = { 7 | base: { 8 | backgroundPosition: '1.2em center', 9 | backgroundRepeat: 'no-repeat', 10 | backgroundSize: '1.8em', 11 | borderRadius: '0.4em', 12 | color: '#fff', 13 | fontFamily: 'Helvetica Neue, sans-serif', 14 | height: '4em', 15 | lineHeight: '1.4', 16 | margin: '1.5em', 17 | opacity: '0.8', 18 | paddingLeft: '4.2em', 19 | position: 'fixed', 20 | width: '15.4em', 21 | zIndex: '999' 22 | }, 23 | disconnected: { 24 | backgroundColor: '#000', 25 | backgroundImage: 'url(' + offline + ')' 26 | }, 27 | error: { 28 | backgroundColor: '#F42A2A', 29 | backgroundImage: 'url(' + error + ')' 30 | }, 31 | protocolError: { 32 | backgroundColor: '#000', 33 | backgroundImage: 'url(' + refresh + ')' 34 | }, 35 | sending: { 36 | backgroundColor: '#000', 37 | backgroundImage: 'url(' + refresh + ')' 38 | }, 39 | synchronized: { 40 | backgroundColor: '#000', 41 | backgroundImage: 'url(' + success + ')' 42 | }, 43 | text: { 44 | display: 'table-cell', 45 | height: '4em', 46 | verticalAlign: 'middle' 47 | }, 48 | wait: { 49 | backgroundColor: '#000', 50 | backgroundImage: 'url(' + offline + ')' 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /badge/styles/offline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /badge/styles/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /badge/styles/success.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/errors.ts: -------------------------------------------------------------------------------- 1 | import { defineAction } from '@logux/actions' 2 | import { Action } from '@logux/core' 3 | 4 | import { Client } from '../index.js' 5 | 6 | let client = new Client({ 7 | subprotocol: '1.0.0', 8 | server: 'ws://localhost', 9 | userId: '10' 10 | }) 11 | 12 | // THROWS Type 'number' is not assignable to type 'string' 13 | client.log.add({ type: 'A' }, { tab: 1 }) 14 | 15 | new Client({ 16 | subprotocol: '1.0.0', 17 | server: 'ws://localhost', 18 | // THROWS Type 'boolean' is not assignable to type 'string'. 19 | userId: false 20 | }) 21 | 22 | type RenameAction = Action & { 23 | type: 'rename' 24 | name: string 25 | } 26 | 27 | let userRename = defineAction('rename') 28 | 29 | // THROWS '"rename2"' is not assignable to parameter of type '"rename"' 30 | client.type('rename2', action => { 31 | document.title = action.name 32 | }) 33 | 34 | client.type('rename', action => { 35 | // THROWS 'fullName' does not exist on type 'RenameAction' 36 | document.title = action.fullName 37 | }) 38 | 39 | client.type(userRename, action => { 40 | // THROWS 'fullName' does not exist on type 'RenameAction' 41 | document.title = action.fullName 42 | }) 43 | -------------------------------------------------------------------------------- /client/types.ts: -------------------------------------------------------------------------------- 1 | import { defineAction } from '@logux/actions' 2 | import type { Action } from '@logux/core' 3 | 4 | import { Client } from '../index.js' 5 | 6 | let client = new Client({ 7 | server: 'ws://localhost', 8 | subprotocol: '1.0.0', 9 | userId: '10' 10 | }) 11 | 12 | client.log.add({ type: 'A' }, { extra: 1 }) 13 | 14 | type UserRenameAction = { 15 | name: string 16 | type: 'user/rename' 17 | userId: string 18 | } & Action 19 | 20 | let userRename = defineAction('user/rename') 21 | 22 | client.type('user/rename', action => { 23 | document.title = action.name 24 | }) 25 | 26 | client.type(userRename, action => { 27 | document.title = action.name 28 | }) 29 | -------------------------------------------------------------------------------- /confirm/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '../client/index.js' 2 | 3 | /** 4 | * Show confirm popup, when user close tab with non-synchronized actions. 5 | * 6 | * ```js 7 | * import { confirm } from '@logux/client' 8 | * confirm(client) 9 | * ``` 10 | * 11 | * @param client Observed Client instance. 12 | * @returns Unbind listener. 13 | */ 14 | export function confirm(client: Client): () => void 15 | -------------------------------------------------------------------------------- /confirm/index.js: -------------------------------------------------------------------------------- 1 | function block(e) { 2 | e.returnValue = 'unsynced' 3 | return 'unsynced' 4 | } 5 | 6 | export function confirm(client) { 7 | let disconnected = client.state === 'disconnected' 8 | let wait = false 9 | 10 | let update = () => { 11 | if (client.state === 'disconnected') { 12 | disconnected = true 13 | } else if (client.state === 'synchronized') { 14 | disconnected = false 15 | wait = false 16 | } 17 | 18 | if (typeof window !== 'undefined' && window.addEventListener) { 19 | if (client.role !== 'follower' && wait && disconnected) { 20 | window.addEventListener('beforeunload', block) 21 | } else { 22 | window.removeEventListener('beforeunload', block) 23 | } 24 | } 25 | } 26 | 27 | let unbind = [] 28 | unbind.push(client.on('role', update)) 29 | unbind.push(client.on('state', update)) 30 | update() 31 | 32 | unbind.push( 33 | client.on('add', (action, meta) => { 34 | if (action.type === 'logux/subscribe') { 35 | return 36 | } else if (action.type === 'logux/unsubscribe') { 37 | return 38 | } 39 | if (disconnected && meta.sync && meta.added) { 40 | wait = true 41 | update() 42 | } 43 | }) 44 | ) 45 | 46 | return () => { 47 | for (let i of unbind) i() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /confirm/index.test.ts: -------------------------------------------------------------------------------- 1 | import { TestPair } from '@logux/core' 2 | import { restoreAll, spyOn } from 'nanospy' 3 | import { beforeEach, expect, it } from 'vitest' 4 | 5 | import { confirm, CrossTabClient } from '../index.js' 6 | 7 | function setState(client: any, state: string): void { 8 | client.node.setState(state) 9 | } 10 | 11 | function emit(obj: any, event: string, ...args: any[]): void { 12 | obj.emitter.emit(event, ...args) 13 | } 14 | 15 | async function createClient(): Promise { 16 | let pair = new TestPair() 17 | 18 | let client = new CrossTabClient({ 19 | server: pair.left, 20 | subprotocol: '1.0.0', 21 | userId: '10' 22 | }) 23 | 24 | client.node.catch(() => true) 25 | client.role = 'leader' 26 | 27 | await pair.left.connect() 28 | return client 29 | } 30 | 31 | let beforeunloader: ((event?: any) => string) | false 32 | function callBeforeloader(event?: any): string { 33 | if (beforeunloader === false) { 34 | throw new Error('beforeunloader was not set') 35 | } else { 36 | return beforeunloader(event) 37 | } 38 | } 39 | 40 | beforeEach(() => { 41 | restoreAll() 42 | beforeunloader = false 43 | 44 | spyOn(window, 'addEventListener', (event: string, callback: any) => { 45 | if (event === 'beforeunload') beforeunloader = callback 46 | }) 47 | spyOn(window, 'removeEventListener', (event: string, callback: any) => { 48 | if (event === 'beforeunload' && beforeunloader === callback) { 49 | beforeunloader = false 50 | } 51 | }) 52 | }) 53 | 54 | it('confirms close', async () => { 55 | let client = await createClient() 56 | confirm(client) 57 | 58 | setState(client, 'disconnected') 59 | expect(beforeunloader).toBe(false) 60 | 61 | await Promise.all([ 62 | client.log.add({ type: 'logux/subscribe' }, { reasons: ['t'], sync: true }), 63 | client.log.add( 64 | { type: 'logux/unsubscribe' }, 65 | { reasons: ['t'], sync: true } 66 | ) 67 | ]) 68 | expect(beforeunloader).toBe(false) 69 | 70 | await client.log.add({ type: 'A' }, { reasons: ['t'], sync: true }) 71 | expect(callBeforeloader({})).toBe('unsynced') 72 | 73 | setState(client, 'sending') 74 | let e: any = {} 75 | callBeforeloader(e) 76 | expect(e.returnValue).toBe('unsynced') 77 | }) 78 | 79 | it('does not confirm on synchronized state', async () => { 80 | let client = await createClient() 81 | confirm(client) 82 | setState(client, 'disconnected') 83 | await client.log.add({ type: 'A' }, { reasons: ['t'], sync: true }) 84 | 85 | setState(client, 'synchronized') 86 | expect(beforeunloader).toBe(false) 87 | 88 | setState(client, 'disconnected') 89 | expect(beforeunloader).toBe(false) 90 | }) 91 | 92 | it('does not confirm on follower tab', async () => { 93 | let client = await createClient() 94 | confirm(client) 95 | 96 | setState(client, 'disconnected') 97 | expect(beforeunloader).toBe(false) 98 | 99 | await client.log.add({ type: 'A' }, { reasons: ['t'], sync: true }) 100 | client.role = 'follower' 101 | emit(client, 'role') 102 | expect(beforeunloader).toBe(false) 103 | }) 104 | 105 | it('returns unbind function', async () => { 106 | let client = await createClient() 107 | let unbind = confirm(client) 108 | unbind() 109 | setState(client, 'disconnected') 110 | expect(beforeunloader).toBe(false) 111 | await client.log.add({ type: 'A' }, { reasons: ['t'], sync: true }) 112 | expect(beforeunloader).toBe(false) 113 | }) 114 | -------------------------------------------------------------------------------- /create-auth/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { ReadableAtom } from 'nanostores' 2 | 3 | import type { Client } from '../client/index.js' 4 | 5 | /** 6 | * Auth store. Use {@link createAuth} to create it. 7 | */ 8 | export interface AuthStore 9 | extends ReadableAtom<{ 10 | isAuthenticated: boolean 11 | userId: string 12 | }> { 13 | /** 14 | * While store is loading initial state. 15 | */ 16 | readonly loading: Promise 17 | } 18 | 19 | /** 20 | * Create store with user’s authentication state. 21 | * 22 | * ```js 23 | * import { createAuth } from '@logux/client' 24 | * 25 | * let auth = createAuth(client) 26 | * await auth.loading 27 | * console.log(auth.get()) 28 | * ``` 29 | * 30 | * @param client Logux Client. 31 | */ 32 | export function createAuth(client: Client): AuthStore 33 | -------------------------------------------------------------------------------- /create-auth/index.js: -------------------------------------------------------------------------------- 1 | import { atom, onMount } from 'nanostores' 2 | 3 | export function createAuth(client) { 4 | let auth = atom({ 5 | isAuthenticated: client.node.state === 'synchronized', 6 | userId: client.options.userId 7 | }) 8 | 9 | onMount(auth, () => { 10 | auth.set({ 11 | isAuthenticated: client.node.state === 'synchronized', 12 | userId: client.options.userId 13 | }) 14 | 15 | let load 16 | let loaded = auth.value.isAuthenticated 17 | auth.loading = new Promise(resolve => { 18 | if (loaded) resolve() 19 | load = () => { 20 | loaded = true 21 | resolve() 22 | } 23 | }) 24 | 25 | let stateBinded = false 26 | let unbindState 27 | 28 | let bindState = () => { 29 | stateBinded = true 30 | unbindState = client.node.on('state', () => { 31 | if (client.node.state === 'synchronized') { 32 | auth.set({ 33 | isAuthenticated: true, 34 | userId: auth.value.userId 35 | }) 36 | if (!loaded) load() 37 | unbindState() 38 | stateBinded = false 39 | } 40 | }) 41 | } 42 | 43 | bindState() 44 | 45 | let unbindError = client.node.catch(error => { 46 | if (error.type === 'wrong-credentials') { 47 | if (!stateBinded) bindState() 48 | auth.set({ 49 | isAuthenticated: false, 50 | userId: auth.value.userId 51 | }) 52 | if (!loaded) load() 53 | } 54 | }) 55 | let unbindUser = client.on('user', userId => { 56 | auth.set({ 57 | isAuthenticated: auth.value.isAuthenticated, 58 | userId 59 | }) 60 | }) 61 | 62 | return () => { 63 | unbindState() 64 | unbindError() 65 | unbindUser() 66 | } 67 | }) 68 | 69 | return auth 70 | } 71 | -------------------------------------------------------------------------------- /create-auth/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { expect, it } from 'vitest' 3 | 4 | import { TestClient } from '../index.js' 5 | import { createAuth } from './index.js' 6 | 7 | function emit(obj: any, event: string, ...args: any[]): void { 8 | obj.emitter.emit(event, ...args) 9 | } 10 | 11 | function getEventsCount(obj: any, event: string): number { 12 | return obj.emitter.events[event]?.length || 0 13 | } 14 | 15 | it('returns the state of authentication', async () => { 16 | let client = new TestClient('10') 17 | let auth = createAuth(client) 18 | 19 | expect(auth.get().userId).toBe('10') 20 | expect(auth.get().isAuthenticated).toBe(false) 21 | 22 | await client.connect() 23 | expect(auth.get().isAuthenticated).toBe(true) 24 | }) 25 | 26 | it('switches loading state on connect', async () => { 27 | let client = new TestClient('10') 28 | client.connect() 29 | 30 | let auth = createAuth(client) 31 | 32 | expect(auth.get().userId).toBe('10') 33 | expect(auth.get().isAuthenticated).toBe(false) 34 | 35 | await auth.loading 36 | expect(auth.get().isAuthenticated).toBe(true) 37 | }) 38 | 39 | it('switches loading state on catch error', async () => { 40 | let client = new TestClient('10') 41 | let auth = createAuth(client) 42 | 43 | expect(auth.get().isAuthenticated).toBe(false) 44 | 45 | emit(client.node, 'error', { type: 'wrong-credentials' }) 46 | await auth.loading 47 | expect(auth.get().isAuthenticated).toBe(false) 48 | }) 49 | 50 | it('change state on wrong credentials', async () => { 51 | let client = new TestClient('10') 52 | let auth = createAuth(client) 53 | 54 | await client.connect() 55 | expect(auth.get().isAuthenticated).toBe(true) 56 | 57 | emit(client.node, 'error', { type: 'wrong-credentials' }) 58 | await delay(1) 59 | expect(auth.get().isAuthenticated).toBe(false) 60 | }) 61 | 62 | it('doesn’t change state on disconnection', async () => { 63 | let client = new TestClient('10') 64 | let auth = createAuth(client) 65 | 66 | await client.connect() 67 | expect(auth.get().isAuthenticated).toBe(true) 68 | 69 | client.disconnect() 70 | await delay(1) 71 | expect(auth.get().isAuthenticated).toBe(true) 72 | }) 73 | 74 | it('updates user id after user change', async () => { 75 | let client = new TestClient('10') 76 | let auth = createAuth(client) 77 | 78 | await client.connect() 79 | expect(auth.get().userId).toBe('10') 80 | 81 | client.changeUser('20', 'token') 82 | await delay(1) 83 | expect(auth.get().userId).toBe('20') 84 | }) 85 | 86 | it('unbinds client events', async () => { 87 | let client = new TestClient('10') 88 | let auth = createAuth(client) 89 | 90 | expect(getEventsCount(client, 'user')).toBe(0) 91 | expect(getEventsCount(client.node, 'error')).toBe(0) 92 | expect(getEventsCount(client.node, 'state')).toBe(1) 93 | 94 | let destroy = auth.listen(() => {}) 95 | expect(auth.get().isAuthenticated).toBe(false) 96 | expect(getEventsCount(client, 'user')).toBe(1) 97 | expect(getEventsCount(client.node, 'error')).toBe(1) 98 | expect(getEventsCount(client.node, 'state')).toBe(2) 99 | 100 | await client.connect() 101 | expect(auth.get().isAuthenticated).toBe(true) 102 | expect(getEventsCount(client.node, 'state')).toBe(1) 103 | 104 | emit(client.node, 'error', { type: 'wrong-credentials' }) 105 | await delay(1) 106 | expect(auth.get().isAuthenticated).toBe(false) 107 | expect(getEventsCount(client.node, 'state')).toBe(2) 108 | 109 | destroy() 110 | await delay(1000) 111 | expect(getEventsCount(client, 'user')).toBe(0) 112 | expect(getEventsCount(client.node, 'error')).toBe(0) 113 | expect(getEventsCount(client.node, 'state')).toBe(1) 114 | }) 115 | -------------------------------------------------------------------------------- /create-client-store/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Atom, MapStore } from 'nanostores' 2 | 3 | import type { Client } from '../client/index.js' 4 | 5 | interface CreateClientStore { 6 | /** 7 | * Create stores to keep client instance and update it on user ID changes. 8 | * 9 | * ```js 10 | * import { createClientStore, Client, log } from '@logux/client' 11 | * import { persistentMap } from '@nanostores/persistent' 12 | * 13 | * let sessionStore = persistentMap<{ userId: string }>('session:', { 14 | * userId: 'anonymous' 15 | * }) 16 | * 17 | * export const clientStore = createClientStore(sessionStore, session => { 18 | * let client new Client({ 19 | * subprotocol: SUBPROTOCOL, 20 | * server: 'ws://example.com', 21 | * userId: session.userId 22 | * }) 23 | * log(client) 24 | * return client 25 | * }) 26 | * ``` 27 | * 28 | * @param userIdStore Store with object and `userId` key. 29 | * @param builder Callback which return client 30 | * @returns Atom store with client 31 | */ 32 | ( 33 | userIdStore: MapStore, 34 | builder: (value: UserId) => Client 35 | ): Atom 36 | 37 | ( 38 | userIdStore: MapStore, 39 | builder: (value: UserId) => Client | undefined 40 | ): Atom 41 | } 42 | 43 | export const createClientStore: CreateClientStore 44 | -------------------------------------------------------------------------------- /create-client-store/index.js: -------------------------------------------------------------------------------- 1 | import { atom, onMount } from 'nanostores' 2 | 3 | export function createClientStore(userIdStore, builder) { 4 | let clientStore = atom() 5 | 6 | let prevClient 7 | function destroyPrev() { 8 | if (prevClient) prevClient.destroy() 9 | prevClient = undefined 10 | } 11 | 12 | let prevUserId 13 | function listener(value) { 14 | if (prevUserId !== value.userId) { 15 | prevUserId = value.userId 16 | 17 | destroyPrev() 18 | let client 19 | if (value.userId) { 20 | client = builder(value) 21 | if (client) { 22 | prevClient = client 23 | client.start() 24 | clientStore.set(client) 25 | } 26 | } 27 | if (!client) { 28 | clientStore.set(undefined) 29 | } 30 | } 31 | } 32 | 33 | listener(userIdStore.get()) 34 | onMount(clientStore, () => { 35 | let unbind = userIdStore.subscribe(listener) 36 | return () => { 37 | unbind() 38 | destroyPrev() 39 | clientStore.set(undefined) 40 | } 41 | }) 42 | 43 | return clientStore 44 | } 45 | -------------------------------------------------------------------------------- /create-client-store/index.test.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'nanodelay' 2 | import { map, STORE_UNMOUNT_DELAY } from 'nanostores' 3 | import { expect, it } from 'vitest' 4 | 5 | import { type Client, createClientStore } from '../index.js' 6 | 7 | it('creates client from user ID', async () => { 8 | let user = map<{ enabled: boolean; userId?: string }>({ 9 | enabled: true, 10 | userId: '1' 11 | }) 12 | 13 | let events: string[] = [] 14 | let clientStore = createClientStore(user, ({ enabled, userId }) => { 15 | if (!enabled) return undefined 16 | return { 17 | destroy() { 18 | events.push(`${userId} destroy`) 19 | }, 20 | nodeId: userId, 21 | start() { 22 | events.push(`${userId} start`) 23 | } 24 | } as Client 25 | }) 26 | 27 | let unbind = clientStore.listen(() => {}) 28 | 29 | expect(events).toEqual(['1 start']) 30 | expect(clientStore.get()?.nodeId).toBe('1') 31 | 32 | user.setKey('userId', '2') 33 | expect(events).toEqual(['1 start', '1 destroy', '2 start']) 34 | expect(clientStore.get()?.nodeId).toBe('2') 35 | 36 | user.setKey('enabled', false) 37 | expect(events).toEqual(['1 start', '1 destroy', '2 start']) 38 | 39 | user.setKey('userId', '3') 40 | expect(events).toEqual(['1 start', '1 destroy', '2 start', '2 destroy']) 41 | expect(clientStore.get()).toBeUndefined() 42 | 43 | user.set({ enabled: true, userId: '4' }) 44 | expect(events).toEqual([ 45 | '1 start', 46 | '1 destroy', 47 | '2 start', 48 | '2 destroy', 49 | '4 start' 50 | ]) 51 | expect(clientStore.get()?.nodeId).toBe('4') 52 | 53 | user.setKey('userId', undefined) 54 | expect(events).toEqual([ 55 | '1 start', 56 | '1 destroy', 57 | '2 start', 58 | '2 destroy', 59 | '4 start', 60 | '4 destroy' 61 | ]) 62 | expect(clientStore.get()).toBeUndefined() 63 | 64 | user.setKey('userId', '5') 65 | unbind() 66 | await delay(STORE_UNMOUNT_DELAY) 67 | expect(user.lc).toBe(0) 68 | expect(events).toEqual([ 69 | '1 start', 70 | '1 destroy', 71 | '2 start', 72 | '2 destroy', 73 | '4 start', 74 | '4 destroy', 75 | '5 start', 76 | '5 destroy' 77 | ]) 78 | expect(clientStore.get()).toBeUndefined() 79 | }) 80 | -------------------------------------------------------------------------------- /create-filter/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { SyncMapValues } from '@logux/actions' 2 | import type { MapStore } from 'nanostores' 3 | 4 | import type { Client } from '../client/index.js' 5 | import type { 6 | LoadedSyncMapValue, 7 | SyncMapStore, 8 | SyncMapTemplate 9 | } from '../sync-map-template/index.js' 10 | 11 | export type Filter = { 12 | [Key in keyof Value]?: Value[Key] 13 | } 14 | 15 | export interface FilterOptions { 16 | listChangesOnly?: boolean 17 | } 18 | 19 | export type LoadedFilterValue = { 20 | isEmpty: boolean 21 | isLoading: false 22 | list: LoadedSyncMapValue[] 23 | stores: Map> 24 | } 25 | 26 | export type FilterValue = 27 | | { isLoading: true } 28 | | LoadedFilterValue 29 | 30 | export interface FilterStore 31 | extends MapStore> { 32 | /** 33 | * While store is loading initial data from server or log. 34 | */ 35 | readonly loading: Promise 36 | } 37 | 38 | /** 39 | * Load list of `SyncMap` with simple key-value requirements. 40 | * 41 | * It will look for stores in loaded cache, log (for offline maps) and will 42 | * subscribe to list from server (for remote maps). 43 | * 44 | * ```js 45 | * import { createFilter } from '@logux/client' 46 | * 47 | * import { User } from '../store' 48 | * 49 | * let usersInProject = createFilter(client, User, { projectId }) 50 | * await usersInProject.loading 51 | * console.log(usersInProject.get()) 52 | * ``` 53 | * 54 | * @param client Logux Client. 55 | * @param Template Store template from {@link syncMapTemplate}. 56 | * @param filter Key-value to filter stores. 57 | * @param opts Loading options. 58 | */ 59 | export function createFilter( 60 | client: Client, 61 | Template: SyncMapTemplate, 62 | filter?: Filter, 63 | opts?: FilterOptions 64 | ): FilterStore 65 | -------------------------------------------------------------------------------- /cross-tab-client/errors.ts: -------------------------------------------------------------------------------- 1 | import { CrossTabClient } from '../index.js' 2 | 3 | let client = new CrossTabClient({ 4 | subprotocol: '1.0.0', 5 | server: 'ws://localhost', 6 | userId: '10' 7 | }) 8 | 9 | client.on('preadd', (action, meta) => { 10 | // THROWS Type 'number' is not assignable to type 'string'. 11 | action.type = 1 12 | // THROWS Type 'number' is not assignable to type 'string' 13 | meta.tab = 1 14 | }) 15 | -------------------------------------------------------------------------------- /cross-tab-client/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Action, Log } from '@logux/core' 2 | import type { Unsubscribe } from 'nanoevents' 3 | 4 | import { Client } from '../client/index.js' 5 | import type { ClientActionListener, ClientMeta } from '../client/index.js' 6 | 7 | /** 8 | * Low-level browser API for Logux. 9 | * 10 | * Instead of {@link Client}, this class prevents conflicts 11 | * between Logux instances in different tabs on single browser. 12 | * 13 | * ```js 14 | * import { CrossTabClient } from '@logux/client' 15 | * 16 | * const userId = document.querySelector('meta[name=user]').content 17 | * const token = document.querySelector('meta[name=token]').content 18 | * 19 | * const client = new CrossTabClient({ 20 | * subprotocol: '1.0.0', 21 | * server: 'wss://example.com:1337', 22 | * userId, 23 | * token 24 | * }) 25 | * client.start() 26 | * ``` 27 | */ 28 | export class CrossTabClient< 29 | Headers extends object = {}, 30 | ClientLog extends Log = Log 31 | > extends Client { 32 | /** 33 | * Cache for localStorage detection. Can be overridden to disable leader tab 34 | * election in tests. 35 | */ 36 | isLocalStorage: boolean 37 | 38 | /** 39 | * Current tab role. Only `leader` tab connects to server. `followers` just 40 | * listen to events from `leader`. 41 | * 42 | * ```js 43 | * client.on('role', () => { 44 | * console.log('Tab role:', client.role) 45 | * }) 46 | * ``` 47 | */ 48 | role: 'follower' | 'leader' 49 | 50 | on( 51 | event: 'add' | 'clean' | 'preadd', 52 | listener: ClientActionListener 53 | ): Unsubscribe 54 | /** 55 | * Subscribe for synchronization events. It implements nanoevents API. 56 | * Supported events: 57 | * 58 | * * `preadd`: action is going to be added (in current tab). 59 | * * `add`: action has been added to log (by any tab). 60 | * * `clean`: action has been removed from log (by any tab). 61 | * * `role`: tab role has been changed. 62 | * * `state`: leader tab synchronization state has been changed. 63 | * * `user`: user ID was changed. 64 | * 65 | * ```js 66 | * client.on('add', (action, meta) => { 67 | * dispatch(action) 68 | * }) 69 | * ``` 70 | * 71 | * @param event The event name. 72 | * @param listener The listener function. 73 | * @returns Unbind listener from event. 74 | */ 75 | on(event: 'role' | 'state', listener: () => void): Unsubscribe 76 | on(event: 'user', listener: (userId: string) => void): Unsubscribe 77 | } 78 | -------------------------------------------------------------------------------- /cross-tab-client/index.js: -------------------------------------------------------------------------------- 1 | import { actionEvents, LoguxError } from '@logux/core' 2 | 3 | import { Client } from '../client/index.js' 4 | 5 | function storageKey(client, name) { 6 | return client.options.prefix + ':' + client.options.userId + ':' + name 7 | } 8 | 9 | function sendToTabs(client, event, data) { 10 | if (!client.isLocalStorage) return 11 | let key = storageKey(client, event) 12 | let json = JSON.stringify(data) 13 | try { 14 | localStorage.setItem(key, json) 15 | } catch (e) { 16 | console.error(e) 17 | client.isLocalStorage = false 18 | client.role = 'leader' 19 | client.emitter.emit('role') 20 | if (client.autoconnect) client.node.connection.connect() 21 | } 22 | } 23 | 24 | function compareSubprotocols(left, right) { 25 | let leftParts = left.split('.') 26 | let rightParts = right.split('.') 27 | for (let i = 0; i < 3; i++) { 28 | let leftNumber = parseInt(leftParts[i] || 0) 29 | let rightNumber = parseInt(rightParts[i] || 0) 30 | if (leftNumber > rightNumber) { 31 | return 1 32 | } else if (leftNumber < rightNumber) { 33 | return -1 34 | } 35 | } 36 | return 0 37 | } 38 | 39 | function setState(client, state) { 40 | client.state = state 41 | client.emitter.emit('state') 42 | sendToTabs(client, 'state', client.state) 43 | } 44 | 45 | function isMemory(store) { 46 | return Array.isArray(store.entries) && Array.isArray(store.added) 47 | } 48 | 49 | export class CrossTabClient extends Client { 50 | constructor(opts = {}) { 51 | super(opts) 52 | this.leaderState = this.node.state 53 | this.role = 'follower' 54 | 55 | this.node.on('state', () => { 56 | if (this.role === 'leader') { 57 | setState(this, this.node.state) 58 | } 59 | }) 60 | 61 | this.log.on('add', (action, meta) => { 62 | actionEvents(this.emitter, 'add', action, meta) 63 | if (meta.tab !== this.tabId) { 64 | sendToTabs(this, 'add', [this.tabId, action, meta]) 65 | } 66 | }) 67 | this.log.on('clean', (action, meta) => { 68 | actionEvents(this.emitter, 'clean', action, meta) 69 | }) 70 | 71 | if (typeof window !== 'undefined' && window.addEventListener) { 72 | window.addEventListener('storage', e => this.onStorage(e)) 73 | window.addEventListener('pagehide', e => this.onUnload(e)) 74 | } 75 | 76 | if (this.isLocalStorage) { 77 | let subprotocolKey = storageKey(this, 'subprotocol') 78 | if (localStorage.getItem(subprotocolKey) !== this.options.subprotocol) { 79 | sendToTabs(this, 'subprotocol', this.options.subprotocol) 80 | } 81 | } 82 | } 83 | 84 | changeUser(userId, token) { 85 | sendToTabs(this, 'user', [this.tabId, userId]) 86 | super.changeUser(userId, token) 87 | } 88 | 89 | clean() { 90 | if (this.isLocalStorage) { 91 | localStorage.removeItem(storageKey(this, 'add')) 92 | localStorage.removeItem(storageKey(this, 'state')) 93 | localStorage.removeItem(storageKey(this, 'client')) 94 | } 95 | return super.clean() 96 | } 97 | 98 | destroy() { 99 | super.destroy() 100 | this.role = 'follower' 101 | this.emitter.emit('role') 102 | if (this.unlead) this.unlead() 103 | if (typeof window !== 'undefined' && window.removeEventListener) { 104 | window.removeEventListener('storage', this.onStorage) 105 | } 106 | } 107 | 108 | getClientId() { 109 | let key = storageKey(this, 'client') 110 | if (!this.isLocalStorage) { 111 | return super.getClientId() 112 | } else if (localStorage.getItem(key)) { 113 | return localStorage.getItem(key) 114 | } else { 115 | let clientId = super.getClientId() 116 | localStorage.setItem(key, clientId) 117 | return clientId 118 | } 119 | } 120 | 121 | on(event, listener) { 122 | if (event === 'preadd') { 123 | return this.log.emitter.on(event, listener) 124 | } else { 125 | return this.emitter.on(event, listener) 126 | } 127 | } 128 | 129 | onStorage(e) { 130 | if (e.newValue === null) return 131 | 132 | let data 133 | if (e.key === storageKey(this, 'add')) { 134 | data = JSON.parse(e.newValue) 135 | if (data[0] !== this.tabId) { 136 | let action = data[1] 137 | let meta = data[2] 138 | if (!meta.tab || meta.tab === this.tabId) { 139 | if (isMemory(this.log.store)) { 140 | this.log.store.add(action, meta) 141 | } 142 | actionEvents(this.emitter, 'add', action, meta) 143 | if (this.role === 'leader') { 144 | this.node.onAdd(action, meta) 145 | } 146 | } 147 | } 148 | } else if (e.key === storageKey(this, 'state')) { 149 | let state = JSON.parse(localStorage.getItem(e.key)) 150 | if (this.leaderState !== state) { 151 | this.leaderState = state 152 | this.emitter.emit('state') 153 | } 154 | } else if (e.key === storageKey(this, 'user')) { 155 | data = JSON.parse(e.newValue) 156 | if (data[0] !== this.tabId) { 157 | this.emitter.emit('user', data[1]) 158 | } 159 | } else if (e.key === storageKey(this, 'subprotocol')) { 160 | let other = JSON.parse(e.newValue) 161 | let compare = compareSubprotocols(this.options.subprotocol, other) 162 | if (compare === 1) { 163 | sendToTabs(this, 'subprotocol', this.options.subprotocol) 164 | } else if (compare === -1) { 165 | let err = new LoguxError( 166 | 'wrong-subprotocol', 167 | { supported: other, used: this.options.subprotocol }, 168 | true 169 | ) 170 | this.node.emitter.emit('error', err) 171 | } 172 | } 173 | } 174 | 175 | start(connect = true) { 176 | this.autoconnect = connect 177 | this.cleanPrevActions() 178 | 179 | if ( 180 | typeof navigator === 'undefined' || 181 | !navigator.locks || 182 | !this.isLocalStorage 183 | ) { 184 | this.role = 'leader' 185 | this.emitter.emit('role') 186 | if (connect) this.node.connection.connect() 187 | return 188 | } 189 | 190 | let json = localStorage.getItem(storageKey(this, 'state')) 191 | if (json && json !== null && json !== '"disconnected"') { 192 | this.state = JSON.parse(json) 193 | this.emitter.emit('state') 194 | } 195 | 196 | navigator.locks.request('logux_leader', () => { 197 | this.role = 'leader' 198 | this.emitter.emit('role') 199 | if (connect) this.node.connection.connect() 200 | return new Promise(resolve => { 201 | this.unlead = resolve 202 | }) 203 | }) 204 | } 205 | 206 | type(type, listener, opts = {}) { 207 | if (opts.event === 'preadd') { 208 | return this.log.type(type, listener, opts) 209 | } else { 210 | let event = opts.event || 'add' 211 | let id = opts.id || '' 212 | return this.emitter.on(`${event}-${type}-${id}`, listener) 213 | } 214 | } 215 | 216 | set state(value) { 217 | this.leaderState = value 218 | } 219 | 220 | get state() { 221 | return this.leaderState 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /cross-tab-client/types.ts: -------------------------------------------------------------------------------- 1 | import { CrossTabClient } from '../index.js' 2 | 3 | let client = new CrossTabClient({ 4 | server: 'ws://localhost', 5 | subprotocol: '1.0.0', 6 | userId: '10' 7 | }) 8 | 9 | client.on('preadd', (action, meta) => { 10 | console.log(action.type) 11 | meta.tab = client.tabId 12 | }) 13 | -------------------------------------------------------------------------------- /encrypt-actions/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '../client/index.js' 2 | 3 | /** 4 | * Encrypt actions before sending them to server. 5 | * 6 | * Actions will be converted to `{ type: '0', d: encrypt(action) }` 7 | * 8 | * ```js 9 | * import { encryptActions } from '@logux/client' 10 | * encryptActions(client, localStorage.getItem('userPassword'), { 11 | * ignore: ['server/public'] // action.type to not be encrypted 12 | * }) 13 | * ``` 14 | * 15 | * @param client Observed Client instance. 16 | * @param secret Password for encryption, or a CryptoKey AES key. 17 | * @param opts Encryption options -- can pass in strings 18 | * to *not* encrypt. 19 | * @returns Unbind listener. 20 | */ 21 | export function encryptActions( 22 | client: Client, 23 | secret: CryptoKey | string, 24 | opts?: { 25 | ignore: string[] 26 | } 27 | ): void 28 | 29 | export function getRandomSpaces(): string 30 | -------------------------------------------------------------------------------- /encrypt-actions/index.js: -------------------------------------------------------------------------------- 1 | let pool = new Uint8Array(128) 2 | let poolOffset = pool.length 3 | 4 | function getRandomBytes(size) { 5 | if (poolOffset + size > pool.length) { 6 | crypto.getRandomValues(pool) 7 | poolOffset = 0 8 | } 9 | let result = pool.slice(poolOffset, poolOffset + size) 10 | poolOffset += size 11 | return result 12 | } 13 | 14 | const SPACES = ' \t\n\r' 15 | 16 | export function getRandomSpaces() { 17 | let size = getRandomBytes(1)[0] % 32 18 | let bytes = getRandomBytes(Math.ceil(size / 4)) 19 | let result = '' 20 | for (let byte of bytes) { 21 | // Binary operations to use one random byte to get 4 random spaces 22 | result += SPACES[byte & 3] 23 | result += SPACES[(byte & 12) >> 2] 24 | result += SPACES[(byte & 48) >> 4] 25 | result += SPACES[(byte & 192) >> 6] 26 | } 27 | return result.slice(0, size) 28 | } 29 | 30 | function sha256(string) { 31 | return crypto.subtle.digest('SHA-256', new TextEncoder().encode(string)) 32 | } 33 | 34 | function bytesToObj(bytes) { 35 | return JSON.parse(new TextDecoder().decode(bytes)) 36 | } 37 | 38 | function objToBytes(object) { 39 | return new TextEncoder().encode(JSON.stringify(object) + getRandomSpaces()) 40 | } 41 | 42 | function aes(iv) { 43 | return { iv, name: 'AES-GCM' } 44 | } 45 | 46 | function bytesToBase64(bytes) { 47 | let binaryString = String.fromCharCode.apply(null, bytes) 48 | return window.btoa(binaryString) 49 | } 50 | 51 | function base64ToBytes(string) { 52 | let binaryString = window.atob(string) 53 | let length = binaryString.length 54 | let bytes = new Uint8Array(length) 55 | for (let i = 0; i < length; i++) { 56 | bytes[i] = binaryString.charCodeAt(i) 57 | } 58 | return bytes 59 | } 60 | 61 | async function encrypt(action, key) { 62 | let iv = getRandomBytes(12) 63 | let encrypted = await crypto.subtle.encrypt(aes(iv), key, objToBytes(action)) 64 | 65 | return { 66 | d: bytesToBase64(new Uint8Array(encrypted)), 67 | iv: bytesToBase64(iv), 68 | type: '0' 69 | } 70 | } 71 | 72 | async function decrypt(action, key) { 73 | let bytes = await crypto.subtle.decrypt( 74 | aes(base64ToBytes(action.iv)), 75 | key, 76 | base64ToBytes(action.d) 77 | ) 78 | return bytesToObj(bytes) 79 | } 80 | 81 | export function encryptActions(client, secret, opts = {}) { 82 | let key 83 | if (secret instanceof CryptoKey) { 84 | key = secret 85 | } 86 | 87 | async function buildKey() { 88 | return crypto.subtle.importKey( 89 | 'raw', 90 | await sha256(secret), 91 | { name: 'AES-GCM' }, 92 | false, 93 | ['encrypt', 'decrypt'] 94 | ) 95 | } 96 | 97 | let ignore = new Set(opts.ignore || []) 98 | 99 | async function onReceive(action, meta) { 100 | if (action.type === '0') { 101 | if (!key) key = await buildKey() 102 | let decrypted = await decrypt(action, key) 103 | return [decrypted, meta] 104 | } else { 105 | return [action, meta] 106 | } 107 | } 108 | 109 | let originOnSend = client.node.options.onSend 110 | client.node.options.onSend = async (action, meta) => { 111 | let result = await originOnSend(action, meta) 112 | if (!result) { 113 | return false 114 | } else if (result[0].type === '0/clean' || ignore.has(result[0].type)) { 115 | return [result[0], result[1]] 116 | } else { 117 | if (!key) key = await buildKey() 118 | let encrypted = await encrypt(result[0], key) 119 | return [encrypted, result[1]] 120 | } 121 | } 122 | 123 | client.node.options.onReceive = onReceive 124 | 125 | client.log.on('clean', (action, meta) => { 126 | if (meta.sync) { 127 | client.log.add({ id: meta.id, type: '0/clean' }, { sync: true }) 128 | } 129 | }) 130 | } 131 | -------------------------------------------------------------------------------- /encrypt-actions/index.test.ts: -------------------------------------------------------------------------------- 1 | import { TestPair, TestTime } from '@logux/core' 2 | import { Crypto, CryptoKey } from '@peculiar/webcrypto' 3 | import { delay } from 'nanodelay' 4 | import { TextDecoder, TextEncoder } from 'node:util' 5 | import { expect, it } from 'vitest' 6 | 7 | import { Client, encryptActions } from '../index.js' 8 | import { getRandomSpaces } from './index.js' 9 | 10 | window.TextEncoder = TextEncoder 11 | // @ts-expect-error 12 | window.TextDecoder = TextDecoder 13 | 14 | if (process.version.startsWith('v18')) { 15 | global.crypto = new Crypto() 16 | global.CryptoKey = CryptoKey 17 | } 18 | 19 | function privateMethods(obj: object): any { 20 | return obj 21 | } 22 | 23 | function getPair(client: Client): TestPair { 24 | return privateMethods(client.node.connection).pair 25 | } 26 | 27 | function deviation(array: Record, runs: number): number { 28 | let values = Object.values(array) 29 | return (values.length * (Math.max(...values) - Math.min(...values))) / runs 30 | } 31 | 32 | const BASE64 = expect.stringMatching(/^[\w+/]+=?=?$/) 33 | 34 | function createClient(): Client { 35 | let pair = new TestPair() 36 | let client = new Client({ 37 | server: pair.left, 38 | subprotocol: '1.0.0', 39 | time: new TestTime(), 40 | userId: '10' 41 | }) 42 | client.on('preadd', (action, meta) => { 43 | meta.reasons.push('test') 44 | }) 45 | return client 46 | } 47 | 48 | async function connect(client: Client): Promise { 49 | await client.node.connection.connect() 50 | let pair = getPair(client) 51 | await pair.wait('right') 52 | pair.right.send([ 53 | 'connected', 54 | client.node.localProtocol, 55 | 'server', 56 | [0, 0], 57 | { token: 'good' } 58 | ]) 59 | await pair.wait('left') 60 | await Promise.resolve() 61 | } 62 | 63 | it('encrypts and decrypts actions', async () => { 64 | let client1 = createClient() 65 | let client2 = createClient() 66 | 67 | encryptActions(client1, 'password') 68 | encryptActions(client2, 'password') 69 | 70 | await Promise.all([connect(client1), connect(client2)]) 71 | getPair(client1).clear() 72 | 73 | client1.log.add({ type: 'sync', value: 'secret' }, { sync: true }) 74 | await delay(50) 75 | expect(getPair(client1).leftSent).toMatchObject([ 76 | [ 77 | 'sync', 78 | 1, 79 | { d: BASE64, iv: BASE64, type: '0' }, 80 | { id: 1, time: expect.any(Number) } 81 | ] 82 | ]) 83 | 84 | getPair(client2).right.send(getPair(client1).leftSent[0]) 85 | await delay(10) 86 | expect(privateMethods(client2.log).actions()).toEqual([ 87 | { type: 'sync', value: 'secret' } 88 | ]) 89 | 90 | client1.log.add({ type: 'sync', value: 'same size' }, { sync: true }) 91 | client1.log.add({ type: 'sync', value: 'same size' }, { sync: true }) 92 | client1.log.add({ type: 'sync', value: 'same size' }, { sync: true }) 93 | client1.log.add({ type: 'sync', value: 'same size' }, { sync: true }) 94 | await delay(100) 95 | let size1 = getPair(client1).leftSent[1][2].d.length 96 | let size2 = getPair(client1).leftSent[2][2].d.length 97 | let size3 = getPair(client1).leftSent[3][2].d.length 98 | let size4 = getPair(client1).leftSent[4][2].d.length 99 | expect(size1 !== size2 || size1 !== size3 || size1 !== size4).toBeTruthy() 100 | }) 101 | 102 | it('accepts key', async () => { 103 | let client1 = createClient() 104 | let client2 = createClient() 105 | 106 | let key = await crypto.subtle.generateKey( 107 | { 108 | length: 256, 109 | name: 'AES-GCM' 110 | }, 111 | true, 112 | ['encrypt', 'decrypt'] 113 | ) 114 | 115 | encryptActions(client1, key) 116 | encryptActions(client2, key) 117 | 118 | await Promise.all([connect(client1), connect(client2)]) 119 | getPair(client1).clear() 120 | 121 | client1.log.add({ type: 'sync', value: 'secret' }, { sync: true }) 122 | await delay(50) 123 | expect(getPair(client1).leftSent).toMatchObject([ 124 | [ 125 | 'sync', 126 | 1, 127 | { d: BASE64, iv: BASE64, type: '0' }, 128 | { id: 1, time: expect.any(Number) } 129 | ] 130 | ]) 131 | 132 | getPair(client2).right.send(getPair(client1).leftSent[0]) 133 | await delay(10) 134 | expect(privateMethods(client2.log).actions()).toEqual([ 135 | { type: 'sync', value: 'secret' } 136 | ]) 137 | }) 138 | 139 | it('ignores specific actions', async () => { 140 | let client1 = createClient() 141 | let client2 = createClient() 142 | 143 | encryptActions(client1, 'password', { ignore: ['server'] }) 144 | encryptActions(client2, 'password') 145 | 146 | await Promise.all([connect(client1), connect(client2)]) 147 | getPair(client1).clear() 148 | 149 | client1.log.add({ type: 'sync' }, { sync: true }) 150 | await delay(10) 151 | client1.log.add({ type: 'server' }, { sync: true }) 152 | await delay(10) 153 | client1.log.add({ type: 'nonsync' }) 154 | await delay(10) 155 | expect(getPair(client1).leftSent).toMatchObject([ 156 | [ 157 | 'sync', 158 | 1, 159 | { d: BASE64, iv: BASE64, type: '0' }, 160 | { id: 1, time: expect.any(Number) } 161 | ], 162 | ['sync', 2, { type: 'server' }, { id: 2, time: expect.any(Number) }] 163 | ]) 164 | 165 | getPair(client2).right.send(getPair(client1).leftSent[0]) 166 | getPair(client2).right.send(getPair(client1).leftSent[1]) 167 | await delay(10) 168 | expect(privateMethods(client2.log).actions()).toEqual([ 169 | { type: 'sync' }, 170 | { type: 'server' } 171 | ]) 172 | }) 173 | 174 | it('cleans actions on server', async () => { 175 | let client = createClient() 176 | encryptActions(client, 'password') 177 | await connect(client) 178 | 179 | let meta = await client.log.add({ type: 'sync' }, { sync: true }) 180 | if (meta === false) throw new Error('Action was no inserted') 181 | await delay(10) 182 | getPair(client).clear() 183 | 184 | await client.log.removeReason('test') 185 | await client.log.removeReason('syncing') 186 | await delay(10) 187 | expect(getPair(client).leftSent).toMatchObject([ 188 | [ 189 | 'sync', 190 | 2, 191 | { id: meta.id, type: '0/clean' }, 192 | { id: 2, time: expect.any(Number) } 193 | ] 194 | ]) 195 | }) 196 | 197 | it('has normal distribution of random spaces', () => { 198 | let sizes: Record = {} 199 | let symbols: Record = {} 200 | 201 | for (let i = 0; i < 100000; i++) { 202 | let spaces = getRandomSpaces() 203 | 204 | if (!sizes[spaces.length]) sizes[spaces.length] = 0 205 | sizes[spaces.length] += 1 206 | 207 | for (let symbol of spaces) { 208 | if (!symbols[symbol]) symbols[symbol] = 0 209 | symbols[symbol] += 1 210 | } 211 | } 212 | 213 | expect(Object.keys(sizes).length).toBe(32) 214 | expect(Object.keys(symbols).length).toBe(4) 215 | 216 | expect(deviation(sizes, 100000)).toBeLessThan(0.2) 217 | expect(deviation(symbols, 100000)).toBeLessThan(0.2) 218 | }) 219 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import loguxTsConfig from '@logux/eslint-config/ts' 2 | import globals from 'globals' 3 | 4 | /** @type {import('eslint').Linter.FlatConfig[]} */ 5 | export default [ 6 | { 7 | ignores: ['test/demo/dist', '**/errors.ts'] 8 | }, 9 | ...loguxTsConfig, 10 | { 11 | languageOptions: { 12 | globals: globals.browser 13 | }, 14 | rules: { 15 | '@typescript-eslint/no-explicit-any': 'off', 16 | 'camelcase': 'off', 17 | 'n/no-unsupported-features/node-builtins': [ 18 | 'error', 19 | { 20 | ignores: [ 21 | 'WebSocket', 22 | 'navigator', 23 | 'crypto', 24 | 'CryptoKey', 25 | 'localStorage' 26 | ] 27 | } 28 | ], 29 | 'no-console': 'off', 30 | 'symbol-description': 'off' 31 | } 32 | } 33 | ] 34 | -------------------------------------------------------------------------------- /favicon/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '../client/index.js' 2 | 3 | interface FaviconLinks { 4 | /** 5 | * Error favicon link. 6 | */ 7 | error?: string 8 | 9 | /** 10 | * Default favicon link. By default, it will be taken from current favicon. 11 | */ 12 | normal?: string 13 | 14 | /** 15 | * Offline favicon link. 16 | */ 17 | offline?: string 18 | } 19 | 20 | /** 21 | * Change favicon to show Logux synchronization status. 22 | * 23 | * ```js 24 | * import { favicon } from '@logux/client' 25 | * favicon(client, { 26 | * normal: '/favicon.ico', 27 | * offline: '/offline.ico', 28 | * error: '/error.ico' 29 | * }) 30 | * ``` 31 | * 32 | * @param client Observed Client instance. 33 | * @param links Favicon links. 34 | * @returns Unbind listener. 35 | */ 36 | export function favicon(client: Client, links: FaviconLinks): () => void 37 | -------------------------------------------------------------------------------- /favicon/index.js: -------------------------------------------------------------------------------- 1 | export function favicon(client, links) { 2 | let normal = links.normal 3 | let offline = links.offline 4 | let error = links.error 5 | 6 | let unbind = [] 7 | let doc = document 8 | let fav = false 9 | let prevFav = false 10 | 11 | function update() { 12 | if (client.connected && prevFav !== normal) { 13 | fav.href = prevFav = normal 14 | } else if ( 15 | !client.connected && 16 | offline && 17 | prevFav !== offline && 18 | prevFav !== error 19 | ) { 20 | fav.href = prevFav = offline 21 | } 22 | } 23 | 24 | function setError() { 25 | if (error && prevFav !== error) { 26 | fav.href = prevFav = error 27 | } 28 | } 29 | 30 | if (doc) { 31 | fav = doc.querySelector('link[rel~="icon"]') 32 | 33 | if (typeof normal === 'undefined') { 34 | normal = fav ? fav.href : '' 35 | } 36 | 37 | if (!fav) { 38 | fav = doc.createElement('link') 39 | fav.rel = 'icon' 40 | fav.href = '' 41 | doc.head.appendChild(fav) 42 | } 43 | 44 | unbind.push(client.on('state', update)) 45 | update() 46 | 47 | unbind.push( 48 | client.on('add', action => { 49 | if (action.type === 'logux/undo' && action.reason) setError() 50 | }) 51 | ) 52 | 53 | unbind.push( 54 | client.node.on('error', err => { 55 | if (err.type !== 'timeout') setError() 56 | }) 57 | ) 58 | } 59 | 60 | return () => { 61 | for (let i of unbind) i() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /favicon/index.test.ts: -------------------------------------------------------------------------------- 1 | import { LoguxError, TestPair } from '@logux/core' 2 | import { afterEach, beforeAll, expect, it } from 'vitest' 3 | 4 | import { CrossTabClient, favicon } from '../index.js' 5 | 6 | function getFavNode(): HTMLLinkElement { 7 | let node = document.querySelector('link[rel~="icon"]') 8 | if (node === null || !(node instanceof HTMLLinkElement)) { 9 | throw new Error('Favicon tag was not found') 10 | } else { 11 | return node 12 | } 13 | } 14 | 15 | function getFavHref(): string { 16 | return new URL(getFavNode().href).pathname 17 | } 18 | 19 | function setFavHref(href: string): void { 20 | getFavNode().href = href 21 | } 22 | 23 | function setState(node: any, state: string): void { 24 | node.setState(state) 25 | } 26 | 27 | function emit(obj: any, event: string, ...args: any[]): void { 28 | obj.emitter.emit(event, ...args) 29 | } 30 | 31 | async function createClient(): Promise { 32 | let pair = new TestPair() 33 | let client = new CrossTabClient({ 34 | server: pair.left, 35 | subprotocol: '1.0.0', 36 | userId: '10' 37 | }) 38 | 39 | client.node.catch(() => {}) 40 | client.role = 'leader' 41 | 42 | await pair.left.connect() 43 | return client 44 | } 45 | 46 | beforeAll(() => { 47 | let fav = document.createElement('link') 48 | fav.rel = 'icon' 49 | fav.href = '' 50 | document.head.appendChild(fav) 51 | }) 52 | 53 | afterEach(() => { 54 | setFavHref('') 55 | }) 56 | 57 | it('set favicon with current state', async () => { 58 | let client = await createClient() 59 | favicon(client, { 60 | normal: '/default.ico', 61 | offline: '/offline.ico' 62 | }) 63 | expect(getFavHref()).toBe('/offline.ico') 64 | }) 65 | 66 | it('changes favicon on state event', async () => { 67 | getFavNode().href = '/custom.ico' 68 | let client = await createClient() 69 | favicon(client, { 70 | normal: '/default.ico', 71 | offline: '/offline.ico' 72 | }) 73 | 74 | setState(client.node, 'sending') 75 | expect(getFavHref()).toBe('/default.ico') 76 | 77 | setState(client.node, 'disconnected') 78 | expect(getFavHref()).toBe('/offline.ico') 79 | }) 80 | 81 | it('works without favicon tag', async () => { 82 | getFavNode().remove() 83 | let client = await createClient() 84 | favicon(client, { offline: '/offline.ico' }) 85 | expect(getFavHref()).toBe('/offline.ico') 86 | 87 | setState(client.node, 'sending') 88 | expect(getFavHref()).toBe('/') 89 | }) 90 | 91 | it('uses current favicon as normal', async () => { 92 | getFavNode().href = '/custom.ico' 93 | let client = await createClient() 94 | favicon(client, { offline: '/offline.ico' }) 95 | setState(client.node, 'sending') 96 | expect(getFavHref()).toBe('/custom.ico') 97 | }) 98 | 99 | it('does not double favicon changes', async () => { 100 | let client = await createClient() 101 | favicon(client, { error: '/error.ico' }) 102 | emit(client.node, 'error', new Error('test')) 103 | expect(getFavHref()).toBe('/error.ico') 104 | 105 | setFavHref('') 106 | emit(client.node, 'error', new Error('test')) 107 | expect(getFavHref()).toBe('/') 108 | }) 109 | 110 | it('uses error icon on undo', async () => { 111 | let client = await createClient() 112 | favicon(client, { error: '/error.ico' }) 113 | await client.log.add({ reason: 'error', type: 'logux/undo' }) 114 | expect(getFavHref()).toBe('/error.ico') 115 | }) 116 | 117 | it('allows to miss timeout error', async () => { 118 | let client = await createClient() 119 | favicon(client, { error: '/error.ico' }) 120 | emit(client.node, 'error', new LoguxError('timeout')) 121 | expect(getFavHref()).toBe('/') 122 | }) 123 | 124 | it('does not override error by offline', async () => { 125 | let client = await createClient() 126 | favicon(client, { 127 | error: '/error.ico', 128 | offline: '/offline.ico' 129 | }) 130 | emit(client.node, 'error', new Error('test')) 131 | expect(getFavHref()).toBe('/error.ico') 132 | 133 | setState(client.node, 'disconnected') 134 | expect(getFavHref()).toBe('/error.ico') 135 | }) 136 | 137 | it('supports cross-tab synchronization', async () => { 138 | let client = await createClient() 139 | favicon(client, { 140 | normal: '/default.ico', 141 | offline: '/offline.ico' 142 | }) 143 | 144 | client.state = 'sending' 145 | emit(client, 'state') 146 | expect(getFavHref()).toBe('/default.ico') 147 | }) 148 | 149 | it('returns unbind function', async () => { 150 | let client = await createClient() 151 | let unbind = favicon(client, { error: '/error.ico' }) 152 | 153 | unbind() 154 | emit(client.node, 'error', new Error('test')) 155 | 156 | expect(getFavHref()).not.toBe('/error.ico') 157 | }) 158 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export { attention } from './attention/index.js' 2 | export { 3 | badge, 4 | badgeEn, 5 | BadgeMessages, 6 | badgeRu, 7 | BadgeStyles 8 | } from './badge/index.js' 9 | export { Client, ClientMeta, ClientOptions } from './client/index.js' 10 | export { confirm } from './confirm/index.js' 11 | 12 | export { AuthStore, createAuth } from './create-auth/index.js' 13 | export { createClientStore } from './create-client-store/index.js' 14 | export { 15 | createFilter, 16 | Filter, 17 | FilterOptions, 18 | FilterStore, 19 | FilterValue, 20 | LoadedFilterValue 21 | } from './create-filter/index.js' 22 | export { CrossTabClient } from './cross-tab-client/index.js' 23 | export { encryptActions } from './encrypt-actions/index.js' 24 | export { favicon } from './favicon/index.js' 25 | export { IndexedStore } from './indexed-store/index.js' 26 | export { log } from './log/index.js' 27 | export { 28 | ChannelDeniedError, 29 | ChannelError, 30 | ChannelNotFoundError, 31 | ChannelServerError, 32 | LoguxUndoError 33 | } from './logux-undo-error/index.js' 34 | export { emptyInTest, prepareForTest } from './prepare-for-test/index.js' 35 | export { request, RequestOptions } from './request/index.js' 36 | export { status } from './status/index.js' 37 | export { 38 | buildNewSyncMap, 39 | changeSyncMap, 40 | changeSyncMapById, 41 | createSyncMap, 42 | deleteSyncMap, 43 | deleteSyncMapById, 44 | ensureLoaded, 45 | LoadedSyncMapValue, 46 | LoadedValue, 47 | loadValue, 48 | SyncMapStore, 49 | syncMapTemplate, 50 | SyncMapTemplate, 51 | SyncMapTemplateLike, 52 | SyncMapValue 53 | } from './sync-map-template/index.js' 54 | export { TestClient } from './test-client/index.js' 55 | export { TestServer } from './test-server/index.js' 56 | export { track } from './track/index.js' 57 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { attention } from './attention/index.js' 2 | export { badge, badgeEn, badgeRu } from './badge/index.js' 3 | export { Client } from './client/index.js' 4 | export { confirm } from './confirm/index.js' 5 | export { createAuth } from './create-auth/index.js' 6 | export { createClientStore } from './create-client-store/index.js' 7 | export { createFilter } from './create-filter/index.js' 8 | export { CrossTabClient } from './cross-tab-client/index.js' 9 | export { encryptActions } from './encrypt-actions/index.js' 10 | export { favicon } from './favicon/index.js' 11 | export { IndexedStore } from './indexed-store/index.js' 12 | export { log } from './log/index.js' 13 | export { LoguxUndoError } from './logux-undo-error/index.js' 14 | export { emptyInTest, prepareForTest } from './prepare-for-test/index.js' 15 | export { request } from './request/index.js' 16 | export { status } from './status/index.js' 17 | export { 18 | buildNewSyncMap, 19 | changeSyncMap, 20 | changeSyncMapById, 21 | createSyncMap, 22 | deleteSyncMap, 23 | deleteSyncMapById, 24 | ensureLoaded, 25 | loadValue, 26 | syncMapTemplate 27 | } from './sync-map-template/index.js' 28 | export { TestClient } from './test-client/index.js' 29 | export { TestServer } from './test-server/index.js' 30 | export { track } from './track/index.js' 31 | -------------------------------------------------------------------------------- /indexed-store/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'fake-indexeddb' 2 | declare module 'fake-indexeddb/lib/FDBKeyRange' 3 | -------------------------------------------------------------------------------- /indexed-store/index.d.ts: -------------------------------------------------------------------------------- 1 | import { LogStore } from '@logux/core' 2 | 3 | /** 4 | * `IndexedDB` store for Logux log. 5 | * 6 | * ```js 7 | * import { IndexedStore } from '@logux/client' 8 | * const client = new CrossTabClient({ 9 | * …, 10 | * store: new IndexedStore() 11 | * }) 12 | * ``` 13 | */ 14 | export class IndexedStore extends LogStore { 15 | /** 16 | * Database name. 17 | */ 18 | name: string 19 | 20 | /** 21 | * @param name Database name to run multiple Logux instances on same web page. 22 | */ 23 | constructor(name?: string) 24 | } 25 | -------------------------------------------------------------------------------- /indexed-store/index.js: -------------------------------------------------------------------------------- 1 | import { isFirstOlder } from '@logux/core' 2 | 3 | const VERSION = 2 4 | 5 | function rejectify(request, reject) { 6 | request.onerror = e => { 7 | /* c8 ignore next 2 */ 8 | reject(e.target.error) 9 | } 10 | } 11 | 12 | function promisify(request) { 13 | return new Promise((resolve, reject) => { 14 | rejectify(request, reject) 15 | request.onsuccess = e => { 16 | resolve(e.target.result) 17 | } 18 | }) 19 | } 20 | 21 | function isDefined(value) { 22 | return typeof value !== 'undefined' 23 | } 24 | 25 | export class IndexedStore { 26 | constructor(name = 'logux') { 27 | this.name = name 28 | this.adding = {} 29 | } 30 | 31 | async add(action, meta) { 32 | let id = meta.id.split(' ') 33 | let entry = { 34 | action, 35 | created: [meta.time, id[1], id[2], id[0]].join(' '), 36 | id: meta.id, 37 | indexes: meta.indexes || [], 38 | meta, 39 | reasons: meta.reasons, 40 | time: meta.time 41 | } 42 | 43 | if (this.adding[entry.created]) { 44 | return false 45 | } 46 | this.adding[entry.created] = true 47 | 48 | let store = await this.init() 49 | let exist = await promisify(store.os('log').index('id').get(meta.id)) 50 | if (exist) { 51 | return false 52 | } else { 53 | let added = await promisify(store.os('log', 'write').add(entry)) 54 | delete store.adding[entry.created] 55 | meta.added = added 56 | return meta 57 | } 58 | } 59 | 60 | async byId(id) { 61 | let store = await this.init() 62 | let result = await promisify(store.os('log').index('id').get(id)) 63 | if (result) { 64 | return [result.action, result.meta] 65 | } else { 66 | return [null, null] 67 | } 68 | } 69 | 70 | async changeMeta(id, diff) { 71 | let store = await this.init() 72 | let entry = await promisify(store.os('log').index('id').get(id)) 73 | if (!entry) { 74 | return false 75 | } else { 76 | for (let key in diff) entry.meta[key] = diff[key] 77 | if (diff.reasons) entry.reasons = diff.reasons 78 | await promisify(store.os('log', 'write').put(entry)) 79 | return true 80 | } 81 | } 82 | 83 | async clean() { 84 | let store = await this.init() 85 | store.db.close() 86 | await promisify(indexedDB.deleteDatabase(store.name)) 87 | } 88 | 89 | async get({ index, order }) { 90 | let store = await this.init() 91 | return new Promise((resolve, reject) => { 92 | let log = store.os('log') 93 | let request 94 | if (index) { 95 | if (order === 'created') { 96 | request = log.index('created').openCursor(null, 'prev') 97 | } else { 98 | let keyRange = IDBKeyRange.only(index) 99 | request = log.index('indexes').openCursor(keyRange, 'prev') 100 | } 101 | } else if (order === 'created') { 102 | request = log.index('created').openCursor(null, 'prev') 103 | } else { 104 | request = log.openCursor(null, 'prev') 105 | } 106 | rejectify(request, reject) 107 | 108 | let entries = [] 109 | request.onsuccess = function (e) { 110 | let cursor = e.target.result 111 | if (!cursor) { 112 | resolve({ entries }) 113 | return 114 | } 115 | if (!index || cursor.value.indexes.includes(index)) { 116 | cursor.value.meta.added = cursor.value.added 117 | entries.unshift([cursor.value.action, cursor.value.meta]) 118 | } 119 | cursor.continue() 120 | } 121 | }) 122 | } 123 | 124 | async getLastAdded() { 125 | let store = await this.init() 126 | let cursor = await promisify(store.os('log').openCursor(null, 'prev')) 127 | return cursor ? cursor.value.added : 0 128 | } 129 | 130 | async getLastSynced() { 131 | let store = await this.init() 132 | let data = await promisify(store.os('extra').get('lastSynced')) 133 | if (data) { 134 | return { received: data.received, sent: data.sent } 135 | } else { 136 | return { received: 0, sent: 0 } 137 | } 138 | } 139 | 140 | init() { 141 | if (this.initing) return this.initing 142 | 143 | let store = this 144 | let opening = indexedDB.open(this.name, VERSION) 145 | 146 | opening.onupgradeneeded = function (e) { 147 | let db = e.target.result 148 | 149 | let log 150 | if (e.oldVersion < 1) { 151 | log = db.createObjectStore('log', { 152 | autoIncrement: true, 153 | keyPath: 'added' 154 | }) 155 | log.createIndex('id', 'id', { unique: true }) 156 | log.createIndex('created', 'created', { unique: true }) 157 | log.createIndex('reasons', 'reasons', { multiEntry: true }) 158 | db.createObjectStore('extra', { keyPath: 'key' }) 159 | } 160 | if (e.oldVersion < 2) { 161 | if (!log) { 162 | /* c8 ignore next 2 */ 163 | log = opening.transaction.objectStore('log') 164 | } 165 | log.createIndex('indexes', 'indexes', { multiEntry: true }) 166 | } 167 | } 168 | 169 | this.initing = promisify(opening).then(db => { 170 | store.db = db 171 | db.onversionchange = function () { 172 | store.db.close() 173 | if (typeof document !== 'undefined' && document.reload) { 174 | document.reload() 175 | } 176 | } 177 | return store 178 | }) 179 | 180 | return this.initing 181 | } 182 | 183 | os(name, write) { 184 | let mode = write ? 'readwrite' : 'readonly' 185 | return this.db.transaction(name, mode).objectStore(name) 186 | } 187 | 188 | async remove(id) { 189 | let store = await this.init() 190 | let entry = await promisify(store.os('log').index('id').get(id)) 191 | if (!entry) { 192 | return false 193 | } else { 194 | await promisify(store.os('log', 'write').delete(entry.added)) 195 | entry.meta.added = entry.added 196 | return [entry.action, entry.meta] 197 | } 198 | } 199 | 200 | async removeReason(reason, criteria, callback) { 201 | let store = await this.init() 202 | if (criteria.id) { 203 | let entry = await promisify(store.os('log').index('id').get(criteria.id)) 204 | if (entry) { 205 | let index = entry.meta.reasons.indexOf(reason) 206 | if (index !== -1) { 207 | entry.meta.reasons.splice(index, 1) 208 | entry.reasons = entry.meta.reasons 209 | if (entry.meta.reasons.length === 0) { 210 | callback(entry.action, entry.meta) 211 | await promisify(store.os('log', 'write').delete(entry.added)) 212 | } else { 213 | await promisify(store.os('log', 'write').put(entry)) 214 | } 215 | } 216 | } 217 | } else { 218 | await new Promise((resolve, reject) => { 219 | let log = store.os('log', 'write') 220 | let request = log.index('reasons').openCursor(reason) 221 | rejectify(request, reject) 222 | request.onsuccess = function (e) { 223 | if (!e.target.result) { 224 | resolve() 225 | return 226 | } 227 | 228 | let entry = e.target.result.value 229 | let m = entry.meta 230 | let c = criteria 231 | 232 | if (isDefined(c.olderThan) && !isFirstOlder(m, c.olderThan)) { 233 | e.target.result.continue() 234 | return 235 | } 236 | if (isDefined(c.youngerThan) && !isFirstOlder(c.youngerThan, m)) { 237 | e.target.result.continue() 238 | return 239 | } 240 | if (isDefined(c.minAdded) && entry.added < c.minAdded) { 241 | e.target.result.continue() 242 | return 243 | } 244 | if (isDefined(c.maxAdded) && entry.added > c.maxAdded) { 245 | e.target.result.continue() 246 | return 247 | } 248 | 249 | entry.reasons = entry.reasons.filter(i => i !== reason) 250 | entry.meta.reasons = entry.reasons 251 | 252 | let process 253 | if (entry.reasons.length === 0) { 254 | entry.meta.added = entry.added 255 | callback(entry.action, entry.meta) 256 | process = log.delete(entry.added) 257 | } else { 258 | process = log.put(entry) 259 | } 260 | 261 | rejectify(process, reject) 262 | process.onsuccess = function () { 263 | e.target.result.continue() 264 | } 265 | } 266 | }) 267 | } 268 | } 269 | 270 | async setLastSynced(values) { 271 | let store = await this.init() 272 | let data = await promisify(store.os('extra').get('lastSynced')) 273 | if (!data) data = { key: 'lastSynced', received: 0, sent: 0 } 274 | if (typeof values.sent !== 'undefined') { 275 | data.sent = values.sent 276 | } 277 | if (typeof values.received !== 'undefined') { 278 | data.received = values.received 279 | } 280 | await promisify(store.os('extra', 'write').put(data)) 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /indexed-store/index.test.ts: -------------------------------------------------------------------------------- 1 | import 'fake-indexeddb/auto' 2 | 3 | import { 4 | type Action, 5 | eachStoreCheck, 6 | type LogPage, 7 | type Meta 8 | } from '@logux/core' 9 | import { spyOn } from 'nanospy' 10 | import { afterEach, expect, it } from 'vitest' 11 | 12 | import { IndexedStore } from '../index.js' 13 | 14 | type Entry = [Action, Meta] 15 | 16 | declare global { 17 | interface Document { 18 | reload: () => void 19 | } 20 | namespace NodeJS { 21 | interface Global { 22 | indexedDB: IDBFactory 23 | } 24 | } 25 | } 26 | 27 | function privateMethods(obj: object): any { 28 | return obj 29 | } 30 | 31 | function promisify(request: IDBRequest): Promise { 32 | return new Promise((resolve, reject) => { 33 | request.onerror = (e: any) => { 34 | reject(e.target.error) 35 | } 36 | request.onsuccess = resolve 37 | }) 38 | } 39 | 40 | async function all( 41 | request: Promise, 42 | list: Entry[] = [] 43 | ): Promise { 44 | let page = await request 45 | list = list.concat(page.entries) 46 | if (typeof page.next !== 'undefined') { 47 | return all(page.next(), list) 48 | } else { 49 | return list 50 | } 51 | } 52 | 53 | async function check( 54 | db: IndexedStore, 55 | created: Entry[], 56 | added = created 57 | ): Promise { 58 | let entriesCreated = await all(db.get({ order: 'created' })) 59 | expect(entriesCreated).toEqual(created) 60 | let entriesAdded = await all(db.get({ order: 'added' })) 61 | expect(entriesAdded).toEqual(added) 62 | } 63 | 64 | let store: IndexedStore | undefined 65 | 66 | afterEach(async () => { 67 | await store?.clean() 68 | store = undefined 69 | // @ts-expect-error 70 | delete document.reload 71 | }) 72 | 73 | eachStoreCheck((desc, creator) => { 74 | it( 75 | `${desc}`, 76 | creator(() => { 77 | store = new IndexedStore() 78 | return store 79 | }) 80 | ) 81 | }) 82 | 83 | it('use logux as default name', async () => { 84 | store = new IndexedStore() 85 | await privateMethods(store).init() 86 | expect(privateMethods(store).db.name).toBe('logux') 87 | expect(store.name).toBe('logux') 88 | }) 89 | 90 | it('allows to change DB name', async () => { 91 | store = new IndexedStore('custom') 92 | await privateMethods(store).init() 93 | expect(privateMethods(store).db.name).toBe('custom') 94 | expect(store.name).toBe('custom') 95 | }) 96 | 97 | it('reloads page on database update', async () => { 98 | document.reload = () => true 99 | let reload = spyOn(document, 'reload') 100 | store = new IndexedStore() 101 | await privateMethods(store).init() 102 | let opening = indexedDB.open(store.name, 1000) 103 | await new Promise((resolve, reject) => { 104 | opening.onsuccess = (e: any) => { 105 | e.target.result.close() 106 | resolve() 107 | } 108 | opening.onerror = (e: any) => { 109 | reject(e.target.error) 110 | } 111 | }) 112 | expect(reload.callCount).toEqual(1) 113 | }) 114 | 115 | it('works with broken lastSynced', async () => { 116 | store = new IndexedStore() 117 | await privateMethods(store).init() 118 | await promisify( 119 | privateMethods(store).os('extra', 'write').delete('lastSynced') 120 | ) 121 | let synced = await store.getLastSynced() 122 | expect(synced).toEqual({ received: 0, sent: 0 }) 123 | await store.setLastSynced({ received: 1, sent: 1 }) 124 | }) 125 | 126 | it('updates reasons cache', async () => { 127 | store = new IndexedStore() 128 | await store.add({ type: 'A' }, { added: 1, id: '1', reasons: ['a'], time: 1 }) 129 | await store.changeMeta('1', { reasons: ['a', 'b', 'b', 'c'] }) 130 | await store.removeReason('b', {}, () => {}) 131 | await check(store, [ 132 | [{ type: 'A' }, { added: 1, id: '1', reasons: ['a', 'c'], time: 1 }] 133 | ]) 134 | }) 135 | -------------------------------------------------------------------------------- /log/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ignores actions by request 1`] = ` 4 | "Logux added and cleaned C action 5 | Action {"type":"C"} 6 | Meta {"id":"3 10:1:1 0","time":3,"reasons":[],"subprotocol":"1.0.0"} 7 | " 8 | `; 9 | 10 | exports[`prints log 1`] = ` 11 | "Logux tab role is leader 12 | Logux state is connecting 13 | Node ID: 10:1:1 14 | Server: ws://example.com 15 | Logux state is synchronized 16 | Server ID: server:uuid 17 | Logux added A action 18 | Action {"type":"A"} 19 | Meta {"sync":true,"id":"1 10:1:1 0","time":1,"reasons":["syncing"],"subprotocol":"1.0.0","added":1} 20 | Logux state is sending 21 | Logux state is synchronized 22 | Logux cleaned A action 23 | Action {"type":"A"} 24 | Meta {"sync":true,"id":"1 10:1:1 0","time":1,"reasons":[],"subprotocol":"1.0.0","added":1} 25 | Logux action A was undone because of error 26 | Reverted Action {"type":"A"} 27 | Logux subscribing to users channel 28 | Logux state is sending 29 | Logux state is synchronized 30 | Logux subscribed to users/1 channel by server 31 | Logux added A action 32 | Action {"type":"A"} 33 | Meta {"id":"2 server:uuid 1","sync":true,"time":2,"reasons":["syncing"],"added":4} 34 | From: server:uuid 35 | Logux subscribed to users channel 36 | Processed Action {"channel":"users","type":"logux/subscribe"} 37 | Logux state is disconnected 38 | Logux state is connecting 39 | Node ID: 10:1:1 40 | Server: ws://example.com 41 | Logux subscribing to users channel 42 | Action {"channel":"users","type":"logux/subscribe","since":{"id":"2 server:uuid 0","time":2}} 43 | Logux state is synchronized 44 | Server ID: server:uuid 45 | Logux subscribing to users channel 46 | Action {"channel":"users","since":1,"type":"logux/subscribe"} 47 | Logux state is sending 48 | Logux state is synchronized 49 | Logux subscription to users was undone because of error 50 | Reverted Action {"channel":"users","since":1,"type":"logux/subscribe"} 51 | Undo Action {"action":{"channel":"users","since":1,"type":"logux/subscribe"},"id":"3 10:1:1 0","reason":"error","type":"logux/undo","wrongProp":"sync"} 52 | Logux unsubscribed from channel users 53 | Logux unsubscribed from channel users 54 | Action {"bad":true,"channel":"users","type":"logux/unsubscribe"} 55 | Logux added B action 56 | Action {"type":"B"} 57 | Meta {"sync":true,"id":"8 10:1:1 0","time":8,"reasons":["syncing"],"subprotocol":"1.0.0","added":7} 58 | Logux state is sending 59 | Logux state is synchronized 60 | Logux action logux/unsubscribe was processed 61 | Processed Action {"bad":true,"channel":"users","type":"logux/unsubscribe"} 62 | Logux action 200 10:1:1 0 was processed 63 | Logux action B was undone because of error 64 | Reverted Action {"type":"B"} 65 | Logux user ID was changed to 20 66 | Node ID: 20:2:2 67 | " 68 | `; 69 | 70 | exports[`supports cross-tab synchronization 1`] = ` 71 | "Logux tab role is follower 72 | Logux state is connecting 73 | Node ID: 10:1:1 74 | Server: ws://example.com 75 | " 76 | `; 77 | -------------------------------------------------------------------------------- /log/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '../client/index.js' 2 | 3 | interface LogMessages { 4 | /** 5 | * Disable action added messages. 6 | */ 7 | add?: boolean 8 | 9 | /** 10 | * Disable action cleaned messages. 11 | */ 12 | clean?: boolean 13 | 14 | /** 15 | * Disable error messages. 16 | */ 17 | error?: boolean 18 | 19 | /** 20 | * Disable action messages with specific types. 21 | */ 22 | ignoreActions?: string[] 23 | 24 | /** 25 | * Disable tab role messages. 26 | */ 27 | role?: boolean 28 | 29 | /** 30 | * Disable connection state messages. 31 | */ 32 | state?: boolean 33 | 34 | /** 35 | * Disable user ID changing. 36 | */ 37 | user?: boolean 38 | } 39 | 40 | /** 41 | * Display Logux events in browser console. 42 | * 43 | * ```js 44 | * import { log } from '@logux/client' 45 | * log(client, { ignoreActions: ['user/add'] }) 46 | * ``` 47 | * 48 | * @param client Observed Client instance. 49 | * @param messages Disable specific message types. 50 | * @returns Unbind listener. 51 | */ 52 | export function log(client: Client, messages?: LogMessages): () => void 53 | -------------------------------------------------------------------------------- /log/index.js: -------------------------------------------------------------------------------- 1 | import { parseId } from '@logux/core' 2 | 3 | function bold(string) { 4 | return '%c' + string + '%c' 5 | } 6 | 7 | const RED = '#c00000' 8 | 9 | function showLog(text, details, color) { 10 | text = '%cLogux%c ' + text 11 | let args = Array.from(text.match(/%c/g)).map((_, i) => { 12 | if (i === 0) { 13 | if (color === RED) { 14 | return `color:${color};font-weight:bold` 15 | } else { 16 | return 'color:#ffa200;font-weight:bold' 17 | } 18 | } else if (i % 2 === 0) { 19 | return color ? `font-weight:bold;color:${color}` : 'font-weight:bold' 20 | } else { 21 | return 'font-weight:normal' 22 | } 23 | }) 24 | 25 | if (details) { 26 | console.groupCollapsed(text, ...args) 27 | for (let name in details) { 28 | if (typeof details[name] === 'string') { 29 | console.log(name + ': %c' + details[name], 'font-weight:bold') 30 | } else { 31 | console.log(name, details[name]) 32 | } 33 | } 34 | console.groupEnd() 35 | } else { 36 | console.log(text, ...args) 37 | } 38 | } 39 | 40 | export function log(client, messages = {}) { 41 | let node = client.node 42 | 43 | let sent = {} 44 | let unbind = [] 45 | let prevConnected = false 46 | 47 | if (messages.state !== false) { 48 | unbind.push( 49 | client.on('state', () => { 50 | let details 51 | if (client.state === 'connecting' && node.connection.url) { 52 | details = { 53 | 'Node ID': node.localNodeId, 54 | 'Server': node.connection.url 55 | } 56 | } else if (client.connected && !prevConnected && node.remoteNodeId) { 57 | prevConnected = true 58 | details = { 59 | 'Server ID': node.remoteNodeId 60 | } 61 | } else if (!client.connected) { 62 | prevConnected = false 63 | } 64 | showLog('state is ' + bold(client.state), details) 65 | }) 66 | ) 67 | } 68 | 69 | if (messages.role !== false) { 70 | unbind.push( 71 | client.on('role', () => { 72 | showLog('tab role is ' + bold(client.role)) 73 | }) 74 | ) 75 | } 76 | 77 | let cleaned = {} 78 | let ignore = (messages.ignoreActions || []).reduce((all, i) => { 79 | all[i] = true 80 | return all 81 | }, {}) 82 | 83 | if (messages.add !== false) { 84 | unbind.push( 85 | client.on('add', (action, meta) => { 86 | if (meta.tab && meta.tab !== client.tabId) return 87 | if (ignore[action.type]) return 88 | if (meta.sync) sent[meta.id] = action 89 | let message 90 | if (action.type === 'logux/subscribe') { 91 | message = 'subscribing to ' + bold(action.channel) + ' channel' 92 | if (Object.keys(action).length === 2) { 93 | showLog(message) 94 | } else { 95 | showLog(message, { Action: action }) 96 | } 97 | } else if (action.type === 'logux/subscribed') { 98 | showLog( 99 | 'subscribed to ' + bold(action.channel) + ' channel by server' 100 | ) 101 | } else if (action.type === 'logux/unsubscribe') { 102 | message = 'unsubscribed from channel ' + bold(action.channel) 103 | if (Object.keys(action).length === 2) { 104 | showLog(message) 105 | } else { 106 | showLog(message, { Action: action }) 107 | } 108 | } else if (action.type === 'logux/processed') { 109 | if (sent[action.id]) { 110 | let processed = sent[action.id] 111 | let details = { 112 | 'Processed Action': processed 113 | } 114 | if (processed.type === 'logux/subscribe') { 115 | showLog( 116 | 'subscribed to ' + bold(processed.channel) + ' channel', 117 | details 118 | ) 119 | } else { 120 | showLog( 121 | 'action ' + bold(processed.type) + ' was processed', 122 | details 123 | ) 124 | } 125 | delete sent[action.id] 126 | } else { 127 | showLog('action ' + bold(action.id) + ' was processed') 128 | } 129 | } else if (action.type === 'logux/undo') { 130 | if (action.action.type === 'logux/subscribe') { 131 | message = 'subscription to ' + bold(action.action.channel) 132 | } else { 133 | message = 'action ' + bold(action.action.type) 134 | } 135 | message += ' was undone because of ' + bold(action.reason) 136 | let details = { 137 | 'Reverted Action': action.action 138 | } 139 | if (Object.keys(action).length > 4) { 140 | details['Undo Action'] = action 141 | } 142 | if (sent[action.id]) { 143 | delete sent[action.id] 144 | } 145 | showLog(message, details, RED) 146 | } else { 147 | let details = { Action: action, Meta: meta } 148 | message = 'added ' 149 | if (meta.reasons.length === 0) { 150 | cleaned[meta.id] = true 151 | message += 'and cleaned ' 152 | } 153 | message += bold(action.type) + ' action' 154 | let { nodeId } = parseId(meta.id) 155 | if (nodeId !== node.localNodeId) { 156 | details.From = nodeId 157 | } 158 | showLog(message, details, '#008000') 159 | } 160 | }) 161 | ) 162 | } 163 | 164 | if (messages.user !== false) { 165 | unbind.push( 166 | client.on('user', userId => { 167 | let message = 'user ID was changed to ' + bold(userId) 168 | showLog(message, { 'Node ID': client.nodeId }) 169 | }) 170 | ) 171 | } 172 | 173 | if (messages.clean !== false) { 174 | unbind.push( 175 | client.on('clean', (action, meta) => { 176 | if (cleaned[meta.id]) { 177 | delete cleaned[meta.id] 178 | return 179 | } 180 | if (meta.tab && meta.tab !== client.tabId) return 181 | if (ignore[action.type]) return 182 | if (action.type.startsWith('logux/')) return 183 | let message = 'cleaned ' + bold(action.type) + ' action' 184 | showLog(message, { Action: action, Meta: meta }) 185 | }) 186 | ) 187 | } 188 | 189 | return () => { 190 | for (let i of unbind) i() 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /log/index.test.ts: -------------------------------------------------------------------------------- 1 | import { type TestLog, TestPair, TestTime } from '@logux/core' 2 | import { spyOn } from 'nanospy' 3 | import pico from 'picocolors' 4 | import { beforeAll, beforeEach, expect, it } from 'vitest' 5 | 6 | import { type ClientMeta, CrossTabClient, log } from '../index.js' 7 | 8 | function setState(client: any, state: string): void { 9 | client.node.setState(state) 10 | } 11 | 12 | function emit(obj: any, event: string, ...args: any[]): void { 13 | obj.emitter.emit(event, ...args) 14 | } 15 | 16 | function privateMethods(obj: object): any { 17 | return obj 18 | } 19 | 20 | async function createClient(): Promise< 21 | CrossTabClient<{}, TestLog> 22 | > { 23 | let pair = new TestPair() 24 | privateMethods(pair.left).url = 'ws://example.com' 25 | let client = new CrossTabClient<{}, TestLog>({ 26 | server: pair.left, 27 | subprotocol: '1.0.0', 28 | time: new TestTime(), 29 | userId: '10' 30 | }) 31 | 32 | client.role = 'leader' 33 | client.node.catch(() => {}) 34 | 35 | await pair.left.connect() 36 | return client 37 | } 38 | 39 | let group = false 40 | let out = '' 41 | 42 | function format(...args: (object | string)[]): string { 43 | let color = (s: string): string => s 44 | let logoColor = pico.yellow 45 | return ( 46 | (group ? ' ' : '') + 47 | args 48 | .filter(i => { 49 | if (typeof i === 'string') { 50 | if (i === '') { 51 | return false 52 | } else if (i.includes('color:')) { 53 | if (i.includes('#c00000')) { 54 | logoColor = pico.red 55 | color = pico.red 56 | } else if (i.includes('#008000')) { 57 | color = pico.green 58 | } 59 | return false 60 | } else if (i.includes('font-weight:')) { 61 | return false 62 | } else { 63 | return true 64 | } 65 | } else { 66 | return true 67 | } 68 | }) 69 | .map(i => { 70 | if (typeof i === 'string') { 71 | return i 72 | .replace(/%cLogux%c/, logoColor(pico.bold('Logux'))) 73 | .replace(/%c([^%]+)(%c)?/g, color(pico.bold('$1'))) 74 | } else { 75 | return JSON.stringify(i) 76 | } 77 | }) 78 | .join(' ') 79 | ) 80 | } 81 | 82 | beforeAll(() => { 83 | spyOn(console, 'groupCollapsed', (...args: any[]) => { 84 | console.log(...args) 85 | group = true 86 | }) 87 | spyOn(console, 'groupEnd', () => { 88 | group = false 89 | }) 90 | spyOn(console, 'log', (...args: any[]) => { 91 | out += format(...args) + '\n' 92 | }) 93 | }) 94 | 95 | beforeEach(() => { 96 | out = '' 97 | }) 98 | 99 | it('prints log', async () => { 100 | let client = await createClient() 101 | client.node.connected = false 102 | setState(client, 'disconnected') 103 | log(client) 104 | 105 | emit(client, 'role') 106 | setState(client, 'connecting') 107 | 108 | client.node.remoteNodeId = 'server:uuid' 109 | client.node.connected = true 110 | setState(client, 'synchronized') 111 | 112 | await client.node.log.add({ type: 'A' }, { sync: true }) 113 | setState(client, 'sending') 114 | setState(client, 'synchronized') 115 | await client.node.log.add( 116 | { 117 | action: { type: 'A' }, 118 | id: '1 10:1:1 0', 119 | reason: 'error', 120 | type: 'logux/undo' 121 | }, 122 | { id: '1 server:uuid 0' } 123 | ) 124 | 125 | await client.node.log.add( 126 | { channel: 'users', type: 'logux/subscribe' }, 127 | { sync: true } 128 | ) 129 | setState(client, 'sending') 130 | setState(client, 'synchronized') 131 | await client.node.log.add( 132 | { channel: 'users/1', type: 'logux/subscribed' }, 133 | { sync: true } 134 | ) 135 | await client.node.log.add( 136 | { type: 'A' }, 137 | { id: '2 server:uuid 1', sync: true } 138 | ) 139 | await client.node.log.add( 140 | { 141 | id: '2 10:1:1 0', 142 | type: 'logux/processed' 143 | }, 144 | { id: '2 server:uuid 0' } 145 | ) 146 | 147 | client.node.connected = false 148 | setState(client, 'disconnected') 149 | 150 | setState(client, 'connecting') 151 | client.node.connected = true 152 | setState(client, 'synchronized') 153 | 154 | await client.node.log.add( 155 | { channel: 'users', since: 1, type: 'logux/subscribe' }, 156 | { sync: true } 157 | ) 158 | setState(client, 'sending') 159 | setState(client, 'synchronized') 160 | await client.node.log.add( 161 | { 162 | action: { channel: 'users', since: 1, type: 'logux/subscribe' }, 163 | id: '3 10:1:1 0', 164 | reason: 'error', 165 | type: 'logux/undo', 166 | wrongProp: 'sync' 167 | }, 168 | { id: '3 server:uuid 0' } 169 | ) 170 | await client.node.log.add( 171 | { channel: 'users', type: 'logux/unsubscribe' }, 172 | { sync: true } 173 | ) 174 | await client.node.log.add( 175 | { 176 | bad: true, 177 | channel: 'users', 178 | type: 'logux/unsubscribe' 179 | }, 180 | { sync: true } 181 | ) 182 | 183 | await client.node.log.add({ type: 'B' }, { sync: true }) 184 | setState(client, 'sending') 185 | setState(client, 'synchronized') 186 | await client.node.log.add( 187 | { 188 | id: '7 10:1:1 0', 189 | type: 'logux/processed' 190 | }, 191 | { id: ' server:uuid 0' } 192 | ) 193 | 194 | await client.node.log.add( 195 | { 196 | id: '200 10:1:1 0', 197 | type: 'logux/processed' 198 | }, 199 | { id: '4 server:uuid 0' } 200 | ) 201 | 202 | await client.node.log.add( 203 | { 204 | action: { type: 'B' }, 205 | id: '201 10:1:1 0', 206 | reason: 'error', 207 | type: 'logux/undo' 208 | }, 209 | { id: '5 server:uuid 0' } 210 | ) 211 | 212 | client.nodeId = '20:2:2' 213 | emit(client, 'user', '20') 214 | 215 | expect(out).toMatchSnapshot() 216 | }) 217 | 218 | it('returns unbind function', async () => { 219 | let client = await createClient() 220 | let unbind = log(client) 221 | 222 | unbind() 223 | emit(client, 'role') 224 | 225 | expect(out).toBe('') 226 | }) 227 | 228 | it('supports cross-tab synchronization', async () => { 229 | let client = await createClient() 230 | client.role = 'follower' 231 | log(client) 232 | 233 | emit(client, 'role') 234 | client.state = 'connecting' 235 | emit(client, 'state') 236 | 237 | expect(out).toMatchSnapshot() 238 | }) 239 | 240 | it('ignores different tab actions', async () => { 241 | let client = await createClient() 242 | log(client) 243 | 244 | await client.node.log.add({ type: 'A' }, { reasons: ['test'], tab: 'X' }) 245 | await client.node.log.removeReason('test') 246 | 247 | expect(out).toBe('') 248 | }) 249 | 250 | it('ignores actions by request', async () => { 251 | let client = await createClient() 252 | log(client, { ignoreActions: ['A', 'B'] }) 253 | 254 | await Promise.all([ 255 | client.node.log.add({ type: 'A' }, { reasons: ['test'] }), 256 | client.node.log.add({ type: 'B' }) 257 | ]) 258 | await client.node.log.removeReason('test') 259 | await client.node.log.add({ type: 'C' }) 260 | 261 | expect(out).toMatchSnapshot() 262 | }) 263 | -------------------------------------------------------------------------------- /logux-undo-error/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { LoguxSubscribeAction, LoguxUndoAction } from '@logux/actions' 2 | 3 | /** 4 | * Error on `logux/undo` action from the server. 5 | * 6 | * ```js 7 | * try { 8 | * client.sync(action) 9 | * } catch (e) { 10 | * if (e.name === 'LoguxUndoError') { 11 | * console.log(e.action.action.type ' was undid') 12 | * } 13 | * } 14 | * ``` 15 | */ 16 | export class LoguxUndoError< 17 | RevertedAction extends LoguxUndoAction = LoguxUndoAction 18 | > extends Error { 19 | /** 20 | * Server `logux/undo` action. It has origin actions (which was undid) 21 | * in `action.action`. 22 | * 23 | * ```js 24 | * console.log(error.action.action.type ' was undid') 25 | * ``` 26 | */ 27 | action: RevertedAction 28 | 29 | /** 30 | * The better way to check error, than `instanceof`. 31 | * 32 | * ```js 33 | * if (error.name === 'LoguxUndoError') { 34 | * ``` 35 | */ 36 | name: 'LoguxUndoError' 37 | 38 | constructor(action: RevertedAction) 39 | } 40 | 41 | export type ChannelNotFoundError = LoguxUndoError< 42 | LoguxUndoAction 43 | > 44 | 45 | export type ChannelDeniedError = LoguxUndoError< 46 | LoguxUndoAction 47 | > 48 | 49 | export type ChannelServerError = LoguxUndoError< 50 | LoguxUndoAction 51 | > 52 | 53 | export type ChannelError = 54 | | ChannelDeniedError 55 | | ChannelNotFoundError 56 | | ChannelServerError 57 | -------------------------------------------------------------------------------- /logux-undo-error/index.js: -------------------------------------------------------------------------------- 1 | export class LoguxUndoError extends Error { 2 | constructor(action) { 3 | let type = action.action ? action.action.type : 'action' 4 | super(`Server undid ${type} because of ${action.reason}`) 5 | this.name = 'LoguxUndoError' 6 | this.action = action 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@logux/client", 3 | "version": "0.21.1", 4 | "description": "Logux base components to build web client", 5 | "keywords": [ 6 | "logux", 7 | "client", 8 | "websocket", 9 | "cross-tab", 10 | "indexeddb" 11 | ], 12 | "scripts": { 13 | "clean": "rm -Rf coverage/ test/demo/dist/", 14 | "test:lint": "eslint .", 15 | "test:size": "size-limit", 16 | "test:types": "check-dts", 17 | "test:build": "pnpm build", 18 | "test": "vitest run --coverage && pnpm run /^test:/", 19 | "build": "vite build test/demo/ -m production --base /client/", 20 | "start": "vite test/demo/ --open" 21 | }, 22 | "author": "Andrey Sitnik ", 23 | "license": "MIT", 24 | "homepage": "https://logux.org/", 25 | "repository": "logux/client", 26 | "sideEffects": false, 27 | "type": "module", 28 | "types": "./index.d.ts", 29 | "exports": { 30 | ".": "./index.js", 31 | "./vue": "./vue/index.js", 32 | "./react": "./react/index.js", 33 | "./preact": "./preact/index.js", 34 | "./package.json": "./package.json", 35 | "./badge/styles": "./badge/styles/index.js" 36 | }, 37 | "engines": { 38 | "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 39 | }, 40 | "peerDependencies": { 41 | "@logux/core": "^0.9.0", 42 | "@nanostores/preact": ">=0.0.0", 43 | "@nanostores/react": ">=0.0.0", 44 | "@nanostores/vue": ">=0.0.0", 45 | "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0", 46 | "preact": ">=10.0.0", 47 | "react": ">=18.0.0", 48 | "react-dom": ">=16.8.0", 49 | "vue": ">=3.3.2" 50 | }, 51 | "peerDependenciesMeta": { 52 | "@nanostores/preact": { 53 | "optional": true 54 | }, 55 | "@nanostores/react": { 56 | "optional": true 57 | }, 58 | "@nanostores/vue": { 59 | "optional": true 60 | }, 61 | "preact": { 62 | "optional": true 63 | }, 64 | "react": { 65 | "optional": true 66 | }, 67 | "react-dom": { 68 | "optional": true 69 | }, 70 | "vue": { 71 | "optional": true 72 | } 73 | }, 74 | "dependencies": { 75 | "@logux/actions": "^0.4.0", 76 | "fast-json-stable-stringify": "^2.1.0", 77 | "nanodelay": "^2.0.2", 78 | "nanoevents": "^9.0.0", 79 | "nanoid": "^5.0.7" 80 | }, 81 | "devDependencies": { 82 | "@logux/core": "^0.9.0", 83 | "@logux/eslint-config": "^53.3.0", 84 | "@nanostores/preact": "^0.5.2", 85 | "@nanostores/react": "^0.7.3", 86 | "@nanostores/vue": "^0.10.0", 87 | "@peculiar/webcrypto": "^1.5.0", 88 | "@size-limit/preset-small-lib": "^11.1.4", 89 | "@testing-library/preact": "^3.2.4", 90 | "@testing-library/react": "^16.0.0", 91 | "@testing-library/vue": "^8.1.0", 92 | "@types/node": "^22.1.0", 93 | "@types/react": "^18.3.3", 94 | "@types/react-dom": "^18.3.0", 95 | "@types/ws": "^8.5.12", 96 | "@vitest/coverage-v8": "^2.0.5", 97 | "@vue/compiler-sfc": "^3.4.36", 98 | "check-dts": "^0.8.0", 99 | "clean-publish": "^5.0.0", 100 | "eslint": "^9.8.0", 101 | "fake-indexeddb": "^6.0.0", 102 | "globals": "^15.9.0", 103 | "happy-dom": "^14.12.3", 104 | "nanospy": "^1.0.0", 105 | "nanostores": "^0.11.2", 106 | "picocolors": "^1.0.1", 107 | "postcss": "^8.4.41", 108 | "preact": "10.23.1", 109 | "print-snapshots": "^0.4.2", 110 | "react": "^18.3.1", 111 | "react-dom": "^18.3.1", 112 | "size-limit": "^11.1.4", 113 | "svgo": "^3.3.2", 114 | "typescript": "^5.5.4", 115 | "vite": "^5.4.0", 116 | "vitest": "^2.0.5", 117 | "vue": "^3.4.36" 118 | }, 119 | "prettier": { 120 | "arrowParens": "avoid", 121 | "jsxSingleQuote": false, 122 | "quoteProps": "consistent", 123 | "semi": false, 124 | "singleQuote": true, 125 | "trailingComma": "none" 126 | }, 127 | "size-limit": [ 128 | { 129 | "name": "CrossTabClient", 130 | "path": "./cross-tab-client/index.js", 131 | "import": "{ CrossTabClient }", 132 | "limit": "4 KB" 133 | }, 134 | { 135 | "name": "Helpers", 136 | "import": { 137 | "./index.js": "{ attention, confirm, favicon, status, log, badge, badgeEn }" 138 | }, 139 | "limit": "3 KB" 140 | }, 141 | { 142 | "name": "React", 143 | "import": { 144 | "./index.js": "{ CrossTabClient, syncMapTemplate, changeSyncMap }", 145 | "./react/index.js": "{ ClientContext, useSync, ChannelErrors, useFilter, useAuth }" 146 | }, 147 | "limit": "7 KB" 148 | }, 149 | { 150 | "name": "Vue", 151 | "import": { 152 | "./index.js": "{ CrossTabClient, syncMapTemplate, changeSyncMap }", 153 | "./vue/index.js": "{ loguxPlugin, useSync, ChannelErrors, useFilter, useAuth }" 154 | }, 155 | "limit": "7 KB" 156 | } 157 | ] 158 | } 159 | -------------------------------------------------------------------------------- /preact/errors.ts: -------------------------------------------------------------------------------- 1 | import { map, type MapStore } from 'nanostores' 2 | 3 | import { syncMapTemplate } from '../sync-map-template/index.js' 4 | import { useFilter, useSync } from './index.js' 5 | 6 | type Post = { 7 | id: string 8 | title: string 9 | } 10 | 11 | let $post = syncMapTemplate('posts') 12 | 13 | let post = useSync($post, '10') 14 | let postList = useFilter($post, { id: '10' }) 15 | 16 | let $custom = (id: string): MapStore => map({ id, text: 'A' }) 17 | let custom = useSync($custom, '10') 18 | 19 | if (post.isLoading) { 20 | // THROWS Property 'title' does not exist 21 | post.title = 'New title' 22 | } 23 | 24 | if (!postList.isLoading) { 25 | let postListItem = postList.stores.get('10')!.value! 26 | if (postListItem.isLoading) { 27 | // THROWS Property 'title' does not exist 28 | postListItem.title = 'New title' 29 | } 30 | } 31 | 32 | if (custom.isLoading) { 33 | // THROWS Property 'title' does not exist 34 | custom.title = 'B' 35 | } 36 | -------------------------------------------------------------------------------- /preact/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { LoguxNotFoundError, SyncMapValues } from '@logux/actions' 2 | import type { StoreValue } from 'nanostores' 3 | import { Component } from 'preact' 4 | import type { 5 | ComponentChild, 6 | ComponentType, 7 | Context as PreactContext 8 | } from 'preact' 9 | 10 | import type { Client } from '../client/index.js' 11 | import type { AuthStore } from '../create-auth/index.js' 12 | import type { 13 | Filter, 14 | FilterOptions, 15 | FilterStore 16 | } from '../create-filter/index.js' 17 | import type { 18 | ChannelDeniedError, 19 | ChannelError, 20 | ChannelNotFoundError 21 | } from '../logux-undo-error/index.js' 22 | import type { 23 | SyncMapTemplate, 24 | SyncMapTemplateLike, 25 | SyncMapValue 26 | } from '../sync-map-template/index.js' 27 | 28 | /** 29 | * Context to send Logux Client or object space to components deep in the tree. 30 | * 31 | * ```js 32 | * import { ClientContext, ChannelErrors } from '@logux/client/preact' 33 | * import { CrossTabClient } from '@logux/client' 34 | * 35 | * let client = new CrossTabClient(…) 36 | * 37 | * render( 38 | * 39 | * 40 | * 41 | * 42 | * , 43 | * document.body 44 | * ) 45 | * ``` 46 | */ 47 | export const ClientContext: PreactContext 48 | 49 | /** 50 | * Hook to return Logux client, which you set by ``. 51 | * 52 | * ```js 53 | * import { useClient } from '@logux/client/preact' 54 | * 55 | * import { User } from '../stores/user' 56 | * 57 | * export const NewUserForm = () => { 58 | * let client = useClient() 59 | * let onAdd = data => { 60 | * User.create(client, data) 61 | * } 62 | * } 63 | * ``` 64 | */ 65 | export function useClient(): Client 66 | 67 | interface PreactErrorHandlers { 68 | AccessDenied?: ComponentType<{ error: ChannelDeniedError }> 69 | Error?: ComponentType<{ error: ChannelError }> 70 | NotFound?: ComponentType<{ error: ChannelNotFoundError | LoguxNotFoundError }> 71 | } 72 | 73 | /** 74 | * Context to pass error handlers from `ChannelErrors`. 75 | */ 76 | export const ErrorsContext: PreactContext 77 | 78 | /** 79 | * Show error message to user on subscription errors in components 80 | * deep in the tree. 81 | * 82 | * ```js 83 | * import { ChannelErrors } from '@logux/client/preact' 84 | * 85 | * export const App: FC = () => { 86 | * return <> 87 | * 88 | * 93 | * 94 | * 95 | * <> 96 | * } 97 | * ``` 98 | */ 99 | export class ChannelErrors extends Component { 100 | render(): ComponentChild 101 | } 102 | 103 | /** 104 | * Create store by ID, subscribe and get store’s value. 105 | * 106 | * ```js 107 | * import { useSync } from '@logux/client/preact' 108 | * 109 | * import { User } from '../stores/user' 110 | * 111 | * export const UserPage: FC = ({ id }) => { 112 | * let user = useSync(User, id) 113 | * if (user.isLoading) { 114 | * return 115 | * } else { 116 | * return

{user.name}

117 | * } 118 | * } 119 | * ``` 120 | * 121 | * @param Template Store template. 122 | * @param id Store ID. 123 | * @param args Other store arguments. 124 | * @returns Store value. 125 | */ 126 | export function useSync( 127 | Template: SyncMapTemplate | SyncMapTemplateLike, 128 | id: string 129 | ): SyncMapValue 130 | export function useSync( 131 | Template: SyncMapTemplate | SyncMapTemplateLike, 132 | id: string, 133 | ...args: Args 134 | ): Value 135 | 136 | /** 137 | * The way to {@link createFilter} in React. 138 | * 139 | * ```js 140 | * import { useFilter } from '@logux/client/preact' 141 | * 142 | * import { User } from '../stores/user' 143 | * 144 | * export const Users = ({ projectId }) => { 145 | * let users = useFilter(User, { projectId }) 146 | * return
147 | * {users.list.map(user => )} 148 | * {users.isLoading && } 149 | *
150 | * } 151 | * ``` 152 | * 153 | * @param Template Store class. 154 | * @param filter Key-value filter for stores. 155 | * @param opts Filter options. 156 | * @returns Filter store to use with map. 157 | */ 158 | export function useFilter( 159 | Template: SyncMapTemplate | SyncMapTemplateLike, 160 | filter?: Filter, 161 | opts?: FilterOptions 162 | ): StoreValue> 163 | 164 | /** 165 | * Hook to return user's current authentication state and ID. 166 | * 167 | * ```js 168 | * import { useAuth } from '@logux/client/preact' 169 | * 170 | * export const UserPage = () => { 171 | * let { isAuthenticated, userId } = useAuth() 172 | * if (isAuthenticated) { 173 | * return 174 | * } else { 175 | * return 176 | * } 177 | * } 178 | * ``` 179 | */ 180 | export function useAuth(): StoreValue 181 | -------------------------------------------------------------------------------- /preact/index.js: -------------------------------------------------------------------------------- 1 | import { useStore } from '@nanostores/preact' 2 | import { Component, createContext, h } from 'preact' 3 | import { useContext, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks' 4 | 5 | import { createAuth } from '../create-auth/index.js' 6 | import { createFilter } from '../create-filter/index.js' 7 | 8 | export let ClientContext = /*#__PURE__*/ createContext() 9 | 10 | export let ErrorsContext = /*#__PURE__*/ createContext() 11 | 12 | let useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect 13 | 14 | export function useClient() { 15 | let client = useContext(ClientContext) 16 | if (process.env.NODE_ENV !== 'production' && !client) { 17 | throw new Error('Wrap components in Logux ') 18 | } 19 | return client 20 | } 21 | 22 | function useSyncStore(store) { 23 | let [error, setError] = useState(null) 24 | let [, forceRender] = useState({}) 25 | let value = store.get() 26 | 27 | if (process.env.NODE_ENV !== 'production') { 28 | let errorProcessors = useContext(ErrorsContext) || {} 29 | if ( 30 | !errorProcessors.Error && 31 | (!errorProcessors.NotFound || !errorProcessors.AccessDenied) 32 | ) { 33 | throw new Error( 34 | 'Wrap components in Logux ' + 35 | '' 36 | ) 37 | } 38 | } 39 | 40 | useIsomorphicLayoutEffect(() => { 41 | let batching 42 | let unbind = store.listen(() => { 43 | if (batching) return 44 | batching = 1 45 | setTimeout(() => { 46 | batching = undefined 47 | forceRender({}) 48 | }) 49 | }) 50 | 51 | if (store.loading) { 52 | store.loading.catch(e => { 53 | setError(e) 54 | }) 55 | } 56 | 57 | return unbind 58 | }, [store]) 59 | 60 | if (error) throw error 61 | return value 62 | } 63 | 64 | export function useSync(Template, id, ...builderArgs) { 65 | if (process.env.NODE_ENV !== 'production') { 66 | if (typeof Template !== 'function') { 67 | throw new Error('Use useStore() from @nanostores/preact for stores') 68 | } 69 | } 70 | 71 | let client = useClient() 72 | let store = Template(id, client, ...builderArgs) 73 | 74 | return useSyncStore(store) 75 | } 76 | 77 | export function useFilter(Builder, filter = {}, opts = {}) { 78 | let client = useClient() 79 | let instance = createFilter(client, Builder, filter, opts) 80 | return useSyncStore(instance) 81 | } 82 | 83 | let ErrorsCheckerProvider = ({ children, ...props }) => { 84 | let prevErrors = useContext(ErrorsContext) || {} 85 | let errors = { ...props, ...prevErrors } 86 | return h(ErrorsContext.Provider, { value: errors }, children) 87 | } 88 | 89 | export class ChannelErrors extends Component { 90 | constructor(props) { 91 | super(props) 92 | this.state = { error: null } 93 | } 94 | 95 | static getDerivedStateFromError(error) { 96 | return { error } 97 | } 98 | 99 | render() { 100 | let error = this.state.error 101 | if (!error) { 102 | if (process.env.NODE_ENV === 'production') { 103 | /* c8 ignore next */ 104 | return this.props.children 105 | } else { 106 | return h(ErrorsCheckerProvider, this.props) 107 | } 108 | } else if ( 109 | error.name !== 'LoguxUndoError' && 110 | error.name !== 'LoguxNotFoundError' 111 | ) { 112 | throw error 113 | } else if ( 114 | (error.name === 'LoguxNotFoundError' || 115 | error.action.reason === 'notFound') && 116 | this.props.NotFound 117 | ) { 118 | return h(this.props.NotFound, { error }) 119 | } else if ( 120 | error.action && 121 | error.action.reason === 'denied' && 122 | this.props.AccessDenied 123 | ) { 124 | return h(this.props.AccessDenied, { error }) 125 | } else if (this.props.Error) { 126 | return h(this.props.Error, { error }) 127 | } else { 128 | throw error 129 | } 130 | } 131 | } 132 | 133 | export function useAuth() { 134 | let client = useClient() 135 | let authRef = useRef() 136 | if (!authRef.current) authRef.current = createAuth(client) 137 | return useStore(authRef.current) 138 | } 139 | -------------------------------------------------------------------------------- /preact/types.ts: -------------------------------------------------------------------------------- 1 | import type { MapStore} from 'nanostores' 2 | import { map } from 'nanostores' 3 | 4 | import { syncMapTemplate } from '../sync-map-template/index.js' 5 | import { useFilter, useSync } from './index.js' 6 | 7 | type Post = { 8 | id: string 9 | title: string 10 | } 11 | 12 | let $post = syncMapTemplate('posts') 13 | 14 | let post = useSync($post, '10') 15 | let postList = useFilter($post, { id: '10' }) 16 | 17 | let $custom = (id: string): MapStore => map({ id, text: 'A' }) 18 | let custom = useSync($custom, '10') 19 | let customList = useFilter($custom, { id: '10' }) 20 | 21 | console.log({ custom, customList, post, postList }) 22 | -------------------------------------------------------------------------------- /prepare-for-test/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { SyncMapValues } from '@logux/actions' 2 | import type { MapStore } from 'nanostores' 3 | 4 | import type { Client } from '../client/index.js' 5 | import type { 6 | SyncMapStore, 7 | SyncMapTemplate, 8 | SyncMapTemplateLike 9 | } from '../sync-map-template/index.js' 10 | 11 | interface PrepareForTest { 12 | ( 13 | client: Client, 14 | Template: SyncMapTemplateLike, 15 | value: { id?: string } & Omit 16 | ): MapStore 17 | ( 18 | client: Client, 19 | Template: SyncMapTemplate, 20 | value: { id?: string } & Omit 21 | ): SyncMapStore 22 | } 23 | 24 | /** 25 | * Create and load stores to builder’s cache to use them in tests 26 | * or storybook. 27 | * 28 | * ```js 29 | * import { prepareForTest, cleanStores, TestClient } from '@logux/client' 30 | * 31 | * import { User } from '../store' 32 | * 33 | * let client = new TestClient('10') 34 | * 35 | * beforeEach(() => { 36 | * prepareForTest(client, User, { name: 'Test user 1' }) 37 | * prepareForTest(client, User, { name: 'Test user 2' }) 38 | * }) 39 | * 40 | * afterEach(() => { 41 | * cleanStores(User) 42 | * }) 43 | * ``` 44 | * 45 | * @param client `TestClient` instance. 46 | * @param Template Store builder. 47 | * @param value Store values. 48 | * @returns The mocked store. 49 | */ 50 | export const prepareForTest: PrepareForTest 51 | 52 | /** 53 | * Disable loader for filter for this builder. 54 | * 55 | * ```js 56 | * import { emptyInTest, cleanStores } from '@logux/client' 57 | * 58 | * beforeEach(() => { 59 | * prepareForTest(client, User, { name: 'Test user 1' }) 60 | * prepareForTest(client, User, { name: 'Test user 2' }) 61 | * }) 62 | * 63 | * afterEach(() => { 64 | * cleanStores(User) 65 | * }) 66 | * ``` 67 | * 68 | * @param Template Store builder. 69 | */ 70 | export function emptyInTest(Template: SyncMapTemplate): void 71 | -------------------------------------------------------------------------------- /prepare-for-test/index.js: -------------------------------------------------------------------------------- 1 | let lastId = 0 2 | 3 | export function emptyInTest(Template) { 4 | if (!Template.mocks) Template.mocked = true 5 | } 6 | 7 | export function prepareForTest(client, Template, value) { 8 | if (!Template.mocks) Template.mocked = true 9 | 10 | let { id, ...keys } = value 11 | if (!id) { 12 | if (Template.plural) { 13 | id = `${Template.plural}:${Object.keys(Template.cache).length + 1}` 14 | } else { 15 | id = `${++lastId}` 16 | } 17 | } 18 | 19 | let store = Template(id, client) 20 | store.listen(() => {}) 21 | 22 | if ('isLoading' in store.value) { 23 | store.setKey('isLoading', false) 24 | } 25 | for (let key in keys) { 26 | store.setKey(key, keys[key]) 27 | } 28 | 29 | return store 30 | } 31 | -------------------------------------------------------------------------------- /prepare-for-test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { cleanStores, map, type MapStore } from 'nanostores' 2 | import { afterEach, expect, it } from 'vitest' 3 | 4 | import { 5 | createFilter, 6 | emptyInTest, 7 | ensureLoaded, 8 | prepareForTest, 9 | syncMapTemplate, 10 | TestClient 11 | } from '../index.js' 12 | 13 | let client = new TestClient('10') 14 | let User = syncMapTemplate<{ name: string }>('users') 15 | 16 | afterEach(() => { 17 | cleanStores(User) 18 | }) 19 | 20 | it('prepares instances', () => { 21 | let user1a = prepareForTest(client, User, { id: '1', name: 'Test user' }) 22 | let user1b = User('1', client) 23 | 24 | expect(user1a).toBe(user1b) 25 | expect(user1b.get()).toEqual({ 26 | id: '1', 27 | isLoading: false, 28 | name: 'Test user' 29 | }) 30 | 31 | let user2 = User('2', client) 32 | expect(user2.get()).toEqual({ 33 | id: '2', 34 | isLoading: true 35 | }) 36 | }) 37 | 38 | it('generates IDs', () => { 39 | let user1 = prepareForTest(client, User, { name: 'Test 1' }) 40 | let user2 = prepareForTest(client, User, { name: 'Test 2' }) 41 | 42 | expect(user1.get().id).toBe('users:1') 43 | expect(user2.get().id).toBe('users:2') 44 | }) 45 | 46 | it('generates IDs without class name', () => { 47 | function Custom(id: string): MapStore { 48 | return map({ id }) 49 | } 50 | let custom1 = prepareForTest(client, Custom, { name: 'Test 1' }) 51 | let custom2 = prepareForTest(client, Custom, { name: 'Test 2' }) 52 | 53 | expect(custom1.get().id).toBe('1') 54 | expect(custom2.get().id).toBe('2') 55 | }) 56 | 57 | it('works with filters', () => { 58 | prepareForTest(client, User, { name: 'Test 1' }) 59 | prepareForTest(client, User, { name: 'Test 2' }) 60 | 61 | let users1 = createFilter(client, User) 62 | users1.listen(() => {}) 63 | 64 | expect(users1.get().isLoading).toBe(false) 65 | expect(ensureLoaded(users1.get()).list).toEqual([ 66 | { id: 'users:1', isLoading: false, name: 'Test 1' }, 67 | { id: 'users:2', isLoading: false, name: 'Test 2' } 68 | ]) 69 | 70 | cleanStores(User) 71 | let users2 = createFilter(client, User) 72 | expect(users2.get().isLoading).toBe(true) 73 | }) 74 | 75 | it('marks empty', () => { 76 | emptyInTest(User) 77 | 78 | let users1 = createFilter(client, User) 79 | users1.listen(() => {}) 80 | 81 | expect(users1.get().isLoading).toBe(false) 82 | expect(ensureLoaded(users1.get()).list).toEqual([]) 83 | }) 84 | -------------------------------------------------------------------------------- /react/errors.ts: -------------------------------------------------------------------------------- 1 | import { map, type MapStore } from 'nanostores' 2 | 3 | import { syncMapTemplate } from '../sync-map-template/index.js' 4 | import { useFilter, useSync } from './index.js' 5 | 6 | type Post = { 7 | id: string 8 | title: string 9 | } 10 | 11 | let $post = syncMapTemplate('posts') 12 | 13 | let post = useSync($post, '10') 14 | let postList = useFilter($post, { id: '10' }) 15 | 16 | let $custom = (id: string): MapStore => map({ id, text: 'A' }) 17 | let custom = useSync($custom, '10') 18 | 19 | if (post.isLoading) { 20 | // THROWS Property 'title' does not exist 21 | post.title = 'New title' 22 | } 23 | 24 | if (!postList.isLoading) { 25 | let postListItem = postList.stores.get('10')!.value! 26 | if (postListItem.isLoading) { 27 | // THROWS Property 'title' does not exist 28 | postListItem.title = 'New title' 29 | } 30 | } 31 | 32 | if (custom.isLoading) { 33 | // THROWS Property 'title' does not exist 34 | custom.title = 'B' 35 | } 36 | -------------------------------------------------------------------------------- /react/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { LoguxNotFoundError, SyncMapValues } from '@logux/actions' 2 | import type { StoreValue } from 'nanostores' 3 | import { Component } from 'react' 4 | import type { ComponentType, Context as ReactContext, ReactNode } from 'react' 5 | 6 | import type { Client } from '../client/index.js' 7 | import type { AuthStore } from '../create-auth/index.js' 8 | import type { 9 | Filter, 10 | FilterOptions, 11 | FilterStore 12 | } from '../create-filter/index.js' 13 | import type { 14 | ChannelDeniedError, 15 | ChannelError, 16 | ChannelNotFoundError 17 | } from '../logux-undo-error/index.js' 18 | import type { 19 | SyncMapTemplate, 20 | SyncMapTemplateLike, 21 | SyncMapValue 22 | } from '../sync-map-template/index.js' 23 | 24 | /** 25 | * Context to send Logux Client or object space to components deep in the tree. 26 | * 27 | * ```js 28 | * import { ClientContext, ChannelErrors } from '@logux/client/react' 29 | * import { CrossTabClient } from '@logux/client' 30 | * 31 | * let client = new CrossTabClient(…) 32 | * 33 | * render( 34 | * 35 | * 36 | * 37 | * 38 | * , 39 | * document.body 40 | * ) 41 | * ``` 42 | */ 43 | export const ClientContext: ReactContext 44 | 45 | /** 46 | * Hook to return Logux client, which you set by ``. 47 | * 48 | * ```js 49 | * import { useClient } from '@logux/client/react' 50 | * 51 | * import { User } from '../stores/user' 52 | * 53 | * export const NewUserForm = () => { 54 | * let client = useClient() 55 | * let onAdd = data => { 56 | * User.create(client, data) 57 | * } 58 | * } 59 | * ``` 60 | */ 61 | export function useClient(): Client 62 | 63 | interface ReactErrorHandlers { 64 | AccessDenied?: ComponentType<{ error: ChannelDeniedError }> 65 | Error?: ComponentType<{ error: ChannelError }> 66 | NotFound?: ComponentType<{ error: ChannelNotFoundError | LoguxNotFoundError }> 67 | } 68 | 69 | /** 70 | * Context to pass error handlers from `ChannelErrors`. 71 | */ 72 | export const ErrorsContext: ReactContext 73 | 74 | /** 75 | * Show error message to user on subscription errors in components 76 | * deep in the tree. 77 | * 78 | * ```js 79 | * import { ChannelErrors } from '@logux/client/react' 80 | * 81 | * export const App: FC = () => { 82 | * return <> 83 | * 84 | * 89 | * 90 | * 91 | * <> 92 | * } 93 | * ``` 94 | */ 95 | export class ChannelErrors extends Component< 96 | { children?: ReactNode } & ReactErrorHandlers 97 | > {} 98 | 99 | /** 100 | * Create store by ID, subscribe and get store’s value. 101 | * 102 | * ```js 103 | * import { useSync } from '@logux/client/react' 104 | * 105 | * import { User } from '../stores/user' 106 | * 107 | * export const UserPage: FC = ({ id }) => { 108 | * let user = useSync(User, id) 109 | * if (user.isLoading) { 110 | * return 111 | * } else { 112 | * return

{user.name}

113 | * } 114 | * } 115 | * ``` 116 | * 117 | * @param Template Store builder. 118 | * @param id Store ID. 119 | * @param args Other store arguments. 120 | * @returns Store value. 121 | */ 122 | export function useSync( 123 | Template: SyncMapTemplate | SyncMapTemplateLike, 124 | id: string 125 | ): SyncMapValue 126 | export function useSync( 127 | Template: SyncMapTemplate | SyncMapTemplateLike, 128 | id: string, 129 | ...args: Args 130 | ): Value 131 | 132 | /** 133 | * The way to {@link createFilter} in React. 134 | * 135 | * ```js 136 | * import { useFilter } from '@logux/client/react' 137 | * 138 | * import { User } from '../stores/user' 139 | * 140 | * export const Users = ({ projectId }) => { 141 | * let users = useFilter(User, { projectId }) 142 | * return
143 | * {users.list.map(user => )} 144 | * {users.isLoading && } 145 | *
146 | * } 147 | * ``` 148 | * 149 | * @param Template Store template. 150 | * @param filter Key-value filter for stores. 151 | * @param opts Filter options. 152 | * @returns Filter store to use with map. 153 | */ 154 | export function useFilter( 155 | Template: SyncMapTemplate | SyncMapTemplateLike, 156 | filter?: Filter, 157 | opts?: FilterOptions 158 | ): StoreValue> 159 | 160 | /** 161 | * Hook to return user's current authentication state and ID. 162 | * 163 | * ```js 164 | * import { useAuth } from '@logux/client/react' 165 | * 166 | * export const UserPage = () => { 167 | * let { isAuthenticated, userId } = useAuth() 168 | * if (isAuthenticated) { 169 | * return 170 | * } else { 171 | * return 172 | * } 173 | * } 174 | * ``` 175 | */ 176 | export function useAuth(): StoreValue 177 | -------------------------------------------------------------------------------- /react/index.js: -------------------------------------------------------------------------------- 1 | import { useStore } from '@nanostores/react' 2 | import React from 'react' 3 | 4 | import { createAuth } from '../create-auth/index.js' 5 | import { createFilter } from '../create-filter/index.js' 6 | 7 | export let ClientContext = /*#__PURE__*/ React.createContext() 8 | 9 | export let ErrorsContext = /*#__PURE__*/ React.createContext() 10 | 11 | let useIsomorphicLayoutEffect = 12 | typeof document !== 'undefined' ? React.useLayoutEffect : React.useEffect 13 | 14 | export function useClient() { 15 | let client = React.useContext(ClientContext) 16 | if (process.env.NODE_ENV !== 'production' && !client) { 17 | throw new Error('Wrap components in Logux ') 18 | } 19 | return client 20 | } 21 | 22 | function useSyncStore(store) { 23 | let [error, setError] = React.useState(null) 24 | let [, forceRender] = React.useState({}) 25 | let value = store.get() 26 | 27 | if (process.env.NODE_ENV !== 'production') { 28 | let errorProcessors = React.useContext(ErrorsContext) || {} 29 | if ( 30 | !errorProcessors.Error && 31 | (!errorProcessors.NotFound || !errorProcessors.AccessDenied) 32 | ) { 33 | throw new Error( 34 | 'Wrap components in Logux ' + 35 | '' 36 | ) 37 | } 38 | } 39 | 40 | useIsomorphicLayoutEffect(() => { 41 | let unbind = store.listen(() => { 42 | forceRender({}) 43 | }) 44 | 45 | if (store.loading) { 46 | store.loading.catch(e => { 47 | setError(e) 48 | }) 49 | } 50 | 51 | return unbind 52 | }, [store]) 53 | 54 | if (error) throw error 55 | return value 56 | } 57 | 58 | export function useSync(Template, id, ...builderArgs) { 59 | if (process.env.NODE_ENV !== 'production') { 60 | if (typeof Template !== 'function') { 61 | throw new Error('Use useStore() from @nanostores/react for stores') 62 | } 63 | } 64 | 65 | let client = useClient() 66 | let store = Template(id, client, ...builderArgs) 67 | 68 | return useSyncStore(store) 69 | } 70 | 71 | export function useFilter(Builder, filter = {}, opts = {}) { 72 | let client = useClient() 73 | let instance = createFilter(client, Builder, filter, opts) 74 | return useSyncStore(instance) 75 | } 76 | 77 | let ErrorsCheckerProvider = ({ children, ...props }) => { 78 | let prevErrors = React.useContext(ErrorsContext) || {} 79 | let errors = { ...props, ...prevErrors } 80 | return React.createElement( 81 | ErrorsContext.Provider, 82 | { value: errors }, 83 | children 84 | ) 85 | } 86 | 87 | export class ChannelErrors extends React.Component { 88 | constructor(props) { 89 | super(props) 90 | this.state = { error: null } 91 | } 92 | 93 | static getDerivedStateFromError(error) { 94 | return { error } 95 | } 96 | 97 | render() { 98 | let error = this.state.error 99 | let h = React.createElement 100 | if (!error) { 101 | if (process.env.NODE_ENV === 'production') { 102 | /* c8 ignore next */ 103 | return this.props.children 104 | } else { 105 | return h(ErrorsCheckerProvider, this.props) 106 | } 107 | } else if ( 108 | error.name !== 'LoguxUndoError' && 109 | error.name !== 'LoguxNotFoundError' 110 | ) { 111 | throw error 112 | } else if ( 113 | (error.name === 'LoguxNotFoundError' || 114 | error.action.reason === 'notFound') && 115 | this.props.NotFound 116 | ) { 117 | return h(this.props.NotFound, { error }) 118 | } else if ( 119 | error.action && 120 | error.action.reason === 'denied' && 121 | this.props.AccessDenied 122 | ) { 123 | return h(this.props.AccessDenied, { error }) 124 | } else if (this.props.Error) { 125 | return h(this.props.Error, { error }) 126 | } else { 127 | throw error 128 | } 129 | } 130 | } 131 | 132 | export function useAuth() { 133 | let client = useClient() 134 | let authRef = React.useRef() 135 | if (!authRef.current) authRef.current = createAuth(client) 136 | return useStore(authRef.current) 137 | } 138 | -------------------------------------------------------------------------------- /react/types.ts: -------------------------------------------------------------------------------- 1 | import type { MapStore} from 'nanostores' 2 | import { map } from 'nanostores' 3 | 4 | import { syncMapTemplate } from '../sync-map-template/index.js' 5 | import { useFilter, useSync } from './index.js' 6 | 7 | type Post = { 8 | id: string 9 | title: string 10 | } 11 | 12 | let $post = syncMapTemplate('posts') 13 | 14 | let post = useSync($post, '10') 15 | let postList = useFilter($post, { id: '10' }) 16 | 17 | let $custom = (id: string): MapStore => map({ id, text: 'A' }) 18 | let custom = useSync($custom, '10') 19 | let customList = useFilter($custom, { id: '10' }) 20 | 21 | console.log({ custom, customList, post, postList }) 22 | -------------------------------------------------------------------------------- /request/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Action, AnyAction } from '@logux/core' 2 | 3 | import type { ClientOptions } from '../client/index.js' 4 | 5 | export interface RequestOptions extends Omit { 6 | userId?: string 7 | } 8 | 9 | /** 10 | * Create temporary client instance, send an action, wait response action 11 | * from the server and destroy client. 12 | * 13 | * Useful for simple actions like signin or signup. 14 | * 15 | * ```js 16 | * import { request } from '@logux/client' 17 | * 18 | * let action = { type: 'signin', login, password } 19 | * 20 | * request(action, { 21 | * server: 'wss://example.com', 22 | * subprotocol: '1.0.0 23 | * }).then(response => { 24 | * saveToken(response.token) 25 | * }).catch(error => { 26 | * showError(error.action.reason) 27 | * }) 28 | * ``` 29 | * 30 | * @param action Action which we need to send to the server. 31 | * @return Action of server response. 32 | */ 33 | export function request( 34 | action: AnyAction, 35 | opts: RequestOptions 36 | ): Promise 37 | -------------------------------------------------------------------------------- /request/index.js: -------------------------------------------------------------------------------- 1 | import { Client } from '../client/index.js' 2 | import { LoguxUndoError } from '../logux-undo-error/index.js' 3 | 4 | export function request(action, opts) { 5 | if (!opts.userId) opts.userId = 'anonymous' 6 | let client = new Client(opts) 7 | return new Promise((resolve, reject) => { 8 | client.node.catch(e => { 9 | client.destroy() 10 | reject(e) 11 | }) 12 | client.on('add', response => { 13 | if (response === action) return 14 | client.destroy() 15 | if (response.type === 'logux/undo') { 16 | reject(new LoguxUndoError(response)) 17 | } else { 18 | resolve(response) 19 | } 20 | }) 21 | client.log.add(action, { sync: true }) 22 | client.start() 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /request/index.test.ts: -------------------------------------------------------------------------------- 1 | import { type AnyAction, TestPair, TestTime } from '@logux/core' 2 | import { delay } from 'nanodelay' 3 | import { expect, it } from 'vitest' 4 | 5 | import { request, type RequestOptions } from '../index.js' 6 | 7 | type Test = { 8 | answer: AnyAction | Error | undefined 9 | pair: TestPair 10 | response(answer: AnyAction): Promise 11 | } 12 | 13 | async function createTest( 14 | action: AnyAction, 15 | opts: Partial = {} 16 | ): Promise { 17 | let test: Test = { 18 | answer: undefined, 19 | pair: new TestPair(), 20 | async response(answer) { 21 | test.pair.right.send(['synced', 1]) 22 | test.pair.right.send(['sync', 2, answer, { id: 2, time: 2 }]) 23 | await delay(15) 24 | } 25 | } 26 | let time = new TestTime() 27 | 28 | request(action, { 29 | server: test.pair.left, 30 | subprotocol: '1.0.0', 31 | time, 32 | ...opts 33 | }) 34 | .then(answer => { 35 | test.answer = answer 36 | }) 37 | .catch(e => { 38 | test.answer = e 39 | }) 40 | await test.pair.wait() 41 | 42 | return test 43 | } 44 | 45 | async function connectTest( 46 | action: AnyAction, 47 | opts: Partial = {} 48 | ): Promise { 49 | let test = await createTest(action, opts) 50 | await test.pair.wait() 51 | test.pair.right.send(['connected', 4, 'server:uuid', [0, 0]]) 52 | await delay(15) 53 | return test 54 | } 55 | 56 | it('sends action to the server and wait for response', async () => { 57 | let test = await connectTest({ type: 'test' }) 58 | await delay(1) 59 | expect(test.answer).toBeUndefined() 60 | expect(test.pair.leftSent).toEqual([ 61 | ['connect', 4, 'anonymous:1:1', 0, { subprotocol: '1.0.0' }], 62 | ['sync', 1, { type: 'test' }, { id: 1, time: 1 }] 63 | ]) 64 | 65 | await test.response({ type: 'response' }) 66 | expect(test.answer).toEqual({ type: 'response' }) 67 | }) 68 | 69 | it('waits for logux/undo', async () => { 70 | let test = await connectTest({ type: 'test' }, { userId: '10' }) 71 | 72 | expect(test.answer).toBeUndefined() 73 | expect(test.pair.leftSent).toEqual([ 74 | ['connect', 4, '10:1:1', 0, { subprotocol: '1.0.0' }], 75 | ['sync', 1, { type: 'test' }, { id: 1, time: 1 }] 76 | ]) 77 | 78 | await test.response({ id: '1 10:1:1 0', reason: 'test', type: 'logux/undo' }) 79 | expect(test.answer?.message).toBe('Server undid action because of test') 80 | }) 81 | 82 | it('throws Logux errors', async () => { 83 | let test = await createTest({ type: 'test ' }) 84 | 85 | test.pair.right.send(['error', 'wrong-subprotocol', { supported: '2.0.0' }]) 86 | await delay(20) 87 | 88 | expect(test.answer?.name).toBe('LoguxError') 89 | expect(test.answer?.message).toContain('Logux received wrong-subprotocol') 90 | }) 91 | -------------------------------------------------------------------------------- /status/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '@logux/core' 2 | 3 | import type { Client, ClientMeta } from '../client/index.js' 4 | 5 | interface StatusListener { 6 | ( 7 | current: 8 | | 'connecting' 9 | | 'connectingAfterWait' 10 | | 'denied' 11 | | 'disconnected' 12 | | 'error' 13 | | 'protocolError' 14 | | 'syncError' 15 | | 'synchronized' 16 | | 'synchronizedAfterWait' 17 | | 'wait', 18 | details: { action: Action; meta: ClientMeta } | { error: Error } | undefined 19 | ): void 20 | } 21 | 22 | interface StatusOptions { 23 | /** 24 | * Synchronized state duration. Default is `3000`. 25 | */ 26 | duration?: number 27 | } 28 | 29 | /** 30 | * Low-level function to show Logux synchronization status with your custom UI. 31 | * It is used in {@link badge} widget. 32 | * 33 | * ```js 34 | * import { status } from '@logux/client' 35 | * status(client, current => { 36 | * updateUI(current) 37 | * }) 38 | * ``` 39 | * 40 | * @param client Observed Client instance. 41 | * @param messages Disable specific message types. 42 | * @returns Unbind listener. 43 | */ 44 | export function status( 45 | client: Client, 46 | callback: StatusListener, 47 | options?: StatusOptions 48 | ): () => void 49 | -------------------------------------------------------------------------------- /status/index.js: -------------------------------------------------------------------------------- 1 | export function status(client, callback, options = {}) { 2 | let observable = client.on ? client : client.node 3 | let disconnected = observable.state === 'disconnected' 4 | let wait = false 5 | let old = false 6 | 7 | if (typeof options.duration === 'undefined') options.duration = 3000 8 | 9 | let timeout 10 | let unbind = [] 11 | let processing = {} 12 | 13 | function setSynchronized() { 14 | if (Object.keys(processing).length === 0) { 15 | if (wait) { 16 | wait = false 17 | callback('synchronizedAfterWait') 18 | timeout = setTimeout(() => { 19 | callback('synchronized') 20 | }, options.duration) 21 | } else { 22 | callback('synchronized') 23 | } 24 | } 25 | } 26 | 27 | function changeState() { 28 | clearTimeout(timeout) 29 | 30 | if (old) return 31 | if (observable.state === 'disconnected') { 32 | disconnected = true 33 | callback(wait ? 'wait' : 'disconnected') 34 | } else if (observable.state === 'synchronized') { 35 | disconnected = false 36 | setSynchronized() 37 | } else if (observable.state === 'connecting') { 38 | timeout = setTimeout(() => { 39 | callback('connecting' + (wait ? 'AfterWait' : '')) 40 | }, 100) 41 | } else { 42 | callback(client.state + (wait ? 'AfterWait' : '')) 43 | } 44 | } 45 | 46 | unbind.push(observable.on('state', changeState)) 47 | 48 | unbind.push( 49 | client.node.on('error', error => { 50 | if ( 51 | error.type === 'wrong-protocol' || 52 | error.type === 'wrong-subprotocol' 53 | ) { 54 | old = true 55 | callback('protocolError') 56 | } else if (error.type !== 'timeout') { 57 | callback('syncError', { error }) 58 | } 59 | }) 60 | ) 61 | 62 | unbind.push( 63 | client.node.on('clientError', error => { 64 | callback('syncError', { error }) 65 | }) 66 | ) 67 | 68 | let log = client.on ? client : client.log 69 | unbind.push( 70 | log.on('add', (action, meta) => { 71 | if (action.type === 'logux/subscribe') { 72 | return 73 | } else if (action.type === 'logux/unsubscribe') { 74 | return 75 | } 76 | 77 | if (action.type === 'logux/processed') { 78 | delete processing[action.id] 79 | setSynchronized() 80 | } else if (action.type === 'logux/undo') { 81 | delete processing[action.id] 82 | } else if (meta.sync) { 83 | processing[meta.id] = true 84 | } 85 | 86 | if (action.type === 'logux/undo' && action.reason) { 87 | if (action.reason === 'denied') { 88 | callback('denied', { action, meta }) 89 | } else { 90 | callback('error', { action, meta }) 91 | } 92 | } else if (disconnected && meta.sync && meta.added) { 93 | if (!wait) callback('wait') 94 | wait = true 95 | } 96 | }) 97 | ) 98 | 99 | changeState() 100 | 101 | return () => { 102 | for (let i of unbind) i() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /status/index.test.ts: -------------------------------------------------------------------------------- 1 | import { LoguxError, type TestLog, TestPair, TestTime } from '@logux/core' 2 | import { delay } from 'nanodelay' 3 | import { expect, it } from 'vitest' 4 | 5 | import { CrossTabClient, status } from '../index.js' 6 | 7 | function setState(node: any, state: string): void { 8 | node.setState(state) 9 | } 10 | 11 | function emit(obj: any, event: string, ...args: any[]): void { 12 | obj.emitter.emit(event, ...args) 13 | } 14 | 15 | async function createTest(options?: { duration?: number }): Promise<{ 16 | args: any[] 17 | calls: string[] 18 | client: CrossTabClient<{}, TestLog> 19 | }> { 20 | let pair = new TestPair() 21 | let client = new CrossTabClient<{}, TestLog>({ 22 | server: pair.left, 23 | subprotocol: '1.0.0', 24 | time: new TestTime(), 25 | userId: '10' 26 | }) 27 | 28 | client.role = 'leader' 29 | client.node.catch(() => {}) 30 | 31 | let calls: string[] = [] 32 | let args: any[] = [] 33 | 34 | status( 35 | client, 36 | (state, details) => { 37 | calls.push(state) 38 | args.push(details) 39 | }, 40 | options 41 | ) 42 | 43 | return { args, calls, client } 44 | } 45 | 46 | it('notifies about states', async () => { 47 | let test = await createTest() 48 | setState(test.client.node, 'connecting') 49 | await delay(105) 50 | test.client.node.connected = true 51 | setState(test.client.node, 'synchronized') 52 | expect(test.calls).toEqual(['disconnected', 'connecting', 'synchronized']) 53 | }) 54 | 55 | it('notifies about other tab states', async () => { 56 | let test = await createTest() 57 | test.client.state = 'synchronized' 58 | emit(test.client, 'state') 59 | expect(test.calls).toEqual(['disconnected', 'synchronized']) 60 | }) 61 | 62 | it('notifies only about wait for sync actions', async () => { 63 | let test = await createTest({ duration: 10 }) 64 | test.client.node.log.add( 65 | { type: 'logux/subscribe' }, 66 | { reasons: ['t'], sync: true } 67 | ) 68 | test.client.node.log.add( 69 | { type: 'logux/unsubscribe' }, 70 | { reasons: ['t'], sync: true } 71 | ) 72 | expect(test.calls).toEqual(['disconnected']) 73 | test.client.node.log.add({ type: 'A' }, { reasons: ['t'], sync: true }) 74 | test.client.node.log.add({ type: 'B' }, { reasons: ['t'], sync: true }) 75 | setState(test.client.node, 'connecting') 76 | await delay(105) 77 | setState(test.client.node, 'disconnected') 78 | setState(test.client.node, 'connecting') 79 | await delay(105) 80 | setState(test.client.node, 'sending') 81 | setState(test.client.node, 'synchronized') 82 | expect(test.calls).toEqual([ 83 | 'disconnected', 84 | 'wait', 85 | 'connectingAfterWait', 86 | 'wait', 87 | 'connectingAfterWait', 88 | 'sendingAfterWait' 89 | ]) 90 | test.client.node.log.add({ id: '3 10:1:1 0', type: 'logux/undo' }) 91 | await delay(1) 92 | expect(test.calls).toEqual([ 93 | 'disconnected', 94 | 'wait', 95 | 'connectingAfterWait', 96 | 'wait', 97 | 'connectingAfterWait', 98 | 'sendingAfterWait' 99 | ]) 100 | test.client.node.log.add({ id: '4 10:1:1 0', type: 'logux/processed' }) 101 | await delay(1) 102 | expect(test.calls).toEqual([ 103 | 'disconnected', 104 | 'wait', 105 | 'connectingAfterWait', 106 | 'wait', 107 | 'connectingAfterWait', 108 | 'sendingAfterWait', 109 | 'synchronizedAfterWait' 110 | ]) 111 | await delay(15) 112 | expect(test.calls).toEqual([ 113 | 'disconnected', 114 | 'wait', 115 | 'connectingAfterWait', 116 | 'wait', 117 | 'connectingAfterWait', 118 | 'sendingAfterWait', 119 | 'synchronizedAfterWait', 120 | 'synchronized' 121 | ]) 122 | }) 123 | 124 | it('skips connecting notification if it took less than 100ms', async () => { 125 | let test = await createTest() 126 | setState(test.client.node, 'connecting') 127 | test.client.node.connected = true 128 | setState(test.client.node, 'synchronized') 129 | expect(test.calls).toEqual(['disconnected', 'synchronized']) 130 | }) 131 | 132 | it('notifies about synchronization error', async () => { 133 | let test = await createTest() 134 | await test.client.node.connection.connect() 135 | 136 | let error1 = { type: 'any error' } 137 | emit(test.client.node, 'error', error1) 138 | 139 | let error2 = new LoguxError('timeout', 10, true) 140 | emit(test.client.node, 'clientError', error2) 141 | 142 | expect(test.calls).toEqual(['disconnected', 'syncError', 'syncError']) 143 | expect(test.args).toEqual([undefined, { error: error1 }, { error: error2 }]) 144 | }) 145 | 146 | it('ignores timeout error', async () => { 147 | let test = await createTest() 148 | await test.client.node.connection.connect() 149 | let error1 = { type: 'timeout' } 150 | emit(test.client.node, 'error', error1) 151 | expect(test.calls).toEqual(['disconnected']) 152 | }) 153 | 154 | it('notifies about old client', async () => { 155 | let test = await createTest() 156 | await test.client.node.connection.connect() 157 | let protocol = new LoguxError('wrong-protocol', { 158 | supported: '1.0.0', 159 | used: '0.1.0' 160 | }) 161 | emit(test.client.node, 'error', protocol) 162 | 163 | let subprotocol = new LoguxError('wrong-subprotocol', { 164 | supported: '1.0.0', 165 | used: '0.1.0' 166 | }) 167 | emit(test.client.node, 'error', subprotocol) 168 | 169 | setState(test.client.node, 'disconnected') 170 | 171 | expect(test.calls).toEqual(['disconnected', 'protocolError', 'protocolError']) 172 | }) 173 | 174 | it('notifies about server error', async () => { 175 | let test = await createTest() 176 | await test.client.node.connection.connect() 177 | test.client.node.log.add({ reason: 'error', type: 'logux/undo' }) 178 | expect(test.calls).toEqual(['disconnected', 'error']) 179 | expect(test.args[1].action.type).toBe('logux/undo') 180 | expect(test.args[1].meta.time).toBe(1) 181 | }) 182 | 183 | it('notifies about problem with access', async () => { 184 | let test = await createTest() 185 | await test.client.node.connection.connect() 186 | test.client.node.log.add({ reason: 'denied', type: 'logux/undo' }) 187 | expect(test.calls).toEqual(['disconnected', 'denied']) 188 | expect(test.args[1].action.type).toBe('logux/undo') 189 | expect(test.args[1].meta.time).toBe(1) 190 | }) 191 | 192 | it('removes listeners', () => { 193 | let pair = new TestPair() 194 | let client = new CrossTabClient({ 195 | server: pair.left, 196 | subprotocol: '1.0.0', 197 | time: new TestTime(), 198 | userId: '10' 199 | }) 200 | 201 | let calls = 0 202 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 203 | let unbind = status(client, (state, details) => { 204 | if (state === 'denied') { 205 | calls += 1 206 | } 207 | }) 208 | 209 | client.log.add({ reason: 'denied', type: 'logux/undo' }) 210 | unbind() 211 | client.log.add({ reason: 'denied', type: 'logux/undo' }) 212 | 213 | expect(calls).toBe(1) 214 | }) 215 | -------------------------------------------------------------------------------- /sync-map-template/errors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildNewSyncMap, 3 | syncMapTemplate, 4 | changeSyncMap, 5 | createSyncMap, 6 | Client 7 | } from '../index.js' 8 | 9 | let client = new Client({ 10 | subprotocol: '1.0.0', 11 | server: 'ws://localhost', 12 | userId: '10' 13 | }) 14 | 15 | let User = syncMapTemplate<{ 16 | name: string 17 | age?: number 18 | }>('users') 19 | 20 | let user = User('user:id', client) 21 | // THROWS 'firstName' does not exist in type 22 | changeSyncMap(user, { firstName: 'Ivan' }) 23 | // THROWS Type 'string' is not assignable to type 'number' 24 | changeSyncMap(user, { age: '26' }) 25 | // THROWS 'id' does not exist in type 26 | changeSyncMap(user, { id: '26' }) 27 | // THROWS firstName"' is not assignable to parameter of type '"name" | "age" 28 | changeSyncMap(user, 'firstName', 'Ivan') 29 | // THROWS 'string' is not assignable to parameter of type 'number' 30 | changeSyncMap(user, 'age', '26') 31 | // THROWS '"id"' is not assignable to parameter of type '"name" | "age" 32 | changeSyncMap(user, 'id', '26') 33 | 34 | // THROWS '{ name: string; }' is not assignable to parameter 35 | let user1 = createSyncMap(client, User, { name: 'A' }) 36 | // THROWS 'string' is not assignable to type 'number' 37 | let user2 = createSyncMap(client, User, { id: 'user:2', name: 'B', age: '12' }) 38 | // THROWS '{ id: string; }' is not assignable to parameter 39 | let user3 = buildNewSyncMap(client, User, { id: 'user:3' }) 40 | -------------------------------------------------------------------------------- /sync-map-template/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildNewSyncMap, 3 | changeSyncMap, 4 | Client, 5 | createSyncMap, 6 | syncMapTemplate 7 | } from '../index.js' 8 | 9 | let client = new Client({ 10 | server: 'ws://localhost', 11 | subprotocol: '1.0.0', 12 | userId: '10' 13 | }) 14 | 15 | let User = syncMapTemplate<{ 16 | age?: number 17 | name: string 18 | }>('users') 19 | 20 | let user = User('user:id', client) 21 | changeSyncMap(user, { name: 'Ivan' }) 22 | changeSyncMap(user, 'name', 'Ivan') 23 | changeSyncMap(user, 'age', 26) 24 | 25 | createSyncMap(client, User, { id: 'user:1', name: 'A' }) 26 | buildNewSyncMap(client, User, { age: 12, id: 'user:2', name: 'B' }) 27 | -------------------------------------------------------------------------------- /test-client/errors.ts: -------------------------------------------------------------------------------- 1 | import { TestClient } from '../index.js' 2 | 3 | let client = new TestClient<{ locale: string }>('10') 4 | 5 | interface PostRenameAction { 6 | type: 'post/rename' 7 | postId: string 8 | } 9 | 10 | // THROWS 'lang' does not exist in type '{ locale: string; } 11 | client.node.setLocalHeaders({ lang: 'ru' }) 12 | 13 | client.server.resend( 14 | // THROWS posts/rename"' is not assignable to parameter of type '"post/rename 15 | 'posts/rename', 16 | action => `posts/${action.postId}` 17 | ) 18 | 19 | client.server.resend( 20 | 'post/rename', 21 | // THROWS Property 'post' does not exist on type 'PostRenameAction' 22 | action => `posts/${action.post}` 23 | ) 24 | -------------------------------------------------------------------------------- /test-client/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Action, TestLog, TestPair } from '@logux/core' 2 | 3 | import { Client } from '../client/index.js' 4 | import type { ClientMeta } from '../client/index.js' 5 | import type { TestServer } from '../test-server/index.js' 6 | 7 | export interface TestClientOptions { 8 | headers?: Headers 9 | server?: TestServer 10 | subprotocol?: string 11 | } 12 | 13 | /** 14 | * Virtual client to test client-side code end store extnesions. 15 | * 16 | * ```js 17 | * import { TestClient } from '@logux/client' 18 | * 19 | * it('connects and sends actions', async () => { 20 | * let client = new TestClient() 21 | * let user = new UserStore(client, '10') 22 | * 23 | * client.server.onChannel('users/10', [ 24 | * { type: 'users/name', userId: 10, value: 'New name' } 25 | * ]) 26 | * await client.connect() 27 | * await delay(10) 28 | * 29 | * expect(user.name).toEqual('New name') 30 | * }) 31 | * ``` 32 | */ 33 | export class TestClient extends Client< 34 | Headers, 35 | TestLog 36 | > { 37 | /** 38 | * Connection between client and server. 39 | */ 40 | readonly pair: TestPair 41 | 42 | /** 43 | * Virtual server to test client. 44 | * 45 | * ```js 46 | * expect(client.server.log.actions()).toEqual([ 47 | * { type: 'logux/subscribe', channel: 'users/10' } 48 | * ]) 49 | * ``` 50 | */ 51 | readonly server: TestServer 52 | 53 | /** 54 | * @param userId User ID. 55 | * @param opts Other options. 56 | */ 57 | constructor(userId: string, opts?: TestClientOptions) 58 | 59 | /** 60 | * Connect to virtual server. 61 | * 62 | * ```js 63 | * await client.connect() 64 | * ``` 65 | * 66 | * @returns Promise until connection will be established. 67 | */ 68 | connect(): Promise 69 | 70 | /** 71 | * Disconnect from virtual server. 72 | * 73 | * ```js 74 | * client.disconnect() 75 | * ``` 76 | */ 77 | disconnect(): void 78 | 79 | /** 80 | * Collect actions sent by client during the `test` call. 81 | * 82 | * ```js 83 | * let answers = await client.sent(async () => { 84 | * client.log.add({ type: 'local' }) 85 | * }) 86 | * expect(actions).toEqual([{ type: 'local' }]) 87 | * ``` 88 | * 89 | * @param test Function, where do you expect action will be received 90 | * @returns Promise with all received actions 91 | */ 92 | sent(test: () => Promise | void): Promise 93 | 94 | /** 95 | * Does client subscribed to specific channel. 96 | * 97 | * ```js 98 | * let user = new UserStore(client, '10') 99 | * await delay(10) 100 | * expect(client.subscribed('users/10')).toBe(true) 101 | * ``` 102 | * 103 | * @param channel Channel name. 104 | * @returns Does client has an active subscription. 105 | */ 106 | subscribed(channel: string): boolean 107 | } 108 | -------------------------------------------------------------------------------- /test-client/index.js: -------------------------------------------------------------------------------- 1 | import { TestPair } from '@logux/core' 2 | import { delay } from 'nanodelay' 3 | 4 | import { Client } from '../client/index.js' 5 | import { TestServer } from '../test-server/index.js' 6 | 7 | export class TestClient extends Client { 8 | constructor(userId, opts = {}) { 9 | let server = opts.server || new TestServer() 10 | let pair = new TestPair() 11 | super({ 12 | attempts: 0, 13 | server: pair.left, 14 | subprotocol: opts.subprotocol || '0.0.0', 15 | time: server.time, 16 | userId 17 | }) 18 | this.pair = pair 19 | this.server = server 20 | } 21 | 22 | connect() { 23 | this.server.connect(this.nodeId, this.pair.right) 24 | this.node.connection.connect() 25 | return this.node.waitFor('synchronized') 26 | } 27 | 28 | disconnect() { 29 | this.node.connection.disconnect() 30 | } 31 | 32 | async sent(test) { 33 | let actions = [] 34 | let unbind = this.log.on('add', (action, meta) => { 35 | if (meta.sync && meta.id.includes(` ${this.nodeId} `)) { 36 | actions.push(action) 37 | } 38 | }) 39 | await test() 40 | await delay(1) 41 | unbind() 42 | return actions 43 | } 44 | 45 | subscribed(channel) { 46 | let nodes = this.server.subscriptions[channel] || {} 47 | return !!nodes[this.nodeId] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test-client/types.ts: -------------------------------------------------------------------------------- 1 | import { TestClient } from '../index.js' 2 | 3 | let client = new TestClient<{ locale: string }>('10') 4 | 5 | interface PostRenameAction { 6 | postId: string 7 | type: 'post/rename' 8 | } 9 | 10 | client.node.setLocalHeaders({ locale: 'ru' }) 11 | 12 | client.server.resend( 13 | 'post/rename', 14 | action => `posts/${action.postId}` 15 | ) 16 | -------------------------------------------------------------------------------- /test-server/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Action, AnyAction, Meta, TestLog } from '@logux/core' 2 | 3 | /** 4 | * Virtual server to test client. 5 | * 6 | * ```js 7 | * let client = new TestClient() 8 | * client.server //=> TestServer 9 | * ``` 10 | */ 11 | export class TestServer { 12 | /** 13 | * All actions received from the client. 14 | * 15 | * ```js 16 | * expect(client.server.log.actions()).toEqual([ 17 | * { type: 'logux/subscribe', channel: 'users/10' } 18 | * ]) 19 | * ``` 20 | */ 21 | log: TestLog 22 | 23 | /** 24 | * Stop to response with `logux/processed` on all new action 25 | * and send `logux/processed` for all received actions when `test` 26 | * callback will be finished. 27 | * 28 | * ```js 29 | * await client.server.freezeProcessing(() => { 30 | * user.rename('Another name') 31 | * expect(user.nameIsSaving).toBe(true) 32 | * }) 33 | * await delay(10) 34 | * expect(user.nameIsSaving).toBe(false) 35 | * ``` 36 | * 37 | * @param test Function, where server will not send `logux/processed`. 38 | * @returns Promise until `test` will be finished. 39 | */ 40 | freezeProcessing(test: () => Promise): Promise 41 | 42 | /** 43 | * Define server’s responses for specific channel. 44 | * 45 | * Second call with the same channel name will override previous data. 46 | * 47 | * ```js 48 | * client.server.onChannel('users/10', [ 49 | * { type: 'users/name', userId: 10, value: 'New name' } 50 | * ]) 51 | * let user = new UserStore(client, '10') 52 | * await delay(10) 53 | * expect(user.name).toEqual('New name') 54 | * ``` 55 | * 56 | * @param channel The channel name. 57 | * @param response Actions to send back on subscription. 58 | */ 59 | onChannel( 60 | channel: string, 61 | response: [AnyAction, Partial][] | AnyAction | AnyAction[] 62 | ): void 63 | 64 | /** 65 | * Set channels for client’s actions. 66 | * @param type Action type. 67 | * @param resend Callback returns channel name. 68 | */ 69 | resend( 70 | type: ResentAction['type'], 71 | resend: (action: ResentAction, meta: Meta) => string | string[] 72 | ): void 73 | 74 | /** 75 | * Send action to all connected clients. 76 | * 77 | * ```js 78 | * client.server.sendAll(action) 79 | * ``` 80 | * 81 | * @param action Action. 82 | * @param meta Action‘s meta. 83 | */ 84 | sendAll( 85 | action: SentAction, 86 | meta?: Partial 87 | ): Promise 88 | 89 | /** 90 | * Response with `logux/undo` instead of `logux/process` on receiving 91 | * specific action. 92 | * 93 | * ```js 94 | * client.server.undoAction( 95 | * { type: 'rename', userId: '10', value: 'Bad name' } 96 | * ) 97 | * user.rename('Good name') 98 | * user.rename('Bad name') 99 | * await delay(10) 100 | * expect(user.name).toEqual('Good name') 101 | * ``` 102 | * 103 | * @param action Action to be undone on receiving 104 | * @param reason Optional code for reason. Default is `'error'`. 105 | * @param extra Extra fields to `logux/undo` action. 106 | */ 107 | undoAction( 108 | action: RevertedAction, 109 | reason?: string, 110 | extra?: object 111 | ): void 112 | 113 | /** 114 | * Response with `logux/undo` instead of `logux/process` on next action 115 | * from the client. 116 | * 117 | * ```js 118 | * client.server.undoNext() 119 | * user.rename('Another name') 120 | * await delay(10) 121 | * expect(user.name).toEqual('Old name') 122 | * ``` 123 | * 124 | * @param reason Optional code for reason. Default is `'error'`. 125 | * @param extra Extra fields to `logux/undo` action. 126 | */ 127 | undoNext(reason?: string, extra?: object): void 128 | } 129 | -------------------------------------------------------------------------------- /test-server/index.js: -------------------------------------------------------------------------------- 1 | import { parseId, ServerNode, TestTime } from '@logux/core' 2 | import stringify from 'fast-json-stable-stringify' 3 | import { delay } from 'nanodelay' 4 | 5 | export class TestServer { 6 | constructor() { 7 | this.time = new TestTime() 8 | this.log = this.time.nextLog({ nodeId: 'server:id' }) 9 | this.undo = [] 10 | this.bad = {} 11 | this.subscriptions = {} 12 | this.frozen = false 13 | this.deferred = [] 14 | this.channels = {} 15 | this.resenders = {} 16 | this.connected = new Set() 17 | this.log.on('preadd', (action, meta) => { 18 | if (this.resenders[action.type]) { 19 | let channels = this.resenders[action.type](action, meta) 20 | if (typeof channels === 'string') channels = [channels] 21 | meta.channels = channels 22 | } 23 | }) 24 | this.log.on('add', (action, meta) => { 25 | if (action.type === 'logux/subscribed') { 26 | if (!this.subscriptions[action.channel]) { 27 | this.subscriptions[action.channel] = {} 28 | } 29 | for (let nodeId of meta.nodes || []) { 30 | if (!this.subscriptions[action.channel][nodeId]) { 31 | this.subscriptions[action.channel][nodeId] = [] 32 | } 33 | this.subscriptions[action.channel][nodeId].push(action.filter || true) 34 | } 35 | } 36 | if (meta.id.includes(' server:')) return 37 | if (this.frozen) { 38 | this.deferred.push([action, meta]) 39 | } else { 40 | this.process(action, meta) 41 | } 42 | }) 43 | } 44 | 45 | connect(nodeId, connection) { 46 | this.connected.add(nodeId) 47 | let server = this 48 | let node = new ServerNode('server:id', this.log, connection, { 49 | onReceive(action, meta) { 50 | return [action, { ...meta, subprotocol: node.remoteSubprotocol }] 51 | }, 52 | onSend(action, meta) { 53 | let access 54 | if (meta.channels) { 55 | access = meta.channels.some(channel => { 56 | let nodes = server.subscriptions[channel] || {} 57 | return !!nodes[nodeId] 58 | }) 59 | } else if (meta.nodes) { 60 | access = meta.nodes.includes(nodeId) 61 | } else { 62 | access = 63 | !action.type.startsWith('logux/') && !meta.channels && !meta.nodes 64 | } 65 | if (access) { 66 | let cleaned = {} 67 | for (let i in meta) { 68 | if (i !== 'nodes' && i !== 'channels') cleaned[i] = meta[i] 69 | } 70 | return [action, cleaned] 71 | } else { 72 | return false 73 | } 74 | } 75 | }) 76 | node.on('state', () => { 77 | if (node.state === 'disconnected' && nodeId) { 78 | this.connected.delete(nodeId) 79 | for (let channel in this.subscriptions) { 80 | delete this.subscriptions[channel][nodeId] 81 | } 82 | } 83 | }) 84 | } 85 | 86 | async freezeProcessing(test) { 87 | this.frozen = true 88 | await test() 89 | this.frozen = false 90 | for (let [action, meta] of this.deferred) { 91 | this.process(action, meta) 92 | } 93 | this.deferred = [] 94 | await delay(20) 95 | } 96 | 97 | onChannel(channel, response) { 98 | this.channels[channel] = response 99 | } 100 | 101 | process(action, meta) { 102 | let id = meta.id 103 | let nodeId = parseId(id).nodeId 104 | let nodes = [nodeId] 105 | 106 | if (this.sendUndo(action, meta, this.undo.shift())) return 107 | if (this.sendUndo(action, meta, this.bad[stringify(action)])) return 108 | 109 | if (action.type === 'logux/subscribe') { 110 | if (!this.subscriptions[action.channel]) { 111 | this.subscriptions[action.channel] = {} 112 | } 113 | if (!this.subscriptions[action.channel][nodeId]) { 114 | this.subscriptions[action.channel][nodeId] = [] 115 | } 116 | this.subscriptions[action.channel][nodeId].push(action.filter || true) 117 | let responses = this.channels[action.channel] || [] 118 | if (Array.isArray(responses)) { 119 | for (let response of responses) { 120 | if (Array.isArray(response)) { 121 | this.log.add(response[0], { nodes, ...response[1] }) 122 | } else { 123 | this.log.add(response, { nodes }) 124 | } 125 | } 126 | } else { 127 | this.log.add(responses, { nodes }) 128 | } 129 | } else if (action.type === 'logux/unsubscribe') { 130 | let hasValue 131 | if (action.filter) { 132 | hasValue = it => !compareFilters(action.filter, it) 133 | } else { 134 | hasValue = it => it !== true 135 | } 136 | if ( 137 | this.subscriptions[action.channel] && 138 | this.subscriptions[action.channel][nodeId] && 139 | !this.subscriptions[action.channel][nodeId].every(hasValue) 140 | ) { 141 | this.subscriptions[action.channel][nodeId] = 142 | this.subscriptions[action.channel][nodeId].filter(hasValue) 143 | if (this.subscriptions[action.channel][nodeId].length === 0) { 144 | delete this.subscriptions[action.channel][nodeId] 145 | } 146 | } else { 147 | /* c8 ignore next 8 */ 148 | throw new Error( 149 | `Client was not subscribed to ${action.channel} ` + 150 | (action.filter 151 | ? `with filter ${JSON.stringify(action.filter)} ` 152 | : '') + 153 | 'but it tries to unsubscribe from it' 154 | ) 155 | } 156 | } 157 | this.log.add({ id, type: 'logux/processed' }, { nodes }) 158 | } 159 | 160 | resend(type, resend) { 161 | this.resenders[type] = resend 162 | } 163 | 164 | async sendAll(action, meta = {}) { 165 | await this.log.add(action, { ...meta, nodes: Array.from(this.connected) }) 166 | await delay(10) 167 | } 168 | 169 | sendUndo(action, meta, record) { 170 | if (!record) return false 171 | let [reason, extra] = record 172 | this.log.add( 173 | { action, id: meta.id, reason, type: 'logux/undo', ...extra }, 174 | { nodes: [parseId(meta.id).nodeId] } 175 | ) 176 | return true 177 | } 178 | 179 | undoAction(action, reason, extra) { 180 | this.bad[stringify(action)] = [reason || 'error', extra || {}] 181 | } 182 | 183 | undoNext(reason, extra) { 184 | this.undo.push([reason || 'error', extra || {}]) 185 | } 186 | } 187 | 188 | function compareFilters(first, second) { 189 | let firstKeys = Object.keys(first) 190 | return ( 191 | firstKeys.length === Object.keys(second).length && 192 | firstKeys.every(key => first[key] === second[key]) 193 | ) 194 | } 195 | -------------------------------------------------------------------------------- /test/demo/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/logux/client/70ea410ff22d070b300e71cecbd6a70c71434954/test/demo/error.png -------------------------------------------------------------------------------- /test/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Logux Client Demo 6 | 7 | 55 | 56 | 57 | 58 |
59 | Logux logo 65 |

Logux Client

66 |

67 | Logux 68 | is a client-server communication tool. It synchronizes actions between 69 | clients and server logs. 70 |

71 |

72 | This page tests 73 | Logux Client 74 | features: 75 |

76 |
    77 |
  • cross-tab sends actions between browser tabs.
  • 78 |
  • 79 | attention highlights tab on synchronization error to 80 | notify user. 81 |
  • 82 |
  • 83 | confirm shows confirm popup, when user close tab with 84 | non-synchronized actions. 85 |
  • 86 |
  • 87 | favicon changes favicon to show synchronization 88 | status. 89 |
  • 90 |
  • 91 | badge shows widget to display Logux synchronization 92 | status. 93 |
  • 94 |
  • 95 | status low-level API to get Logux synchronization 96 | status. 97 |
  • 98 |
  • log displays Logux events in browser console.
  • 99 |
100 |

Controls

101 |
102 |
103 | 110 |
111 |
Status: disconnected
112 |
113 |
114 | 115 |
116 |
117 | 118 |
119 |
120 | 121 |
122 |
123 | 124 |
125 |
126 | 127 |
128 |
129 | 130 |
131 |
132 | 133 |
134 |
135 | 136 | 137 | -------------------------------------------------------------------------------- /test/demo/index.js: -------------------------------------------------------------------------------- 1 | import { BaseNode, ClientNode, LocalPair, Log, MemoryStore } from '@logux/core' 2 | 3 | import { badgeStyles } from '../../badge/styles/index.js' 4 | import { 5 | attention, 6 | badge, 7 | badgeEn, 8 | confirm, 9 | CrossTabClient, 10 | favicon, 11 | log, 12 | status 13 | } from '../../index.js' 14 | import faviconError from './error.png' 15 | import faviconNormal from './normal.png' 16 | import faviconOffline from './offline.png' 17 | 18 | let pair = new LocalPair(500) 19 | 20 | let serverLog = new Log({ 21 | nodeId: 'server:uuid', 22 | store: new MemoryStore() 23 | }) 24 | new BaseNode('server:uuid', serverLog, pair.right) 25 | 26 | serverLog.on('add', (action, meta) => { 27 | if (action.type !== 'logux/processed') { 28 | setTimeout(() => { 29 | serverLog.add({ id: meta.id, type: 'logux/processed' }) 30 | }, 500) 31 | } 32 | }) 33 | 34 | let client = new CrossTabClient({ 35 | server: 'wss://example.com/', 36 | subprotocol: location.hash.slice(1) || '1.0.0', 37 | userId: '10' 38 | }) 39 | 40 | let node = new ClientNode(client.node.localNodeId, client.log, pair.left) 41 | node.connection.url = 'wss://example.com/' 42 | node.emitter = client.node.emitter 43 | client.node = node 44 | 45 | attention(client) 46 | confirm(client) 47 | favicon(client, { 48 | error: faviconError, 49 | normal: faviconNormal, 50 | offline: faviconOffline 51 | }) 52 | badge(client, { 53 | messages: badgeEn, 54 | styles: badgeStyles 55 | }) 56 | log(client) 57 | status(client, s => { 58 | document.all.status.innerText = s 59 | }) 60 | 61 | let count = 0 62 | function emoji(state) { 63 | if (state === 'disconnected') { 64 | return '😴' 65 | } else if (state === 'connecting') { 66 | return '🔌' 67 | } else { 68 | return '😊' 69 | } 70 | } 71 | function role(value) { 72 | return value.slice(0, 1).toUpperCase() 73 | } 74 | function updateTitle() { 75 | document.title = emoji(client.state) + ' ' + role(client.role) + ' ' + count 76 | } 77 | 78 | client.on('state', () => { 79 | document.querySelector('#connection').checked = client.connected 80 | updateTitle() 81 | }) 82 | client.on('role', () => { 83 | updateTitle() 84 | document.querySelector('#connection').disabled = client.role !== 'leader' 85 | }) 86 | client.on('add', action => { 87 | if (action.type === 'TICK') count++ 88 | updateTitle() 89 | }) 90 | client.on('clean', action => { 91 | if (action.type === 'TICK') count-- 92 | updateTitle() 93 | }) 94 | 95 | client.log 96 | .each(action => { 97 | if (action.type === 'TICK') count++ 98 | }) 99 | .then(() => { 100 | updateTitle() 101 | }) 102 | 103 | client.on('role', () => { 104 | let isLeader = client.role === 'leader' 105 | document.all.connection.disabled = !isLeader 106 | document.all.disabled.style.display = isLeader ? 'none' : 'inline' 107 | }) 108 | 109 | client.start() 110 | 111 | document.querySelector('#connection').onchange = e => { 112 | if (e.target.checked) { 113 | client.node.connection.connect() 114 | } else { 115 | client.node.connection.disconnect() 116 | } 117 | } 118 | 119 | document.querySelector('#add').onclick = () => { 120 | client.log.add({ type: 'TICK' }, { reasons: ['tick'], sync: true }) 121 | } 122 | 123 | document.querySelector('#clean').onclick = () => { 124 | client.log.removeReason('tick') 125 | } 126 | 127 | document.querySelector('#error').onclick = () => { 128 | setTimeout(() => { 129 | client.log.add({ 130 | action: { type: 'TICK' }, 131 | reason: 'error', 132 | type: 'logux/undo' 133 | }) 134 | }, 3000) 135 | } 136 | 137 | document.querySelector('#denied').onclick = () => { 138 | setTimeout(() => { 139 | client.log.add({ 140 | action: { type: 'TICK' }, 141 | reason: 'denied', 142 | type: 'logux/undo' 143 | }) 144 | }, 3000) 145 | } 146 | 147 | document.querySelector('#serverError').onclick = () => { 148 | setTimeout(() => { 149 | pair.right.send(['error', 'wrong-format']) 150 | }, 3000) 151 | } 152 | 153 | document.querySelector('#subprotocolError').onclick = () => { 154 | client.node.syncError('wrong-subprotocol', { 155 | supported: '2.x', 156 | used: '1.0.0' 157 | }) 158 | } 159 | 160 | if (client.options.subprotocol === '1.0.1') { 161 | document.querySelector('#subprotocolClient').disabled = true 162 | } else { 163 | document.querySelector('#subprotocolClient').onclick = () => { 164 | window.open(location.toString() + '#1.0.1', '_blank') 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /test/demo/normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/logux/client/70ea410ff22d070b300e71cecbd6a70c71434954/test/demo/normal.png -------------------------------------------------------------------------------- /test/demo/offline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/logux/client/70ea410ff22d070b300e71cecbd6a70c71434954/test/demo/offline.png -------------------------------------------------------------------------------- /test/local-storage.js: -------------------------------------------------------------------------------- 1 | let errorOnSet 2 | 3 | export function setLocalStorage() { 4 | errorOnSet = undefined 5 | window.localStorage = { 6 | clear() { 7 | this.storage = {} 8 | }, 9 | getItem(key) { 10 | if (key in this.storage) { 11 | return this.storage[key] 12 | } else { 13 | return null 14 | } 15 | }, 16 | removeItem(key) { 17 | delete this[key] 18 | delete this.storage[key] 19 | }, 20 | setItem(key, value) { 21 | if (errorOnSet) throw errorOnSet 22 | this[key] = value 23 | this.storage[key] = String(value) 24 | }, 25 | storage: {} 26 | } 27 | } 28 | 29 | export function breakLocalStorage(error) { 30 | errorOnSet = error 31 | } 32 | 33 | export function emitStorage(key, newValue) { 34 | window.dispatchEvent(new StorageEvent('storage', { key, newValue })) 35 | } 36 | -------------------------------------------------------------------------------- /track/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { ID, Log } from '@logux/core' 2 | 3 | import type { Client } from '../client/index.js' 4 | 5 | /** 6 | * Track for `logux/processed` or `logux/undo` answer from server 7 | * for the cases when `Client#sync` can’t be used. 8 | * 9 | * ```js 10 | * client.type('pay', (action, meta) => { 11 | * track(client, id).then(() => { 12 | * console.log('paid') 13 | * }).catch(() => { 14 | * console.log('unpaid') 15 | * }) 16 | * }) 17 | * ``` 18 | * 19 | * @param client Logux Client. 20 | * @param id Action ID. 21 | * @returns Promise when action was proccessed. 22 | */ 23 | export function track(client: Client | Log, id: ID): Promise 24 | -------------------------------------------------------------------------------- /track/index.js: -------------------------------------------------------------------------------- 1 | export function track(client, id) { 2 | if (client.processing[id]) return client.processing[id][0] 3 | 4 | let rejectCallback, resolveCallback 5 | let promise = new Promise((resolve, reject) => { 6 | resolveCallback = resolve 7 | rejectCallback = reject 8 | }) 9 | client.processing[id] = [promise, resolveCallback, rejectCallback] 10 | 11 | return promise 12 | } 13 | -------------------------------------------------------------------------------- /track/index.test.ts: -------------------------------------------------------------------------------- 1 | import { type TestLog, TestPair, TestTime } from '@logux/core' 2 | import { delay } from 'nanodelay' 3 | import { expect, it } from 'vitest' 4 | 5 | import { Client, track } from '../index.js' 6 | 7 | it('tracks action processing', async () => { 8 | let pair = new TestPair() 9 | let client = new Client<{}, TestLog>({ 10 | server: pair.left, 11 | subprotocol: '1.0.0', 12 | time: new TestTime(), 13 | userId: '10' 14 | }) 15 | client.node.catch(() => {}) 16 | 17 | let results: string[] = [] 18 | 19 | client.type('A', async (action, meta) => { 20 | try { 21 | await track(client, meta.id) 22 | results.push('processed ' + meta.id) 23 | } catch (e) { 24 | if (e instanceof Error) results.push(e.message) 25 | } 26 | }) 27 | 28 | let meta1 = await client.log.add({ type: 'A' }) 29 | if (meta1 !== false) { 30 | await client.log.add({ id: meta1.id, type: 'logux/processed' }) 31 | } 32 | await delay(10) 33 | expect(results).toEqual(['processed 1 10:1:1 0']) 34 | 35 | results = [] 36 | let meta2 = await client.log.add({ type: 'A' }) 37 | if (meta2 !== false) { 38 | await client.log.add({ id: meta2.id, reason: 'test', type: 'logux/undo' }) 39 | } 40 | await delay(10) 41 | expect(results).toEqual(['Server undid action because of test']) 42 | }) 43 | 44 | it('works on multiple calls', async () => { 45 | let pair = new TestPair() 46 | let client = new Client<{}, TestLog>({ 47 | server: pair.left, 48 | subprotocol: '1.0.0', 49 | time: new TestTime(), 50 | userId: '10' 51 | }) 52 | client.node.catch(() => {}) 53 | 54 | let resolves: string[] = [] 55 | 56 | let id = client.log.generateId() 57 | client.sync({ type: 'FOO' }, { id }).then(() => { 58 | resolves.push('sync') 59 | }) 60 | track(client, id).then(() => { 61 | resolves.push('track1') 62 | }) 63 | track(client, id).then(() => { 64 | resolves.push('track2') 65 | }) 66 | 67 | await client.log.add({ id, type: 'logux/processed' }) 68 | await delay(10) 69 | expect(resolves).toEqual(['sync', 'track1', 'track2']) 70 | }) 71 | -------------------------------------------------------------------------------- /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": ["**/errors.ts", "test/demo/dist/**.js"] 13 | } 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | exclude: [ 7 | '*/errors.ts', 8 | '*/types.ts', 9 | '**/*.d.ts', 10 | 'test/demo', 11 | '*.config.*', 12 | '**/*.test.ts' 13 | ], 14 | provider: 'v8', 15 | thresholds: { 16 | lines: 100 17 | } 18 | }, 19 | environment: 'happy-dom' 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /vue/errors.ts: -------------------------------------------------------------------------------- 1 | import { map, type MapStore } from 'nanostores' 2 | 3 | import { syncMapTemplate } from '../sync-map-template/index.js' 4 | import { useFilter, useSync } from './index.js' 5 | 6 | type Post = { 7 | id: string 8 | title: string 9 | } 10 | 11 | let $post = syncMapTemplate('posts') 12 | 13 | let post = useSync($post, '10') 14 | let postList = useFilter($post, { id: '10' }) 15 | 16 | let $custom = (id: string): MapStore => map({ id, text: 'A' }) 17 | let custom = useSync($custom, '10') 18 | 19 | if (post.value.isLoading) { 20 | // THROWS Property 'title' does not exist 21 | post.value.title = 'New title' 22 | } else { 23 | // THROWS Cannot assign to 'title' because it is a read-only 24 | post.value.title = 'New title' 25 | } 26 | 27 | if (!postList.value.isLoading) { 28 | let postListItem = postList.value.stores.get('10')!.value! 29 | if (postListItem.isLoading) { 30 | // THROWS Property 'title' does not exist 31 | postListItem.title = 'New title' 32 | } else { 33 | // THROWS Cannot assign to 'title' because it is a read-only 34 | postListItem.title = 'New title' 35 | } 36 | } 37 | 38 | if (custom.value.isLoading) { 39 | // THROWS Property 'title' does not exist 40 | custom.value.title = 'B' 41 | } else { 42 | // THROWS Cannot assign to 'title' because it is a read-only 43 | custom.value.title = 'B' 44 | } 45 | -------------------------------------------------------------------------------- /vue/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { LoguxNotFoundError, SyncMapValues } from '@logux/actions' 2 | import type { StoreValue } from 'nanostores' 3 | import type { 4 | App, 5 | Component, 6 | ComponentPublicInstance, 7 | ComputedRef, 8 | DeepReadonly, 9 | InjectionKey, 10 | Ref 11 | } from 'vue' 12 | 13 | import type { Client } from '../client/index.js' 14 | import type { 15 | Filter, 16 | FilterOptions, 17 | FilterStore 18 | } from '../create-filter/index.js' 19 | import type { ChannelError } from '../logux-undo-error/index.js' 20 | import type { 21 | SyncMapTemplate, 22 | SyncMapTemplateLike, 23 | SyncMapValue 24 | } from '../sync-map-template/index.js' 25 | 26 | export type Refable = Ref | Type 27 | type ReadonlyRef = DeepReadonly> 28 | 29 | export const ClientKey: InjectionKey 30 | export const ErrorsKey: InjectionKey 31 | 32 | /** 33 | * Plugin that injects Logux Client into all components within the application. 34 | * 35 | * ```js 36 | * import { createApp } from 'vue' 37 | * import { loguxPlugin } from '@logux/client/vue' 38 | * import { CrossTabClient } from '@logux/client' 39 | * 40 | * let client = new CrossTabClient(…) 41 | * let app = createApp(…) 42 | * 43 | * app.use(loguxPlugin, client) 44 | * ``` 45 | */ 46 | export function loguxPlugin(app: App, client: Client): void 47 | 48 | /** 49 | * Returns the Logux Client instance. 50 | * 51 | * ```html 52 | * 62 | * ``` 63 | */ 64 | export function useClient(): Client 65 | 66 | /** 67 | * Create store by ID, subscribe to store changes and get store’s value. 68 | * 69 | * ```html 70 | * 74 | * 75 | * 88 | * ``` 89 | * 90 | * @param Template Store template. 91 | * @param id Store ID. 92 | * @param args Other store arguments. 93 | * @returns Store value. 94 | */ 95 | export function useSync( 96 | Template: SyncMapTemplate | SyncMapTemplateLike, 97 | id: Refable 98 | ): ReadonlyRef> 99 | export function useSync( 100 | Template: SyncMapTemplate | SyncMapTemplateLike, 101 | id: Refable, 102 | ...args: Args 103 | ): ReadonlyRef 104 | 105 | /** 106 | * The way to {@link createFilter} in Vue. 107 | * 108 | * ```html 109 | * 113 | * 114 | * 127 | * ``` 128 | * 129 | * @param Template Store class. 130 | * @param filter Key-value filter for stores. 131 | * @param opts Filter options. 132 | * @returns Filter store to use with map. 133 | */ 134 | export function useFilter( 135 | Template: SyncMapTemplate | SyncMapTemplateLike, 136 | filter?: Refable>, 137 | opts?: Refable 138 | ): ReadonlyRef>> 139 | 140 | /** 141 | * Show error message to user on subscription errors in components 142 | * deep in the tree. 143 | * 144 | * ```html 145 | * 153 | * 154 | * 161 | * ``` 162 | */ 163 | export const ChannelErrors: Component 164 | 165 | export interface ChannelErrorsSlotProps { 166 | code: ReadonlyRef 167 | error: ReadonlyRef<{ 168 | data: ChannelError | LoguxNotFoundError 169 | info: string 170 | instance: ComponentPublicInstance 171 | } | null> 172 | } 173 | 174 | /** 175 | * Returns user's current authentication state and ID. 176 | * 177 | * ```html 178 | * 182 | * 183 | * 191 | * ``` 192 | * 193 | * @param client Logux Client instance. 194 | */ 195 | export function useAuth(client?: Client): { 196 | isAuthenticated: ComputedRef 197 | userId: ComputedRef 198 | } 199 | -------------------------------------------------------------------------------- /vue/index.js: -------------------------------------------------------------------------------- 1 | import { registerStore, useStore } from '@nanostores/vue' 2 | import { 3 | computed, 4 | getCurrentInstance, 5 | getCurrentScope, 6 | inject, 7 | onErrorCaptured, 8 | onScopeDispose, 9 | provide, 10 | reactive, 11 | readonly, 12 | ref, 13 | shallowRef, 14 | unref, 15 | watch 16 | } from 'vue' 17 | 18 | import { createAuth } from '../create-auth/index.js' 19 | import { createFilter } from '../create-filter/index.js' 20 | 21 | function createSymbol(name) { 22 | return process.env.NODE_ENV !== 'production' ? Symbol(name) : Symbol() 23 | } 24 | 25 | export const ClientKey = /*#__PURE__*/ createSymbol('logux-client') 26 | export const ErrorsKey = /*#__PURE__*/ createSymbol('logux-errors') 27 | 28 | export function loguxPlugin(app, client) { 29 | app.provide(ClientKey, client) 30 | app.config.globalProperties.$logux = client 31 | } 32 | 33 | export function useClient() { 34 | let client = inject(ClientKey) 35 | if (process.env.NODE_ENV !== 'production' && !client) { 36 | throw new Error( 37 | `Install Logux Client using loguxPlugin: ` + 38 | `app.use(loguxPlugin, client).` 39 | ) 40 | } 41 | return client 42 | } 43 | 44 | function checkErrorProcessor() { 45 | let processor = getCurrentInstance() && inject(ErrorsKey, null) 46 | if (!processor) { 47 | throw new Error( 48 | 'Wrap components in Logux ' 49 | ) 50 | } 51 | } 52 | 53 | function useSyncStore(store) { 54 | let error = shallowRef() 55 | let state = shallowRef() 56 | 57 | let unsubscribe = store.subscribe(value => { 58 | state.value = value 59 | }) 60 | 61 | if (store.loading) { 62 | watch(error, () => { 63 | throw error.value 64 | }) 65 | store.loading.catch(e => { 66 | error.value = e 67 | }) 68 | } 69 | 70 | getCurrentScope() && 71 | onScopeDispose(() => { 72 | unsubscribe() 73 | }) 74 | 75 | if (process.env.NODE_ENV !== 'production') { 76 | registerStore(store) 77 | } 78 | 79 | return [state, unsubscribe] 80 | } 81 | 82 | function syncRefs(source, target) { 83 | return watch( 84 | source, 85 | value => { 86 | target.value = value 87 | }, 88 | { deep: true, flush: 'sync', immediate: true } 89 | ) 90 | } 91 | 92 | export function useSync(Template, id, ...builderArgs) { 93 | if (process.env.NODE_ENV !== 'production') { 94 | if (typeof Template !== 'function') { 95 | throw new Error('Use useStore() from @nanostores/vue for stores') 96 | } 97 | } 98 | 99 | if (process.env.NODE_ENV !== 'production') { 100 | checkErrorProcessor() 101 | } 102 | 103 | let client = useClient() 104 | let state = ref() 105 | let store 106 | let unwatch 107 | let unsubscribe 108 | 109 | watch( 110 | () => unref(id), 111 | newId => { 112 | if (unwatch) unwatch() 113 | if (unsubscribe) unsubscribe() 114 | ;[store, unsubscribe] = useSyncStore( 115 | Template(newId, client, ...builderArgs) 116 | ) 117 | unwatch = syncRefs(store, state) 118 | }, 119 | { immediate: true } 120 | ) 121 | 122 | if (process.env.NODE_ENV !== 'production') { 123 | return readonly(state) 124 | } 125 | /* c8 ignore next 2 */ 126 | return state 127 | } 128 | 129 | export function useFilter(Template, filter = {}, opts = {}) { 130 | if (process.env.NODE_ENV !== 'production') { 131 | checkErrorProcessor() 132 | } 133 | 134 | let client = useClient() 135 | let state = ref() 136 | let store 137 | let unwatch 138 | let unsubscribe 139 | 140 | watch( 141 | () => [unref(filter), unref(opts)], 142 | ([newFilter, newOpts]) => { 143 | if (unwatch) unwatch() 144 | if (unsubscribe) unsubscribe() 145 | ;[store, unsubscribe] = useSyncStore( 146 | createFilter(client, Template, newFilter, newOpts) 147 | ) 148 | unwatch = syncRefs(store, state) 149 | }, 150 | { deep: true, immediate: true } 151 | ) 152 | 153 | if (process.env.NODE_ENV !== 'production') { 154 | return readonly(state) 155 | } 156 | /* c8 ignore next 2 */ 157 | return state 158 | } 159 | 160 | export let ChannelErrors = { 161 | name: 'LoguxChannelErrors', 162 | setup(props, { slots }) { 163 | let error = shallowRef() 164 | let code = computed(() => { 165 | if (!error.value) { 166 | return undefined 167 | } else { 168 | let { action, name } = error.value.data 169 | if (name === 'LoguxNotFoundError' || action.reason === 'notFound') { 170 | return 404 171 | } else if (action.reason === 'denied') { 172 | return 403 173 | } else { 174 | return 500 175 | } 176 | } 177 | }) 178 | 179 | if (process.env.NODE_ENV !== 'production') { 180 | provide(ErrorsKey, readonly(reactive({ code, error }))) 181 | } 182 | 183 | onErrorCaptured((e, instance, info) => { 184 | if (e.name === 'LoguxUndoError' || e.name === 'LoguxNotFoundError') { 185 | error.value = { data: e, info, instance } 186 | return false 187 | } 188 | return undefined 189 | }) 190 | 191 | return () => slots.default({ code, error }) || null 192 | } 193 | } 194 | 195 | export function useAuth(client) { 196 | let auth = useStore(createAuth(client || useClient())) 197 | return { 198 | isAuthenticated: computed(() => auth.value.isAuthenticated), 199 | userId: computed(() => auth.value.userId) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /vue/types.ts: -------------------------------------------------------------------------------- 1 | import type { MapStore} from 'nanostores' 2 | import { map } from 'nanostores' 3 | 4 | import { syncMapTemplate } from '../sync-map-template/index.js' 5 | import { useFilter, useSync } from './index.js' 6 | 7 | type Post = { 8 | id: string 9 | title: string 10 | } 11 | 12 | let $post = syncMapTemplate('posts') 13 | 14 | let post = useSync($post, '10') 15 | let postList = useFilter($post, { id: '10' }) 16 | 17 | let $custom = (id: string): MapStore => map({ id, text: 'A' }) 18 | let custom = useSync($custom, '10') 19 | let customList = useFilter($custom, { id: '10' }) 20 | 21 | console.log({ custom, customList, post, postList }) 22 | --------------------------------------------------------------------------------