Repository: logux/logux-server Branch: main Commit: ee982148be70 Files: 86 Total size: 356.2 KB Directory structure: gitextract_s1og6kor/ ├── .editorconfig ├── .github/ │ └── workflows/ │ ├── api.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── add-http-pages/ │ ├── hello.html │ ├── index.js │ └── index.test.ts ├── add-sync-map/ │ ├── index.d.ts │ ├── index.js │ └── index.test.ts ├── allowed-meta/ │ ├── index.d.ts │ ├── index.js │ └── index.test.ts ├── base-server/ │ ├── index.d.ts │ ├── index.js │ └── index.test.ts ├── context/ │ ├── index.d.ts │ ├── index.js │ └── index.test.ts ├── create-http-server/ │ └── index.js ├── create-reporter/ │ ├── __snapshots__/ │ │ └── index.test.ts.snap │ ├── index.js │ └── index.test.ts ├── filter-meta/ │ ├── index.d.ts │ ├── index.js │ └── index.test.ts ├── filtered-node/ │ ├── index.js │ └── index.test.ts ├── human-formatter/ │ ├── index.js │ └── utils.js ├── index.d.ts ├── index.js ├── options-loader/ │ ├── __snapshots__/ │ │ └── index.test.js.snap │ ├── index.js │ ├── index.test.js │ └── test.env ├── oxfmt.config.ts ├── oxlint.config.ts ├── package.json ├── request/ │ ├── index.d.ts │ └── index.js ├── server/ │ ├── __snapshots__/ │ │ └── index.test.ts.snap │ ├── errors.ts │ ├── index.d.ts │ ├── index.js │ ├── index.test.ts │ └── types.ts ├── server-client/ │ ├── index.d.ts │ ├── index.js │ └── index.test.ts ├── test/ │ ├── fixtures/ │ │ ├── cert.pem │ │ └── key.pem │ ├── force-colors.js │ └── servers/ │ ├── autoload-error-modules.js │ ├── autoload-modules.js │ ├── destroy.js │ ├── eacces.js │ ├── eaddrinuse.js │ ├── error-modules/ │ │ └── wrond-export/ │ │ └── index.js │ ├── json.js │ ├── logger.js │ ├── missed.js │ ├── modules/ │ │ ├── child/ │ │ │ ├── index.foo.js │ │ │ ├── index.js │ │ │ └── lib/ │ │ │ └── lib.js │ │ ├── root.js │ │ └── root.test.js │ ├── options.js │ ├── root.js │ ├── throw.js │ ├── unbind.js │ ├── uncatch.js │ └── unknown.js ├── test-client/ │ ├── index.d.ts │ ├── index.js │ └── index.test.ts ├── test-server/ │ ├── index.d.ts │ └── index.js ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .github/workflows/api.yml ================================================ name: Update API on: create: tags: - '*.*.*' permissions: {} jobs: api: runs-on: ubuntu-latest steps: - name: Start logux.org re-build run: | 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"}' ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - '*' permissions: contents: write jobs: release: name: Release On Tag if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - name: Checkout the repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Extract the changelog id: changelog run: | TAG_NAME=${GITHUB_REF/refs\/tags\//} READ_SECTION=false CHANGELOG="" while IFS= read -r line; do if [[ "$line" =~ ^#+\ +(.*) ]]; then if [[ "${BASH_REMATCH[1]}" == "$TAG_NAME" ]]; then READ_SECTION=true elif [[ "$READ_SECTION" == true ]]; then break fi elif [[ "$READ_SECTION" == true ]]; then CHANGELOG+="$line"$'\n' fi done < "CHANGELOG.md" CHANGELOG=$(echo "$CHANGELOG" | awk '/./ {$1=$1;print}') echo "changelog_content<> $GITHUB_OUTPUT echo "$CHANGELOG" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Create the release if: steps.changelog.outputs.changelog_content != '' uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: name: ${{ github.ref_name }} body: '${{ steps.changelog.outputs.changelog_content }}' draft: false prerelease: false ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: - main - next pull_request: permissions: contents: read jobs: full: name: Node.js Latest Full runs-on: ubuntu-latest steps: - name: Checkout the repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: version: 10 - name: Install Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 25 cache: pnpm - name: Install dependencies run: pnpm install --ignore-scripts - name: Run tests run: pnpm test short: runs-on: ubuntu-latest strategy: matrix: node-version: - 24 - 22 name: Node.js ${{ matrix.node-version }} Quick steps: - name: Checkout the repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: version: 10 - name: Install Node.js ${{ matrix.node-version }} uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: ${{ matrix.node-version }} cache: pnpm - name: Install dependencies run: pnpm install --ignore-scripts - name: Run unit tests run: pnpm vitest run ================================================ FILE: .gitignore ================================================ node_modules/ coverage/ ================================================ FILE: .npmignore ================================================ test/ coverage/ tsconfig.json **/*.test.ts **/types.ts **/errors.ts __snapshots__ ================================================ FILE: .prettierrc.js ================================================ import loguxOxfmtConfig from '@logux/oxc-configs/fmt' export default loguxOxfmtConfig ================================================ FILE: CHANGELOG.md ================================================ # Change Log This project adheres to [Semantic Versioning](http://semver.org/). ## 0.14 “Sliver of Straw” - Removed Node.js 18 support. - Removed backend control. - Moved to number as subprotocol and remove `Context#isSubprotocol()`. - Moved to Logux Core 0.10 and Logux Protocol 5. - Changed custom HTTP listener API. - Added API to use custom Logux server node class. - Added `Server#sendOnConnect()`. - Reduced dependencies. - Changed color auto-detection algorithm to `util.styleText`. - Fixed brute-force lock issue during tests. ## 0.13.1 - Fixed vulnerability audit by moving to `cookie` 0.7. ## 0.13 “Seven Red Suns” - Removed Node.js 14 and Node.js 16 support. - Moved to Logux Core 0.9. - Added action processing queues (by @VladBrok). - Added `unauthenticated` event (by @erictheswift). ## 0.12.10 - Fixed another Node.js 14 regression. ## 0.12.9 - Fixed Node.js 14 regression. ## 0.12.8 - Replaced `ip` to fix vulnerability. - Updated dependencies. ## 0.12.7 - Moved `ip` to `2.x` to fix vulnerability. ## 0.12.6 - Fixed `x/changed` filter in `addSyncMap` (by Eduard Aksamitov). ## 0.12.5 - Fixed async action’s filter in channel (by Eduard Aksamitov). ## 0.12.4 - Fixed docs. ## 0.12.3 - Fixed multiple subscriptions with filters per node (by Eduard Aksamitov). - Fixed types (by Nikita Galaiko). ## 0.12.2 - Fixed `since` in `load` of `addSyncMap` (by Nikita Galaiko). ## 0.12.1 - Fixed `since` in `initial` of `addSyncMapFilter` (by Nikita Galaiko). ## 0.12 “Looks to the Moon” - Dropped Node.js 12 support. - Moved to Logux Core 0.8. - Moved to `pino` 8. - Added `disableHttpServer` option. - Added `return false` support to `load` callback in `addSyncMap`. - Fixed data loading on subscription on `SyncMap` creation. ## 0.11 “Five Pebbles” - Added `addSyncMap()` and `addSyncMapFilter()`. - Added colorization to action ID and client ID (by Bijela Gora). - Added `TestServer#expectError()`. - Added `since` to `TestClient#subscribe()`. - Reduced noise in server log. - Moved to `pino` 7 (by Bijela Gora). ## 0.10.8 - Fixed test server destroying on fatal error. ## 0.10.7 - Reduced dependencies. ## 0.10.6 - Fixed `Promise` support in channel’s `filter` (by Eduard Aksamitov). - Replaced `nanocolors` with `picocolors`. ## 0.10.5 - Fixed `Server#http()`. - Fixed types (by Eduard Aksamitov). ## 0.10.4 - Updated `nanocolors`. ## 0.10.3 - Replaced `colorette` with `nanocolors`. ## 0.10.2 - Fixed `accessAndProcess` on server’s action (by Aleksandr Slepchenkov). - Added warning about circular reference in action. - Marked `action` and `meta` in callbacks as read-only. ## 0.10.1 - Fixed channel name parameters parsing (by Aleksandr Slepchenkov). - Used `LoguxNotFoundError` from `@logux/actions`. ## 0.10 “Doraemon” - Moved project to ESM-only type. Applications must use ESM too. - Dropped Node.js 10 support. - Moved health check to `/health`. - Added `Server#http()` for custom HTTP processing. - Added `unsubscribe` callback to `Server#channel` (by @erictheswift). - Added reverted action to `logux/undo` (by Eduard Aksamitov). - Added RegExp support to `BaseServer#type()` (by Taras Vozniuk). - Added `accessAndLoad` and `accessAndProcess` callbacks for REST integration. - Added `LoguxNotFoundError` error for `accessAndLoad` and `accessAndProcess`. - Added request functions and `wasNot403()` for REST integration. - Added `ServerClient#httpHeaders`. - Added support for returning string from `resend` callback. - Added `Server#subscribe()` to send `logux/subscribed` action. - Added `Server#autoloadModules()`. - Added `fileUrl` option for ESM servers. - Added `Server#logger` for custom log messages. - Added `meta.excludeClients`. - Added `TestServer#expectUndo()`. - Added `TestServer#expectDenied()`. - Added `TestClient#received()`. - Added `TestServer#expectWrongCredentials()`. - Added `TestClient#clientId` and `TestClient#userId`. - Added `filter` option to `TestClient#subscribe()`. - Added Logux logotype to `GET /`. - Removed `reporter` option (by Aleksandr Slepchenkov). - Removed `yargs` dependency (by Aleksandr Slepchenkov). - Fixed `:` symbol support for channel names. - Fixed types performance by replacing `type` to `interface`. ## 0.9.6 - Update `yargs`. ## 0.9.5 - Fixed sending server’s actions to backend. ## 0.9.4 - Fix using old action’s IDs in `Server#channel→load`. ## 0.9.3 - Do not process actions from `Server#channel→load` in `Server#type`. - Replace color output library. ## 0.9.2 - Fix cookie support (by Eduard Aksamitov). ## 0.9.1 - Reduce dependencies. ## 0.9 “Robby the Robot” - Use WebSocket Protocol version 4. - Use Back-end Protocol version 4. - Replace `bunyan` logger with `pino` (by Alexander Slepchenkov). - Clean up logger options (by Alexander Slepchenkov). - Allow to return actions from `load` callback. - Add cookie-based authentication. - Add `Server#process()`. - Allow to use action creator in `Server#type()`. - Add `LOGUX_SUBPROTOCOL` and `LOGUX_SUPPORTS` environment variables support. - Add `Server#autoloadModules()` (by Andrey Berezhnoy). - Add `Context#headers`. - Add argument to `TestServer#connect()`. - Add `auth: false` option to `TestServer`. - Fix action double sending. - Fix infinite reconnecting on authentication error. - Fix multiple servers usage in tests. - Fix types. ## 0.8.6 - Add `BaseServer#options` types. ## 0.8.5 - `Context#sendBack` returns Promise until action will be re-send and processed. - Fix `Context#sendBack` typings. ## 0.8.4 - Fix back-end protocol check in HTTP request receiving. ## 0.8.3 - Make node IDs in `TestClient` shorter. ## 0.8.2 - Fix types. ## 0.8.1 - Call `resend` after `access` step in action processing. - Add special reason for unknown action or channel errors. - Fix `TestClient` error on unknown action or channel. - Allow to show log by passing `reporter: "human"` option to `TestServer`. - Fix calling `resend` on server’s own actions. - Fix types (by Andrey Berezhnoy). ## 0.8 “Morpheus” - Rename `init` callback to `load` in `Server#channel()`. - Add `TestServer` and `TestClient` to test servers. - Add `filterMeta` helper. - Fix types. ## 0.7.2 - More flexible types for logger. ## 0.7.1 - Print to the log about denied control requests attempts. - Fix server options types. - Return status code 500 on control requests if server has no secret. ## 0.7 “Eliza Cassan” - Use Logux Core 0.5 and WebSocket Protocol 3. - Use Back-end Protocol 3. - Use the same port for WebSocket and control. - Rename `LOGUX_CONTROL_PASSWORD` to `LOGUX_CONTROL_SECRET`. - Rename `opts.controlPassword` to `opts.controlSecret`. - User ID must be always a string. - Add IP address check for control requests. - Fix types. ## 0.6.1 - Keep context between steps. - Fix re-sending actions back to the author. ## 0.6 “Helios” - Add ES modules support. - Add TypeScript definitions (by Kirill Neruchev). - Move API docs from JSDoc to TypeDoc. ## 0.5.3 - Fix Nano Events API. ## 0.5.2 - Fix subscriptions for clients follower. ## 0.5.1 - Fix JSDoc. ## 0.5 “Icarus” - Add `Context#sendBack()` shortcut. - Add `finally` callback to `Server#type()`. and `Server#channel()`. - Add `resend` callback to `Server#type()`. - Use Backend Protocol 2. - Deny any re-send meta keys from clients (like `channels`). - Add singular re-send meta keys support (`channel`, `client`, etc). - Allow to listen `preadd` and `add` log events in `Server#on()`. - Use `error` as default reason in `Server#undo()`. - Set boolean `false` user ID on client IDs like `false:client:uuid`. ## 0.4 “Daedalus” - Add `.env` support. ## 0.3.4 - Update dependencies. ## 0.3.3 - Improve popular error messages during server launch (by Igor Strebezhev). ## 0.3.2 - Fix backend proxy version (by Dmitry Salahutdinov). - Clean up code (by Vladimir Schedrin). ## 0.3.1 - Fix support for `unknownAction` and `unknownChannel` commands from backend. ## 0.3 “SHODAN” - Rename project from `logux-server` to `@logux/server`. - Rename `meta.nodeIds` to `meta.nodes`. - Rename `Server#clients` to `Server#connected`. - Rename `Server#users` to `Server#userIds`. - Split subscription to `access`, `init`, and `filter` steps. - Add `ctx` to callbacks. - Remove Node.js 6 and 8 support. - `Server.loadOptions` now overrides default options. - Change default port from `:1337` to `:31337`. - Use Logux Core 0.3. - Add brute force protection. - Add built-in proxy mode. - Add HTTP health check API. - Answer `logux/processed` after action processing. - Add `ServerClient#clientId` and `meta.clients`. - Add warning about missed action callbacks. ## 0.2.9 - Use `ws` instead of `uWS`. ## 0.2.8 - Add protection against authentication brute force. ## 0.2.7 - Use `uWS` 9.x with Node.js 10 support. ## 0.2.6 - Use `yargs` 11.x. ## 0.2.5 - Allow to have `:` in user ID. ## 0.2.4 - Use `uWS` 9.x. ## 0.2.3 - Fix `key` option with `{ pem: … }` value on Node.js 9. ## 0.2.2 - Don’t destroy server again on error during destroy. ## 0.2.1 - Don’t show `unknownType` error on server actions without processor. - Better action and meta view in `human` log. ## 0.2 “Neuromancer” - Use Logux Protocol 2. - Use Logux Core 0.2 and Logux Sync 0.2. - Rename `Client#id` to `Client#userId`. - Remove `BaseServer#once` method. - Check action’s node ID to have user ID. - Use `uws` instead of `ws` (by Anton Savoskin). - Use Nano ID for node ID. - Remove deprecated `upgradeReq` from `Client#remoteAddess`. - Use Chalk 2.0. - Add `BaseServer#type` method. - Add `BaseServer#channel` method. - Add `BaseServer#undo` method. - Add `BaseServer#sendAction` method. - Take options from CLI and environment variables (by Pavel Kovalyov). - Add production non-secure protocol warning (by Hanna Stoliar). - Add Bunyan log format support (by Anton Artamonov and Mateusz Derks). - Add `error` event. - Set `meta.server`, `meta.status` and `meta.subprotocol`. - Add `debug` message support (by Roman Fursov). - Add `BaseServer#nodeId` shortcut. - Add node ID conflict fixing. - Export `ALLOWED_META`. - Better start error description (by Grigory Moroz). - Show Client ID in log for non-authenticated users. - Fix docs (by Grigoriy Beziuk, Nick Mitin and Konstantin Krivlenia). - Always use English for `--help` message. - Add security note for server output in development mode. ## 0.1.1 - Fix custom HTTP server support. ## 0.1 “Wintermute” - Initial release. ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright 2016 Andrey Sitnik Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Logux Server [![Cult Of Martians][cult-img]][cult] Logux is a new way to connect client and server. Instead of sending HTTP requests (e.g., AJAX and GraphQL) it synchronizes log of operations between client, server, and other clients. - **[Guide, recipes, and API](https://logux.org/)** - **[Issues](https://github.com/logux/logux/issues)** and **[roadmap](https://github.com/orgs/logux/projects/1)** - **[Projects](https://logux.org/guide/architecture/parts/)** inside Logux ecosystem This repository contains Logux server with: - Framework to write own server. - Proxy between WebSocket and HTTP server on any other language. Sponsored by Evil Martians [cult-img]: http://cultofmartians.com/assets/badges/badge.svg [cult]: http://cultofmartians.com/done.html ### Logux Server as Framework ```js import { isFirstOlder } from '@logux/core' import { dirname } from 'path' import { Server } from '@logux/server' const server = new Server( Server.loadOptions(process, { subprotocol: 1, minSubprotocol: 1, root: import.meta.dirname }) ) server.auth(async ({ userId, token }) => { const user = await findUserByToken(token) return !!user && userId === user.id }) server.channel('user/:id', { access(ctx, action, meta) { return ctx.params.id === ctx.userId }, async load(ctx, action, meta) { const user = await db.loadUser(ctx.params.id) return { type: 'USER_NAME', name: user.name } } }) server.type('CHANGE_NAME', { access(ctx, action, meta) { return action.user === ctx.userId }, resend(ctx, action, meta) { return { channel: `user/${ctx.userId}` } }, async process(ctx, action, meta) { if (isFirstOlder(lastNameChange(action.user), meta)) { await db.changeUserName({ id: action.user, name: action.name }) } } }) server.listen() ``` [documentation]: https://logux.org/ ================================================ FILE: add-http-pages/hello.html ================================================ Logux Server ================================================ FILE: add-http-pages/index.js ================================================ import { readFile } from 'node:fs/promises' import { join } from 'node:path' let hello async function readHello() { if (!hello) { hello = await readFile(join(import.meta.dirname, 'hello.html')) } return hello } export function addHttpPages(server) { if (!server.options.disableHttpServer) { server.http('GET', '/', async (req, res) => { let data = await readHello() res.writeHead(200, { 'Content-Type': 'text/html' }) res.end(data) }) server.http('GET', '/health', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('Logux Server: OK\n') }) } } ================================================ FILE: add-http-pages/index.test.ts ================================================ import { type TestLog, TestTime } from '@logux/core' import http from 'node:http' import { setTimeout } from 'node:timers/promises' import { afterEach, expect, it } from 'vitest' import { BaseServer, type BaseServerOptions, type ServerMeta } from '../index.js' const DEFAULT_OPTIONS = { minSubprotocol: 0, subprotocol: 0 } let lastPort = 9111 function createServer( options: Partial = {} ): BaseServer> { let opts = { ...DEFAULT_OPTIONS, ...options } if (typeof opts.time === 'undefined') { opts.time = new TestTime() opts.id = 'uuid' } if (typeof opts.port === 'undefined') { lastPort += 1 opts.port = lastPort } let created = new BaseServer>(opts) created.auth(() => true) destroyable = created return created } let destroyable: BaseServer | undefined class RequestError extends Error { statusCode: number | undefined constructor(statusCode: number | undefined, body: string) { super(body) this.name = 'RequestError' this.statusCode = statusCode } } interface HttpResponse { body: string headers: http.IncomingHttpHeaders } function request( server: BaseServer, method: string, path: string ): Promise { return new Promise((resolve, reject) => { let req = http.request( { host: '127.0.0.1', method, path, port: server.options.port }, res => { let body = '' res.on('data', chunk => { body += chunk }) res.on('end', () => { if (res.statusCode === 200) { resolve({ body, headers: res.headers }) } else { let error = new RequestError(res.statusCode, body) reject(error) } }) } ) req.on('error', reject) req.end() }) } async function requestError( server: BaseServer, method: string, path: string ): Promise { try { await request(server, method, path) } catch (e) { if (e instanceof RequestError) return e } throw new Error('Error was not found') } afterEach(async () => { if (destroyable) { await destroyable.destroy() destroyable = undefined } }) it('has hello page', async () => { let app = createServer({}) await app.listen() let response = await request(app, 'GET', '/') expect(response.body).toContain('Logux Server') expect(response.body).toContain(' { let app = createServer({ disableHttpServer: true }) await app.listen() let response = false let req = http.request( { host: '127.0.0.1', method: 'GET', path: '/health', port: app.options.port }, () => { response = true } ) req.on('error', () => {}) await setTimeout(100) expect(response).toBe(false) req.destroy() }) it('has health check', async () => { let app = createServer() await app.listen() let response = await request(app, 'GET', '/health') expect(response.body).toContain('OK') }) it('responses 404', async () => { let app = createServer() await app.listen() let err = await requestError(app, 'GET', '/unknown') expect(err.statusCode).toEqual(404) expect(err.message).toEqual('Not found\n') }) it('has custom HTTP processor', async () => { let app = createServer() let unknownGet = 0 let unknownRest = 0 app.http('POST', '/a', (req, res) => { res.end('POST a') }) app.http('GET', '/a', (req, res) => { res.end('GET a') }) app.http('GET', '/b', (req, res) => { res.end('GET b') }) app.http((req, res) => { if (req.method === 'GET') { res.end('GET unknown') unknownGet += 1 return true } else { return false } }) app.http((req, res) => { if (req.url !== '/404') { res.end('unknown') unknownRest += 1 return true } else { return false } }) await app.listen() expect((await request(app, 'GET', '/a')).body).toContain('GET a') expect((await request(app, 'GET', '/a%3Fsecret')).body).toContain('GET a') expect((await request(app, 'GET', '/b')).body).toContain('GET b') expect((await request(app, 'POST', '/a')).body).toContain('POST a') expect((await request(app, 'GET', '/c')).body).toContain('GET unknown') expect((await request(app, 'GET', '/d')).body).toContain('GET unknown') expect((await request(app, 'POST', '/e')).body).toContain('unknown') expect((await requestError(app, 'POST', '/404')).statusCode).toEqual(404) expect(unknownGet).toEqual(2) expect(unknownRest).toEqual(1) }) it('warns that HTTP is disables', () => { let app = createServer({ disableHttpServer: true }) expect(() => { app.http(() => true) }).toThrow(/when `disableHttpServer` enabled/) }) it('waits until all HTTP processing ends', async () => { let app = createServer() let resolveA: (() => void) | undefined app.http('GET', '/a', () => { return new Promise(resolve => { resolveA = resolve }) }) let resolveResult: ((processed: boolean) => void) | undefined app.http(() => { return new Promise(resolve => { resolveResult = resolve }) }) await app.listen() request(app, 'GET', '/a') request(app, 'GET', '/other') await setTimeout(10) let destroyed = false app.destroy().then(() => { destroyed = true }) await setTimeout(100) expect(destroyed).toBe(false) expect((await requestError(app, 'POST', '/a')).message).toEqual( 'The server is shutting down\n' ) resolveA!() await setTimeout(100) expect(destroyed).toBe(false) resolveResult!(true) await setTimeout(100) expect(destroyed).toBe(true) }) ================================================ FILE: add-sync-map/index.d.ts ================================================ import type { LoguxSubscribeAction, SyncMapChangeAction, SyncMapChangedAction, SyncMapCreateAction, SyncMapCreatedAction, SyncMapDeleteAction, SyncMapDeletedAction, SyncMapTypes, SyncMapValues } from '@logux/actions' import type { BaseServer, ServerMeta } from '../base-server/index.js' import type { Context } from '../context/index.js' declare const WITH_TIME: unique symbol export type WithTime = { time: number value: Value [WITH_TIME]: true } export type WithoutTime = { time: undefined value: Value [WITH_TIME]: false } export type SyncMapData = { [Key in keyof Value]: WithoutTime | WithTime } & { id: string } /** * Add last changed time to value to use in conflict resolution. * * If you do not know the time, use {@link NoConflictResolution}. * * @param value The value. * @param time UNIX milliseconds. * @returns Wrapper. */ export function ChangedAt( value: Value, time: number ): WithTime /** * Mark that the value has no last changed date and conflict resolution * can’t be applied. * * @param value The value. * @returns Wrapper. */ export function NoConflictResolution< Value extends SyncMapTypes | SyncMapTypes[] >(value: Value): WithTime interface SyncMapActionFilter { ( ctx: Context, action: | SyncMapChangedAction | SyncMapCreatedAction | SyncMapDeletedAction, meta: ServerMeta ): boolean | Promise } interface SyncMapOperations { access( ctx: Context, id: string, action: | LoguxSubscribeAction | SyncMapChangeAction | SyncMapCreateAction | SyncMapDeleteAction, meta: ServerMeta ): boolean | Promise change?( ctx: Context, id: string, fields: Partial, time: number, action: SyncMapChangeAction, meta: ServerMeta ): boolean | Promise | void create?( ctx: Context, id: string, fields: Value, time: number, action: SyncMapCreateAction, meta: ServerMeta ): boolean | Promise | void delete?( ctx: Context, id: string, action: SyncMapDeleteAction, meta: ServerMeta ): boolean | Promise | void load?( ctx: Context, id: string, since: number | undefined, action: LoguxSubscribeAction, meta: ServerMeta ): false | Promise> | SyncMapData } interface SyncMapFilterOperations { access?( ctx: Context, filter: Partial | undefined, action: LoguxSubscribeAction, meta: ServerMeta ): boolean | Promise actions?( ctx: Context, filter: Partial | undefined, action: LoguxSubscribeAction, meta: ServerMeta ): Promise> | SyncMapActionFilter | void initial( ctx: Context, filter: Partial | undefined, since: number | undefined, action: LoguxSubscribeAction, meta: ServerMeta ): Promise[]> | SyncMapData[] } /** * Add callbacks for client’s `SyncMap`. * * ```js * import { addSyncMap, isFirstTimeOlder, ChangedAt } from '@logux/server' * import { LoguxNotFoundError } from '@logux/actions' * * addSyncMap(server, 'tasks', { * async access (ctx, id) { * const task = await Task.find(id) * return ctx.userId === task.authorId * }, * * async load (ctx, id, since) { * const task = await Task.find(id) * if (!task) throw new LoguxNotFoundError() * return { * id: task.id, * text: ChangedAt(task.text, task.textChanged), * finished: ChangedAt(task.finished, task.finishedChanged), * } * }, * * async create (ctx, id, fields, time) { * await Task.create({ * id, * text: fields.text, * finished: fields.finished, * authorId: ctx.userId, * textChanged: time, * finishedChanged: time * }) * }, * * async change (ctx, id, fields, time) { * const task = await Task.find(id) * if ('text' in fields) { * if (task.textChanged < time) { * await task.update({ * text: fields.text, * textChanged: time * }) * } * } * if ('finished' in fields) { * if (task.finishedChanged < time) { * await task.update({ * finished: fields.finished, * finishedChanged: time * }) * } * } * } * * async delete (ctx, id) { * await Task.delete(id) * } * }) * ``` * * @param server Server instance. * @param plural Prefix for channel names and action types. * @param operations Callbacks. */ export function addSyncMap( server: BaseServer, plural: string, operations: SyncMapOperations ): void /** * Add callbacks for client’s `useFilter`. * * ```js * import { addSyncMapFilter, ChangedAt } from '@logux/server' * * addSyncMapFilter(server, 'tasks', { * access (ctx, filter) { * return true * }, * * initial (ctx, filter, since) { * let tasks = await Tasks.where({ ...filter, authorId: ctx.userId }) * // You can return only data changed after `since` * return tasks.map(task => ({ * id: task.id, * text: ChangedAt(task.text, task.textChanged), * finished: ChangedAt(task.finished, task.finishedChanged), * })) * }, * * actions (filterCtx, filter) { * return (actionCtx, action, meta) => { * return actionCtx.userId === filterCtx.userId * } * } * }) * ``` * @param server Server instance. * @param plural Prefix for channel names and action types. * @param operations Callbacks. */ export function addSyncMapFilter( server: BaseServer, plural: string, operations: SyncMapFilterOperations ): void ================================================ FILE: add-sync-map/index.js ================================================ const WITH_TIME = Symbol('WITH_TIME') export function ChangedAt(value, time) { return { time, value, [WITH_TIME]: true } } export function NoConflictResolution(value) { return { value, [WITH_TIME]: false } } async function addFinished(server, ctx, type, action, meta) { await server.process( { ...action, type }, { excludeClients: [ctx.clientId], time: meta.time } ) } function resendFinished(server, plural, type, all = true) { if (all) { server.type(type, { access() { return false }, resend(ctx, action) { return [plural, `${plural}/${action.id}`] } }) } else { server.type(type, { access() { return false }, resend(ctx, action) { return [`${plural}/${action.id}`] } }) } } function buildFilter(filter) { return (ctx, action) => { if (action.type.endsWith('/created')) { for (let key in filter) { if (action.fields[key] !== filter[key]) return false } } if (action.type.endsWith('/changed')) { for (let key in filter) { if (key in action.fields && action.fields[key] !== filter[key]) { return false } } } return true } } async function sendMap(server, changedType, data, since) { let { id, ...other } = data let byTime = new Map() for (let key in other) { if (other[key][WITH_TIME] === true) { let time = other[key].time if (!byTime.has(time)) byTime.set(time, {}) byTime.get(time)[key] = other[key].value } else if (other[key][WITH_TIME] === false) { if (!byTime.has('now')) byTime.set('now', {}) byTime.get('now')[key] = other[key].value } else { throw new Error('Wrap value into ChangedAt() or NoConflictResolution()') } } for (let [time, fields] of byTime.entries()) { let changedMeta if (time !== 'now') { changedMeta = { time } if (time < since) continue } await server.process( { fields, id, type: changedType }, changedMeta ) } } export function addSyncMap(server, plural, operations) { let createdType = `${plural}/created` let changedType = `${plural}/changed` let deletedType = `${plural}/deleted` resendFinished(server, plural, createdType) resendFinished(server, plural, changedType) resendFinished(server, plural, deletedType, false) if (operations.load) { server.channel(`${plural}/:id`, { access(ctx, action, meta) { return operations.access(ctx, ctx.params.id, action, meta) }, async load(ctx, action, meta) { if (action.creating) return let since = action.since ? action.since.time : 0 let data = await operations.load( ctx, ctx.params.id, since, action, meta ) if (data !== false) { await sendMap(server, changedType, data, since) } } }) } if (operations.create) { server.type(`${plural}/create`, { access(ctx, action, meta) { return operations.access(ctx, action.id, action, meta) }, async process(ctx, action, meta) { let result = await operations.create( ctx, action.id, action.fields, meta.time, action, meta ) if (result !== false) { await addFinished(server, ctx, createdType, action, meta) } } }) } if (operations.change) { server.type(`${plural}/change`, { access(ctx, action, meta) { return operations.access(ctx, action.id, action, meta) }, async process(ctx, action, meta) { let result = await operations.change( ctx, action.id, action.fields, meta.time, action, meta ) if (result !== false) { await addFinished(server, ctx, changedType, action, meta) } } }) } if (operations.delete) { server.type(`${plural}/delete`, { access(ctx, action, meta) { return operations.access(ctx, action.id, action, meta) }, async process(ctx, action, meta) { let result = await operations.delete(ctx, action.id, action, meta) if (result !== false) { await addFinished(server, ctx, deletedType, action, meta) } } }) } } export function addSyncMapFilter(server, plural, operations) { let changedType = `${plural}/changed` server.channel(plural, { access(ctx, action, meta) { return operations.access(ctx, action.filter, action, meta) }, filter(ctx, action, meta) { let filter = action.filter ? buildFilter(action.filter) : () => true let custom = operations.actions ? operations.actions(ctx, action.filter, action, meta) : () => true return (ctx2, action2, meta2) => { return filter(ctx2, action2, meta2) && custom(ctx2, action2, meta2) } }, async load(ctx, action, meta) { let since = action.since ? action.since.time : 0 let data = await operations.initial( ctx, action.filter, since, action, meta ) await Promise.all( data.map(async i => { await server.subscribe(ctx.nodeId, `${plural}/${i.id}`) await sendMap(server, changedType, i, since) }) ) } }) } ================================================ FILE: add-sync-map/index.test.ts ================================================ import { defineSyncMapActions, LoguxNotFoundError, loguxProcessed, loguxSubscribed } from '@logux/actions' import { setTimeout } from 'node:timers/promises' import { afterEach, expect, it } from 'vitest' import { addSyncMap, addSyncMapFilter, ChangedAt, NoConflictResolution, type SyncMapData, type TestClient, TestServer } from '../index.js' type TaskValue = { finished: boolean text: string } type TaskRecord = { finishedChanged: number textChanged: number } & TaskValue let [ createTask, changeTask, deleteTask, createdTask, changedTask, deletedTask ] = defineSyncMapActions('tasks') type CommentValue = { author?: string text?: string } let [ createComment, changeComment, deleteComment, createdComment, changedComment, deletedComment ] = defineSyncMapActions('comments') let tasks = new Map() let destroyable: TestServer | undefined function getTime(client: TestClient, creator: { type: string }): number[] { return client.log .entries() .filter(([action]) => action.type === creator.type) .map(([, meta]) => meta.time) } function getServer(): TestServer { let server = new TestServer() destroyable = server addSyncMap(server, 'tasks', { access(ctx, id, action, meta) { expect(typeof action.type).toBe('string') expect(typeof meta.id).toBe('string') return ctx.userId !== 'wrong' && id !== 'bad' }, change(ctx, id, fields, time, action, meta) { expect(typeof action.type).toBe('string') expect(typeof meta.id).toBe('string') expect(typeof ctx.userId).toBe('string') let task = tasks.get(id)! if ( typeof fields.finished !== 'undefined' && task.finishedChanged < time ) { task.finished = fields.finished task.finishedChanged = time } if (typeof fields.text !== 'undefined' && task.textChanged < time) { task.text = fields.text task.textChanged = time } }, create(ctx, id, fields, time, action, meta) { expect(typeof action.type).toBe('string') expect(typeof meta.id).toBe('string') expect(typeof ctx.userId).toBe('string') tasks.set(id, { ...fields, finishedChanged: time, textChanged: time }) }, delete(ctx, id, action, meta) { expect(typeof action.type).toBe('string') expect(typeof meta.id).toBe('string') expect(typeof ctx.userId).toBe('string') tasks.delete(id) }, load(ctx, id, since, action, meta) { expect(typeof action.type).toBe('string') expect(typeof meta.id).toBe('string') expect(typeof ctx.userId).toBe('string') let task = tasks.get(id) if (!task) throw new LoguxNotFoundError() return { finished: ChangedAt(task.finished, task.finishedChanged), id, text: ChangedAt(task.text, task.textChanged) } } }) addSyncMapFilter(server, 'tasks', { access(ctx, filter, action, meta) { expect(typeof action.type).toBe('string') expect(typeof meta.id).toBe('string') if (ctx.userId === 'wrong') return false if (filter?.text) return false return true }, actions(ctx, filter, action, meta) { expect(typeof action.type).toBe('string') expect(typeof meta.id).toBe('string') return (ctx2, action2) => action2.id !== 'silence' }, initial(ctx, filter, since, action, meta) { expect(typeof action.type).toBe('string') expect(typeof meta.id).toBe('string') let selected: SyncMapData[] = [] for (let [id, task] of tasks.entries()) { if (filter) { let filterKeys = Object.keys(filter) as (keyof TaskValue)[] if (filterKeys.some(i => task[i] !== filter[i])) { continue } } selected.push({ finished: ChangedAt(task.finished, task.finishedChanged), id, text: ChangedAt(task.text, task.textChanged) }) } return selected } }) return server } afterEach(() => { destroyable?.destroy() tasks.clear() }) it('checks SyncMap access', async () => { let server = getServer() let wrong = await server.connect('wrong') await server.expectDenied(() => wrong.subscribe('tasks/10')) await server.expectDenied(() => wrong.subscribe('tasks')) let correct = await server.connect('10') await server.expectDenied(() => correct.subscribe('tasks/bad')) await server.expectDenied(() => correct.subscribe('tasks', { text: 'A' })) await server.expectDenied(() => correct.process( createdTask({ fields: { finished: false, text: 'One' }, id: '10' }) ) ) await server.expectDenied(() => correct.process(deletedTask({ id: '10' }))) }) it('supports 404', async () => { let server = getServer() let client = await server.connect('1') await server.expectUndo('notFound', () => client.subscribe('tasks/10')) }) it('supports SyncMap', async () => { let server = getServer() let client1 = await server.connect('1') let client2 = await server.connect('2') client1.log.keepActions() client2.log.keepActions() await client1.process( createTask({ fields: { finished: false, text: 'One' }, id: '10' }) ) expect(Object.fromEntries(tasks)).toEqual({ 10: { finished: false, finishedChanged: 1, text: 'One', textChanged: 1 } }) expect(await client1.subscribe('tasks/10')).toEqual([ changedTask({ fields: { finished: false, text: 'One' }, id: '10' }) ]) expect(getTime(client1, changedTask)).toEqual([1]) await client2.subscribe('tasks/10') expect( await client2.collect(() => client1.process(changeTask({ fields: { text: 'One1' }, id: '10' })) ) ).toEqual([changedTask({ fields: { text: 'One1' }, id: '10' })]) expect(Object.fromEntries(tasks)).toEqual({ 10: { finished: false, finishedChanged: 1, text: 'One1', textChanged: 10 } }) expect(getTime(client2, changedTask)).toEqual([1, 10]) expect( await client1.collect(async () => { await client1.process(changeTask({ fields: { text: 'One2' }, id: '10' })) }) ).toEqual([loguxProcessed({ id: '13 1:1:1 0' })]) await client1.process(changeTask({ fields: { text: 'One0' }, id: '10' }), { time: 12 }) expect(Object.fromEntries(tasks)).toEqual({ 10: { finished: false, finishedChanged: 1, text: 'One2', textChanged: 13 } }) let client3 = await server.connect('3') expect( await client3.subscribe('tasks/10', undefined, { id: '', time: 12 }) ).toEqual([changedTask({ fields: { text: 'One2' }, id: '10' })]) let client4 = await server.connect('3') expect( await client4.subscribe('tasks/10', undefined, { id: '', time: 20 }) ).toEqual([]) }) it('supports SyncMap filters', async () => { let server = getServer() let client1 = await server.connect('1') let client2 = await server.connect('2') expect(await client1.subscribe('tasks')).toEqual([]) expect( await client1.process( createTask({ fields: { finished: false, text: 'One' }, id: '1' }) ) ).toEqual([loguxProcessed({ id: '3 1:1:1 0' })]) await client1.process( createTask({ fields: { finished: true, text: 'Two' }, id: '2' }) ) await client1.process( createTask({ fields: { finished: false, text: 'Three' }, id: '3' }) ) expect(await client2.subscribe('tasks', { finished: false })).toEqual([ loguxSubscribed({ channel: 'tasks/1' }), loguxSubscribed({ channel: 'tasks/3' }), changedTask({ fields: { finished: false, text: 'One' }, id: '1' }), changedTask({ fields: { finished: false, text: 'Three' }, id: '3' }) ]) expect( await client2.collect(async () => { await client1.process(changeTask({ fields: { text: 'One1' }, id: '1' })) }) ).toEqual([changedTask({ fields: { text: 'One1' }, id: '1' })]) expect( await client2.collect(async () => { await client1.process(deleteTask({ id: '3' })) }) ).toEqual([deletedTask({ id: '3' })]) expect(Object.fromEntries(tasks)).toEqual({ 1: { finished: false, finishedChanged: 3, text: 'One1', textChanged: 18 }, 2: { finished: true, finishedChanged: 6, text: 'Two', textChanged: 6 } }) expect( await client2.collect(async () => { await client1.process( createTask({ fields: { finished: false, text: 'Four' }, id: '4' }) ) }) ).toEqual([ createdTask({ fields: { finished: false, text: 'Four' }, id: '4' }) ]) expect( await client2.collect(async () => { await client1.process( createTask({ fields: { finished: true, text: 'Five' }, id: '5' }) ) }) ).toEqual([]) expect( await client2.collect(async () => { await client1.process( createTask({ fields: { finished: true, text: 'S' }, id: 'silence' }) ) }) ).toEqual([]) let client3 = await server.connect('3') expect( await client3.subscribe('tasks', undefined, { id: '', time: 15 }) ).toEqual([ loguxSubscribed({ channel: 'tasks/1' }), loguxSubscribed({ channel: 'tasks/2' }), loguxSubscribed({ channel: 'tasks/4' }), loguxSubscribed({ channel: 'tasks/5' }), loguxSubscribed({ channel: 'tasks/silence' }), changedTask({ fields: { text: 'One1' }, id: '1' }), changedTask({ fields: { finished: false, text: 'Four' }, id: '4' }), changedTask({ fields: { finished: true, text: 'Five' }, id: '5' }), changedTask({ fields: { finished: true, text: 'S' }, id: 'silence' }) ]) expect( await client3.collect(async () => { await client1.process( createTask({ fields: { finished: true, text: 'Six' }, id: '6' }) ) }) ).toEqual([createdTask({ fields: { finished: true, text: 'Six' }, id: '6' })]) }) it('supports simpler SyncMap', async () => { let server = getServer() addSyncMap(server, 'comments', { access() { return true }, load(ctx, id, since) { if (since) { return { author: NoConflictResolution('A'), id, text: NoConflictResolution('updated') } } return { author: NoConflictResolution('A'), id, text: NoConflictResolution('full') } } }) addSyncMapFilter(server, 'comments', { access() { return true }, initial() { return [] } }) let client1 = await server.connect('1') expect(await client1.subscribe('comments/1')).toEqual([ changedComment({ fields: { author: 'A', text: 'full' }, id: '1' }) ]) expect( await client1.subscribe('comments/2', undefined, { id: '', time: 2 }) ).toEqual([ changedComment({ fields: { author: 'A', text: 'updated' }, id: '2' }) ]) let client2 = await server.connect('2') await client2.subscribe('comments') await client2.collect(() => server.process( changedComment({ fields: { author: 'A', text: '2' }, id: '10' }) ) ) }) it('allows to disable changes', async () => { let server = getServer() addSyncMap(server, 'comments', { access() { return true }, change(ctx, id) { return id !== 'bad' }, create(ctx, id) { return id !== 'bad' }, delete(ctx, id) { return id !== 'bad' }, load(ctx, id) { return { id } } }) addSyncMapFilter(server, 'comments', { access() { return true }, initial() { return [] } }) let client1 = await server.connect('1') let client2 = await server.connect('2') await client2.subscribe('comments') await client2.subscribe('comments/good') await client2.subscribe('comments/bad') expect( await client2.collect(async () => { await client1.process(createComment({ fields: {}, id: 'good' })) await client1.process(changeComment({ fields: {}, id: 'good' })) await client1.process(deleteComment({ id: 'good' })) await client1.process(createComment({ fields: {}, id: 'bad' })) await client1.process(changeComment({ fields: {}, id: 'bad' })) await client1.process(deleteComment({ id: 'bad' })) }) ).toEqual([ createdComment({ fields: {}, id: 'good' }), changedComment({ fields: {}, id: 'good' }), deletedComment({ id: 'good' }) ]) }) it('does not load data on creating', async () => { let loaded = 0 let server = getServer() addSyncMap(server, 'comments', { access() { return true }, load(ctx, id) { loaded += 1 return { id } } }) let client = await server.connect('1') await client.log.add({ channel: 'comments/new', type: 'logux/subscribe' }) await setTimeout(10) expect(loaded).toBe(1) await client.log.add({ channel: 'comments/new', creating: true, type: 'logux/subscribe' }) await setTimeout(10) expect(loaded).toBe(1) }) it('throws an error on missed value wrapper', async () => { let server = getServer() addSyncMap(server, 'comments', { access() { return true }, // @ts-expect-error load(ctx, id) { return { id, text: 'Text' } } }) let client = await server.connect('1') await server.expectError(/Wrap value/, () => client.subscribe('comments/1')) }) ================================================ FILE: allowed-meta/index.d.ts ================================================ /** * List of meta keys permitted for clients. * *```js * import { ALLOWED_META } from '@logux/server' * async function onSend (action, meta) { * const filtered = { } * for (const i in meta) { * if (ALLOWED_META.includes(i)) { * filtered[i] = meta[i] * } * } * return [action, filtered] * } * ``` */ export const ALLOWED_META: string[] ================================================ FILE: allowed-meta/index.js ================================================ export const ALLOWED_META = ['id', 'time', 'subprotocol'] ================================================ FILE: allowed-meta/index.test.ts ================================================ import { expect, it } from 'vitest' import { ALLOWED_META } from '../index.js' it('has allowed meta keys list', () => { for (let key of ALLOWED_META) { expect(typeof key).toEqual('string') } }) ================================================ FILE: base-server/index.d.ts ================================================ import type { AbstractActionCreator, LoguxSubscribeAction, LoguxUnsubscribeAction } from '@logux/actions' import type { Action, AnyAction, ID, Log, LogStore, Meta, ServerConnection, TestTime } from '@logux/core' import type { Unsubscribe } from 'nanoevents' import type { Server as HTTPServer, IncomingMessage, ServerResponse } from 'node:http' import type { WebSocket } from 'ws' import type { ChannelContext, ConnectContext, Context } from '../context/index.js' import type { ServerClient } from '../server-client/index.js' interface LogFn { (...objs: unknown[]): void } interface TypeOptions { /** * Name of the queue that will be used to process actions * of the specified type. Default is 'main' */ queue?: string } interface ChannelOptions { /** * Name of the queue that will be used to process channels * with the specified name pattern. Default is 'main' */ queue?: string } interface ConnectLoader { ( ctx: ConnectContext, lastSynced: number ): | [Action, ServerMeta][] | Promise< [ Action, Partial> & Pick ][] > } type ServerNodeConstructor = new (...args: unknown[]) => ServerNode export interface ServerMeta extends Meta { /** * All nodes subscribed to channel will receive the action. */ channel?: string /** * All nodes subscribed to listed channels will receive the action. */ channels?: string[] /** * All nodes with listed client ID will receive the action. */ client?: string /** * All nodes with listed client IDs will receive the action. */ clients?: string[] /** * Client IDs, which will not receive the action. */ excludeClients?: string[] /** * Node with listed node ID will receive the action. */ node?: string /** * All nodes with listed node IDs will receive the action. */ nodes?: string[] /** * Node ID of the server received the action. */ server: string /** * Action processing status */ status?: 'error' | 'processed' | 'waiting' /** * All nodes with listed user ID will receive the action. */ user?: string /** * All nodes with listed user IDs will receive the action. */ users?: string[] } export interface BaseServerOptions { /** * SSL certificate or path to it. Path could be relative from server * root. It is required in production mode, because WSS is highly * recommended. */ cert?: string /** * Regular expression which should be cleaned from error message and stack. * * By default it cleans `Bearer [^\s"]+`. */ cleanFromLog?: RegExp /** * Disable health check endpoint, {@link Server#http}. * * The server will process only WebSocket connection and ignore all other * HTTP request (so they can be processed by other HTTP server). */ disableHttpServer?: boolean /** * Development or production server mode. By default, * it will be taken from `NODE_ENV` environment variable. * On empty `NODE_ENV` it will be `'development'`. */ env?: 'development' | 'production' /** * URL of main JS file in the root dir for the cases where you can’t use * `import.meta.dirname`. * * ``` * fileUrl: import.meta.url * ``` */ fileUrl?: string /** * IP-address to bind server. Default is `127.0.0.1`. */ host?: string /** * Custom random ID to be used in node ID. */ id?: string /** * SSL key or path to it. Path could be relative from server root. * It is required in production mode, because WSS is highly recommended. */ key?: { pem: string } | string /** * The version requirements for client subprotocol version. */ minSubprotocol?: number /** * Replace class for ServerNode. */ Node?: ServerNodeConstructor /** * Process ID, to display in logs. */ pid?: number /** * Milliseconds since last message to test connection by sending ping. * Default is `20000`. */ ping?: number /** * Port to bind server. It will create HTTP server manually to connect * WebSocket server to it. Default is `31337`. */ port?: number | string /** * URL to Redis for Logux Server Pro scaling. */ redis?: string /** * Application root to load files and show errors. * Default is `process.cwd()`. * * ```js * root: import.meta.dirname * ``` */ root?: string /** * HTTP server to serve Logux’s WebSocket and HTTP requests. * * Logux will remove previous HTTP callbacks. Do not use it with Express.js * or other HTTP servers with defined routes. */ server?: HTTPServer /** * Store to save log. Will be {@link @logux/core:MemoryStore}, by default. */ store?: LogStore /** * Server current application subprotocol version. */ subprotocol?: number /** * Test time to test server. */ time?: TestTime /** * Timeout in milliseconds to disconnect connection. * Default is `70000`. */ timeout?: number } export interface AuthenticatorOptions { client: ServerClient cookie: Record headers: Headers token: string userId: string } export type SendBackActions = | [Action, Partial][] | Action | Action[] | void /** * The authentication callback. * * @param userId User ID. * @param token The client credentials. * @param client Client object. * @returns `true` if credentials was correct */ interface ServerAuthenticator { (user: AuthenticatorOptions): boolean | Promise } /** * Check does user can do this action. * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. * @returns `true` if client are allowed to use this action. */ interface Authorizer< TypeAction extends Action, Data extends object, Headers extends object > { ( ctx: Context, action: Readonly, meta: Readonly ): boolean | Promise } /** * Return object with keys for meta to resend action to other users. * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. * @returns Meta’s keys. */ interface Resender< TypeAction extends Action, Data extends object, Headers extends object > { ( ctx: Context, action: Readonly, meta: Readonly ): Promise | Resend } /** * Action business logic. * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. * @returns Promise when processing will be finished. */ interface Processor< TypeAction extends Action, Data extends object, Headers extends object > { ( ctx: Context, action: Readonly, meta: Readonly ): Promise | void } /** * Callback which will be run on the end of action/subscription * processing or on an error. * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. */ interface ActionFinally< TypeAction extends Action, Data extends object, Headers extends object > { ( ctx: Context, action: Readonly, meta: Readonly ): void } /** * Channel filter callback * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. * @returns Should action be sent to client. */ interface ChannelFilter { ( ctx: Context, action: Readonly, meta: Readonly ): boolean | Promise } /** * Channel authorizer callback * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. * @returns `true` if client are allowed to subscribe to this channel. */ interface ChannelAuthorizer< SubscribeAction extends Action, Data extends object, ChannelParams extends object | string[], Headers extends object > { ( ctx: ChannelContext, action: Readonly, meta: Readonly ): boolean | Promise } /** * Generates custom filter for channel’s actions. * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. * @returns Actions filter. */ interface FilterCreator< SubscribeAction extends Action, Data extends object, ChannelParams extends object | string[], Headers extends object > { ( ctx: ChannelContext, action: Readonly, meta: Readonly ): ChannelFilter | Promise> | void } /** * Send actions with current state. * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. * @returns Promise during current actions loading. */ interface ChannelLoader< SubscribeAction extends Action, Data extends object, ChannelParams extends object | string[], Headers extends object > { ( ctx: ChannelContext, action: Readonly, meta: Readonly ): Promise | SendBackActions } /** * Callback which will be run on the end of subscription * processing or on an error. * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. */ interface ChannelFinally< SubscribeAction extends Action, Data extends object, ChannelParams extends object | string[], Headers extends object > { ( ctx: ChannelContext, action: Readonly, meta: Readonly ): void } /** * Callback which will be called on listener unsubscribe * (with explicit intent or because of disconnect) * * @param ctx Information about node, who create this action. * @param action The action data. * @param meta The action metadata. */ interface ChannelUnsubscribe< Data extends object, ChannelParams extends object | string[], Headers extends object > { ( ctx: ChannelContext, action: LoguxUnsubscribeAction, meta: Readonly ): void } type ActionCallbacks< TypeAction extends Action, Data extends object, Headers extends object > = ( | { access: Authorizer process?: Processor } | { accessAndProcess: Processor } ) & { finally?: ActionFinally resend?: Resender } type ChannelCallbacks< SubscribeAction extends Action, Data extends object, ChannelParams extends object | string[], Headers extends object > = ( | { access: ChannelAuthorizer load?: ChannelLoader } | { accessAndLoad: ChannelLoader< SubscribeAction, Data, ChannelParams, Headers > } ) & { filter?: FilterCreator finally?: ChannelFinally unsubscribe?: ChannelUnsubscribe } interface ActionReporter { action: Readonly meta: Readonly } interface SubscriptionReporter { actionId: ID channel: string } interface CleanReporter { actionId: ID } interface AuthenticationReporter { connectionId: string nodeId: string subprotocol: string } interface ReportersArguments { add: ActionReporter addClean: ActionReporter authenticated: AuthenticationReporter clean: CleanReporter clientError: { connectionId?: string err: Error nodeId?: string } connect: { connectionId: string ipAddress: string } denied: CleanReporter destroy: void disconnect: { connectionId?: string nodeId?: string } error: { actionId?: ID connectionId?: string err: Error fatal?: true nodeId?: string } listen: { cert: boolean environment: 'development' | 'production' host: string loguxServer: string minSubprotocol: number nodeId: string notes: object port: string redis: string server: boolean subprotocol: number } processed: { actionId: ID latency: number } subscribed: SubscriptionReporter unauthenticated: AuthenticationReporter unknownType: { actionId: ID type: string } unsubscribed: SubscriptionReporter useless: ActionReporter wrongChannel: SubscriptionReporter zombie: { nodeId: string } } export interface Reporter { ( event: Event, payload: ReportersArguments[Event] ): void } export type Resend = | { channel?: string channels?: string[] client?: string clients?: string[] excludeClients?: string[] node?: string nodes?: string[] user?: string users?: string[] } | string | string[] export interface Logger { debug(details: object, message: string): void error(details: object, message: string): void fatal(details: object, message: string): void info(details: object, message: string): void warn(details: object, message: string): void } /** * Return `false` if `cb()` got response error with 403. * * ```js * import { wasNot403 } from '@logux/server' * * server.auth(({ userId, token }) => { * return wasNot403(async () => { * get(`/checkUser/${userId}/${token}`) * }) * }) * ``` * * @param cb Callback with `request` calls. */ export function wasNot403(cb: () => Promise): Promise /** * Base server class to extend. */ export class BaseServer< Headers extends object = unknown, ServerLog extends Log = Log > { /** * Connected client by client ID. * * Do not rely on this data, when you have multiple Logux servers. * Each server will have a different list. */ clientIds: Map /** * Connected clients. * * ```js * for (let client of server.connected.values()) { * console.log(client.remoteAddress) * } * ``` */ connected: Map /** * Production or development mode. * * ```js * if (server.env === 'development') { * logDebugData() * } * ``` */ env: 'development' | 'production' /** * Server actions log. * * ```js * server.log.each(finder) * ``` */ log: ServerLog /** * Console for custom log records. It uses `pino` API. * * ```js * server.on('connected', client => { * server.logger.info( * { domain: client.httpHeaders.domain }, * 'Client domain' * ) * }) * ``` */ logger: { debug: LogFn error: LogFn fatal: LogFn info: LogFn warn: LogFn } /** * Server unique ID. * * ```js * console.log('Error was raised on ' + server.nodeId) * ``` */ nodeId: string /** * Connected client by node ID. * * Do not rely on this data, when you have multiple Logux servers. * Each server will have a different list. */ nodeIds: Map /** * Server options. * * ```js * console.log('Server options', server.options.subprotocol) * ``` */ options: BaseServerOptions /** * Clients subscribed to some channel. * * Do not rely on this data, when you have multiple Logux servers. * Each server will have a different list. */ subscribers: { [channel: string]: { [nodeId: string]: { filters: Record | true> unsubscribe?: (action: LoguxUnsubscribeAction, meta: ServerMeta) => void } } } /** * Connected client by user ID. * * Do not rely on this data, when you have multiple Logux servers. * Each server will have a different list. */ userIds: Map /** * @param opts Server options. */ constructor(opts: BaseServerOptions) /** * Add new client for server. You should call this method manually * mostly for test purposes. * * ```js * server.addClient(test.right) * ``` * * @param connection Logux connection to client. * @returns Client ID. */ addClient(connection: ServerConnection): number /** * Set authenticate function. It will receive client credentials * and node ID. It should return a Promise with `true` or `false`. * * ```js * server.auth(async ({ userId, cookie }) => { * const user = await findUserByToken(cookie.token) * return !!user && userId === user.id * }) * ``` * * @param authenticator The authentication callback. */ auth(authenticator: ServerAuthenticator): void /** * Define the channel. * * ```js * server.channel('user/:id', { * access (ctx, action, meta) { * return ctx.params.id === ctx.userId * } * filter (ctx, action, meta) { * return (otherCtx, otherAction, otherMeta) => { * return !action.hidden * } * } * async load (ctx, action, meta) { * const user = await db.loadUser(ctx.params.id) * ctx.sendBack({ type: 'USER_NAME', name: user.name }) * } * }) * ``` * * @param pattern Pattern for channel name. * @param callbacks Callback during subscription process. * @param options Additional options */ channel< ChannelParams extends object = unknown, Data extends object = unknown, SubscribeAction extends LoguxSubscribeAction = LoguxSubscribeAction >( pattern: string, callbacks: ChannelCallbacks, options?: ChannelOptions ): void /** * @param pattern Regular expression for channel name. * @param callbacks Callback during subscription process. * @param options Additional options */ channel< ChannelParams extends string[] = string[], Data extends object = unknown, SubscribeAction extends LoguxSubscribeAction = LoguxSubscribeAction >( pattern: RegExp, callbacks: ChannelCallbacks, options?: ChannelOptions ): void /** * Send runtime error stacktrace to all clients. * * ```js * process.on('uncaughtException', e => { * server.debugError(e) * }) * ``` * * @param error Runtime error instance. */ debugError(error: Error): void /** * Stop server and unbind all listeners. * * ```js * afterEach(() => { * testServer.destroy() * }) * ``` * * @returns Promise when all listeners will be removed. */ destroy(): Promise /** * Handle WebSocket connection explicitly * * This is a low-level method allowing to integrate Logux server with an existing server * * ```js * fastify.get('/', { websocket: true }, (socket, req) => { * loguxServer.handleClient(socket, req) * }) * ``` */ handleClient(ws: WebSocket, req: IncomingMessage): void /** * Add non-WebSocket HTTP request processor. * * ```js * server.http('GET', '/auth', (req, res) => { * let token = signIn(req) * if (token) { * res.setHeader('Set-Cookie', `token=${token}; Secure; HttpOnly`) * res.end() * } else { * res.statusCode = 400 * res.end('Wrong user or password') * } * }) * ``` */ http( method: string, url: string, listener: ( req: IncomingMessage, res: ServerResponse ) => Promise | void ): void http( listener: ( req: IncomingMessage, res: ServerResponse ) => boolean | Promise ): void /** * Start WebSocket server and listen for clients. * * @returns When the server has been bound. */ listen(): Promise /** * @param event The event name. * @param listener Event listener. */ on(event: 'subscriptionCancelled', listener: () => void): Unsubscribe /** * @param event The event name. * @param listener Subscription listener. */ on( event: 'subscribing', listener: (action: LoguxSubscribeAction, meta: Readonly) => void ): Unsubscribe /** * @param event The event name. * @param listener Processing listener. */ on( event: 'processed', listener: ( action: Action, meta: Readonly, latencyMilliseconds: number ) => void ): Unsubscribe /** * @param event The event name. * @param listener Action listener. */ on( event: 'add' | 'clean', listener: (action: Action, meta: Readonly) => void ): Unsubscribe /** * @param event The event name. * @param listener Client listener. */ on( event: 'connected' | 'disconnected', listener: (client: ServerClient) => void ): Unsubscribe /** * Subscribe for synchronization events. It implements nanoevents API. * Supported events: * * * `error`: server error during action processing. * * `fatal`: server error during loading. * * `clientError`: wrong client behaviour. * * `connected`: new client was connected. * * `disconnected`: client was disconnected. * * `authenticated`: client was authenticated. * * `preadd`: action is going to be added to the log. * The best place to set `reasons`. * * `add`: action was added to the log. * * `clean`: action was cleaned from the log. * * `processed`: action processing was finished. * * `subscribed`: channel initial data was loaded. * * `subscribing`: channel initial data started to be loaded. * * `unsubscribed`: node was unsubscribed. * * `subscriptionCancelled`: subscription was cancelled because the client * is not connected. * * ```js * server.on('error', error => { * trackError(error) * }) * ``` * * @param event The event name. * @param listener The listener function. * @returns Unbind listener from event. */ on( event: 'clientError' | 'fatal', listener: (err: Error) => void ): Unsubscribe /** * @param event The event name. * @param listener Error listener. */ on( event: 'error', listener: (err: Error, action: Action, meta: Readonly) => void ): Unsubscribe /** * @param event The event name. * @param listener Client listener. */ on( event: 'authenticated' | 'unauthenticated', listener: (client: ServerClient, latencyMilliseconds: number) => void ): Unsubscribe /** * @param event The event name. * @param listener Action listener. */ on( event: 'preadd', listener: (action: Action, meta: ServerMeta) => void ): Unsubscribe /** * @param event The event name. * @param listener Subscription listener. */ on( event: 'subscribed', listener: ( action: LoguxSubscribeAction, meta: Readonly, latencyMilliseconds: number ) => void ): Unsubscribe /** * @param event The event name. * @param listener Subscription listener. */ on( event: 'unsubscribed', listener: ( action: LoguxUnsubscribeAction, meta: Readonly, clientNodeId: string ) => void ): Unsubscribe /** * @param event The event name. * @param listener Report listener. */ on(event: 'report', listener: Reporter): Unsubscribe /** * Set callbacks for unknown channel subscription. * *```js * server.otherChannel({ * async access (ctx, action, meta) { * const res = await phpBackend.checkChannel(ctx.params[0], ctx.userId) * if (res.code === 404) { * this.wrongChannel(action, meta) * return false * } else { * return response.body === 'granted' * } * } * }) * ``` * * @param callbacks Callback during subscription process. */ otherChannel( callbacks: ChannelCallbacks ): void /** * Define callbacks for actions, which type was not defined * by any {@link Server#type}. Useful for proxy or some hacks. * * Without this settings, server will call {@link Server#unknownType} * on unknown type. * * ```js * server.otherType( * async access (ctx, action, meta) { * const response = await phpBackend.checkByHTTP(action, meta) * if (response.code === 404) { * this.unknownType(action, meta) * return false * } else { * return response.body === 'granted' * } * } * async process (ctx, action, meta) { * return await phpBackend.sendHTTP(action, meta) * } * }) * ``` * * @param callbacks Callbacks for actions with this type. */ otherType( callbacks: ActionCallbacks ): void /** * Add new action to the server and return the Promise until it will be * resend to clients and processed. * * @param action New action to resend and process. * @param meta Action’s meta. * @returns Promise until new action will be resend to clients and processed. */ process( action: AnyAction, meta?: Partial ): Promise> /** * Send action, received by other server, to all clients of current server. * This method is for multi-server configuration only. * * ```js * server.on('add', (action, meta) => { * if (meta.server === server.nodeId) { * sendToOtherServers(action, meta) * } * }) * onReceivingFromOtherServer((action, meta) => { * server.sendAction(action, meta) * }) * ``` * * @param action New action. * @param meta Action’s metadata. */ sendAction(action: Action, meta: ServerMeta): Promise | void /** * Change a way how server loads actions history for the client. * * ```js * server.sendOnConnect(async (ctx, lastSynced) => { * return db.loadActions({ user: ctx.userId, after: lastSynced }) * }) * ``` * * @param loader Callback which loads list of actions and meta. */ sendOnConnect(loader: ConnectLoader): void /** * Send `logux/subscribed` if client was not already subscribed. * * ```js * server.subscribe(ctx.nodeId, `users/${loaded}`) * ``` * * @param nodeId Node ID. * @param channel Channel name. */ subscribe(nodeId: string, channel: string): void /** * @param actionCreator Action creator function. * @param callbacks Callbacks for action created by creator. * @param options Additional options */ type( actionCreator: Creator, callbacks: ActionCallbacks, Data, Headers>, options?: TypeOptions ): void /** * Define action type’s callbacks. * * ```js * server.type('CHANGE_NAME', { * access (ctx, action, meta) { * return action.user === ctx.userId * }, * resend (ctx, action) { * return `user/${ action.user }` * } * process (ctx, action, meta) { * if (isFirstOlder(lastNameChange(action.user), meta)) { * return db.changeUserName({ id: action.user, name: action.name }) * } * } * }) * ``` * * @param name The action’s type or action’s type matching rule as RegExp.. * @param callbacks Callbacks for actions with this type. * @param options Additional options */ type( name: RegExp | TypeAction['type'], callbacks: ActionCallbacks, options?: TypeOptions ): void /** * Undo action from client. * * ```js * if (couldNotFixConflict(action, meta)) { * server.undo(action, meta) * } * ``` * * @param action The original action to undo. * @param meta The action’s metadata. * @param reason Optional code for reason. Default is `'error'`. * @param extra Extra fields to `logux/undo` action. * @returns When action was saved to the log. */ undo( action: Action, meta: ServerMeta, reason?: string, extra?: object ): Promise /** * If you receive action with unknown type, this method will mark this action * with `error` status and undo it on the clients. * * If you didn’t set {@link Server#otherType}, * Logux will call it automatically. * * ```js * server.otherType({ * access (ctx, action, meta) { * if (action.type.startsWith('myapp/')) { * return proxy.access(action, meta) * } else { * server.unknownType(action, meta) * } * } * }) * ``` * * @param action The action with unknown type. * @param meta Action’s metadata. */ unknownType(action: Action, meta: ServerMeta): void /** * Report that client try to subscribe for unknown channel. * * Logux call it automatically, * if you will not set {@link Server#otherChannel}. * * ```js * server.otherChannel({ * async access (ctx, action, meta) { * const res = phpBackend.checkChannel(params[0], ctx.userId) * if (res.code === 404) { * this.wrongChannel(action, meta) * return false * } else { * return response.body === 'granted' * } * } * }) * ``` * * @param action The subscribe action. * @param meta Action’s metadata. */ wrongChannel(action: LoguxSubscribeAction, meta: ServerMeta): void } ================================================ FILE: base-server/index.js ================================================ import { LoguxNotFoundError } from '@logux/actions' import { Log, MemoryStore, parseId, ServerConnection } from '@logux/core' import { createNanoEvents } from 'nanoevents' import { nanoid } from 'nanoid' import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import UrlPattern from 'url-pattern' import { WebSocketServer } from 'ws' import { addHttpPages } from '../add-http-pages/index.js' import { Context } from '../context/index.js' import { createHttpServer } from '../create-http-server/index.js' import { ServerClient } from '../server-client/index.js' const SKIP_PROCESS = Symbol('skipProcess') const RESEND_META = ['channels', 'users', 'clients', 'nodes'] function optionError(msg) { let error = new Error(msg) error.logux = true error.note = 'Check server constructor and Logux Server documentation' throw error } export async function wasNot403(cb) { try { await cb() return true } catch (e) { if (e.name === 'ResponseError' && e.statusCode === 403) { return false } throw e } } function normalizeTypeCallbacks(name, callbacks) { if (callbacks && callbacks.accessAndProcess) { callbacks.access = (ctx, ...args) => { return wasNot403(async () => { await callbacks.accessAndProcess(ctx, ...args) ctx[SKIP_PROCESS] = true }) } callbacks.process = async (ctx, ...args) => { if (!ctx[SKIP_PROCESS]) await callbacks.accessAndProcess(ctx, ...args) } } if (!callbacks || !callbacks.access) { throw new Error(`${name} must have access callback`) } } function normalizeChannelCallbacks(pattern, callbacks) { if (callbacks && callbacks.accessAndLoad) { callbacks.access = (ctx, ...args) => { return wasNot403(async () => { try { ctx.data.load = await callbacks.accessAndLoad(ctx, ...args) } catch (e) { if (e.name === 'LoguxNotFoundError') { ctx.data.notFound = true } else if (e.name === 'ResponseError' && e.statusCode === 404) { ctx.data.notFound = true } else { throw e } } }) } callbacks.load = ctx => { if (ctx.data.notFound) { throw new LoguxNotFoundError() } else { return ctx.data.load } } } if (!callbacks || !callbacks.access) { throw new Error(`Channel ${pattern} must have access callback`) } } function subscriberFilterId(action) { return JSON.stringify(action.filter || {}) } export class BaseServer { constructor(opts = {}) { this.options = opts this.env = this.options.env || process.env.NODE_ENV || 'development' if (typeof this.options.subprotocol === 'undefined') { throw optionError('Missed `subprotocol` option in server constructor') } if (typeof this.options.minSubprotocol === 'undefined') { throw optionError('Missed `minSubprotocol` option in server constructor') } if (this.options.key && !this.options.cert) { throw optionError('You must set `cert` option if you use `key` option') } if (!this.options.key && this.options.cert) { throw optionError('You must set `key` option if you use `cert` option') } if (!this.options.server) { if (!this.options.port) this.options.port = 31337 if (!this.options.host) this.options.host = '127.0.0.1' } this.nodeId = `server:${this.options.id || nanoid(8)}` if (this.options.fileUrl) { this.options.root = dirname(fileURLToPath(this.options.fileUrl)) } this.options.root = this.options.root || process.cwd() if (typeof this.options.port === 'string') { this.options.port = parseInt(this.options.port, 10) } let store = this.options.store || new MemoryStore() let log if (this.options.time) { log = this.options.time.nextLog({ nodeId: this.nodeId, store }) } else { log = new Log({ nodeId: this.nodeId, store }) } this.logger = console this.contexts = new WeakMap() this.log = log let cleaned = {} this.on('preadd', (action, meta) => { let isLogux = action.type.slice(0, 6) === 'logux/' if (!meta.server) { meta.server = this.nodeId } if (!meta.status && !isLogux) { meta.status = 'waiting' } if (meta.id.split(' ')[1] === this.nodeId) { if (!meta.subprotocol) { meta.subprotocol = this.options.subprotocol } if ( !isLogux && !this.types[action.type] && !this.getRegexProcessor(action.type) ) { meta.status = 'processed' } } this.replaceResendShortcuts(meta) }) this.on('add', async (action, meta) => { let start = Date.now() if (meta.reasons.length === 0) { cleaned[meta.id] = true this.emitter.emit('report', 'addClean', { action, meta }) } else { this.emitter.emit('report', 'add', { action, meta }) } if (this.destroying && !this.actionToQueue.has(meta.id)) { return } if (action.type === 'logux/subscribe') { if (meta.server === this.nodeId) { void this.subscribeAction(action, meta, start) } return } if (action.type === 'logux/unsubscribe') { if (meta.server === this.nodeId) { this.unsubscribeAction(action, meta) } return } let processor = this.getProcessor(action.type) if (processor && processor.resend && meta.status === 'waiting') { let ctx = this.createContext(action, meta) let resend try { resend = await processor.resend(ctx, action, meta) } catch (e) { this.undo(action, meta, 'error') this.emitter.emit('error', e, action, meta) this.finally(processor, ctx, action, meta) return } if (resend) { if (typeof resend === 'string') { resend = { channels: [resend] } } else if (Array.isArray(resend)) { resend = { channels: resend } } else { this.replaceResendShortcuts(resend) } let diff = {} for (let i of RESEND_META) { if (resend[i]) diff[i] = resend[i] } await this.log.changeMeta(meta.id, diff) meta = { ...meta, ...diff } } } if (this.isUseless(action, meta)) { this.emitter.emit('report', 'useless', { action, meta }) } await this.sendAction(action, meta) if (meta.status === 'waiting') { if (!processor) { this.internalUnknownType(action, meta) return } if (processor.process) { void this.processAction(processor, action, meta, start) } else { this.emitter.emit('processed', action, meta, 0) this.finally( processor, this.createContext(action, meta), action, meta ) this.markAsProcessed(meta) } } else { this.emitter.emit('processed', action, meta, 0) this.finally(processor, this.createContext(action, meta), action, meta) } }) this.on('clean', (action, meta) => { if (cleaned[meta.id]) { delete cleaned[meta.id] return } this.emitter.emit('report', 'clean', { actionId: meta.id }) }) this.emitter = createNanoEvents() this.on('fatal', err => { this.emitter.emit('report', 'error', { err, fatal: true }) }) this.on('error', (err, action, meta) => { if (meta) { this.emitter.emit('report', 'error', { actionId: meta.id, err }) } else if (err.nodeId) { this.emitter.emit('report', 'error', { err, nodeId: err.nodeId }) } else if (err.connectionId) { this.emitter.emit('report', 'error', { connectionId: err.connectionId, err }) } if (this.env === 'development') this.debugError(err) }) this.on('clientError', err => { if (err.nodeId) { this.emitter.emit('report', 'clientError', { err, nodeId: err.nodeId }) } else if (err.connectionId) { this.emitter.emit('report', 'clientError', { connectionId: err.connectionId, err }) } }) this.on('connected', client => { this.emitter.emit('report', 'connect', { connectionId: client.key, ipAddress: client.remoteAddress }) }) this.on('disconnected', client => { if (!client.zombie) { if (client.nodeId) { this.emitter.emit('report', 'disconnect', { nodeId: client.nodeId }) } else { this.emitter.emit('report', 'disconnect', { connectionId: client.key }) } } }) this.unbind = [] this.connected = new Map() this.nodeIds = new Map() this.clientIds = new Map() this.userIds = new Map() this.types = {} this.regexTypes = new Map() this.processing = 0 this.lastClient = 0 this.channels = [] this.subscribers = {} this.authAttempts = {} this.unknownTypes = {} this.wrongChannels = {} this.timeouts = {} this.lastTimeout = 0 this.typeToQueue = new Map() this.queues = new Map() this.actionToQueue = new Map() this.httpListeners = {} this.httpAllListeners = [] addHttpPages(this) this.listenNotes = {} let end = (actionId, queue, queueKey, ...args) => { this.actionToQueue.delete(actionId) if (queue.length() === 0) { this.queues.delete(queueKey) } queue.next(...args) } let undoRemainingTasks = queue => { let remainingTasks = queue.getQueue() if (remainingTasks) { for (let task of remainingTasks) { this.undo(task.action, task.meta, 'error') this.actionToQueue.delete(task.meta.id) } } queue.killAndDrain() } this.on('error', (e, action, meta) => { let queueKey = this.actionToQueue.get(meta?.id) if (queueKey) { let queue = this.queues.get(queueKey) undoRemainingTasks(queue) end(meta.id, queue, queueKey, e) } }) this.on('processed', (action, meta) => { if (action.type === 'logux/undo') { let queueKey = this.actionToQueue.get(action.id) if (queueKey) { let queue = this.queues.get(queueKey) undoRemainingTasks(queue) end(action.id, queue, queueKey, null, meta) } } else if (action.type === 'logux/processed') { let queueKey = this.actionToQueue.get(action.id) if (queueKey) { let queue = this.queues.get(queueKey) end(action.id, queue, queueKey, null, meta) } } else if ( action.type !== 'logux/subscribed' && action.type !== 'logux/unsubscribed' ) { let queueKey = this.actionToQueue.get(meta.id) if (queueKey) { let queue = this.queues.get(queueKey) end(meta.id, queue, queueKey, null, meta) } } }) this.unbind.push(() => { for (let i of this.connected.values()) i.destroy() for (let i in this.timeouts) { clearTimeout(this.timeouts[i]) } }) this.unbind.push(() => { return new Promise(resolve => { if (this.processing === 0) { resolve() } else { this.on('processed', () => { if (this.processing === 0) resolve() }) } }) }) this.unbind.push(() => { return Promise.allSettled( [...this.queues.values()].map(queue => { return new Promise(resolve => { queue.drain = resolve }) }) ) }) } addClient(connection) { this.lastClient += 1 let key = this.lastClient.toString() let client = new ServerClient(this, connection, key) this.connected.set(key, client) return this.lastClient } auth(authenticator) { this.authenticator = authenticator } buildUndo(action, meta, reason, extra) { let undoMeta = { status: 'processed' } if (meta.users) undoMeta.users = meta.users.slice(0) if (meta.nodes) undoMeta.nodes = meta.nodes.slice(0) if (meta.clients) undoMeta.clients = meta.clients.slice(0) if (meta.reasons) undoMeta.reasons = meta.reasons.slice(0) if (meta.channels) undoMeta.channels = meta.channels.slice(0) if (meta.excludeClients) { undoMeta.excludeClients = meta.excludeClients.slice(0) } let undoAction = { ...extra, action, id: meta.id, reason, type: 'logux/undo' } return [undoAction, undoMeta] } channel(pattern, callbacks, options = {}) { normalizeChannelCallbacks(`Channel ${pattern}`, callbacks) let channel = Object.assign({}, callbacks) if (typeof pattern === 'string') { channel.pattern = new UrlPattern(pattern, { segmentValueCharset: '^/' }) } else { channel.regexp = pattern } channel.queueName = options.queue || 'main' this.channels.push(channel) } createContext(action, meta) { let context = this.contexts.get(action) if (!context) { context = new Context(this, meta) this.contexts.set(action, context) } return context } debugActionError(meta, msg) { if (this.env === 'development') { let clientId = parseId(meta.id).clientId if (this.clientIds.has(clientId)) { this.clientIds.get(clientId).connection.send(['debug', 'error', msg]) } } } debugError(error) { for (let i of this.connected.values()) { if (i.connection.connected) { try { i.connection.send(['debug', 'error', error.stack]) } catch {} } } } denyAction(action, meta) { this.emitter.emit('report', 'denied', { actionId: meta.id }) this.undo(action, meta, 'denied') this.debugActionError(meta, `Action "${meta.id}" was denied`) } destroy() { this.destroying = true this.emitter.emit('report', 'destroy') return Promise.all(this.unbind.map(i => i())) } finally(processor, ctx, action, meta) { this.contexts.delete(action) if (processor && processor.finally) { try { processor.finally(ctx, action, meta) } catch (err) { this.emitter.emit('error', err, action, meta) } } } getProcessor(type) { return ( this.types[type] || this.getRegexProcessor(type) || this.otherProcessor ) } getRegexProcessor(type) { for (let regexp of this.regexTypes.keys()) { if (type.match(regexp) !== null) { return this.regexTypes.get(regexp) } } return undefined } handleClient(ws, req) { ws.upgradeReq = req this.addClient(new ServerConnection(ws)) } http(method, url, listener) { if (this.options.disableHttpServer) { throw new Error( '`server.http()` can not be called when `disableHttpServer` enabled' ) } if (!url) { this.httpAllListeners.push(method) } else { this.httpListeners[`${method} ${url}`] = listener } } internalUnknownType(action, meta) { this.contexts.delete(action) this.log.changeMeta(meta.id, { status: 'error' }) this.emitter.emit('report', 'unknownType', { actionId: meta.id, type: action.type }) if (parseId(meta.id).userId !== 'server') { this.undo(action, meta, 'unknownType') } this.debugActionError(meta, `Action with unknown type ${action.type}`) } internalWrongChannel(action, meta) { this.contexts.delete(action) this.emitter.emit('report', 'wrongChannel', { actionId: meta.id, channel: action.channel }) this.undo(action, meta, 'wrongChannel') this.debugActionError(meta, `Wrong channel name ${action.channel}`) } isBruteforce(ip) { let attempts = this.authAttempts[ip] return attempts && attempts >= 3 } isUseless(action, meta) { if ( meta.status !== 'processed' || this.types[action.type] || this.getRegexProcessor(action.type) ) { return false } for (let i of ['channels', 'nodes', 'clients', 'users']) { if (Array.isArray(meta[i]) && meta[i].length > 0) return false } return true } async listen() { if (!this.authenticator) { throw new Error('You must set authentication callback by server.auth()') } this.httpServer = await createHttpServer(this.options) this.ws = new WebSocketServer({ server: this.httpServer }) if (!this.options.server) { await new Promise((resolve, reject) => { this.ws.on('error', reject) this.httpServer.listen(this.options.port, this.options.host, resolve) }) } let processing = 0 let waiting this.unbind.push(() => { return new Promise(resolve => { let end = () => { this.ws.close(resolve) this.httpServer.close() } if (processing === 0) { end() } else { waiting = end } }) }) if (!this.options.disableHttpServer) { this.httpServer.on('request', async (req, res) => { if (this.destroying) { res.writeHead(503, { 'Content-Type': 'text/plain' }) res.end('The server is shutting down\n') return } processing += 1 await this.processHttp(req, res) processing -= 1 if (processing === 0 && waiting) waiting() }) } let pkg = JSON.parse( await readFile(join(import.meta.dirname, '..', 'package.json')) ) this.ws.on('connection', (ws, req) => this.handleClient(ws, req)) this.emitter.emit('report', 'listen', { cert: !!this.options.cert, environment: this.env, host: this.options.host, loguxServer: pkg.version, minSubprotocol: this.options.minSubprotocol, nodeId: this.nodeId, notes: this.listenNotes, port: this.options.port, redis: this.options.redis, server: !!this.options.server, subprotocol: this.options.subprotocol }) } markAsProcessed(meta) { this.log.changeMeta(meta.id, { status: 'processed' }) let data = parseId(meta.id) if (data.userId !== 'server') { this.log.add( { id: meta.id, type: 'logux/processed' }, { clients: [data.clientId], status: 'processed' } ) } } on(event, listener) { if (event === 'preadd' || event === 'add' || event === 'clean') { return this.log.emitter.on(event, listener) } else { return this.emitter.on(event, listener) } } otherChannel(callbacks) { normalizeChannelCallbacks('Unknown channel', callbacks) if (this.otherSubscriber) { throw new Error('Callbacks for unknown channel are already defined') } let channel = Object.assign({}, callbacks) channel.pattern = { match(name) { return [name] } } this.otherSubscriber = channel } otherType(callbacks) { if (this.otherProcessor) { throw new Error('Callbacks for unknown types are already defined') } normalizeTypeCallbacks('Unknown type', callbacks) this.otherProcessor = callbacks } performUnsubscribe(clientNodeId, action, meta) { if (action.channel === '__proto__' || clientNodeId === '__proto__') return if (this.subscribers[action.channel]) { let subscriber = this.subscribers[action.channel][clientNodeId] if (subscriber) { if (subscriber.unsubscribe) { subscriber.unsubscribe(action, meta) this.contexts.delete(action) } let filterId = subscriberFilterId(action) delete subscriber.filters[filterId] if (Object.keys(subscriber.filters).length === 0) { delete this.subscribers[action.channel][clientNodeId] } if (Object.keys(this.subscribers[action.channel]).length === 0) { delete this.subscribers[action.channel] } } } this.emitter.emit('unsubscribed', action, meta, clientNodeId) this.emitter.emit('report', 'unsubscribed', { actionId: meta.id, channel: action.channel }) } process(action, meta = {}) { return new Promise((resolve, reject) => { let unbindError = this.on('error', (e, errorAction) => { if (errorAction === action) { unbindError() unbindProcessed() reject(e) } }) let unbindProcessed = this.on('processed', (processed, processedMeta) => { if (processed === action) { unbindError() unbindProcessed() resolve(processedMeta) } }) this.log.add(action, meta) }) } async processAction(processor, action, meta, start) { let ctx = this.createContext(action, meta) let latency this.processing += 1 try { await processor.process(ctx, action, meta) latency = Date.now() - start this.markAsProcessed(meta) } catch (e) { this.log.changeMeta(meta.id, { status: 'error' }) this.undo(action, meta, 'error') this.emitter.emit('error', e, action, meta) } finally { this.finally(processor, ctx, action, meta) } if (typeof latency === 'undefined') latency = Date.now() - start this.processing -= 1 this.emitter.emit('processed', action, meta, latency) } async processHttp(req, res) { let urlString = req.url if (/^\/\w+%3F/.test(urlString)) { urlString = decodeURIComponent(urlString) } let reqUrl = new URL(urlString, 'http://localhost') let rule = this.httpListeners[req.method + ' ' + reqUrl.pathname] if (!rule) { let processed = false for (let listener of this.httpAllListeners) { let result = await listener(req, res) if (result === true) { processed = true break } } if (!processed) { res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Not found\n') } } else { await rule(req, res) } } rememberBadAuth(ip) { this.authAttempts[ip] = (this.authAttempts[ip] || 0) + 1 this.setTimeout(() => { if (this.authAttempts[ip] === 1) { delete this.authAttempts[ip] } else { this.authAttempts[ip] -= 1 } }, 3000) } replaceResendShortcuts(meta) { if (meta.channel) { meta.channels = [meta.channel] delete meta.channel } if (meta.user) { meta.users = [meta.user] delete meta.user } if (meta.client) { meta.clients = [meta.client] delete meta.client } if (meta.node) { meta.nodes = [meta.node] delete meta.node } } async sendAction(action, meta) { let from = parseId(meta.id).clientId let ignoreClients = new Set(meta.excludeClients || []) ignoreClients.add(from) if (meta.nodes) { for (let id of meta.nodes) { let client = this.nodeIds.get(id) if (client) { ignoreClients.add(client.clientId) client.node.onAdd(action, meta) } } } if (meta.clients) { for (let id of meta.clients) { if (this.clientIds.has(id)) { let client = this.clientIds.get(id) ignoreClients.add(client.clientId) client.node.onAdd(action, meta) } } } if (meta.users) { for (let userId of meta.users) { let users = this.userIds.get(userId) if (users) { for (let client of users) { if (!ignoreClients.has(client.clientId)) { ignoreClients.add(client.clientId) client.node.onAdd(action, meta) } } } } } if (meta.channels) { for (let channel of meta.channels) { if (this.subscribers[channel]) { for (let nodeId in this.subscribers[channel]) { let clientId = parseId(nodeId).clientId if (!ignoreClients.has(clientId)) { let subscriber = this.subscribers[channel][nodeId] if (subscriber) { let ctx = this.createContext(action, meta) let client = this.clientIds.get(clientId) for (let filter of Object.values(subscriber.filters)) { filter = typeof filter === 'function' ? await filter(ctx, action, meta) : filter if (filter && client) { ignoreClients.add(clientId) client.node.onAdd(action, meta) } } } } } } } } } sendOnConnect(loader) { this.connectLoader = loader } setTimeout(callback, ms) { this.lastTimeout += 1 let id = this.lastTimeout this.timeouts[id] = setTimeout(() => { delete this.timeouts[id] callback() }, ms) } subscribe(nodeId, channel) { if (channel === '__proto__' || nodeId === '__proto__') return if (!this.subscribers[channel] || !this.subscribers[channel][nodeId]) { if (!this.subscribers[channel]) { this.subscribers[channel] = {} } this.subscribers[channel][nodeId] = { filters: { '{}': true } } this.log.add({ channel, type: 'logux/subscribed' }, { nodes: [nodeId] }) } } async subscribeAction(action, meta, start) { if (typeof action.channel !== 'string' || action.channel === '__proto__') { this.wrongChannel(action, meta) return } let channels = this.channels if (this.otherSubscriber) { channels = this.channels.concat([this.otherSubscriber]) } let match for (let channel of channels) { if (channel.pattern) { match = channel.pattern.match(action.channel) } else { match = action.channel.match(channel.regexp) } let subscribed = false if (match) { let ctx = this.createContext(action, meta) if (ctx.nodeId === '__proto__') return ctx.params = match try { let access = await channel.access(ctx, action, meta) if (this.wrongChannels[meta.id]) { delete this.wrongChannels[meta.id] return } if (!access) { this.denyAction(action, meta) return } let client = this.clientIds.get(ctx.clientId) if (!client) { this.emitter.emit('subscriptionCancelled') return } let filterId = subscriberFilterId(action) let filters = { [filterId]: true } if (channel.filter) { let filter = await channel.filter(ctx, action, meta) filters = { [filterId]: filter } } this.emitter.emit('report', 'subscribed', { actionId: meta.id, channel: action.channel }) if (!this.subscribers[action.channel]) { this.subscribers[action.channel] = {} this.emitter.emit('subscribing', action, meta) } let subscriber = this.subscribers[action.channel][ctx.nodeId] if (subscriber) { filters = { ...subscriber.filters, ...filters } } this.subscribers[action.channel][ctx.nodeId] = { filters, unsubscribe: channel.unsubscribe ? (unsubscribeAction, unsubscribeMeta) => channel.unsubscribe(ctx, unsubscribeAction, unsubscribeMeta) : undefined } subscribed = true if (channel.load) { let sendBack = await channel.load(ctx, action, meta) if (Array.isArray(sendBack)) { await Promise.all( sendBack.map(i => { return Array.isArray(i) ? ctx.sendBack(...i) : ctx.sendBack(i) }) ) } else if (sendBack) { await ctx.sendBack(sendBack) } } this.emitter.emit('subscribed', action, meta, Date.now() - start) this.markAsProcessed(meta) } catch (e) { if (e.name === 'LoguxNotFoundError') { this.undo(action, meta, 'notFound') } else { this.emitter.emit('error', e, action, meta) this.undo(action, meta, 'error') } if (subscribed) { this.unsubscribe(action, meta) } } finally { this.finally(channel, ctx, action, meta) } break } } if (!match) this.wrongChannel(action, meta) } type(name, callbacks, options = {}) { let queue = options.queue || 'main' this.typeToQueue.set(name, queue) if (typeof name === 'function') name = name.type normalizeTypeCallbacks(`Action type ${name}`, callbacks) if (name instanceof RegExp) { this.regexTypes.set(name, callbacks) } else { if (this.types[name]) { throw new Error(`Action type ${name} was already defined`) } this.types[name] = callbacks } } undo(action, meta, reason = 'error', extra = {}) { let clientId = parseId(meta.id).clientId let [undoAction, undoMeta] = this.buildUndo(action, meta, reason, extra) undoMeta.clients = (undoMeta.clients || []).concat([clientId]) return this.log.add(undoAction, undoMeta) } unknownType(action, meta) { this.internalUnknownType(action, meta) this.unknownTypes[meta.id] = true } unsubscribe(action, meta) { let clientNodeId = meta.id.split(' ')[1] this.performUnsubscribe(clientNodeId, action, meta) } unsubscribeAction(action, meta) { if (typeof action.channel !== 'string') { this.wrongChannel(action, meta) return } this.unsubscribe(action, meta) this.markAsProcessed(meta) this.contexts.delete(action) } wrongChannel(action, meta) { this.internalWrongChannel(action, meta) this.wrongChannels[meta.id] = true } } ================================================ FILE: base-server/index.test.ts ================================================ import { defineAction } from '@logux/actions' import { type Action, Log, MemoryStore, type TestLog, TestTime } from '@logux/core' import { restoreAll, spy, type Spy, spyOn } from 'nanospy' import { readFileSync } from 'node:fs' import http from 'node:http' import https from 'node:https' import { join } from 'node:path' import { setTimeout } from 'node:timers/promises' import { afterEach, expect, it } from 'vitest' import WebSocket from 'ws' import { BaseServer, type BaseServerOptions, type ServerMeta } from '../index.js' const ROOT = join(import.meta.dirname, '..') const DEFAULT_OPTIONS = { minSubprotocol: 0, subprotocol: 0 } const CERT = join(ROOT, 'test/fixtures/cert.pem') const KEY = join(ROOT, 'test/fixtures/key.pem') let lastPort = 9111 function createServer( options: Partial = {} ): BaseServer> { let opts = { ...DEFAULT_OPTIONS, ...options } if (typeof opts.time === 'undefined') { opts.time = new TestTime() opts.id = 'uuid' } if (typeof opts.port === 'undefined') { lastPort += 1 opts.port = lastPort } let created = new BaseServer>(opts) created.auth(() => true) destroyable = created return created } let destroyable: BaseServer | undefined let httpServer: http.Server | undefined function createReporter(opts: Partial = {}): { app: BaseServer> names: string[] reports: [string, any][] } { let names: string[] = [] let reports: [string, any][] = [] let app = createServer(opts) app.on('report', (name: string, details?: any) => { names.push(name) if (details?.meta) { details.meta = JSON.parse(JSON.stringify(details.meta)) } reports.push([name, details]) }) return { app, names, reports } } let originEnv = process.env.NODE_ENV function privateMethods(obj: object): any { return obj } function emit(obj: any, event: string, ...args: any): void { obj.emitter.emit(event, ...args) } async function catchError(cb: () => Promise): Promise { try { await cb() } catch (e) { if (e instanceof Error) return e } throw new Error('Error was not thrown') } function calls(fn: Function | undefined): any[][] { return (fn as any as Spy).calls } function called(fn: Function | undefined): boolean { return (fn as any as Spy).called } function callCount(fn: Function | undefined): number { return (fn as any as Spy).callCount } afterEach(async () => { restoreAll() process.env.NODE_ENV = originEnv if (destroyable) { await destroyable.destroy() destroyable = undefined } if (httpServer) httpServer.close() }) it('saves server options', () => { let app = new BaseServer({ minSubprotocol: 1, subprotocol: 1 }) expect(app.options.minSubprotocol).toEqual(1) }) it('generates node ID', () => { let app = new BaseServer({ minSubprotocol: 1, subprotocol: 1 }) expect(app.nodeId).toMatch(/^server:[\w-]+$/) }) it('throws on missed subprotocol', () => { expect(() => { new BaseServer({}) }).toThrow(/Missed `subprotocol` option/) }) it('throws on missed supported subprotocols', () => { expect(() => { new BaseServer({ subprotocol: 0 }) }).toThrow(/Missed `minSubprotocol` option/) }) it('sets development environment by default', () => { delete process.env.NODE_ENV let app = new BaseServer(DEFAULT_OPTIONS) expect(app.env).toEqual('development') }) it('takes environment from NODE_ENV', () => { process.env.NODE_ENV = 'production' let app = new BaseServer(DEFAULT_OPTIONS) expect(app.env).toEqual('production') }) it('sets environment from user', () => { let app = new BaseServer({ env: 'production', minSubprotocol: 0, subprotocol: 0 }) expect(app.env).toEqual('production') }) it('uses cwd as default root', () => { let app = new BaseServer(DEFAULT_OPTIONS) expect(app.options.root).toEqual(process.cwd()) }) it('supports string as port', () => { let app = new BaseServer({ ...DEFAULT_OPTIONS, port: '8080' }) expect(app.options.port).toEqual(8080) }) it('uses user root', () => { let app = new BaseServer({ minSubprotocol: 0, root: '/a', subprotocol: 0 }) expect(app.options.root).toEqual('/a') }) it('creates log with default store', () => { let app = new BaseServer(DEFAULT_OPTIONS) expect(app.log instanceof Log).toBe(true) expect(app.log.store instanceof MemoryStore).toBe(true) }) it('creates log with custom store', () => { let store = new MemoryStore() let app = new BaseServer({ minSubprotocol: 0, store, subprotocol: 0 }) expect(app.log.store).toBe(store) }) it('uses test time and ID', () => { let store = new MemoryStore() let app = new BaseServer({ id: 'uuid', minSubprotocol: 0, store, subprotocol: 0, time: new TestTime() }) expect(app.log.store).toEqual(store) expect(app.log.generateId()).toEqual('1 server:uuid 0') }) it('destroys application without runned server', async () => { let app = new BaseServer(DEFAULT_OPTIONS) await app.destroy() app.destroy() }) it('throws without authenticator', async () => { expect.assertions(1) let app = new BaseServer(DEFAULT_OPTIONS) let error = await catchError(() => app.listen()) expect(error.message).toMatch(/authentication/) }) it('sets default ports and hosts', () => { let app = createServer() expect(app.options.port).toEqual(31337) expect(app.options.host).toEqual('127.0.0.1') }) it('uses user port', () => { let app = createServer({ port: 1337 }) expect(app.options.port).toEqual(1337) }) it('throws a error on key without certificate', () => { expect(() => { createServer({ key: readFileSync(KEY).toString() }) }).toThrow(/set `cert` option/) }) it('throws a error on certificate without key', () => { expect(() => { createServer({ cert: readFileSync(CERT).toString() }) }).toThrow(/set `key` option/) }) it('uses HTTPS', async () => { let app = createServer({ cert: readFileSync(CERT).toString(), key: readFileSync(KEY).toString() }) await app.listen() expect(privateMethods(app).httpServer instanceof https.Server).toBe(true) }) it('loads keys by absolute path', async () => { let app = createServer({ cert: CERT, key: KEY }) await app.listen() expect(privateMethods(app).httpServer instanceof https.Server).toBe(true) }) it('loads keys by relative path', async () => { let app = createServer({ cert: 'fixtures/cert.pem', key: 'fixtures/key.pem', root: join(ROOT, 'test/') }) await app.listen() expect(privateMethods(app).httpServer instanceof https.Server).toBe(true) }) it('supports object in SSL key', async () => { let app = createServer({ cert: readFileSync(CERT).toString(), key: { pem: readFileSync(KEY).toString() } }) await app.listen() expect(privateMethods(app).httpServer instanceof https.Server).toBe(true) }) it('reporters on start listening', async () => { let test = createReporter({ redis: '//localhost' }) let pkgFile = readFileSync(join(ROOT, 'package.json')) let pkg = JSON.parse(pkgFile.toString()) privateMethods(test.app).listenNotes.prometheus = 'http://127.0.0.1:31338/prometheus' let promise = test.app.listen() expect(test.reports).toEqual([]) await promise expect(test.reports).toEqual([ [ 'listen', { cert: false, environment: 'test', host: '127.0.0.1', loguxServer: pkg.version, minSubprotocol: 0, nodeId: 'server:uuid', notes: { prometheus: 'http://127.0.0.1:31338/prometheus' }, port: test.app.options.port, redis: '//localhost', server: false, subprotocol: 0 } ] ]) }) it('reporters on log events', async () => { let test = createReporter() test.app.type('A', { access: () => true }) test.app.type('B', { access: () => true }) await test.app.log.add({ type: 'A' }, { reasons: ['some'] }) await test.app.log.add({ type: 'B' }) await test.app.log.removeReason('some') expect(test.reports).toEqual([ [ 'add', { action: { type: 'A' }, meta: { added: 1, id: '1 server:uuid 0', reasons: ['some'], server: 'server:uuid', status: 'waiting', subprotocol: 0, time: 1 } } ], [ 'addClean', { action: { type: 'B' }, meta: { id: '2 server:uuid 0', reasons: [], server: 'server:uuid', status: 'waiting', subprotocol: 0, time: 2 } } ], [ 'clean', { actionId: '1 server:uuid 0' } ] ]) }) it('reporters on destroying', () => { let test = createReporter() let promise = test.app.destroy() expect(test.reports).toEqual([['destroy', undefined]]) return promise }) it('creates a client on connection', async () => { let app = createServer() await app.listen() let ws = new WebSocket(`ws://127.0.0.1:${app.options.port}`) await new Promise((resolve, reject) => { ws.onopen = resolve ws.onerror = reject }) expect(app.connected.size).toEqual(1) expect(app.connected.get('1')?.remoteAddress).toEqual('127.0.0.1') }) it('creates a client manually', () => { let app = createServer() app.addClient({ on: () => { return () => true }, ws: { _socket: { remoteAddress: '127.0.0.1' }, upgradeReq: { headers: {} } } } as any) expect(app.connected.size).toEqual(1) expect(app.connected.get('1')?.remoteAddress).toEqual('127.0.0.1') }) it('sends debug message to clients on runtimeError', () => { let app = createServer() app.connected.set('1', { connection: { connected: true, send: spy() }, destroy: () => false } as any) app.connected.set('2', { connection: { connected: false, send: spy() }, destroy: () => false } as any) app.connected.set('3', { connection: { connected: true, send: () => { throw new Error() } }, destroy: () => false } as any) let error = new Error('Test Error') error.stack = `${error.stack?.split('\n')[0]}\nfake stacktrace` app.debugError(error) expect(calls(app.connected.get('1')?.connection.send)).toEqual([ [['debug', 'error', 'Error: Test Error\nfake stacktrace']] ]) expect(called(app.connected.get('2')?.connection.send)).toBe(false) }) it('disconnects client on destroy', () => { let app = createServer() app.connected.set('1', { destroy: spy() } as any) app.destroy() expect(callCount(app.connected.get('1')?.destroy)).toEqual(1) }) it('accepts custom HTTP server', async () => { httpServer = http.createServer() let app = createServer({ server: httpServer }) await new Promise(resolve => { httpServer?.listen(app.options.port, resolve) }) await app.listen() let ws = new WebSocket(`ws://localhost:${app.options.port}`) await new Promise((resolve, reject) => { ws.onopen = resolve ws.onerror = reject }) expect(app.connected.size).toEqual(1) }) it('marks actions with own node ID', async () => { let app = createServer() app.type('A', { access: () => true }) let servers: string[] = [] app.on('add', (action, meta) => { servers.push(meta.server) }) await app.log.add({ type: 'A' }) await app.log.add({ type: 'A' }, { server: 'server2' }) expect(servers).toEqual([app.nodeId, 'server2']) }) it('marks actions with waiting status', async () => { let app = createServer() app.type('A', { access: () => true }) app.channel('a', { access: () => true }) let statuses: (string | undefined)[] = [] app.on('add', (action, meta) => { statuses.push(meta.status) }) await app.log.add({ type: 'A' }) await app.log.add({ type: 'A' }, { status: 'processed' }) await app.log.add({ channel: 'a', type: 'logux/subscribe' }) expect(statuses).toEqual(['waiting', 'processed', undefined]) }) it('defines actions types', () => { let app = createServer() app.type('FOO', { access: () => true }) expect(privateMethods(app).types.FOO).not.toBeUndefined() }) it('does not allow to define type twice', () => { let app = createServer() app.type('FOO', { access: () => true }) expect(() => { app.type('FOO', { access: () => true }) }).toThrow(/already/) }) it('requires access callback for type', () => { let app = createServer() expect(() => { // @ts-expect-error app.type('FOO') }).toThrow(/access callback/) }) it('reports about unknown action type', async () => { let test = createReporter() await test.app.log.add({ type: 'UNKNOWN' }, { id: '1 10:uuid 0' }) expect(test.names).toEqual(['addClean', 'unknownType', 'addClean']) expect(test.reports[1]).toEqual([ 'unknownType', { actionId: '1 10:uuid 0', type: 'UNKNOWN' } ]) }) it('ignores unknown type for processed actions', async () => { let test = createReporter() await test.app.log.add( { type: 'A' }, { channels: ['a'], status: 'processed' } ) expect(test.names).toEqual(['addClean']) }) it('reports about fatal error', () => { let test = createReporter({ env: 'development' }) let err = new Error('Test') emit(test.app, 'fatal', err) expect(test.reports).toEqual([['error', { err, fatal: true }]]) }) it('sends errors to clients in development', () => { let test = createReporter({ env: 'development' }) test.app.connected.set('0', { connection: { connected: true, send: spy() }, destroy: () => false } as any) let err = new Error('Test') err.stack = 'stack' privateMethods(err).nodeId = '10:uuid' emit(test.app, 'error', err) expect(test.reports).toEqual([['error', { err, nodeId: '10:uuid' }]]) expect(calls(test.app.connected.get('0')?.connection.send)).toEqual([ [['debug', 'error', 'stack']] ]) }) it('does not send errors in non-development mode', () => { let app = createServer({ env: 'production' }) app.connected.set('0', { connection: { send: spy() }, destroy: () => false } as any) emit(app, 'error', new Error('Test')) expect(called(app.connected.get('0')?.connection.send)).toBe(false) }) it('processes actions', async () => { let test = createReporter() let processed: Action[] = [] let fired: Action[] = [] test.app.type('FOO', { access: () => true, async process(ctx, action, meta) { expect(meta.added).toEqual(1) expect(ctx.isServer).toBe(true) await setTimeout(25) processed.push(action) } }) test.app.on('processed', (action, meta, latency) => { expect(typeof latency).toEqual('number') expect(meta.added).toEqual(1) fired.push(action) }) await test.app.log.add({ type: 'FOO' }, { reasons: ['test'] }) expect(fired).toEqual([]) expect(test.app.log.entries()[0][1].status).toEqual('waiting') await setTimeout(30) expect(test.app.log.entries()[0][1].status).toEqual('processed') expect(processed).toEqual([{ type: 'FOO' }]) expect(fired).toEqual([{ type: 'FOO' }]) }) it('processes regex matching action', async () => { let test = createReporter() let processed: Action[] = [] let fired: Action[] = [] test.app.type(/.*TODO$/, { access: () => true, async process(ctx, action, meta) { expect(meta.added).toEqual(1) expect(ctx.isServer).toBe(true) await setTimeout(25) processed.push(action) } }) test.app.on('processed', (action, meta, latency) => { expect(typeof latency).toEqual('number') expect(meta.added).toEqual(1) fired.push(action) }) await test.app.log.add({ type: 'ADD_TODO' }, { reasons: ['test'] }) expect(fired).toEqual([]) expect(test.app.log.entries()[0][1].status).toEqual('waiting') await setTimeout(30) expect(test.app.log.entries()[0][1].status).toEqual('processed') expect(processed).toEqual([{ type: 'ADD_TODO' }]) expect(fired).toEqual([{ type: 'ADD_TODO' }]) }) it('has full events API', () => { let app = createServer() let events = 0 let unbind = app.on('processed', () => { events += 1 }) emit(app, 'processed', { type: 'FOO' }, { id: '1 1:1 0' }) emit(app, 'processed', { type: 'FOO' }, { id: '1 1:1 0' }) unbind() emit(app, 'processed', { type: 'FOO' }, { id: '1 1:1 0' }) expect(events).toEqual(2) }) it('waits for last processing before destroy', async () => { let app = createServer() let started = 0 let process: (() => void) | undefined app.type('FOO', { access: () => true, process() { started += 1 return new Promise(resolve => { process = resolve }) } }) let destroyed = false await app.log.add({ type: 'FOO' }) app.destroy().then(() => { destroyed = true }) await setTimeout(1) expect(destroyed).toBe(false) expect(privateMethods(app).processing).toEqual(1) await app.log.add({ type: 'FOO' }) expect(started).toEqual(1) if (typeof process === 'undefined') throw new Error('process is not set') process() await setTimeout(1) expect(destroyed).toBe(true) }) it('reports about error during action processing', async () => { let test = createReporter() let err = new Error('Test') test.app.type('FOO', { access: () => true, process() { throw err } }) await test.app.log.add({ type: 'FOO' }, { reasons: ['test'] }) await setTimeout(1) expect(test.names).toEqual(['add', 'error', 'add']) expect(test.reports[1]).toEqual([ 'error', { actionId: '1 server:uuid 0', err } ]) expect(test.reports[2][1].action).toEqual({ action: { type: 'FOO' }, id: '1 server:uuid 0', reason: 'error', type: 'logux/undo' }) }) it('undoes actions on client', async () => { let app = createServer() app.undo( { type: 'FOO' }, { added: 1, channels: ['user/1'], clients: ['2:client'], excludeClients: ['3:client'], id: '1 1:client:uuid 0', nodes: ['2:client:uuid'], reasons: ['user/1/lastValue'], server: 'server:uuid', time: 1, users: ['3'] }, 'magic', { one: 1 } ) expect(app.log.entries()).toEqual([ [ { action: { type: 'FOO' }, id: '1 1:client:uuid 0', one: 1, reason: 'magic', type: 'logux/undo' }, { added: 1, channels: ['user/1'], clients: ['2:client', '1:client'], excludeClients: ['3:client'], id: '1 server:uuid 0', nodes: ['2:client:uuid'], reasons: ['user/1/lastValue'], server: 'server:uuid', status: 'processed', subprotocol: 0, time: 1, users: ['3'] } ] ]) }) it('adds current subprotocol to meta', async () => { let app = createServer({ subprotocol: 1 }) app.type('A', { access: () => true }) await app.log.add({ type: 'A' }, { reasons: ['test'] }) expect(app.log.entries()[0][1].subprotocol).toEqual(1) }) it('adds current subprotocol only to own actions', async () => { let app = createServer({ subprotocol: 1 }) app.type('A', { access: () => true }) await app.log.add({ type: 'A' }, { id: '1 0:other 0', reasons: ['test'] }) expect(app.log.entries()[0][1].subprotocol).toBeUndefined() }) it('allows to override subprotocol in meta', async () => { let app = createServer({ subprotocol: 2 }) app.type('A', { access: () => true }) await app.log.add({ type: 'A' }, { reasons: ['test'], subprotocol: 1 }) expect(app.log.entries()[0][1].subprotocol).toEqual(1) }) it('checks channel definition', () => { let app = createServer() expect(() => { // @ts-expect-error app.channel('foo/:id') }).toThrow('Channel foo/:id must have access callback') expect(() => { // @ts-expect-error app.channel(/^foo:/, { load: true }) }).toThrow('Channel /^foo:/ must have access callback') }) it('reports about wrong channel name', async () => { let test = createReporter({ env: 'development' }) test.app.channel('foo', { access: () => true }) let client: any = { connection: { send: spy() }, node: { onAdd() {} } } test.app.nodeIds.set('10:uuid', client) test.app.clientIds.set('10:uuid', client) await test.app.log.add({ type: 'logux/subscribe' }, { id: '1 10:uuid 0' }) expect(test.names).toEqual(['addClean', 'wrongChannel', 'addClean']) expect(test.reports[1][1]).toEqual({ actionId: '1 10:uuid 0', channel: undefined }) expect(test.reports[2][1].action).toEqual({ action: { type: 'logux/subscribe' }, id: '1 10:uuid 0', reason: 'wrongChannel', type: 'logux/undo' }) expect(calls(client.connection.send)).toEqual([ [['debug', 'error', 'Wrong channel name undefined']] ]) await test.app.log.add({ type: 'logux/unsubscribe' }) expect(test.reports[4]).toEqual([ 'wrongChannel', { actionId: '2 server:uuid 0', channel: undefined } ]) await test.app.log.add({ channel: 'unknown', type: 'logux/subscribe' }) expect(test.reports[7]).toEqual([ 'wrongChannel', { actionId: '4 server:uuid 0', channel: 'unknown' } ]) }) it('checks custom channel name subscriber', () => { let app = createServer() expect(() => { // @ts-expect-error app.otherChannel() }).toThrow('Unknown channel must have access callback') app.otherChannel({ access: () => true }) expect(() => { app.otherChannel({ access: () => true }) }).toThrow('Callbacks for unknown channel are already defined') }) it('allows to have custom channel name check', async () => { let test = createReporter() let channels: string[] = [] test.app.otherChannel({ access(ctx, action, meta) { channels.push(ctx.params[0]) test.app.wrongChannel(action, meta) return false } }) let client: any = { connection: { send() {} }, node: { onAdd() {} } } test.app.nodeIds.set('10:uuid', client) test.app.clientIds.set('10:uuid', client) await test.app.log.add({ channel: 'foo', type: 'logux/subscribe' }) expect(channels).toEqual(['foo']) expect(test.names).toEqual(['addClean', 'wrongChannel', 'addClean']) }) it('ignores subscription for other servers', async () => { let test = createReporter() let action = { type: 'logux/subscribe' } await test.app.log.add(action, { server: 'server:other' }) expect(test.names).toEqual(['addClean']) }) it('checks channel access', async () => { let test = createReporter() let client: any = { node: { onAdd: () => false, remoteSubprotocol: 0 } } test.app.nodeIds.set('10:uuid', client) test.app.clientIds.set('10:uuid', client) let finalled = 0 test.app.channel(/^user\/(\d+)$/, { async access(ctx) { expect(ctx.params[1]).toEqual('10') return false }, finally() { finalled += 1 } }) await test.app.log.add( { channel: 'user/10', type: 'logux/subscribe' }, { id: '1 10:uuid 0' } ) await setTimeout(1) expect(test.names).toEqual(['addClean', 'denied', 'addClean']) expect(test.reports[1][1]).toEqual({ actionId: '1 10:uuid 0' }) expect(test.reports[2][1].action).toEqual({ action: { channel: 'user/10', type: 'logux/subscribe' }, id: '1 10:uuid 0', reason: 'denied', type: 'logux/undo' }) expect(test.app.subscribers).toEqual({}) expect(finalled).toEqual(1) }) it('reports about errors during channel authorization', async () => { let test = createReporter() let client: any = { node: { onAdd: () => false, remoteSubprotocol: 0 } } test.app.nodeIds.set('10:uuid', client) test.app.clientIds.set('10:uuid', client) let err = new Error() test.app.channel(/^user\/(\d+)$/, { access() { throw err } }) await test.app.log.add( { channel: 'user/10', type: 'logux/subscribe' }, { id: '1 10:uuid 0' } ) await Promise.resolve() await Promise.resolve() expect(test.names).toEqual(['addClean', 'error', 'addClean']) expect(test.reports[1][1]).toEqual({ actionId: '1 10:uuid 0', err }) expect(test.reports[2][1].action).toEqual({ action: { channel: 'user/10', type: 'logux/subscribe' }, id: '1 10:uuid 0', reason: 'error', type: 'logux/undo' }) expect(test.app.subscribers).toEqual({}) }) it('subscribes clients', async () => { let test = createReporter() let client: any = { node: { onAdd: () => false, remoteSubprotocol: 0 } } test.app.nodeIds.set('10:a:uuid', client) test.app.clientIds.set('10:a', client) let userSubsriptions = 0 test.app.channel<{ id: string }>('user/:id', { access(ctx, action, meta) { expect(ctx.params.id).toEqual('10') expect(action.channel).toEqual('user/10') expect(meta.id).toEqual('1 10:a:uuid 0') expect(ctx.nodeId).toEqual('10:a:uuid') userSubsriptions += 1 return true } }) let filter = (): boolean => false test.app.channel('posts', { access() { return true }, async filter() { return filter } }) let events = 0 test.app.on('subscribed', (action, meta, latency) => { expect(action.type).toEqual('logux/subscribe') expect(meta.id).toContain('10:a:uuid') expect(latency).toBeCloseTo(25, -2) events += 1 }) await test.app.log.add( { channel: 'user/10', type: 'logux/subscribe' }, { id: '1 10:a:uuid 0' } ) await setTimeout(1) expect(events).toEqual(1) expect(userSubsriptions).toEqual(1) expect(test.names).toEqual(['addClean', 'subscribed', 'addClean']) expect(test.reports[1][1]).toEqual({ actionId: '1 10:a:uuid 0', channel: 'user/10' }) expect(test.reports[2][1].action).toEqual({ id: '1 10:a:uuid 0', type: 'logux/processed' }) expect(test.reports[2][1].meta.clients).toEqual(['10:a']) expect(test.reports[2][1].meta.status).toEqual('processed') expect(test.app.subscribers).toEqual({ 'user/10': { '10:a:uuid': { filters: { '{}': true } } } }) await test.app.log.add( { channel: 'posts', type: 'logux/subscribe' }, { id: '2 10:a:uuid 0' } ) await setTimeout(1) expect(events).toEqual(2) expect(test.app.subscribers).toEqual({ 'posts': { '10:a:uuid': { filters: { '{}': filter } } }, 'user/10': { '10:a:uuid': { filters: { '{}': true } } } }) await test.app.log.add( { channel: 'user/10', type: 'logux/unsubscribe' }, { id: '3 10:a:uuid 0' } ) expect(test.names).toEqual([ 'addClean', 'subscribed', 'addClean', 'addClean', 'subscribed', 'addClean', 'addClean', 'unsubscribed', 'addClean' ]) expect(test.reports[7][1]).toEqual({ actionId: '3 10:a:uuid 0', channel: 'user/10' }) expect(test.reports[8][1].action).toEqual({ id: '3 10:a:uuid 0', type: 'logux/processed' }) expect(test.app.subscribers).toEqual({ posts: { '10:a:uuid': { filters: { '{}': filter } } } }) }) it('subscribes clients with multiple filters', async () => { let test = createReporter() let client: any = { node: { onAdd: () => false, remoteSubprotocol: 0 } } test.app.nodeIds.set('10:a:uuid', client) test.app.clientIds.set('10:a', client) let filter = (): boolean => false test.app.channel('posts', { access() { return true }, async filter() { return filter } }) await test.app.log.add( { channel: 'posts', type: 'logux/subscribe' }, { id: '1 10:a:uuid 0' } ) await test.app.log.add( { channel: 'posts', filter: { category: 'a' }, type: 'logux/subscribe' }, { id: '1 10:a:uuid 0' } ) await test.app.log.add( { channel: 'posts', filter: { category: 'b' }, type: 'logux/subscribe' }, { id: '1 10:a:uuid 0' } ) await setTimeout(1) expect(test.app.subscribers).toEqual({ posts: { '10:a:uuid': { filters: { '{"category":"a"}': filter, '{"category":"b"}': filter, '{}': filter } } } }) await test.app.log.add( { channel: 'posts', type: 'logux/unsubscribe' }, { id: '2 10:a:uuid 0' } ) await test.app.log.add( { channel: 'posts', filter: { category: 'b' }, type: 'logux/unsubscribe' }, { id: '2 10:a:uuid 0' } ) await setTimeout(1) expect(test.app.subscribers).toEqual({ posts: { '10:a:uuid': { filters: { '{"category":"a"}': filter } } } }) }) it('cancels subscriptions on disconnect', async () => { let app = createServer() let client: any = { node: { onAdd: () => false, remoteSubprotocol: 0 } } app.nodeIds.set('10:uuid', client) app.clientIds.set('10:uuid', client) let cancels = 0 app.on('subscriptionCancelled', () => { cancels += 1 }) app.channel('test', { access() { app.clientIds.delete('10:uuid') app.nodeIds.delete('10:uuid') return true }, filter() { throw new Error('no calls') }, load() { throw new Error('no calls') } }) await app.log.add( { channel: 'test', type: 'logux/subscribe' }, { id: '1 10:uuid 0' } ) await setTimeout(10) expect(cancels).toEqual(1) }) it('reports about errors during channel initialization', async () => { let test = createReporter() let client: any = { node: { onAdd: () => false, remoteSubprotocol: 0 } } test.app.nodeIds.set('10:uuid', client) test.app.clientIds.set('10:uuid', client) let err = new Error() test.app.channel(/^user\/(\d+)$/, { access: () => true, load() { throw err } }) await test.app.log.add( { channel: 'user/10', type: 'logux/subscribe' }, { id: '1 10:uuid 0' } ) await setTimeout(1) expect(test.names).toEqual([ 'addClean', 'subscribed', 'error', 'addClean', 'unsubscribed' ]) expect(test.reports[2][1]).toEqual({ actionId: '1 10:uuid 0', err }) expect(test.reports[3][1].action).toEqual({ action: { channel: 'user/10', type: 'logux/subscribe' }, id: '1 10:uuid 0', reason: 'error', type: 'logux/undo' }) expect(test.app.subscribers).toEqual({}) }) it('loads initial actions during subscription', async () => { let test = createReporter({ time: new TestTime() }) let client: any = { node: { onAdd: () => false, remoteSubprotocol: 0 } } test.app.nodeIds.set('10:uuid', client) test.app.clientIds.set('10:uuid', client) test.app.on('preadd', (action, meta) => { meta.reasons.push('test') }) let userLoaded = 0 let initializating: (() => void) | undefined test.app.channel<{ id: string }>('user/:id', { access: () => true, load(ctx, action, meta) { expect(ctx.params.id).toEqual('10') expect(action.channel).toEqual('user/10') expect(meta.id).toEqual('1 10:uuid 0') expect(ctx.nodeId).toEqual('10:uuid') userLoaded += 1 return new Promise(resolve => { initializating = resolve }) } }) await test.app.log.add( { channel: 'user/10', type: 'logux/subscribe' }, { id: '1 10:uuid 0' } ) await setTimeout(1) expect(userLoaded).toEqual(1) expect(test.app.subscribers).toEqual({ 'user/10': { '10:uuid': { filters: { '{}': true } } } }) expect(test.app.log.actions()).toEqual([ { channel: 'user/10', type: 'logux/subscribe' } ]) if (typeof initializating === 'undefined') { throw new Error('callback is not set') } initializating() await setTimeout(1) expect(test.app.log.actions()).toEqual([ { channel: 'user/10', type: 'logux/subscribe' }, { id: '1 10:uuid 0', type: 'logux/processed' } ]) }) it('calls unsubscribe() channel callback with logux/unsubscribe', async () => { let test = createReporter({}) let client: any = { node: { onAdd: () => false, remoteHeaders: { preservedHeaders: true }, remoteSubprotocol: 0 } } let nodeId = '10:uuid' let clientId = '10:uuid' let userId = '10' test.app.nodeIds.set(nodeId, client) test.app.clientIds.set(clientId, client) test.app.on('preadd', (action, meta) => { meta.reasons.push('test') }) let unsubscribeCallback = spy() test.app.channel<{ id: string }, { preservedData?: boolean }>('user/:id', { access(ctx) { ctx.data.preservedData = true return true }, unsubscribe: unsubscribeCallback }) await test.app.log.add( { channel: 'user/10', type: 'logux/subscribe' }, { id: `1 ${nodeId}` } ) expect(Object.keys(test.app.subscribers)).toHaveLength(1) await test.app.log.add( { channel: 'user/10', type: 'logux/unsubscribe' }, { id: `2 ${nodeId}` } ) expect(Object.keys(test.app.subscribers)).toHaveLength(0) expect(test.app.log.actions()).toEqual([ { channel: 'user/10', type: 'logux/subscribe' }, { id: `1 ${nodeId}`, type: 'logux/processed' }, { channel: 'user/10', type: 'logux/unsubscribe' }, { id: `2 ${nodeId}`, type: 'logux/processed' } ]) expect(unsubscribeCallback.calls).toEqual([ [ expect.objectContaining({ clientId, data: { preservedData: true }, headers: { preservedHeaders: true }, nodeId, params: { id: '10' }, subprotocol: 0, userId }), expect.objectContaining({ channel: 'user/10', type: 'logux/unsubscribe' }), expect.objectContaining({ status: 'processed' }) ] ]) }) it('does not need type definition for own actions', async () => { let test = createReporter() await test.app.log.add({ type: 'unknown' }, { users: ['10'] }) expect(test.names).toEqual(['addClean']) expect(test.reports[0][1].action.type).toEqual('unknown') expect(test.reports[0][1].meta.status).toEqual('processed') }) it('checks callbacks in unknown type handler', () => { let app = createServer() expect(() => { // @ts-expect-error app.otherType({ process: () => {} }) }).toThrow('Unknown type must have access callback') app.otherType({ access: () => true }) expect(() => { app.otherType({ access: () => true }) }).toThrow('Callbacks for unknown types are already defined') }) it('reports about useless actions', async () => { let test = createReporter() test.app.type('known', { access: () => true, process: () => {} }) test.app.channel('a', { access: () => true }) test.app.on('preadd', (action, meta) => { meta.reasons.push('test') }) await test.app.log.add({ type: 'unknown' }, { status: 'processed' }) await test.app.log.add({ type: 'known' }) await test.app.log.add({ channel: 'a', type: 'logux/subscribe' }) await test.app.log.add({ type: 'known' }, { channels: ['a'] }) await test.app.log.add({ type: 'known' }, { users: ['10'] }) await test.app.log.add({ type: 'known' }, { clients: ['10:client'] }) await test.app.log.add({ type: 'known' }, { nodes: ['10:client:uuid'] }) expect(test.names).toEqual([ 'add', 'useless', 'add', 'add', 'add', 'add', 'add', 'add' ]) }) it('has shortcuts for resend arrays', async () => { let test = createReporter() test.app.type('A', { access: () => true, process: () => {} }) test.app.on('preadd', (action, meta) => { meta.reasons.push('test') }) await test.app.log.add( { type: 'A' }, { channel: 'a', client: '1:1', node: '1:1:1', user: '1' } ) expect(test.app.log.entries()).toEqual([ [ { type: 'A' }, { added: 1, channels: ['a'], clients: ['1:1'], id: '1 server:uuid 0', nodes: ['1:1:1'], reasons: ['test'], server: 'server:uuid', status: 'waiting', subprotocol: 0, time: 1, users: ['1'] } ] ]) await setTimeout(10) expect(test.app.log.entries()).toEqual([ [ { type: 'A' }, { added: 1, channels: ['a'], clients: ['1:1'], id: '1 server:uuid 0', nodes: ['1:1:1'], reasons: ['test'], server: 'server:uuid', status: 'processed', subprotocol: 0, time: 1, users: ['1'] } ] ]) }) it('tracks action processing on add', async () => { let error = new Error('test') let app = createServer() app.type('FOO', { access: () => false, resend: () => ({ channels: ['foo'] }) }) app.type('ERROR', { access: () => false, process() { throw error } }) let meta = await app.process({ type: 'FOO' }, { a: 1 }) expect(meta.a).toEqual(1) expect(meta.channels).toEqual(['foo']) let err try { await app.process({ type: 'ERROR' }) } catch (e) { err = e } expect(err).toBe(error) }) it('has shortcut API for action creators', async () => { type ActionA = { aValue: string; type: 'A' } let createA = defineAction('A') let processed: string[] = [] let app = createServer() app.type(createA, { access: () => true, process(ctx, action) { processed.push(action.aValue) } }) await app.process(createA({ aValue: 'test' })) expect(processed).toEqual(['test']) }) it('has alias to root from file URL', () => { let app = new BaseServer({ fileUrl: import.meta.url, minSubprotocol: 1, subprotocol: 1 }) expect(app.options.root).toEqual(import.meta.dirname) }) it('has custom logger', () => { let app = new BaseServer({ minSubprotocol: 1, root: import.meta.dirname, subprotocol: 1 }) spyOn(console, 'warn', () => {}) app.logger.warn({ test: 1 }, 'test') expect(calls(console.warn)).toEqual([[{ test: 1 }, 'test']]) }) it('subscribes clients manually', async () => { let app = new BaseServer({ minSubprotocol: 1, root: import.meta.dirname, subprotocol: 1 }) let actions: Action[] = [] app.log.on('add', (action, meta) => { expect(meta.nodes).toEqual(['test:1:1']) actions.push(action) }) app.subscribe('test:1:1', 'users/10') await setTimeout(10) expect(app.subscribers).toEqual({ 'users/10': { 'test:1:1': { filters: { '{}': true } } } }) expect(actions).toEqual([{ channel: 'users/10', type: 'logux/subscribed' }]) app.subscribe('test:1:1', 'users/10') await setTimeout(10) expect(actions).toEqual([{ channel: 'users/10', type: 'logux/subscribed' }]) }) it('processes action with accessAndProcess callback', async () => { let test = createReporter() let accessAndProcess = spy() test.app.type('A', { accessAndProcess }) await test.app.process({ type: 'A' }) expect(accessAndProcess.callCount).toEqual(1) }) ================================================ FILE: context/index.d.ts ================================================ import type { AnyAction } from '@logux/core' import type { ServerMeta } from '../base-server/index.js' import type { ServerClient } from '../server-client/index.js' import type { Server } from '../server/index.js' export class ConnectContext { /** * Unique persistence client ID. * * ```js * server.clientIds.get(node.clientId) * ``` */ clientId: string /** * Client’s headers. * * ```js * ctx.sendBack({ * type: 'error', * message: I18n[ctx.headers.locale || 'en'].error * }) * ``` */ headers: Headers /** * Unique node ID. * * ```js * server.nodeIds.get(node.nodeId) * ``` */ nodeId: string /** * Logux server */ server: Server /** * Action creator application subprotocol version. */ subprotocol: number /** * User ID taken node ID. * * ```js * async access (ctx, action, meta) { * const user = await db.getUser(ctx.userId) * return user.admin * } * ``` */ userId: string constructor(server: Server, client: ServerClient) /** * Send action back to the client. * * ```js * ctx.sendBack({ type: 'login/success', token }) * ``` * * Action will not be processed by server’s callbacks from `Server#type`. * * @param action The action. * @param meta Action’s meta. * @returns Promise until action was added to the server log. */ sendBack(action: AnyAction, meta?: Partial): Promise } /** * Action context. * ``` */ export class Context< Data extends object = unknown, Headers extends object = unknown > extends ConnectContext { /** * Open structure to save some data between different steps of processing. * * ```js * server.type('RENAME', { * access (ctx, action, meta) { * ctx.data.user = findUser(ctx.userId) * return ctx.data.user.hasAccess(action.projectId) * } * process (ctx, action, meta) { * return ctx.data.user.rename(action.projectId, action.name) * } * }) * ``` */ data: Data /** * Was action created by Logux server. * * ```js * access: (ctx, action, meta) => ctx.isServer * ``` */ isServer: boolean constructor(server: Server, meta: ServerMeta) } /** * Subscription context. * * ```js * server.channel('user/:id', { * access (ctx, action, meta) { * return ctx.params.id === ctx.userId * } * }) * ``` */ export class ChannelContext< Data extends object, ChannelParams extends object | string[], Headers extends object > extends Context { /** * Parsed variable parts of channel pattern. * * ```js * server.channel('user/:id', { * access (ctx, action, meta) { * action.channel //=> user/10 * ctx.params //=> { id: '10' } * } * }) * server.channel(/post\/(\d+)/, { * access (ctx, action, meta) { * action.channel //=> post/10 * ctx.params //=> ['post/10', '10'] * } * }) * ``` */ params: ChannelParams } ================================================ FILE: context/index.js ================================================ import { parseId } from '@logux/core' export class Context { constructor(server, meta) { this.server = server this.data = {} let client if (meta.node) { client = meta this.nodeId = client.nodeId this.userId = client.userId this.clientId = client.clientId this.subprotocol = client.node.remoteSubprotocol } else { let parsed = parseId(meta.id) this.nodeId = parsed.nodeId this.userId = parsed.userId this.clientId = parsed.clientId this.isServer = this.userId === 'server' client = server.clientIds.get(this.clientId) if (meta.subprotocol) { this.subprotocol = meta.subprotocol } else if (client) { this.subprotocol = client.node.remoteSubprotocol } } if (client) { this.headers = client.node.remoteHeaders } else { this.headers = {} } } sendBack(action, meta = {}) { return this.server.log.add(action, { clients: [this.clientId], status: 'processed', ...meta }) } } ================================================ FILE: context/index.test.ts ================================================ import type { Action } from '@logux/core' import { beforeEach, expect, it } from 'vitest' import { Context, type ServerMeta } from '../index.js' let added: [Action, ServerMeta][] = [] const FAKE_SERVER: any = { clientIds: new Map([ [ '20:client', { node: { remoteHeaders: { locale: 'fr' }, remoteSubprotocol: 2 } } ] ]), log: { add(action: Action, meta: ServerMeta) { added.push([action, meta]) return Promise.resolve() } } } beforeEach(() => { added = [] }) function createContext( meta: Partial = { id: '1 10:client:uuid 0', subprotocol: 1 } ): Context { return new Context(FAKE_SERVER, meta as ServerMeta) } it('has open data', () => { let ctx = createContext() expect(ctx.data).toEqual({}) }) it('parses meta', () => { let ctx = createContext() expect(ctx.nodeId).toEqual('10:client:uuid') expect(ctx.clientId).toEqual('10:client') expect(ctx.userId).toEqual('10') expect(ctx.subprotocol).toEqual(1) }) it('detects servers', () => { let user = createContext({ id: '1 10:uuid 0' }) expect(user.isServer).toBe(false) let server = createContext({ id: '1 server:uuid 0' }) expect(server.isServer).toBe(true) }) it('takes subprotocol from client', () => { let ctx = createContext({ id: '1 20:client:uuid 0' }) expect(ctx.subprotocol).toEqual(2) }) it('works on missed subprotocol', () => { let ctx = createContext({ id: '1 10:client:uuid 0' }) expect(ctx.subprotocol).toBeUndefined() }) it('takes headers from client', () => { let ctx = createContext({ id: '1 20:client:uuid 0' }) expect(ctx.headers).toEqual({ locale: 'fr' }) }) it('works on missed headers', () => { let ctx = createContext({ id: '1 10:client:uuid 0' }) expect(ctx.headers).toEqual({}) }) it('sends action back', () => { let ctx = createContext() expect(ctx.sendBack({ type: 'A' }) instanceof Promise).toBe(true) ctx.sendBack({ type: 'B' }, { clients: [], reasons: ['1'] }) expect(added).toEqual([ [{ type: 'A' }, { clients: ['10:client'], status: 'processed' }], [{ type: 'B' }, { clients: [], reasons: ['1'], status: 'processed' }] ]) }) ================================================ FILE: create-http-server/index.js ================================================ import { promises as fs } from 'node:fs' import http from 'node:http' import https from 'node:https' import { isAbsolute, join } from 'node:path' const PEM_PREAMBLE = '-----BEGIN' function isPem(content) { if (typeof content === 'object' && content.pem) { return true } else { return content.toString().trim().startsWith(PEM_PREAMBLE) } } function readFrom(root, file) { file = file.toString() if (!isAbsolute(file)) file = join(root, file) return fs.readFile(file) } export async function createHttpServer(opts) { let server if (opts.server) { server = opts.server } else { let key = opts.key let cert = opts.cert if (key && !isPem(key)) key = await readFrom(opts.root, key) if (cert && !isPem(cert)) cert = await readFrom(opts.root, cert) if (key && key.pem) { server = https.createServer({ cert, key: key.pem }) } else if (key) { server = https.createServer({ cert, key }) } else { server = http.createServer() } } return server } ================================================ FILE: create-reporter/__snapshots__/index.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`allows custom loggers 1`] = `"{"details":{"connectionId":"670","ipAddress":"10.110.6.56"},"msg":"Client was connected"}"`; exports[`reports EACCES error 1`] = ` "{"level":60,"time":"1970-01-01T00:00:00.000Z","pid":21384,"err":{},"note":"Non-privileged users can't start a listening socket on ports below 1024. Try to change user or take another port.\\n\\n$ su - \`\`\\n$ npm start -p 80","msg":"You are not allowed to run server on port \`80\`"} FLUSH" `; exports[`reports EACCES error 2`] = ` " FATAL  You are not allowed to run server on port 80 at 1970-01-01 00:00:00 Non-privileged users can't start a listening socket on ports below 1024. Try to change user or take another port.  $ su -  $ npm start -p 80 " `; exports[`reports actions with excludeClients metadata 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"action":{"id":100,"name":"John","type":"ADD_USER"},"meta":{"excludeClients":["1:-lCr7e9s","2:wv0r_O5C"],"id":"1487805099387 100:uImkcF4z 0","reasons":[],"server":"server:H1f8LAyzl","subprotocol":1,"time":1487805099387},"msg":"Action was added"} " `; exports[`reports actions with excludeClients metadata 2`] = ` " INFO  Action was added at 1970-01-01 00:00:00 Action: id: 100 name: "John" type: "ADD_USER" Meta: excludeClients: ["1:-lCr7e9s","2:wv0r_O5C"] id: 1487805099387 100:uImkcF4z 0 reasons: [] server: server:H1f8LAyzl subprotocol: 1 time: 1487805099387 " `; exports[`reports actions with metadata containing 'clients' array 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"action":{"id":100,"name":"John","type":"ADD_USER"},"meta":{"clients":["1:-lCr7e9s","2:wv0r_O5C"],"id":"1487805099387 100:uImkcF4z 0","reasons":[],"server":"server:H1f8LAyzl","subprotocol":1,"time":1487805099387},"msg":"Action was added"} " `; exports[`reports actions with metadata containing 'clients' array 2`] = ` " INFO  Action was added at 1970-01-01 00:00:00 Action: id: 100 name: "John" type: "ADD_USER" Meta: clients: ["1:-lCr7e9s","2:wv0r_O5C"] id: 1487805099387 100:uImkcF4z 0 reasons: [] server: server:H1f8LAyzl subprotocol: 1 time: 1487805099387 " `; exports[`reports add 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"action":{"data":{"array":[1,[2],{"a":"1","b":{"c":2},"d":[],"e":null},null],"name":"John","role":null},"id":100,"type":"CHANGE_USER"},"meta":{"id":"1487805099387 100:uImkcF4z 0","reasons":["lastValue","debug"],"server":"server:H1f8LAyzl","subprotocol":1,"time":1487805099387},"msg":"Action was added"} " `; exports[`reports add 2`] = ` " INFO  Action was added at 1970-01-01 00:00:00 Action: data: array: [1, [2], { a: "1", b: { c: 2 }, d: [], e: null }, null] name: "John" role: null id: 100 type: "CHANGE_USER" Meta: id: 1487805099387 100:uImkcF4z 0 reasons: ["lastValue", "debug"] server: server:H1f8LAyzl subprotocol: 1 time: 1487805099387 " `; exports[`reports add and clean 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"action":{"data":{"array":[1,[2],{"a":"1","b":{"c":2},"d":[],"e":null},null],"name":"John","role":null},"id":100,"type":"CHANGE_USER"},"meta":{"id":"1487805099387 100:uImkcF4z 0","reasons":["lastValue","debug"],"server":"server:H1f8LAyzl","subprotocol":1,"time":1487805099387},"msg":"Action was added and cleaned"} " `; exports[`reports add and clean 2`] = ` " INFO  Action was added and cleaned at 1970-01-01 00:00:00 Action: data: array: [1, [2], { a: "1", b: { c: 2 }, d: [], e: null }, null] name: "John" role: null id: 100 type: "CHANGE_USER" Meta: id: 1487805099387 100:uImkcF4z 0 reasons: ["lastValue", "debug"] server: server:H1f8LAyzl subprotocol: 1 time: 1487805099387 " `; exports[`reports authenticated 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"connectionId":"670","nodeId":"admin:100:uImkcF4z","subprotocol":1,"msg":"User was authenticated"} " `; exports[`reports authenticated 2`] = ` " INFO  User was authenticated at 1970-01-01 00:00:00 Connection ID: 670 Node ID: admin:100 Subprotocol: 1 " `; exports[`reports authenticated without user ID 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"connectionId":"670","nodeId":"uImkcF4z","subprotocol":1,"msg":"User was authenticated"} " `; exports[`reports authenticated without user ID 2`] = ` " INFO  User was authenticated at 1970-01-01 00:00:00 Connection ID: 670 Node ID: uImkcF4z Subprotocol: 1 " `; exports[`reports clean 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"actionId":"1487805099387 100:uImkcF4z 0","msg":"Action was cleaned"} " `; exports[`reports clean 2`] = ` " INFO  Action was cleaned at 1970-01-01 00:00:00 Action ID: 1487805099387 100:uImkcF4z 0 " `; exports[`reports connect 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"connectionId":"670","ipAddress":"10.110.6.56","msg":"Client was connected"} " `; exports[`reports connect 2`] = ` " INFO  Client was connected at 1970-01-01 00:00:00 Connection ID: 670 IP address: 10.110.6.56 " `; exports[`reports denied 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"actionId":"1487805099387 100:uImkcF4z 0","msg":"Action was denied"} " `; exports[`reports denied 2`] = ` " WARN  Action was denied at 1970-01-01 00:00:00 Action ID: 1487805099387 100:uImkcF4z 0 " `; exports[`reports destroy 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"msg":"Shutting down Logux server"} " `; exports[`reports destroy 2`] = ` " INFO  Shutting down Logux server at 1970-01-01 00:00:00 " `; exports[`reports disconnect 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"nodeId":"100:uImkcF4z","msg":"Client was disconnected"} " `; exports[`reports disconnect 2`] = ` " INFO  Client was disconnected at 1970-01-01 00:00:00 Node ID: 100:uImkcF4z " `; exports[`reports disconnect from unauthenticated user 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"connectionId":"670","msg":"Client was disconnected"} " `; exports[`reports disconnect from unauthenticated user 2`] = ` " INFO  Client was disconnected at 1970-01-01 00:00:00 Connection ID: 670 " `; exports[`reports error 1`] = ` "{"level":60,"time":"1970-01-01T00:00:00.000Z","pid":21384,"err":{"message":"Some mistake","name":"Error","stack":"Error: Some mistake\\n at Object. (/dev/app/index.js:28:13)\\n at Module._compile (module.js:573:32)\\n at at runTest (/dev/app/node_modules/jest/index.js:50:10)\\n at process._tickCallback (internal/process/next_tick.js:103:7)"},"msg":"Some mistake"} FLUSH" `; exports[`reports error 2`] = ` " FATAL  Some mistake at 1970-01-01 00:00:00  at Object. (/dev/app/index.js:28:13)  at Module._compile (module.js:573:32)  at at runTest (/dev/app/node_modules/jest/index.js:50:10)  at process._tickCallback (internal/process/next_tick.js:103:7) " `; exports[`reports error from action 1`] = ` "{"level":50,"time":"1970-01-01T00:00:00.000Z","pid":21384,"err":{"message":"Some mistake","name":"Error","stack":"Error: Some mistake\\n at Object. (/dev/app/index.js:28:13)\\n at Module._compile (module.js:573:32)\\n at at runTest (/dev/app/node_modules/jest/index.js:50:10)\\n at process._tickCallback (internal/process/next_tick.js:103:7)"},"actionId":"1487805099387 100:uImkcF4z 0","msg":"Some mistake"} " `; exports[`reports error from action 2`] = ` " ERROR  Some mistake at 1970-01-01 00:00:00  at Object. (/dev/app/index.js:28:13)  at Module._compile (module.js:573:32)  at at runTest (/dev/app/node_modules/jest/index.js:50:10)  at process._tickCallback (internal/process/next_tick.js:103:7) Action ID: 1487805099387 100:uImkcF4z 0 " `; exports[`reports error from client 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"connectionId":"670","msg":"Client error: A timeout was reached (5000 ms)"} " `; exports[`reports error from client 2`] = ` " WARN  Client error: A timeout was reached (5000 ms) at 1970-01-01 00:00:00 Connection ID: 670 " `; exports[`reports error from node 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"nodeId":"100:uImkcF4z","msg":"Sync error: A timeout was reached (5000 ms)"} " `; exports[`reports error from node 2`] = ` " WARN  Sync error: A timeout was reached (5000 ms) at 1970-01-01 00:00:00 Node ID: 100:uImkcF4z " `; exports[`reports error with token 1`] = ` "{"level":50,"time":"1970-01-01T00:00:00.000Z","pid":21384,"err":{"message":"{\\"Authorization\\":\\"[SECRET]\\"}","name":"Error","stack":"Error: {\\"Authorization\\":\\"[SECRET]\\"}\\n at Object. (/dev/app/index.js:28:13)\\n at Module._compile (module.js:573:32)\\n at at runTest (/dev/app/node_modules/jest/index.js:50:10)\\n at process._tickCallback (internal/process/next_tick.js:103:7)"},"actionId":"1487805099387 100:uImkcF4z 0","msg":"{\\"Authorization\\":\\"[SECRET]\\"}"} " `; exports[`reports error with token 2`] = ` " ERROR  {"Authorization":"[SECRET]"} at 1970-01-01 00:00:00  at Object. (/dev/app/index.js:28:13)  at Module._compile (module.js:573:32)  at at runTest (/dev/app/node_modules/jest/index.js:50:10)  at process._tickCallback (internal/process/next_tick.js:103:7) Action ID: 1487805099387 100:uImkcF4z 0 " `; exports[`reports listen 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"environment":"development","loguxServer":"0.0.0","minSubprotocol":0,"nodeId":"server:FnXaqDxY","subprotocol":0,"note":["Server was started in non-secure development mode","Press Ctrl-C to shutdown server"],"listen":"ws://127.0.0.1:31337/","healthCheck":"http://127.0.0.1:31337/health","msg":"Logux server is listening"} " `; exports[`reports listen 2`] = ` " INFO  Logux server is listening at 1970-01-01 00:00:00 PID: 21384 Environment: development Logux server: 0.0.0 Min subprotocol: 0 Node ID: server:FnXaqDxY Subprotocol: 0 Health check: http://127.0.0.1:31337/health Listen: ws://127.0.0.1:31337/ Server was started in non-secure development mode Press Ctrl-C to shutdown server " `; exports[`reports listen for custom domain 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"environment":"development","loguxServer":"0.0.0","minSubprotocol":0,"nodeId":"server:FnXaqDxY","subprotocol":0,"note":["Server was started in non-secure development mode","Press Ctrl-C to shutdown server"],"server":true,"prometheus":"http://127.0.0.1:31338/prometheus","msg":"Logux server is listening"} " `; exports[`reports listen for custom domain 2`] = ` " INFO  Logux server is listening at 1970-01-01 00:00:00 PID: 21384 Environment: development Logux server: 0.0.0 Min subprotocol: 0 Node ID: server:FnXaqDxY Subprotocol: 0 Prometheus: http://127.0.0.1:31338/prometheus Listen: Custom HTTP server Server was started in non-secure development mode Press Ctrl-C to shutdown server " `; exports[`reports listen for production 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"environment":"production","loguxServer":"0.0.0","minSubprotocol":0,"nodeId":"server:FnXaqDxY","subprotocol":0,"listen":"wss://127.0.0.1:31337/","healthCheck":"https://127.0.0.1:31337/health","redis":"//localhost","msg":"Logux server is listening"} " `; exports[`reports listen for production 2`] = ` " INFO  Logux server is listening at 1970-01-01 00:00:00 PID: 21384 Environment: production Logux server: 0.0.0 Min subprotocol: 0 Node ID: server:FnXaqDxY Subprotocol: 0 Health check: https://127.0.0.1:31337/health Redis: //localhost Listen: wss://127.0.0.1:31337/ " `; exports[`reports subscribed 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"actionId":"1487805099387 100:uImkcF4z 0","channel":"user/100","msg":"Client was subscribed"} " `; exports[`reports subscribed 2`] = ` " INFO  Client was subscribed at 1970-01-01 00:00:00 Action ID: 1487805099387 100:uImkcF4z 0 Channel: user/100 " `; exports[`reports unauthenticated 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"connectionId":"670","nodeId":"100:uImkcF4z","subprotocol":1,"msg":"Bad authentication"} " `; exports[`reports unauthenticated 2`] = ` " WARN  Bad authentication at 1970-01-01 00:00:00 Connection ID: 670 Node ID: 100:uImkcF4z Subprotocol: 1 " `; exports[`reports unknownType 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"actionId":"1487805099387 100:vAApgNT9 0","type":"CHANGE_SER","msg":"Action with unknown type"} " `; exports[`reports unknownType 2`] = ` " WARN  Action with unknown type at 1970-01-01 00:00:00 Action ID: 1487805099387 100:vAApgNT9 0 Type: CHANGE_SER " `; exports[`reports unknownType from server 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"actionId":"1650269021700 server:FnXaqDxY 0","type":"CHANGE_SER","msg":"Action with unknown type"} " `; exports[`reports unknownType from server 2`] = ` " WARN  Action with unknown type at 1970-01-01 00:00:00 Action ID: 1650269021700 server:FnXaqDxY 0 Type: CHANGE_SER " `; exports[`reports unsubscribed 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"actionId":"1650271940900 100:uImkcF4z 0","channel":"user/100","msg":"Client was unsubscribed"} " `; exports[`reports unsubscribed 2`] = ` " INFO  Client was unsubscribed at 1970-01-01 00:00:00 Action ID: 1650271940900 100:uImkcF4z 0 Channel: user/100 " `; exports[`reports useless actions 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"action":{"id":100,"name":"John","type":"ADD_USER"},"meta":{"id":"1487805099387 100:uImkcF4z 0","reasons":[],"server":"server:H1f8LAyzl","subprotocol":1,"time":1487805099387},"msg":"Useless action"} " `; exports[`reports useless actions 2`] = ` " WARN  Useless action at 1970-01-01 00:00:00 Action: id: 100 name: "John" type: "ADD_USER" Meta: id: 1487805099387 100:uImkcF4z 0 reasons: [] server: server:H1f8LAyzl subprotocol: 1 time: 1487805099387 " `; exports[`reports wrongChannel 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"actionId":"1650269045800 100:IsvVzqWx 0","channel":"ser/100","msg":"Wrong channel name"} " `; exports[`reports wrongChannel 2`] = ` " WARN  Wrong channel name at 1970-01-01 00:00:00 Action ID: 1650269045800 100:IsvVzqWx 0 Channel: ser/100 " `; exports[`reports wrongChannel without name 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"actionId":"1650269056600 100:uImkcF4z 0","msg":"Wrong channel name"} " `; exports[`reports wrongChannel without name 2`] = ` " WARN  Wrong channel name at 1970-01-01 00:00:00 Action ID: 1650269056600 100:uImkcF4z 0 Channel: undefined " `; exports[`reports zombie 1`] = ` "{"level":40,"time":"1970-01-01T00:00:00.000Z","pid":21384,"nodeId":"100:uImkcF4z","msg":"Zombie client was disconnected"} " `; exports[`reports zombie 2`] = ` " WARN  Zombie client was disconnected at 1970-01-01 00:00:00 Node ID: 100:uImkcF4z " `; exports[`stacktrace > reports EADDRINUSE error 1`] = ` "{"level":60,"time":"1970-01-01T00:00:00.000Z","pid":21384,"err":{},"note":"Another Logux server or other app already running on this port. Probably you haven’t stopped server from other project or previous version of this server was not killed.\\n\\n$ su - root\\n# netstat -nlp | grep 31337\\nProto Local Address State PID/Program name\\ntcp 0.0.0.0:31337 LISTEN \`777\`/node\\n# sudo kill -9 \`777\`","msg":"Port \`31337\` already in use"} FLUSH" `; exports[`stacktrace > reports EADDRINUSE error 2`] = ` " FATAL  Port 31337 already in use at 1970-01-01 00:00:00 Another Logux server or other app already running on this port. Probably you haven’t stopped server from other project or previous version of this server was not killed.  $ su - root # netstat -nlp | grep 31337 Proto Local Address State PID/Program name tcp 0.0.0.0:31337 LISTEN 777/node # sudo kill -9 777 " `; exports[`stacktrace > reports Logux error 1`] = ` "{"level":60,"time":"1970-01-01T00:00:00.000Z","pid":21384,"note":"Maybe there is a mistake in option name or this version of Logux Server doesn’t support this option","msg":"Unknown option \`suprotocol\` in server constructor"} FLUSH" `; exports[`stacktrace > reports Logux error 2`] = ` " FATAL  Unknown option suprotocol in server constructor at 1970-01-01 00:00:00 Maybe there is a mistake in option name or this version of Logux Server doesn’t support this option " `; exports[`stacktrace > reports sync error 1`] = ` "{"level":50,"time":"1970-01-01T00:00:00.000Z","pid":21384,"err":{"message":"Logux received unknown-message error (Unknown message \`bad\` type)","name":"LoguxError"},"connectionId":"670","msg":"Logux received unknown-message error (Unknown message \`bad\` type)"} " `; exports[`stacktrace > reports sync error 2`] = ` " ERROR  Logux received unknown-message error (Unknown message bad type) at 1970-01-01 00:00:00 Connection ID: 670 " `; ================================================ FILE: create-reporter/index.js ================================================ import os from 'node:os' import { sep } from 'node:path' import humanFormatter from '../human-formatter/index.js' const ERROR_CODES = { EACCES: (e, environment) => { let wayToFix = { development: 'In dev mode it can be done with sudo:\n$ sudo npm start', production: `$ su - \`\`\n$ npm start -p ${e.port}` } return { msg: `You are not allowed to run server on port \`${e.port}\``, note: "Non-privileged users can't start a listening socket on ports " + 'below 1024. Try to change user or take another port.\n\n' + (wayToFix[environment] || wayToFix.production) } }, EADDRINUSE: e => { let port = String(e.port) let wayToFix = { darwin: `$ sudo lsof -i ${e.port}\n$ sudo kill -9 \`\``, linux: '$ su - root\n' + `# netstat -nlp | grep ${e.port}\n` + 'Proto Local Address State PID/Program name\n' + `tcp 0.0.0.0:${port.padEnd(8)}LISTEN \`777\`/node\n` + '# sudo kill -9 `777`', win32: 'Run `cmd.exe` as an administrator\n' + 'C:\\> netstat -a -b -n -o\n' + 'C:\\> taskkill /F /PID ``' } return { msg: `Port \`${e.port}\` already in use`, note: 'Another Logux server or other app already running on this port. ' + 'Probably you haven’t stopped server from other project ' + 'or previous version of this server was not killed.\n\n' + (wayToFix[os.platform()] || '') } } } const REPORTERS = { add: () => ({ msg: 'Action was added' }), addClean: () => ({ msg: 'Action was added and cleaned' }), authenticated: () => ({ msg: 'User was authenticated' }), clean: () => ({ msg: 'Action was cleaned' }), clientError: record => { let result = { details: {}, level: 'warn' } if (record.err.received) { result.msg = `Client error: ${record.err.description}` } else { result.msg = `Sync error: ${record.err.description}` } for (let i in record) { if (i !== 'err') { result.details[i] = record[i] } } return result }, connect: () => ({ msg: 'Client was connected' }), denied: () => ({ level: 'warn', msg: 'Action was denied' }), destroy: () => ({ msg: 'Shutting down Logux server' }), disconnect: () => ({ msg: 'Client was disconnected' }), error: record => { let result = { details: { err: { message: record.err.message, name: record.err.name, stack: record.err.stack } }, level: record.fatal ? 'fatal' : 'error', msg: record.err.message } let helper = ERROR_CODES[record.err.code] if (helper) { let help = helper(record.err, record.environment) result.msg = help.msg result.details.note = help.note delete result.details.err.stack } else if (record.err.logux) { result.details.note = record.err.note delete result.details.err } if (record.err.name === 'LoguxError') { delete result.details.err.stack } for (let i in record) { if (i !== 'err' && i !== 'fatal') { result.details[i] = record[i] } } return result }, listen: r => { let details = { environment: r.environment, loguxServer: r.loguxServer, minSubprotocol: r.minSubprotocol, nodeId: r.nodeId, subprotocol: r.subprotocol } if (r.environment === 'development') { details.note = [ 'Server was started in non-secure development mode', 'Press Ctrl-C to shutdown server' ] } if (r.server) { details.server = r.server } else { let wsProtocol = r.cert ? 'wss://' : 'ws://' let httpProtocol = r.cert ? 'https://' : 'http://' details.listen = `${wsProtocol}${r.host}:${r.port}/` details.healthCheck = `${httpProtocol}${r.host}:${r.port}/health` } if (r.redis) { details.redis = r.redis } for (let i in r.notes) details[i] = r.notes[i] return { details, msg: 'Logux server is listening' } }, subscribed: () => ({ msg: 'Client was subscribed' }), unauthenticated: () => ({ level: 'warn', msg: 'Bad authentication' }), unknownType: record => ({ level: /^ server(:| )/.test(record.actionId) ? 'error' : 'warn', msg: 'Action with unknown type' }), unsubscribed: () => ({ msg: 'Client was unsubscribed' }), useless: () => ({ level: 'warn', msg: 'Useless action' }), wrongChannel: () => ({ level: 'warn', msg: 'Wrong channel name' }), zombie: () => ({ level: 'warn', msg: 'Zombie client was disconnected' }) } function cleanFromKeys(obj, regexp, seen) { let result = {} for (let key in obj) { let v = obj[key] if (typeof v === 'string') { result[key] = v.replace(regexp, '[SECRET]') } else if (typeof v === 'object' && !Array.isArray(v) && v !== null) { if (seen.includes(v)) { throw new Error('Circular reference in action') } seen.push(v) result[key] = cleanFromKeys(v, regexp, seen) seen.pop() } else { result[key] = v } } return result } function createRecord(level, details, msg) { /* c8 ignore next 4 */ if (typeof details === 'string') { msg = details details = {} } return { level, time: new Date().toISOString(), pid: process.pid, ...details, msg } } export function createReporter(options) { let cleanFromLog = options.cleanFromLog || /Bearer [^\s"]+/g function reporter(type, details) { let report = REPORTERS[type](details) let level = report.level || 'info' let seen = [] reporter.logger[level]( cleanFromKeys(report.details || details || {}, cleanFromLog, seen), report.msg.replace(cleanFromLog, '[SECRET]') ) } if (typeof options.logger !== 'string' && 'info' in options.logger) { reporter.logger = options.logger } else { let format if (options.logger === 'human' || options.logger.type === 'human') { let basepath = options.root || process.cwd() if (basepath.slice(-1) !== sep) basepath += sep format = humanFormatter({ basepath }) } else { format = record => JSON.stringify(record) + '\n' } let stream = options.logger?.stream ?? process.stderr reporter.logger = { /* c8 ignore next 3 */ debug(details, msg) { stream.write(format(createRecord(20, details, msg))) }, info(details, msg) { stream.write(format(createRecord(30, details, msg))) }, warn(details, msg) { stream.write(format(createRecord(40, details, msg))) }, error(details, msg) { stream.write(format(createRecord(50, details, msg))) }, fatal(details, msg) { if (stream.flushSync) { stream.flushSync(format(createRecord(60, details, msg))) } else { stream.write(format(createRecord(60, details, msg))) } } } } return reporter } ================================================ FILE: create-reporter/index.test.ts ================================================ import '../test/force-colors.js' import { LoguxError } from '@logux/core' import { describe, expect, it } from 'vitest' import { createReporter } from './index.js' class MemoryStream { flushSync: ((chunk: string) => void) | undefined string: string constructor(flushSync: boolean) { this.string = '' if (flushSync) { this.flushSync = chunk => { this.string += chunk + 'FLUSH' } } } write(chunk: string): void { this.string += chunk } } function clean(str: string): string { let cleaned = str .replace(/\r\v/g, '\n') .replace(/\d{4}-\d\d-\d\d \d\d:\d\d:\d\d/g, '1970-01-01 00:00:00') .replace(/"time":"[^"]+"/g, '"time":"1970-01-01T00:00:00.000Z"') .replace(/"hostname":"[^"]+"/g, '"hostname":"localhost"') .replace(/"pid":\d+/g, '"pid":21384') .replace(/PID:(\s+.*m)\d+(.*m)/, 'PID:$121384$2') return cleaned } function check(type: string, details?: object): void { let json = new MemoryStream(true) let jsonReporter = createReporter({ logger: { stream: json, type: 'json' } }) jsonReporter(type, details) expect(clean(json.string)).toMatchSnapshot() let human = new MemoryStream(false) let humanReporter = createReporter({ logger: { stream: human, type: 'human' } }) humanReporter(type, details) expect(clean(human.string)).toMatchSnapshot() } function createError(name: string, message: string): Error { let err = new Error(message) err.name = name err.stack = `${name}: ${message}\n` + ' at Object. (/dev/app/index.js:28:13)\n' + ' at Module._compile (module.js:573:32)\n' + ' at at runTest (/dev/app/node_modules/jest/index.js:50:10)\n' + ' at process._tickCallback (internal/process/next_tick.js:103:7)' return err } it('reports listen', () => { check('listen', { cert: false, environment: 'development', host: '127.0.0.1', loguxServer: '0.0.0', minSubprotocol: 0, nodeId: 'server:FnXaqDxY', notes: {}, port: 31337, redis: undefined, server: false, subprotocol: 0 }) }) it('reports listen for production', () => { check('listen', { cert: true, environment: 'production', host: '127.0.0.1', loguxServer: '0.0.0', minSubprotocol: 0, nodeId: 'server:FnXaqDxY', notes: {}, port: 31337, redis: '//localhost', server: false, subprotocol: 0 }) }) it('reports listen for custom domain', () => { check('listen', { environment: 'development', loguxServer: '0.0.0', minSubprotocol: 0, nodeId: 'server:FnXaqDxY', notes: { prometheus: 'http://127.0.0.1:31338/prometheus' }, server: true, subprotocol: 0 }) }) it('reports connect', () => { check('connect', { connectionId: '670', ipAddress: '10.110.6.56' }) }) it('reports authenticated', () => { check('authenticated', { connectionId: '670', nodeId: 'admin:100:uImkcF4z', subprotocol: 1 }) }) it('reports authenticated without user ID', () => { check('authenticated', { connectionId: '670', nodeId: 'uImkcF4z', subprotocol: 1 }) }) it('reports unauthenticated', () => { check('unauthenticated', { connectionId: '670', nodeId: '100:uImkcF4z', subprotocol: 1 }) }) it('reports add', () => { check('add', { action: { data: { array: [1, [2], { a: '1', b: { c: 2 }, d: [], e: null }, null], name: 'John', role: null }, id: 100, type: 'CHANGE_USER' }, meta: { id: '1487805099387 100:uImkcF4z 0', reasons: ['lastValue', 'debug'], server: 'server:H1f8LAyzl', subprotocol: 1, time: 1487805099387 } }) }) it('reports add and clean', () => { check('addClean', { action: { data: { array: [1, [2], { a: '1', b: { c: 2 }, d: [], e: null }, null], name: 'John', role: null }, id: 100, type: 'CHANGE_USER' }, meta: { id: '1487805099387 100:uImkcF4z 0', reasons: ['lastValue', 'debug'], server: 'server:H1f8LAyzl', subprotocol: 1, time: 1487805099387 } }) }) it('throws on circuital reference', () => { let a: { b: any } = { b: undefined } let b: { a: any } = { a: undefined } a.b = b b.a = a expect(() => { check('add', { action: { a, type: 'CHANGE_USER' }, meta: { id: '1487805099387 100:uImkcF4z 0', reasons: ['lastValue', 'debug'], server: 'server:H1f8LAyzl', subprotocol: 1, time: 1487805099387 } }) }).toThrow('Circular reference in action') }) it('reports clean', () => { check('clean', { actionId: '1487805099387 100:uImkcF4z 0' }) }) it('reports denied', () => { check('denied', { actionId: '1487805099387 100:uImkcF4z 0' }) }) it('reports unknownType', () => { check('unknownType', { actionId: '1487805099387 100:vAApgNT9 0', type: 'CHANGE_SER' }) }) it('reports unknownType from server', () => { check('unknownType', { actionId: '1650269021700 server:FnXaqDxY 0', type: 'CHANGE_SER' }) }) it('reports wrongChannel', () => { check('wrongChannel', { actionId: '1650269045800 100:IsvVzqWx 0', channel: 'ser/100' }) }) it('reports wrongChannel without name', () => { check('wrongChannel', { actionId: '1650269056600 100:uImkcF4z 0', channel: undefined }) }) it('reports subscribed', () => { check('subscribed', { actionId: '1487805099387 100:uImkcF4z 0', channel: 'user/100' }) }) it('reports unsubscribed', () => { check('unsubscribed', { actionId: '1650271940900 100:uImkcF4z 0', channel: 'user/100' }) }) it('reports disconnect', () => { check('disconnect', { nodeId: '100:uImkcF4z' }) }) it('reports disconnect from unauthenticated user', () => { check('disconnect', { connectionId: '670' }) }) it('reports zombie', () => { check('zombie', { nodeId: '100:uImkcF4z' }) }) it('reports destroy', () => { check('destroy') }) it('reports EACCES error', () => { check('error', { err: { code: 'EACCES', port: 80 }, fatal: true }) }) // Old Node.js color formatter is different describe.runIf( !process.version.startsWith('v20.') && !process.version.startsWith('v22.') )('stacktrace', () => { it('reports EADDRINUSE error', () => { check('error', { err: { code: 'EADDRINUSE', port: 31337 }, fatal: true }) }) it('reports Logux error', () => { let err = { logux: true, message: 'Unknown option `suprotocol` in server constructor', note: 'Maybe there is a mistake in option name or this version ' + 'of Logux Server doesn’t support this option' } check('error', { err, fatal: true }) }) it('reports sync error', () => { let err = new LoguxError('unknown-message', 'bad', true) check('error', { connectionId: '670', err }) }) }) it('reports error', () => { check('error', { err: createError('Error', 'Some mistake'), fatal: true }) }) it('reports error from action', () => { check('error', { actionId: '1487805099387 100:uImkcF4z 0', err: createError('Error', 'Some mistake') }) }) it('reports error with token', () => { check('error', { actionId: '1487805099387 100:uImkcF4z 0', err: createError('Error', '{"Authorization":"Bearer secret"}') }) }) it('reports error from client', () => { let err = new LoguxError('timeout', 5000, true) check('clientError', { connectionId: '670', err }) }) it('reports error from node', () => { let err = new LoguxError('timeout', 5000, false) check('clientError', { err, nodeId: '100:uImkcF4z' }) }) it('reports useless actions', () => { check('useless', { action: { id: 100, name: 'John', type: 'ADD_USER' }, meta: { id: '1487805099387 100:uImkcF4z 0', reasons: [], server: 'server:H1f8LAyzl', subprotocol: 1, time: 1487805099387 } }) }) it("reports actions with metadata containing 'clients' array", () => { check('add', { action: { id: 100, name: 'John', type: 'ADD_USER' }, meta: { clients: ['1:-lCr7e9s', '2:wv0r_O5C'], id: '1487805099387 100:uImkcF4z 0', reasons: [], server: 'server:H1f8LAyzl', subprotocol: 1, time: 1487805099387 } }) }) it('reports actions with excludeClients metadata', () => { check('add', { action: { id: 100, name: 'John', type: 'ADD_USER' }, meta: { excludeClients: ['1:-lCr7e9s', '2:wv0r_O5C'], id: '1487805099387 100:uImkcF4z 0', reasons: [], server: 'server:H1f8LAyzl', subprotocol: 1, time: 1487805099387 } }) }) it('allows custom loggers', () => { let text = new MemoryStream(false) let jsonReporter = createReporter({ logger: { info(details: object, msg: string) { text.write(JSON.stringify({ details, msg })) } } }) jsonReporter('connect', { connectionId: '670', ipAddress: '10.110.6.56' }) expect(clean(text.string)).toMatchSnapshot() }) ================================================ FILE: filter-meta/index.d.ts ================================================ import type { ServerMeta } from '../base-server/index.js' /** * Remove all non-allowed keys from meta. * * @param meta Meta to remove keys. * @returns Meta with removed keys. */ export function filterMeta(meta: ServerMeta): ServerMeta ================================================ FILE: filter-meta/index.js ================================================ import { ALLOWED_META } from '../allowed-meta/index.js' export function filterMeta(meta) { let result = {} for (let i of ALLOWED_META) { if (typeof meta[i] !== 'undefined') result[i] = meta[i] } return result } ================================================ FILE: filter-meta/index.test.ts ================================================ import { expect, it } from 'vitest' import { filterMeta, type ServerMeta } from '../index.js' it('filters meta', () => { let meta1: ServerMeta = { added: 0, id: '1 test 0', reasons: [], server: '', status: 'processed', time: 0 } expect(filterMeta(meta1)).toEqual({ id: '1 test 0', time: 0 }) let meta2: ServerMeta = { added: 0, id: '1 test 0', reasons: [], server: '', subprotocol: 1, time: 0 } expect(filterMeta(meta2).subprotocol).toEqual(1) }) ================================================ FILE: filtered-node/index.js ================================================ import { ServerNode } from '@logux/core' function has(array, item) { return array && array.includes(item) } export class FilteredNode extends ServerNode { constructor(client, nodeId, log, connection, options) { super(nodeId, log, connection, options) this.client = client // Remove add event listener this.unbind[0]() this.unbind.splice(0, 1) delete this.received } syncFilter(action, meta) { return ( (has(meta.clients, this.client.clientId) || has(meta.nodes, this.client.nodeId) || has(meta.users, this.client.userId)) && !has(meta.excludeClients, this.client.clientId) ) } } ================================================ FILE: filtered-node/index.test.ts ================================================ import { ClientNode, type TestLog, TestPair, TestTime } from '@logux/core' import { afterEach, expect, it } from 'vitest' import { FilteredNode } from '../filtered-node/index.js' type Test = { client: ClientNode server: FilteredNode } function createTest(): Test { let time = new TestTime() let log1 = time.nextLog() let log2 = time.nextLog() log1.on('preadd', (action, meta) => { meta.reasons.push('test') }) log2.on('preadd', (action, meta) => { meta.reasons.push('test') }) let data = { clientId: '1:a', nodeId: '1:a:b', userId: '1' } let pair = new TestPair() let client = new ClientNode('1:a:b', log1, pair.left) let server = new FilteredNode(data, 'server', log2, pair.right) return { client, server } } let test: Test afterEach(() => { test.client.destroy() test.server.destroy() }) it('does not sync actions on add', async () => { test = createTest() await test.client.connection.connect() await test.client.waitFor('synchronized') await test.server.log.add({ type: 'A' }) await test.server.waitFor('synchronized') expect(test.client.log.actions()).toEqual([]) }) it('synchronizes only node-specific actions on connection', async () => { test = createTest() await test.server.log.add({ type: 'A' }, { nodes: ['1:A:B'] }) await test.server.log.add({ type: 'B' }, { nodes: ['1:a:b'] }) await test.server.log.add({ type: 'C' }) await test.client.connection.connect() await test.server.waitFor('synchronized') expect(test.client.log.actions()).toEqual([{ type: 'B' }]) }) it('synchronizes only client-specific actions on connection', async () => { test = createTest() await test.server.log.add({ type: 'A' }, { clients: ['1:A'] }) await test.server.log.add({ type: 'B' }, { clients: ['1:a'] }) await test.server.log.add({ type: 'C' }) await test.client.connection.connect() await test.server.waitFor('synchronized') expect(test.client.log.actions()).toEqual([{ type: 'B' }]) }) it('synchronizes only user-specific actions on connection', async () => { test = createTest() await test.server.log.add({ type: 'A' }, { users: ['2'] }) await test.server.log.add({ type: 'B' }, { users: ['1'] }) await test.server.log.add({ type: 'C' }) await test.client.connection.connect() await test.server.waitFor('synchronized') expect(test.client.log.actions()).toEqual([{ type: 'B' }]) }) it('still sends only new actions', async () => { test = createTest() await test.server.log.add({ type: 'A' }, { nodes: ['1:a:b'] }) await test.server.log.add({ type: 'B' }, { nodes: ['1:a:b'] }) test.client.lastReceived = 1 await test.client.connection.connect() await test.server.waitFor('synchronized') expect(test.client.log.actions()).toEqual([{ type: 'B' }]) }) ================================================ FILE: human-formatter/index.js ================================================ import os from 'node:os' import { stripVTControlCharacters, styleText } from 'node:util' import { mulberry32, onceXmur3 } from './utils.js' const INDENT = ' ' const PADDING = ' ' const SEPARATOR = os.EOL + os.EOL const NEXT_LINE = os.EOL === '\n' ? '\r\v' : os.EOL const PARAMS_BLACKLIST = { component: true, err: true, hint: true, hostname: true, level: true, listen: true, msg: true, name: true, note: true, pid: true, server: true, time: true, v: true } const LABELS = { 20: str => label(' DEBUG ', 'white', 'bgWhite', 'black', str), 30: str => label(' INFO ', 'green', 'bgGreen', 'black', str), 40: str => label(' WARN ', 'yellow', 'bgYellow', 'black', str), 50: str => label(' ERROR ', 'red', 'bgRed', 'white', str), 60: str => label(' FATAL ', 'red', 'bgRed', 'white', str) } const COLORS = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan'] function formatNow() { let date = new Date() let year = date.getFullYear() let month = String(date.getMonth() + 1).padStart(2, '0') let day = String(date.getDate()).padStart(2, '0') let hour = String(date.getHours()).padStart(2, '0') let minutes = String(date.getMinutes()).padStart(2, '0') let seconds = String(date.getSeconds()).padStart(2, '0') return `${year}-${month}-${day} ${hour}:${minutes}:${seconds}` } function rightPag(str, length) { let add = length - stripVTControlCharacters(str).length for (let i = 0; i < add; i++) str += ' ' return str } function label(type, color, labelBg, labelText, message) { let pagged = rightPag(styleText(labelBg, styleText(labelText, type)), 8) let time = styleText('dim', `at ${formatNow()}`) let highlighted = message.replace(/`([^`]+)`/g, styleText('yellow', '$1')) return `${pagged}${styleText('bold', styleText(color, highlighted))} ${time}` } function formatName(key) { return key .replace(/[A-Z]/g, char => ` ${char.toLowerCase()}`) .split(' ') .map(word => (word === 'ip' || word === 'id' ? word.toUpperCase() : word)) .join(' ') .replace(/^\w/, char => char.toUpperCase()) } function shuffledColors(str) { let index = -1 let result = Array.from(COLORS) let lastIndex = result.length - 1 let seed = onceXmur3(str) let randomFn = mulberry32(seed) while (++index < COLORS.length) { let randIndex = index + Math.floor(randomFn() * (lastIndex - index + 1)) let value = result[randIndex] result[randIndex] = result[index] result[index] = value } return result } function splitAndColorize(partLength, str) { let strBuilder = [] let colors = shuffledColors(str) for ( let start = 0, end = partLength, n = 0, color = colors[n]; start < str.length; start += partLength, end += partLength, n = n + 1, color = colors[n % colors.length] ) { let strToColorize = str.slice(start, end) if (strToColorize.length === 1) { color = colors[Math.abs(n - 1) % colors.length] } strBuilder.push(styleText(color, strToColorize)) } return strBuilder.join('') } function formatNodeId(nodeId) { let pos = nodeId.lastIndexOf(':') if (pos === -1) { return nodeId } else { let s = nodeId.split(':') let id = styleText('bold', s[0]) let random = splitAndColorize(3, s[1]) return `${id}:${random}` } } function formatValue(value) { if (typeof value === 'string') { return '"' + styleText('bold', value) + '"' } else if (Array.isArray(value)) { return formatArray(value) } else if (typeof value === 'object' && value) { return formatObject(value) } else { return styleText('bold', `${value}`) } } function formatObject(obj) { let items = Object.keys(obj).map(k => `${k}: ${formatValue(obj[k])}`) return '{ ' + items.join(', ') + ' }' } function formatArray(array) { let items = array.map(i => formatValue(i)) return '[' + items.join(', ') + ']' } function formatActionId(id) { let p = id.split(' ') if (p.length === 1) { return p } return ( `${styleText('bold', splitAndColorize(4, p[0]))} ` + `${formatNodeId(p[1])} ${styleText('bold', p[2])}` ) } function formatParams(params, parent) { let maxName = params.reduce((max, param) => { let name = param[0] return name.length > max ? name.length : max }, 0) return params .map(param => { let name = param[0] let value = param[1] let start = PADDING + rightPag(`${name}: `, maxName + 2) if (name === 'Node ID' || (parent === 'Meta' && name === 'server')) { return start + formatNodeId(value) } else if ( parent === 'Meta' && (name === 'clients' || name === 'excludeClients') ) { return `${start}[${value.map(v => `"${formatNodeId(v)}"`).join()}]` } else if (name === 'Action ID' || (parent === 'Meta' && name === 'id')) { return start + formatActionId(value) } else if (Array.isArray(value)) { return start + formatArray(value) } else if (typeof value === 'object' && value) { let nested = Object.keys(value).map(key => [key, value[key]]) return ( start + NEXT_LINE + INDENT + formatParams(nested, name) .split(NEXT_LINE) .join(NEXT_LINE + INDENT) ) } else if (typeof value === 'string' && parent) { return start + '"' + styleText('bold', value) + '"' } else { return start + styleText('bold', `${value}`) } }) .join(NEXT_LINE) } function splitByLength(string, max) { let words = string.split(' ') let lines = [''] for (let word of words) { let last = lines[lines.length - 1] if (last.length + word.length > max) { lines.push(`${word} `) } else { lines[lines.length - 1] = `${last}${word} ` } } return lines.map(i => i.trim()) } function prettyStackTrace(stack, basepath) { return stack .split('\n') .slice(1) .map(line => { let match = line.match(/\s+at ([^(]+) \(([^)]+)\)/) let isSystem = !match || !match[2].startsWith(basepath) if (isSystem) { return styleText('gray', line.replace(/^\s*/, PADDING)) } else { let func = match[1] let relative = match[2].slice(basepath.length) let converted = `${PADDING}at ${func} (./${relative})` let isDependency = match[2].includes('node_modules') return isDependency ? styleText('gray', converted) : styleText('red', converted) } }) .join(NEXT_LINE) } export default function humanFormatter(options) { let basepath = options.basepath return function format(record) { let message = [LABELS[record.level](record.msg)] let params = Object.keys(record) .filter(key => !PARAMS_BLACKLIST[key]) .map(key => [formatName(key), record[key]]) if (record.loguxServer) { params.unshift(['PID', record.pid]) if (record.server) { params.push(['Listen', 'Custom HTTP server']) } else { params.push(['Listen', record.listen]) } } if (record.err && record.err.stack) { message.push(prettyStackTrace(record.err.stack, basepath)) } message.push(formatParams(params)) if (record.note) { let note = record.note if (typeof note === 'string') { note = note.replace(/`([^`]+)`/g, styleText('bold', '$1')) note = [].concat( ...note .split('\n') .map(row => splitByLength(row, 80 - PADDING.length)) ) } message.push( note.map(i => PADDING + styleText('gray', i)).join(NEXT_LINE) ) } return message.filter(i => i !== '').join(NEXT_LINE) + SEPARATOR } } ================================================ FILE: human-formatter/utils.js ================================================ export function onceXmur3(str) { let h = 1779033703 ^ str.length for (let i = 0; i < str.length; i++) { h = Math.imul(h ^ str.charCodeAt(i), 3432918353) h = (h << 13) | (h >>> 19) } h = Math.imul(h ^ (h >>> 16), 2246822507) h = Math.imul(h ^ (h >>> 13), 3266489909) return (h ^ (h >>> 16)) >>> 0 } export function mulberry32(a) { return function () { a |= 0 a = (a + 0x6d2b79f5) | 0 let t = Math.imul(a ^ (a >>> 15), 1 | a) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } ================================================ FILE: index.d.ts ================================================ export { addSyncMap, addSyncMapFilter, ChangedAt, NoConflictResolution, SyncMapData, WithoutTime, WithTime } from './add-sync-map/index.js' export { ALLOWED_META } from './allowed-meta/index.js' export { BaseServer, BaseServerOptions, Logger, SendBackActions, ServerMeta, wasNot403 } from './base-server/index.js' export { ChannelContext, Context } from './context/index.js' export { filterMeta } from './filter-meta/index.js' export { del, get, patch, post, put, request, ResponseError } from './request/index.js' export { ServerClient } from './server-client/index.js' export { Server, ServerOptions } from './server/index.js' export { LoguxActionError, TestClient } from './test-client/index.js' export { TestServer, TestServerOptions } from './test-server/index.js' export { Action } from '@logux/core' ================================================ FILE: index.js ================================================ export { addSyncMap, addSyncMapFilter, ChangedAt, NoConflictResolution } from './add-sync-map/index.js' export { ALLOWED_META } from './allowed-meta/index.js' export { BaseServer, wasNot403 } from './base-server/index.js' export { Context } from './context/index.js' export { filterMeta } from './filter-meta/index.js' export { ResponseError } from './request/index.js' export { Server } from './server/index.js' export { TestClient } from './test-client/index.js' export { TestServer } from './test-server/index.js' ================================================ FILE: options-loader/__snapshots__/index.test.js.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`loadOptions > returns help 1`] = ` "Start Logux Server Options: --port  port Examples: test.js" `; exports[`loadOptions > throws on missing values 1`] = ` [Error: Failed to parse port argument value. Expected string, got true] `; exports[`loadOptions > throws on unknown args 1`] = `[Error: Unknown argument: --unknown]`; exports[`loadOptions > throws on unparsed args 1`] = ` [Error: Failed to parse port argument value. Expected number, got W_W] `; exports[`parsers > number > should return error on invalid values 1`] = `"Expected number, got not a number"`; exports[`parsers > oneOf > should return error on invalid values 1`] = `"Expected one of ["1","2"], got 3"`; ================================================ FILE: options-loader/index.js ================================================ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { styleText } from 'node:util' export function loadOptions(spec, process, env) { let rawCliArgs = gatherCliArgs(process.argv) if (rawCliArgs['--help']) { return [composeHelp(spec, process.argv), null] } let namesMap = {} for (let key in spec.options) { let option = spec.options[key] namesMap[composeCliFullName(key)] = key namesMap[composeEnvName(spec.envPrefix, key)] = key if (option.alias) { namesMap[composeCliAliasName(option.alias)] = key } } let cliArgs = parseValues(spec, mapArgs(rawCliArgs, namesMap)) let dotenvArgs = loadEnv(env) if (dotenvArgs) { dotenvArgs = Object.fromEntries( Object.entries(dotenvArgs).filter(([key]) => key.startsWith(spec.envPrefix) ) ) } let envArgs = Object.fromEntries( Object.entries(process.env).filter(([key]) => key.startsWith(spec.envPrefix) ) ) envArgs = parseValues(spec, mapArgs({ ...envArgs, ...dotenvArgs }, namesMap)) return [null, { ...envArgs, ...cliArgs }] } function gatherCliArgs(argv) { let args = {} let key = null let value = [] for (let it of argv) { if (it.startsWith('-')) { if (key) { args[key] = value value = [] } key = it } else if (key) { value = [...value, it] } } if (key) { args[key] = value } for (let k in args) { if (args[k].length === 0) { args[k] = true } else if (args[k].length === 1) { args[k] = args[k][0] } } return args } function parseValues(spec, args) { let parsed = {} for (let key of Object.keys(args)) { let parse = spec.options[key].parse || string let parsingResult = parse(args[key]) if (parsingResult[0] === null) { parsed[key] = parsingResult[1] } else { throw Error( `Failed to parse ${styleText('bold', key)} argument value. \n` + parsingResult[0] ) } } return parsed } function loadEnv(file) { if (!file || !existsSync(file)) { file = join(process.cwd(), '.env') if (!existsSync(file)) return undefined } let result = {} let lines try { lines = readFileSync(file, 'utf8').split('\n') } catch { /* c8 ignore next 2 */ return undefined } for (let line of lines) { if (line.trim().startsWith('#') || !line.includes('=')) { continue } let [key, ...valueParts] = line.split('=') result[key] = valueParts .join('=') .trim() .replace(/(^"|"$|^'|'$)/g, '') } return result } function mapArgs(parsedCliArgs, argsSpec) { return Object.fromEntries( Object.entries(parsedCliArgs).map(([name, value]) => { if (!argsSpec[name]) { throw new Error(`Unknown argument: ${name}`) } return [argsSpec[name], value] }) ) } function composeHelp(spec, argv) { let options = Object.entries(spec.options).map(([name, option]) => ({ alias: option.alias ? composeCliAliasName(option.alias) : '', description: option.description, env: spec.envPrefix ? composeEnvName(spec.envPrefix, name) : '', full: composeCliFullName(name) })) let nameColumnLength = Math.max(...options.map(it => it.full.length)) let envColumnLength = Math.max(...options.map(it => it.env.length)) let composeName = name => styleText('yellow', name.padEnd(nameColumnLength + 2)) let composeEnv = env => styleText('cyan', env.padEnd(envColumnLength + 2)) let composeOptionHelp = option => { return ( composeName(option.full) + composeEnv(option.env) + option.description ) } let examples = [] if (spec.examples) { let pathParts = argv[1].split('/') let lastPart = pathParts[pathParts.length - 1] examples = [ '', styleText('bold', 'Examples:'), ...spec.examples.map(i => i.replace('$0', lastPart)) ] } return [ 'Start Logux Server', '', styleText('bold', 'Options:'), ...options.map(option => composeOptionHelp(option)), ...examples ].join('\n') } function composeEnvName(prefix, name) { return `${prefix}_${name.replace( /[A-Z]/g, match => '_' + match )}`.toUpperCase() } function composeCliFullName(name) { return `--${toKebabCase(name)}` } function composeCliAliasName(name) { return `-${name}` } function toKebabCase(word) { return word.replace(/[A-Z]/, match => `-${match.toLowerCase()}`) } export function oneOf(options, rawValue) { if (!options.includes(rawValue)) { let opt = JSON.stringify(options) return [ `Expected ${styleText('green', 'one of ' + opt)}, ` + `got ${styleText('red', `${rawValue}`)}`, null ] } else { return [null, rawValue] } } export function number(rawValue) { let parsed = Number.parseInt(rawValue, 10) if (Number.isNaN(parsed)) { return [ `Expected ${styleText('green', 'number')}, ` + `got ${styleText('red', `${rawValue}`)}`, null ] } else { return [null, parsed] } } export function string(rawValue) { if (typeof rawValue !== 'string') { return [ `Expected ${styleText('green', 'string')}, ` + `got ${styleText('red', `${rawValue}`)}`, null ] } else { return [null, rawValue] } } ================================================ FILE: options-loader/index.test.js ================================================ import '../test/force-colors.js' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { loadOptions, number, oneOf } from './index.js' function fakeProcess(argv, env = {}) { return { argv, env } } describe('loadOptions', () => { it('returns help', () => { let [help, options] = loadOptions( { examples: ['$0'], options: { port: { description: 'port', parse: number } } }, fakeProcess(['node', 'test/test.js', '--help']) ) expect(help).toMatchSnapshot() expect(options).toBeNull() }) it('uses CLI args for options', () => { let [, options] = loadOptions( { options: { port: { description: 'port', parse: number } } }, fakeProcess(['', '--port', '1337']) ) expect(options.port).toEqual(1337) }) it('uses env for options', () => { let [, options] = loadOptions( { envPrefix: 'LOGUX', options: { port: { description: 'port', parse: number } } }, fakeProcess([], { LOGUX_PORT: '31337' }), {} ) expect(options.port).toEqual(31337) }) it('uses dotenv file for options', () => { let [, options] = loadOptions( { envPrefix: 'LOGUX', options: { port: { description: 'port', parse: number } } }, fakeProcess([], {}), resolve(process.cwd(), 'options-loader/test.env') ) expect(options.port).toEqual(31337) }) it('composes correct env and CLI names for argument with complex name', () => { let [, options] = loadOptions( { envPrefix: 'LOGUX', options: { somePort: { description: 'port', parse: number } } }, fakeProcess(['--some-port', '1'], { LOGUX_SOME_PORT: '1' }), {} ) expect(options.somePort).toEqual(1) }) it('uses combined options', () => { let [, options] = loadOptions( { envPrefix: 'LOGUX', options: { cert: { description: 'cert' }, key: { description: 'port' } } }, fakeProcess(['', '--key', './key.pem'], { LOGUX_CERT: './cert.pem' }) ) expect(options.cert).toEqual('./cert.pem') expect(options.key).toEqual('./key.pem') }) it('uses arg and env in given priority', () => { let optionsSpec = { envPrefix: 'LOGUX', options: { cert: { description: 'cert' }, key: { description: 'key' }, port: { description: 'port', parse: number } } } let [, options1] = loadOptions( optionsSpec, fakeProcess(['', '--port', '3'], { LOGUX_PORT: '2' }), undefined ) let [, options2] = loadOptions( optionsSpec, fakeProcess([], { LOGUX_PORT: '2' }), undefined ) expect(options1.port).toEqual(3) expect(options2.port).toEqual(2) }) it('parses aliases', () => { let [, options] = loadOptions( { options: { port: { alias: 'p', description: 'port' } } }, fakeProcess(['', '-p', '1']) ) expect(options.port).toEqual('1') }) it('parses multiple args', () => { let [, options] = loadOptions( { options: { key: { description: 'key' }, port: { alias: 'p', description: 'port' } } }, fakeProcess(['', '-p', '1', '--key', '1']) ) expect(options.port).toEqual('1') expect(options.key).toEqual('1') }) it('throws on missing values', () => { expect(() => loadOptions( { options: { port: { description: 'port' } } }, fakeProcess(['', '--port']) ) ).toThrowErrorMatchingSnapshot() }) it('throws on unknown args', () => { expect(() => loadOptions( { options: { port: { description: 'port' } } }, fakeProcess(['', '--unknown', '1']) ) ).toThrowErrorMatchingSnapshot() }) it('throws on unparsed args', () => { expect(() => loadOptions( { options: { port: { description: 'port', parse: number } } }, fakeProcess(['', '--port', 'W_W']) ) ).toThrowErrorMatchingSnapshot() }) }) describe('parsers', () => { describe('oneOf', () => { it('should return error on invalid values', () => { let result = oneOf(['1', '2'], '3') expect(result[0]).toMatchSnapshot() }) it('should return null on correct values', () => { expect(oneOf(['1', '2'], '1')).toEqual([null, '1']) }) }) describe('number', () => { it('should return error on invalid values', () => { let result = number('not a number') expect(result[0]).toMatchSnapshot() }) it('should return null on correct values', () => { expect(number('1')).toEqual([null, 1]) expect(number('1why parseInt is so permissive')).toEqual([null, 1]) }) }) }) ================================================ FILE: options-loader/test.env ================================================ # Comment DATABASE_URL=postgresql://localhost/logux LOGUX_PORT=31337 ================================================ FILE: oxfmt.config.ts ================================================ import loguxOxfmtConfig from '@logux/oxc-configs/fmt' export default loguxOxfmtConfig ================================================ FILE: oxlint.config.ts ================================================ import loguxOxlintConfig from '@logux/oxc-configs/lint' import { defineConfig } from 'oxlint' export default defineConfig({ extends: [loguxOxlintConfig], ignorePatterns: ['**/errors.ts'], rules: { 'typescript/no-unnecessary-type-parameters': 'off', 'typescript/no-unnecessary-type-arguments': 'off', 'unicorn/prefer-add-event-listener': 'off', 'node/handle-callback-err': 'off', 'import/no-named-as-default': 'off' }, overrides: [ { files: ['test/**/*', '*/*.test.ts'], rules: { 'typescript/no-unsafe-function-type': 'off', 'typescript/require-await': 'off', 'no-console': 'off' } } ] }) ================================================ FILE: package.json ================================================ { "name": "@logux/server", "version": "0.14.0", "description": "Build own Logux server", "keywords": [ "collaborative", "crdt", "distributed systems", "event sourcing", "framework", "logux", "proxy", "server", "websocket" ], "homepage": "https://logux.org/", "license": "MIT", "author": "Andrey Sitnik ", "repository": "logux/server", "type": "module", "types": "./index.d.ts", "exports": { ".": "./index.js", "./package.json": "./package.json" }, "scripts": { "test:lint": "oxlint", "test:types": "check-dts", "test:coverage": "vitest run --coverage", "test": "pnpm run /^test:/" }, "dependencies": { "@logux/actions": "^0.5.0", "@logux/core": "^0.10.0", "cookie": "^1.1.1", "fastq": "^1.20.1", "nanoevents": "^9.1.0", "nanoid": "^5.1.9", "tinyglobby": "^0.2.16", "url-pattern": "^1.0.3", "ws": "^8.20.0" }, "devDependencies": { "@logux/oxc-configs": "^0.3.4", "@types/cross-spawn": "^6.0.6", "@types/node": "^25.6.0", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.4", "actions-up": "^1.14.0", "check-dts": "^1.0.0", "clean-publish": "^6.0.5", "cross-spawn": "^7.0.6", "eslint-plugin-prefer-let": "^4.2.2", "nanospy": "^1.0.0", "oxlint": "^1.60.0", "oxlint-tsgolint": "^0.21.1", "print-snapshots": "^0.4.2", "typescript": "^6.0.2", "vite": "^8.0.8", "vitest": "^4.1.4" }, "engines": { "node": "^22.0.0 || >=24.0.0" } } ================================================ FILE: request/index.d.ts ================================================ /** * Throwing this error in `accessAndProcess` or `accessAndLoad` * will deny the action. */ export class ResponseError extends Error { name: 'ResponseError' statusCode: number constructor(statusCode: number, url: string) } ================================================ FILE: request/index.js ================================================ export class ResponseError extends Error { constructor(statusCode, url) { super(`${statusCode} response on ${url}`) this.name = 'ResponseError' this.statusCode = statusCode } } ================================================ FILE: server/__snapshots__/index.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`destroys everything on exit 1`] = ` " INFO Logux server is listening at 1970-01-01 00:00:00 PID: 21384 Environment: test Logux server: 0.0.0 Min subprotocol: 1 Node ID: server:FnXaqDxY Subprotocol: 1 Health check: http://127.0.0.1:2000/health Listen: ws://127.0.0.1:2000/ INFO Shutting down Logux server at 1970-01-01 00:00:00 INFO Custom destroy task finished at 1970-01-01 00:00:00 " `; exports[`disables colors for constructor errors 1`] = ` " FATAL Missed minSubprotocol option in server constructor at 1970-01-01 00:00:00 Check server constructor and Logux Server documentation " `; exports[`has custom logger 1`] = ` " INFO Hi from custom logger at 1970-01-01 00:00:00 Field: 1 DEBUG Debug message at 1970-01-01 00:00:00 INFO Logux server is listening at 1970-01-01 00:00:00 PID: 21384 Environment: test Logux server: 0.0.0 Min subprotocol: 1 Node ID: server:FnXaqDxY Subprotocol: 1 Health check: http://127.0.0.1:31337/health Listen: ws://127.0.0.1:31337/ INFO Shutting down Logux server at 1970-01-01 00:00:00 " `; exports[`shows help 1`] = ` "Start Logux Server Options: --cert LOGUX_CERT Path to SSL certificate --host LOGUX_HOST Host to bind server --key LOGUX_KEY Path to SSL key --logger LOGUX_LOGGER Logger type --min-subprotocol LOGUX_MIN_SUBPROTOCOL Check supported client subprotocols --port LOGUX_PORT Port to bind server --redis LOGUX_REDIS Redis URL for Logux Server Pro scaling --subprotocol LOGUX_SUBPROTOCOL Server subprotocol Examples: options.js --port 31337 --host 127.0.0.1 LOGUX_PORT=1337 LOGUX_HOST=127.0.0.1 options.js " `; exports[`shows help about missed option 1`] = ` " FATAL Missed minSubprotocol option in server constructor at 1970-01-01 00:00:00 Check server constructor and Logux Server documentation " `; exports[`shows help about port in use 1`] = ` " FATAL Port 2001 already in use at 1970-01-01 00:00:00 Another Logux server or other app already running on this port. Probably you haven’t stopped server from other project or previous version of this server was not killed. $ su - root # netstat -nlp | grep 2001 Proto Local Address State PID/Program name tcp 0.0.0.0:2001 LISTEN 777/node # sudo kill -9 777 INFO Shutting down Logux server at 1970-01-01 00:00:00 " `; exports[`shows help about privileged port 1`] = ` " FATAL You are not allowed to run server on port 1000 at 1970-01-01 00:00:00 Non-privileged users can't start a listening socket on ports below 1024. Try to change user or take another port. $ su - $ npm start -p 1000 INFO Shutting down Logux server at 1970-01-01 00:00:00 " `; exports[`shows help about unknown option 1`] = ` " FATAL Missed minSubprotocol option in server constructor at 1970-01-01 00:00:00 Check server constructor and Logux Server documentation " `; exports[`shows uncatch errors 1`] = ` " FATAL Test Error at 1970-01-01 00:00:00 fake stacktrace INFO Shutting down Logux server at 1970-01-01 00:00:00 INFO Fatal event: Test Error at 1970-01-01 00:00:00 " `; exports[`shows uncatch rejects 1`] = ` " FATAL Test Error at 1970-01-01 00:00:00 fake stacktrace INFO Shutting down Logux server at 1970-01-01 00:00:00 INFO Fatal event: Test Error at 1970-01-01 00:00:00 " `; exports[`uses .env cwd 1`] = ` " INFO Logux server is listening at 1970-01-01 00:00:00 PID: 21384 Environment: test Logux server: 0.0.0 Min subprotocol: 1 Node ID: server:FnXaqDxY Subprotocol: 1 Health check: http://127.0.0.1:3334/health Listen: ws://127.0.0.1:3334/ INFO Shutting down Logux server at 1970-01-01 00:00:00 " `; exports[`uses .env from root 1`] = ` " INFO Logux server is listening at 1970-01-01 00:00:00 PID: 21384 Environment: test Logux server: 0.0.0 Min subprotocol: 1 Node ID: server:FnXaqDxY Subprotocol: 1 Health check: http://127.0.0.1:3334/health Listen: ws://127.0.0.1:3334/ INFO Shutting down Logux server at 1970-01-01 00:00:00 " `; exports[`uses autoload modules 1`] = ` "Root path module: 1 Child path module: 1 INFO Logux server is listening at 1970-01-01 00:00:00 PID: 21384 Environment: test Logux server: 0.0.0 Min subprotocol: 1 Node ID: server:FnXaqDxY Subprotocol: 1 Health check: http://127.0.0.1:31337/health Listen: ws://127.0.0.1:31337/ INFO Shutting down Logux server at 1970-01-01 00:00:00 " `; exports[`uses autoload wrong export 1`] = ` " INFO Logux server is listening at 1970-01-01 00:00:00 PID: 21384 Environment: test Logux server: 0.0.0 Min subprotocol: 1 Node ID: server:FnXaqDxY Subprotocol: 1 Health check: http://127.0.0.1:31337/health Listen: ws://127.0.0.1:31337/ FATAL Server module should has default export with function that accepts a server at 1970-01-01 00:00:00 error-modules/wrond-export/index.js default export is string INFO Shutting down Logux server at 1970-01-01 00:00:00 " `; exports[`uses environment variables for config 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"environment":"test","loguxServer":"0.0.0","minSubprotocol":1,"nodeId":"server:FnXaqDxY","subprotocol":1,"listen":"ws://127.0.0.1:31337/","healthCheck":"http://127.0.0.1:31337/health","msg":"Logux server is listening"} {"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"msg":"Shutting down Logux server"} " `; exports[`uses logger param 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"environment":"test","loguxServer":"0.0.0","minSubprotocol":1,"nodeId":"server:FnXaqDxY","subprotocol":1,"listen":"ws://127.0.0.1:31337/","healthCheck":"http://127.0.0.1:31337/health","msg":"Logux server is listening"} {"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"msg":"Shutting down Logux server"} " `; exports[`uses logger param for constructor errors 1`] = ` "{"level":60,"time":"1970-01-01T00:00:00.000Z","pid":21384,"note":"Check server constructor and Logux Server documentation","msg":"Missed \`minSubprotocol\` option in server constructor"} " `; exports[`writes JSON log 1`] = ` "{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"environment":"test","loguxServer":"0.0.0","minSubprotocol":1,"nodeId":"server:FnXaqDxY","subprotocol":1,"listen":"ws://127.0.0.1:2000/","healthCheck":"http://127.0.0.1:2000/health","msg":"Logux server is listening"} {"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"msg":"Shutting down Logux server"} " `; exports[`writes about unbind 1`] = ` " INFO Shutting down Logux server at 1970-01-01 00:00:00 " `; ================================================ FILE: server/errors.ts ================================================ import { LoguxSubscribeAction, defineAction } from '@logux/actions' import { Server, Action } from '../index.js' let server = new Server<{ locale: string }>( Server.loadOptions(process, { minSubprotocol: 1, subprotocol: 1, root: '' }) ) server.auth(({ userId, token, headers }) => { // THROWS Property 'lang' does not exist on type '{ locale: string; }' console.log(headers.lang) return token === userId }) class User { id: string name: string constructor(id: string) { this.id = id this.name = 'name' } async save(): Promise {} } type UserRenameAction = Action & { type: 'user/rename' userId: string name: string } type UserSubscribeAction = LoguxSubscribeAction & { fields: ('name' | 'email')[] } type UserData = { user: User } type UserParams = { id: string } type BadParams = number server.type('user/rename', { access(ctx, action) { ctx.data.user = new User(action.userId) return true }, // THROWS is not assignable to type 'Resender resend(_, action) { return { subscriptions: `user/${action.userId}` } }, async process(ctx, action) { // THROWS Property 'lang' does not exist on type '{ locale: string; }' console.log(ctx.headers.lang) // THROWS 'newName' does not exist on type 'Readonly' ctx.data.user.name = action.newName // THROWS Property 'admin' does not exist on type 'UserData'. await ctx.data.admin.save() } }) // THROWS No overload matches this call. server.type('user/changeId', { async process(_, action) { let user = new User(action.userId) user.id = action.newId await user.save() } }) // THROWS "bad"' is not assignable to parameter of type 'RegExp | "user/rename"' server.type('bad', { access() { return true } }) server.channel('user/:id', { access() { return true }, // THROWS ...>' is not assignable to type 'FilterCreator async filter(_, action) { if (action.fields) { return (_: any, otherAction: Action) => { return ( action.fields.includes('name') && otherAction.type === 'user/rename' ) } } else { return undefined } }, async load(ctx) { // THROWS is not assignable to parameter of type 'AnyAction' await ctx.sendBack({ userId: ctx.data.user.id, name: ctx.data.user.name }) } }) server.channel(/admin:\d/, { access(ctx, action, meta) { console.log(meta.id, action.since) // THROWS Property 'id' does not exist on type 'string[]'. return ctx.params.id === ctx.userId } }) // THROWS Type 'number' does not satisfy the constraint 'string[]'. server.channel('posts', { access() { return true } }) let addUser = defineAction<{ type: 'user/remove'; userId: string }>( 'user/remove' ) server.type(addUser, { access(ctx, action) { // THROWS Property 'id' does not exist on type 'Readonly<{ type: "user/remove"; return action.id === ctx.userId } }) ================================================ FILE: server/index.d.ts ================================================ import { BaseServer, type BaseServerOptions, type Logger } from '../base-server/index.js' export interface LogStream { /** * Used to synchronously write log messages on application failure. */ flushSync?(): void write(str: string): void } export interface LoggerOptions { /** * Use color for human output. */ color?: boolean /** * Stream to be used by logger to write log. */ stream?: LogStream /** * Logger message format. */ type?: 'human' | 'json' } export interface ServerOptions extends BaseServerOptions { /** * Logger with custom settings. * * You can either configure built-in logger or provide your own. */ logger?: Logger | LoggerOptions } /** * End-user API to create Logux server. * * ```js * import { Server } from '@logux/server' * * const env = process.env.NODE_ENV || 'development' * const envOptions = {} * if (env === 'production') { * envOptions.cert = 'cert.pem' * envOptions.key = 'key.pem' * } * * const server = new Server(Object.assign({ * subprotocol: 1, * minSubprotocol: 1, * root: import.meta.dirname * }, envOptions)) * * server.listen() * ``` */ export class Server< Headers extends object = unknown > extends BaseServer { /** * Server options. * * ```js * console.log('Server options', server.options.subprotocol) * ``` */ options: ServerOptions /** * @param opts Server options. */ constructor(opts: ServerOptions) /** * Load options from command-line arguments and/or environment. * * ```js * const server = new Server(Server.loadOptions(process, { * minSubprotocol: 1, * subprotocol: 1, * root: import.meta.dirname, * port: 31337 * })) * ``` * * @param process Current process object. * @param defaults Default server options. Arguments and environment * variables will override them. * @returns Parsed options object. */ static loadOptions( process: NodeJS.Process, defaults: ServerOptions ): ServerOptions /** * Load module creators and apply to the server. By default, it will load * files from `modules/*`. * * ```js * await server.autoloadModules() * ``` * * @param files Pattern for module files. */ autoloadModules(files?: string | string[]): Promise } ================================================ FILE: server/index.js ================================================ import { join, relative } from 'node:path' import { styleText } from 'node:util' import { glob } from 'tinyglobby' import { BaseServer } from '../base-server/index.js' import { createReporter } from '../create-reporter/index.js' import { loadOptions, number, oneOf } from '../options-loader/index.js' let cliOptionsSpec = { envPrefix: 'LOGUX', examples: [ '$0 --port 31337 --host 127.0.0.1', 'LOGUX_PORT=1337 LOGUX_HOST=127.0.0.1 $0' ], options: { cert: { description: 'Path to SSL certificate' }, host: { alias: 'h', description: 'Host to bind server' }, key: { description: 'Path to SSL key' }, logger: { alias: 'l', description: 'Logger type', parse: value => oneOf(['human', 'json'], value) }, minSubprotocol: { description: 'Check supported client subprotocols' }, port: { alias: 'p', description: 'Port to bind server', parse: number }, redis: { description: 'Redis URL for Logux Server Pro scaling' }, subprotocol: { description: 'Server subprotocol' } } } export class Server extends BaseServer { constructor(opts = {}) { if (!opts.logger) { opts.logger = 'human' } let reporter = createReporter(opts) let initialized = false let onError = err => { if (initialized) { this.emitter.emit('fatal', err) } else { reporter('error', { err, fatal: true }) process.exit(1) } } process.on('uncaughtException', onError) process.on('unhandledRejection', onError) super(opts) this.logger = reporter.logger this.on('report', reporter) this.on('fatal', async () => { if (initialized) { if (!this.destroying) { await this.destroy() process.exit(1) } } else { process.exit(1) } }) initialized = true let onExit = async () => { await this.destroy() process.exit(0) } process.on('SIGINT', onExit) this.unbind.push(() => { process.removeListener('SIGINT', onExit) }) } static loadOptions(process, defaults) { let [help, options] = loadOptions( cliOptionsSpec, process, defaults.root ? join(defaults.root, '.env') : undefined ) if (help) { process.stdout.write(help + '\n') return process.exit(0) } try { return Object.assign(defaults, options) } catch (e) { process.stderr.write( styleText( 'red', styleText('bgRed', styleText('black', ' FATAL ')) + `${e.message}\n` ) ) return process.exit(1) } } async autoloadModules( files = ['modules/*/index.js', 'modules/*.js', '!**/*.{test,spec}.js'] ) { if (!Array.isArray(files)) files = [files] let matches = await glob(files, { absolute: true, cwd: this.options.root, onlyFiles: true }) await Promise.all( matches.map(async file => { let serverModule = (await import(file)).default if (typeof serverModule === 'function') { await serverModule(this) } else { let name = relative(this.options.root, file) let error = new Error( 'Server module should has default export with function ' + 'that accepts a server' ) error.logux = true error.note = `${name} default export is ${typeof serverModule}` throw error } }) ) } async listen(...args) { try { return BaseServer.prototype.listen.apply(this, args) } catch (err) { this.emitter.emit('report', 'error', { err }) return process.exit(1) } } } ================================================ FILE: server/index.test.ts ================================================ import spawn from 'cross-spawn' import type { ChildProcess, SpawnOptions } from 'node:child_process' import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' import { Server } from '../index.js' const ROOT = join(import.meta.dirname, '..') const DATE = /\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/g let started: ChildProcess | undefined function start(name: string, args?: string[]): Promise { return new Promise(resolve => { started = spawn(join(ROOT, 'test/servers/', name), args) let running = false function callback(): void { if (!running) { running = true resolve() } } started.stdout?.on('data', callback) started.stderr?.on('data', callback) }) } function check( name: string, args?: string[], opts?: SpawnOptions, kill = false ): Promise<[string, number]> { return new Promise<[string, number]>(resolve => { let out = '' let server = spawn(join(ROOT, 'test/servers/', name), args, opts) server.stdout?.on('data', chank => { out += chank }) server.stderr?.on('data', chank => { out += chank }) server.on('close', exit => { let fixed = out .replace(/[^\n]+DeprecationWarning[^\n]+\n/gm, '') .replace(DATE, '1970-01-01 00:00:00') .replace(/"time":"[^"]+"/g, '"time":"1970-01-01T00:00:00.000Z"') .replace(/PID:(\s+)\d+/, 'PID:$121384') .replace(/"pid":\d+,/g, '"pid":21384,') .replace(/Logux server:( +)\d+.\d+.\d+/g, 'Logux server:$10.0.0') .replace(/"loguxServer":"\d+.\d+.\d+"/g, '"loguxServer":"0.0.0"') .replace(/"hostname":"[^"]+"/g, '"hostname":"localhost"') fixed = fixed.replace(/\r\v/g, '\n') resolve([fixed, exit || 0]) }) function waitOut(): void { if (out.length > 0) { server.kill('SIGINT') } else { setTimeout(waitOut, 500) } } if (kill) setTimeout(waitOut, 500) }) } function fakeProcess(argv: string[] = [], env: object = {}): any { return { argv, env } } async function checkOut( name: string, args?: string[], opts?: SpawnOptions ): Promise { let result = await check(name, args, opts, true) let out = result[0] let exit = result[1] expect(out).toMatchSnapshot() if (exit !== 0) { throw new Error(`Fall with:\n${out}`) } } async function checkError( name: string, args?: string[], opts?: SpawnOptions ): Promise { let result = await check(name, args, opts) let out = result[0] let exit = result[1] expect(out).toMatchSnapshot() expect(exit).toEqual(1) } afterEach(() => { if (started) { started.kill('SIGINT') started = undefined } }) it('uses CLI args for options', () => { let options = Server.loadOptions( fakeProcess([ '', '--port', '1337', '--host', '192.168.1.1', '--logger', 'json', '--redis', '//localhost' ]), { minSubprotocol: 1, subprotocol: 1 } ) expect(options.host).toEqual('192.168.1.1') expect(options.port).toEqual(1337) expect(options.logger).toEqual('json') expect(options.cert).toBeUndefined() expect(options.key).toBeUndefined() expect(options.redis).toEqual('//localhost') }) it('uses env for options', () => { let options = Server.loadOptions( fakeProcess([], { LOGUX_HOST: '127.0.1.1', LOGUX_LOGGER: 'json', LOGUX_PORT: '31337', LOGUX_REDIS: '//localhost' }), { minSubprotocol: 1, subprotocol: 1 } ) expect(options.host).toEqual('127.0.1.1') expect(options.port).toEqual(31337) expect(options.logger).toEqual('json') expect(options.redis).toEqual('//localhost') }) it('uses combined options', () => { let options = Server.loadOptions( fakeProcess(['', '--key', './key.pem'], { LOGUX_CERT: './cert.pem' }), { minSubprotocol: 1, port: 31337, subprotocol: 1 } ) expect(options.port).toEqual(31337) expect(options.cert).toEqual('./cert.pem') expect(options.key).toEqual('./key.pem') }) it('uses arg, env, options in given priority', () => { let options1 = Server.loadOptions( fakeProcess(['', '--port', '31337'], { LOGUX_PORT: 21337 }), { minSubprotocol: 1, port: 11337, subprotocol: 1 } ) let options2 = Server.loadOptions(fakeProcess([], { LOGUX_PORT: 21337 }), { minSubprotocol: 1, port: 11337, subprotocol: 1 }) let options3 = Server.loadOptions(fakeProcess(), { minSubprotocol: 1, port: 11337, subprotocol: 1 }) expect(options1.port).toEqual(31337) expect(options2.port).toEqual(21337) expect(options3.port).toEqual(11337) }) it('destroys everything on exit', () => checkOut('destroy.js')) it('writes about unbind', async () => { let result = await check('unbind.js', [], {}, true) expect(result[0]).toMatchSnapshot() }) it('shows uncatch errors', () => checkError('throw.js')) it('shows uncatch rejects', () => checkError('uncatch.js')) it('uses environment variables for config', () => { return checkOut('options.js', [], { env: { ...process.env, LOGUX_LOGGER: 'json', LOGUX_PORT: '31337', NODE_ENV: 'test' } }) }) it('uses logger param', () => checkOut('options.js', ['', '-l', 'json'])) it('uses autoload modules', () => checkOut('autoload-modules.js')) it('uses autoload wrong export', () => checkError('autoload-error-modules.js')) it('uses .env cwd', async () => { let result = await check( 'options.js', [], { cwd: join(ROOT, 'test/fixtures') }, true ) expect(result[0]).toMatchSnapshot() }) it('uses .env from root', () => checkOut('root.js')) it('shows help', async () => { await checkOut('options.js', ['', '--help'], { env: { ...process.env, NO_COLOR: '1' } }) }) it('shows help about port in use', async () => { await start('eaddrinuse.js') let result = await check('eaddrinuse.js') expect(result[0]).toMatchSnapshot() }) it('shows help about privileged port', () => checkError('eacces.js')) it('shows help about unknown option', () => checkError('unknown.js')) it('shows help about missed option', () => checkError('missed.js')) it('disables colors for constructor errors', () => { return checkError('missed.js', [], { env: { ...process.env, NODE_ENV: 'production' } }) }) it('uses logger param for constructor errors', () => { return checkError('missed.js', ['', '-l', 'json']) }) it('writes JSON log', () => checkOut('json.js')) it('has custom logger', () => checkOut('logger.js')) ================================================ FILE: server/types.ts ================================================ import { defineAction, type LoguxSubscribeAction } from '@logux/actions' import { type Action, Server } from '../index.js' let server = new Server<{ locale: string }>( Server.loadOptions(process, { minSubprotocol: 1, root: '', subprotocol: 1 }) ) server.auth(({ token, userId }) => { return token === userId }) class User { id: string name: string constructor(id: string) { this.id = id this.name = 'name' } async save(): Promise {} } type UserRenameAction = { name: string type: 'user/rename' userId: string } & Action type UserSubscribeAction = { fields?: ('email' | 'name')[] } & LoguxSubscribeAction type UserData = { user: User } type UserParams = { id: string } server.type('user/rename', { access(ctx, action, meta) { console.log(meta.id) ctx.data.user = new User(action.userId) return ctx.data.user.id === ctx.userId }, async process(ctx, action) { ctx.data.user.name = action.name await ctx.data.user.save() }, resend(ctx, action) { return { channels: [`user/${action.userId}`, `spellcheck/${ctx.headers.locale}`] } } }) server.channel('user/:id', { access(ctx, action, meta) { console.log(meta.id, action.since) ctx.data.user = new User(ctx.params.id) return ctx.data.user.id === ctx.userId }, filter(ctx, action) { return (cxt2, otherAction) => { if (typeof action.fields !== 'undefined') { return ( action.fields.includes('name') && otherAction.type === 'user/rename' ) } else { return true } } }, async load(ctx) { await ctx.sendBack( { name: ctx.data.user.name, type: 'user/rename', userId: ctx.data.user.id }, { status: 'processed' } ) } }) server.channel(/admin:\d/, { access(ctx, action, meta) { console.log(meta.id, action.since) return ctx.params[1] === ctx.userId } }) server.on('connected', client => { console.log(client.remoteAddress) }) let addUser = defineAction<{ type: 'user/remove'; userId: string }>( 'user/remove' ) server.type(addUser, { access(ctx, action) { return action.userId === ctx.userId } }) ================================================ FILE: server-client/index.d.ts ================================================ import type { ServerConnection, ServerNode } from '@logux/core' import type { BaseServer } from '../base-server/index.js' /** * Logux client connected to server. * * ```js * const client = server.connected.get(0) * ``` */ export class ServerClient { /** * Server, which received client. */ app: BaseServer /** * Unique persistence machine ID. * It will be undefined before correct authentication. */ clientId?: string /** * The Logux wrapper to WebSocket connection. * * ```js * console.log(client.connection.ws.upgradeReq.headers) * ``` */ connection: ServerConnection /** * HTTP headers of WS connection. * * ```js * client.httpHeaders['User-Agent'] * ``` */ httpHeaders: { [name: string]: string } /** * Client number used as `app.connected` key. * * ```js * function stillConnected (client) { * return app.connected.has(client.key) * } * ``` */ key: string /** * Node instance to synchronize logs. * * ```js * if (client.node.state === 'synchronized') * ``` */ node: ServerNode /** * Unique node ID. * It will be undefined before correct authentication. */ nodeId?: string /** * Does server process some action from client. * * ```js * console.log('Clients in processing:', clients.map(i => i.processing)) * ``` */ processing: boolean /** * Client IP address. * * ```js * const clientCity = detectLocation(client.remoteAddress) * ``` */ remoteAddress: string /** * User ID. It will be filled from client’s node ID. * It will be undefined before correct authentication. */ userId?: string /** * @param app The server. * @param connection The Logux connection. * @param key Client number used as `app.connected` key. */ constructor(app: BaseServer, connection: ServerConnection, key: number) /** * Disconnect client. */ destroy(): void } ================================================ FILE: server-client/index.js ================================================ import { LoguxError, parseId } from '@logux/core' import cookie from 'cookie' import fastq from 'fastq' import { ALLOWED_META } from '../allowed-meta/index.js' import { Context } from '../context/index.js' import { filterMeta } from '../filter-meta/index.js' import { FilteredNode } from '../filtered-node/index.js' async function onSend(action, meta) { return [action, filterMeta(meta)] } function reportDetails(client) { return { connectionId: client.key, nodeId: client.nodeId, subprotocol: client.node.remoteSubprotocol } } function denyBack(app, clientId, action, meta) { app.emitter.emit('report', 'denied', { actionId: meta.id }) let [undoAction, undoMeta] = app.buildUndo(action, meta, 'denied') undoMeta.clients = (undoMeta.clients || []).concat([clientId]) app.log.add(undoAction, undoMeta) app.debugActionError(meta, `Action "${meta.id}" was denied`) } async function queueWorker(task, next) { let { action, app, clientId, meta, onReceiveResolve, queue } = task queue.next = next let type = action.type if (type === 'logux/subscribe' || type === 'logux/unsubscribe') { return onReceiveResolve([action, meta]) } let processor = app.getProcessor(type) if (!processor) { app.internalUnknownType(action, meta) return onReceiveResolve(false) } let ctx = app.createContext(action, meta) try { let result = await processor.access(ctx, action, meta) if (app.unknownTypes[meta.id]) { delete app.unknownTypes[meta.id] app.finally(processor, ctx, action, meta) return false } else if (!result) { app.finally(processor, ctx, action, meta) denyBack(app, clientId, action, meta) return onReceiveResolve(false) } else { return onReceiveResolve([action, meta]) } } catch (e) { app.undo(action, meta, 'error') app.emitter.emit('error', e, action, meta) app.finally(processor, ctx, action, meta) return onReceiveResolve(false) } } export class ServerClient { constructor(app, connection, key) { this.app = app this.userId = undefined this.clientId = undefined this.nodeId = undefined this.processing = false this.connection = connection this.key = key.toString() if (connection.ws) { this.remoteAddress = connection.ws._socket.remoteAddress this.httpHeaders = connection.ws.upgradeReq.headers } else { this.remoteAddress = '127.0.0.1' this.httpHeaders = {} } let Node = app.options.Node || FilteredNode this.node = new Node(this, app.nodeId, app.log, connection, { auth: this.auth.bind(this), onReceive: this.onReceive.bind(this), onSend, ping: app.options.ping, subprotocol: app.options.subprotocol, timeout: app.options.timeout }) if (this.app.env === 'development') { this.node.setLocalHeaders({ env: 'development' }) } if (app.connectLoader) { this.node.syncSinceQuery = async lastSynced => { let context = new Context(app, this) let entries = await app.connectLoader(context, lastSynced) let added = entries.reduce((max, entry) => { let meta = filterMeta(entry[1]) entry[1] = meta return meta.added > max ? meta.added : max }, 0) return { added, entries } } } this.node.catch(err => { err.connectionId = this.key this.app.emitter.emit('error', err) }) this.node.on('state', () => { if (!this.node.connected && !this.destroyed) this.destroy() }) this.node.on('clientError', err => { if (err.type !== 'wrong-credentials') { err.connectionId = this.key this.app.emitter.emit('clientError', err) } }) this.app.emitter.emit('connected', this) } async auth(nodeId, token) { this.nodeId = nodeId let { clientId, userId } = parseId(nodeId) this.clientId = clientId this.userId = userId if (this.app.options.minSubprotocol) { if (this.node.remoteSubprotocol < this.app.options.minSubprotocol) { throw new LoguxError('wrong-subprotocol', { supported: this.app.options.minSubprotocol, used: this.node.remoteSubprotocol }) } } if (nodeId === 'server' || userId === 'server') { this.app.emitter.emit('unauthenticated', this, 0) this.app.emitter.emit('report', 'unauthenticated', reportDetails(this)) return false } let ws = this.connection.ws let headers = {} if (ws && ws.upgradeReq && ws.upgradeReq.headers) { headers = ws.upgradeReq.headers } let start = Date.now() let result try { result = await this.app.authenticator({ client: this, cookie: cookie.parse(headers.cookie || ''), headers: this.node.remoteHeaders, token, userId: this.userId }) } catch (e) { if (e.name === 'LoguxError') { /* c8 ignore next 1 */ throw e } else { e.nodeId = nodeId this.app.emitter.emit('error', e) result = false } } if (this.app.isBruteforce(this.remoteAddress)) { let e = new LoguxError('bruteforce') e.nodeId = nodeId this.app.emitter.emit('clientError', e) result = false } if (result) { let zombie = this.app.clientIds.get(this.clientId) if (zombie) { zombie.zombie = true this.app.emitter.emit('report', 'zombie', { nodeId: zombie.nodeId }) zombie.destroy() } this.app.clientIds.set(this.clientId, this) this.app.nodeIds.set(this.nodeId, this) if (this.userId) { if (!this.app.userIds.has(this.userId)) { this.app.userIds.set(this.userId, [this]) } else { this.app.userIds.get(this.userId).push(this) } } this.app.emitter.emit('authenticated', this, Date.now() - start) this.app.emitter.emit('report', 'authenticated', reportDetails(this)) } else { this.app.emitter.emit('unauthenticated', this, Date.now() - start) this.app.emitter.emit('report', 'unauthenticated', reportDetails(this)) this.app.rememberBadAuth(this.remoteAddress) } return result } destroy() { this.destroyed = true this.node.destroy() if (this.userId) { let users = this.app.userIds.get(this.userId) if (users) { users = users.filter(i => i !== this) if (users.length === 0) { this.app.userIds.delete(this.userId) } else { this.app.userIds.set(this.userId, users) } } } if (this.clientId) { for (let channel in this.app.subscribers) { let subscriber = this.app.subscribers[channel][this.nodeId] if (subscriber) { let action = { channel, type: 'logux/unsubscribe' } let actionId = this.app.log.generateId() let meta = { id: actionId, reasons: [], time: parseInt(actionId) } this.app.performUnsubscribe(this.nodeId, action, meta) } } this.app.clientIds.delete(this.clientId) this.app.nodeIds.delete(this.nodeId) } if (!this.app.destroying) { this.app.emitter.emit('disconnected', this) } this.app.connected.delete(this.key) } onReceive(action, meta) { if (this.app.actionToQueue.has(meta.id)) { return Promise.resolve(false) } let actionClientId = parseId(meta.id).clientId let wrongUser = !this.clientId || this.clientId !== actionClientId let wrongMeta = Object.keys(meta).some(i => !ALLOWED_META.includes(i)) if (wrongUser || wrongMeta) { denyBack(this.app, this.clientId, action, meta) return Promise.resolve(false) } return new Promise(resolve => { let clientId = parseId(meta.id).clientId let queueName = '' let isChannel = (action.type === 'logux/subscribe' || action.type === 'logux/unsubscribe') && action.channel if (isChannel) { for (let channel of this.app.channels) { let pattern = channel.regexp || channel.pattern.regex if (action.channel.match(pattern)) { queueName = channel.queue break } } } else { queueName = this.app.typeToQueue.get(action.type) } queueName = queueName || 'main' let queueKey = `${clientId}/${queueName}` let queue = this.app.queues.get(queueKey) if (!queue) { queue = fastq(queueWorker, 1) this.app.queues.set(queueKey, queue) } if (!meta.subprotocol) { meta.subprotocol = this.node.remoteSubprotocol } this.app.actionToQueue.set(meta.id, queueKey) queue.push({ action, app: this.app, clientId, meta, onReceiveResolve: result => { resolve(result) }, queue }) }) } } ================================================ FILE: server-client/index.test.ts ================================================ import { LoguxNotFoundError } from '@logux/actions' import { type Action, LoguxError, type Message, type Meta, type ServerConnection, type TestLog, TestPair, TestTime } from '@logux/core' import { restoreAll, type Spy, spyOn } from 'nanospy' import { setTimeout } from 'node:timers/promises' import { afterEach, expect, it } from 'vitest' import { FilteredNode } from '../filtered-node/index.js' import { BaseServer, type BaseServerOptions, ResponseError, type ServerMeta } from '../index.js' import { ServerClient } from './index.js' let destroyable: { destroy(): void }[] = [] function privateMethods(obj: object): any { return obj } function getPair(client: ServerClient): TestPair { return privateMethods(client.connection).pair } async function sendTo(client: ServerClient, msg: Message): Promise { let pair = getPair(client) pair.right.send(msg) await pair.wait('right') } async function connect( client: ServerClient, nodeId: string = '10:uuid', details: object = {} ): Promise { await client.connection.connect() let protocol = client.node.localProtocol await sendTo(client, [ 'connect', protocol, nodeId, 0, { subprotocol: 1, ...details } ]) } function createConnection(): ServerConnection { let pair = new TestPair() privateMethods(pair.left).ws = { _socket: { remoteAddress: '127.0.0.1' }, upgradeReq: { headers: { 'user-agent': 'browser' } } } return pair.left as any } function createServer( opts: Partial = {} ): BaseServer<{ locale: string }, TestLog> { opts.subprotocol = 1 opts.minSubprotocol = 1 opts.time = new TestTime() let server = new BaseServer<{ locale: string }, TestLog>({ ...opts, minSubprotocol: 1, subprotocol: 1, time: new TestTime() }) server.auth(() => true) server.on('preadd', (action, meta) => { meta.reasons.push('test') }) destroyable.push(server) return server } function createReporter(opts: Partial = {}): { app: BaseServer<{ locale: string }, TestLog> names: string[] reports: [string, any][] } { let names: string[] = [] let reports: [string, any][] = [] let app = createServer(opts) app.on('report', (name: string, details?: any) => { names.push(name) reports.push([name, details]) }) return { app, names, reports } } function createClient(app: BaseServer): ServerClient { let lastClient: number = ++privateMethods(app).lastClient let client = new ServerClient(app, createConnection(), lastClient) app.connected.set(`${lastClient}`, client) destroyable.push(client) return client } async function connectClient( server: BaseServer, nodeId = '10:uuid' ): Promise { let client = createClient(server) privateMethods(client.node).now = () => 0 await connect(client, nodeId) return client } function sent(client: ServerClient): Message[] { return getPair(client).leftSent } function sentNames(client: ServerClient): string[] { return sent(client).map(i => i[0]) } function actions(client: ServerClient): Action[] { let received: Action[] = [] sent(client).forEach(i => { if (i[0] === 'sync') { for (let j = 2; j < i.length; j += 2) { let action: Action = i[j] as any if (action.type !== 'logux/processed') { received.push(action) } } } }) return received } afterEach(() => { restoreAll() destroyable.forEach(i => { i.destroy() }) destroyable = [] }) it('uses server options', () => { let app = createServer({ minSubprotocol: 1, ping: 8000, subprotocol: 1, timeout: 16000 }) app.nodeId = 'server:x' let client = new ServerClient(app, createConnection(), 1) expect(client.node.options.subprotocol).toEqual(1) expect(client.node.options.timeout).toEqual(16000) expect(client.node.options.ping).toEqual(8000) expect(client.node.localNodeId).toEqual('server:x') }) it('saves connection', () => { let connection = createConnection() let client = new ServerClient(createServer(), connection, 1) expect(client.connection).toBe(connection) }) it('uses string key', () => { let client = new ServerClient(createServer(), createConnection(), 1) expect(client.key).toEqual('1') expect(typeof client.key).toEqual('string') }) it('has remote address shortcut', () => { let client = new ServerClient(createServer(), createConnection(), 1) expect(client.remoteAddress).toEqual('127.0.0.1') }) it('has HTTP headers shortcut', () => { let client = new ServerClient(createServer(), createConnection(), 1) expect(client.httpHeaders['user-agent']).toEqual('browser') }) it('has default remote address if ws param does not set', () => { let pair = new TestPair() let client = new ServerClient(createServer(), pair.left as any, 1) expect(client.remoteAddress).toEqual('127.0.0.1') }) it('reports about connection', () => { let test = createReporter() let fired: string[] = [] test.app.on('connected', client => { fired.push(client.key) }) new ServerClient(test.app, createConnection(), 1) expect(test.reports).toEqual([ [ 'connect', { connectionId: '1', ipAddress: '127.0.0.1' } ] ]) expect(fired).toEqual(['1']) }) it('removes itself on destroy', async () => { let test = createReporter() let disconnectedKeys: string[] = [] test.app.on('disconnected', client => { disconnectedKeys.push(client.key) }) let lastPulledReports = new Set() let pullNewReports = (): [string, any][] => { let reports = test.reports let result = reports.filter(x => !lastPulledReports.has(x)) lastPulledReports = new Set(reports) return result } let client1 = createClient(test.app) let client2 = createClient(test.app) await client1.connection.connect() await client2.connection.connect() client1.node.remoteSubprotocol = 1 client2.node.remoteSubprotocol = 1 privateMethods(client1).auth('10:client1', {}) privateMethods(client2).auth('10:client2', {}) await setTimeout(1) expect(pullNewReports()).toMatchObject([ ['connect', { connectionId: '1' }], ['connect', { connectionId: '2' }], ['authenticated', { connectionId: '1', nodeId: '10:client1' }], ['authenticated', { connectionId: '2', nodeId: '10:client2' }] ]) test.app.subscribers = { 'user/10': { '10:client1': { filters: { '{}': true } }, '10:client2': { filters: { '{}': true } } } } let unsubscribedClientNodeIds: string[] = [] test.app.on('unsubscribed', (action, meta, clientNodeId) => { unsubscribedClientNodeIds.push(clientNodeId) expect(test.app.nodeIds.get(clientNodeId)).toBeDefined() }) client1.destroy() await setTimeout(1) expect(unsubscribedClientNodeIds).toEqual(['10:client1']) expect(Array.from(test.app.userIds.keys())).toEqual(['10']) expect(test.app.subscribers).toEqual({ 'user/10': { '10:client2': { filters: { '{}': true } } } }) expect(client1.connection.connected).toBe(false) expect(pullNewReports()).toMatchObject([ ['unsubscribed', { channel: 'user/10' }], ['disconnect', { nodeId: '10:client1' }] ]) client2.destroy() await setTimeout(1) expect(unsubscribedClientNodeIds).toEqual(['10:client1', '10:client2']) expect(pullNewReports()).toMatchObject([ ['unsubscribed', { channel: 'user/10' }], ['disconnect', { nodeId: '10:client2' }] ]) expect(test.app.connected.size).toEqual(0) expect(test.app.clientIds.size).toEqual(0) expect(test.app.nodeIds.size).toEqual(0) expect(test.app.userIds.size).toEqual(0) expect(test.app.subscribers).toEqual({}) expect(disconnectedKeys).toEqual(['1', '2']) }) it('reports client ID before authentication', async () => { let test = createReporter() let client = createClient(test.app) await client.connection.connect() client.destroy() expect(test.reports[1]).toEqual(['disconnect', { connectionId: '1' }]) }) it('does not report users disconnects on server destroy', async () => { let test = createReporter() let client = createClient(test.app) await client.connection.connect() test.app.destroy() expect(test.app.connected.size).toEqual(0) expect(client.connection.connected).toBe(false) expect(test.names).toEqual(['connect', 'destroy']) expect(test.reports[1]).toEqual(['destroy', undefined]) }) it('destroys on disconnect', async () => { let client = createClient(createServer()) spyOn(client, 'destroy') await client.connection.connect() let pair = getPair(client) pair.right.disconnect() await pair.wait() expect((client.destroy as any as Spy).callCount).toEqual(1) }) it('reports on wrong authentication', async () => { let test = createReporter() test.app.auth(async () => false) let client = new ServerClient(test.app, createConnection(), 1) await connect(client) expect(test.names).toEqual(['connect', 'unauthenticated', 'disconnect']) expect(test.reports[1]).toEqual([ 'unauthenticated', { connectionId: '1', nodeId: '10:uuid', subprotocol: 1 } ]) }) it('reports about authentication error', async () => { let test = createReporter() let error = new Error('test') let errors: Error[] = [] test.app.on('error', e => { errors.push(e) }) test.app.auth(() => { throw error }) let client = new ServerClient(test.app, createConnection(), 1) await connect(client) expect(test.names).toEqual([ 'connect', 'error', 'unauthenticated', 'disconnect' ]) expect(test.reports[1]).toEqual([ 'error', { err: error, nodeId: '10:uuid' } ]) expect(errors).toEqual([error]) }) it('blocks authentication bruteforce', async () => { let test = createReporter() test.app.auth(async () => false) async function connectNext(num: number): Promise { let client = new ServerClient(test.app, createConnection(), num) await connect(client, `${num}:uuid`) } await Promise.all([1, 2, 3, 4, 5].map(i => connectNext(i))) expect(test.names.filter(i => i === 'disconnect')).toHaveLength(5) expect(test.names.filter(i => i === 'unauthenticated')).toHaveLength(5) expect(test.names.filter(i => i === 'clientError')).toHaveLength(2) test.reports .filter(i => i[0] === 'clientError') .forEach(report => { expect(report[1].err.type).toEqual('bruteforce') expect(report[1].nodeId).toMatch(/(4|5):uuid/) }) await setTimeout(3050) await connectNext(6) expect(test.names.filter(i => i === 'disconnect')).toHaveLength(6) expect(test.names.filter(i => i === 'unauthenticated')).toHaveLength(6) expect(test.names.filter(i => i === 'clientError')).toHaveLength(2) }) it('reports on server in user name', async () => { let test = createReporter() test.app.auth(async () => true) let client = new ServerClient(test.app, createConnection(), 1) await connect(client, 'server:x') expect(test.names).toEqual(['connect', 'unauthenticated', 'disconnect']) expect(test.reports[1]).toEqual([ 'unauthenticated', { connectionId: '1', nodeId: 'server:x', subprotocol: 1 } ]) }) it('authenticates user', async () => { let test = createReporter() test.app.auth(async ({ client, headers, token, userId }) => { return ( token === 'token' && userId === 'a' && client === testClient && headers.locale === 'fr' ) }) let testClient = createClient(test.app) testClient.node.remoteHeaders = { locale: 'fr' } let authenticated: [ServerClient, number][] = [] test.app.on('authenticated', (...args) => { authenticated.push(args) }) await connect(testClient, 'a:b:uuid', { token: 'token' }) expect(testClient.userId).toEqual('a') expect(testClient.clientId).toEqual('a:b') expect(testClient.nodeId).toEqual('a:b:uuid') expect(testClient.node.authenticated).toBe(true) expect(test.app.nodeIds).toEqual(new Map([['a:b:uuid', testClient]])) expect(test.app.clientIds).toEqual(new Map([['a:b', testClient]])) expect(test.app.userIds).toEqual(new Map([['a', [testClient]]])) expect(test.names).toEqual(['connect', 'authenticated']) expect(test.reports[1]).toEqual([ 'authenticated', { connectionId: '1', nodeId: 'a:b:uuid', subprotocol: 1 } ]) expect(authenticated).toHaveLength(1) expect(authenticated[0][0]).toBe(testClient) expect(typeof authenticated[0][1]).toEqual('number') }) it('supports non-promise authenticator', async () => { let app = createServer() app.auth(({ token }) => token === 'token') let client = createClient(app) await connect(client, '10:uuid', { token: 'token' }) expect(client.node.authenticated).toBe(true) }) it('supports cookie based authenticator', async () => { let app = createServer() app.auth(({ cookie }) => cookie.token === 'good') let client = createClient(app) privateMethods(client.connection.ws).upgradeReq = { headers: { cookie: 'token=good; a=b' } } await connect(client, '10:uuid') expect(client.node.authenticated).toBe(true) }) it('authenticates user without user name', async () => { let app = createServer() let client = createClient(app) await connect(client, 'uuid', { token: 'token' }) expect(client.userId).toBeUndefined() expect(app.userIds.size).toEqual(0) }) it('reports about synchronization errors', async () => { let test = createReporter() let client = createClient(test.app) await client.connection.connect() sendTo(client, ['error', 'wrong-format']) await getPair(client).wait() expect(test.names).toEqual(['connect', 'error']) let err = new LoguxError('wrong-format', undefined, true) // @ts-expect-error Unofficial object extend for internal needs err.connectionId = '1' expect(test.reports[1]).toEqual(['error', { connectionId: '1', err }]) }) it('checks subprotocol', async () => { let test = createReporter() let client = createClient(test.app) await connect(client, '10:uuid', { subprotocol: 0 }) expect(test.names).toEqual(['connect', 'clientError', 'disconnect']) let err = new LoguxError('wrong-subprotocol', { supported: 1, used: 0 }) // @ts-expect-error Unofficial object extend for internal needs err.connectionId = '1' expect(test.reports[1]).toEqual(['clientError', { connectionId: '1', err }]) }) it('sends server environment in development', async () => { let app = createServer({ env: 'development' }) let client = await connectClient(app) let headers = sent(client).find(i => i[0] === 'headers') expect(headers).toEqual(['headers', { env: 'development' }]) }) it('does not send server environment in production', async () => { let app = createServer({ env: 'production' }) app.auth(async () => true) let client = await connectClient(app) expect(sent(client)[0][4]).toEqual({ subprotocol: 1 }) }) it('disconnects zombie', async () => { let test = createReporter() let client1 = createClient(test.app) let client2 = createClient(test.app) await client1.connection.connect() client1.node.remoteSubprotocol = 1 privateMethods(client1).auth('10:client:a', {}) await client2.connection.connect() client2.node.remoteSubprotocol = 1 privateMethods(client2).auth('10:client:b', {}) await setTimeout(0) expect(Array.from(test.app.connected.keys())).toEqual([client2.key]) expect(test.names).toEqual([ 'connect', 'connect', 'authenticated', 'zombie', 'authenticated' ]) expect(test.reports[3]).toEqual(['zombie', { nodeId: '10:client:a' }]) }) it('checks action access', async () => { let test = createReporter() let finalled = 0 test.app.type('FOO', { access: () => false, finally() { finalled += 1 } }) let client = await connectClient(test.app) await sendTo(client, [ 'sync', 2, { type: 'FOO' }, { id: [1, '10:uuid', 0], time: 1 } ]) expect(test.names).toEqual(['connect', 'authenticated', 'denied', 'add']) expect(test.app.log.actions()).toEqual([ { action: { type: 'FOO' }, id: '1 10:uuid 0', reason: 'denied', type: 'logux/undo' } ]) expect(finalled).toEqual(1) }) it('checks action creator', async () => { let test = createReporter() test.app.type('GOOD', { access: () => true }) test.app.type('BAD', { access: () => true }) let client = await connectClient(test.app) await sendTo(client, [ 'sync', 2, { type: 'GOOD' }, { id: [1, '10:uuid', 0], time: 1 }, { type: 'BAD' }, { id: [2, '1:uuid', 0], time: 2 } ]) expect(test.names).toEqual([ 'connect', 'authenticated', 'denied', 'add', 'add', 'add' ]) expect(test.reports[2]).toEqual(['denied', { actionId: '2 1:uuid 0' }]) expect(test.reports[4][1].meta.id).toEqual('1 10:uuid 0') expect(test.app.log.actions()).toEqual([ { type: 'GOOD' }, { action: { type: 'BAD' }, id: '2 1:uuid 0', reason: 'denied', type: 'logux/undo' }, { id: '1 10:uuid 0', type: 'logux/processed' } ]) }) it('allows subscribe and unsubscribe actions', async () => { let test = createReporter() test.app.channel('a', { access: () => true }) let client = await connectClient(test.app) await sendTo(client, [ 'sync', 3, { channel: 'a', type: 'logux/subscribe' }, { id: [1, '10:uuid', 0], time: 1 }, { channel: 'b', type: 'logux/unsubscribe' }, { id: [2, '10:uuid', 0], time: 2 }, { type: 'logux/undo' }, { id: [3, '10:uuid', 0], time: 3 } ]) expect(test.names[8]).toEqual('unknownType') expect(test.reports[8][1].actionId).toEqual('3 10:uuid 0') expect(test.names).toContain('unsubscribed') expect(test.names).toContain('subscribed') }) it('checks action meta', async () => { let test = createReporter() test.app.type('GOOD', { access: () => true }, { queue: '1' }) test.app.type('BAD', { access: () => true }, { queue: '2' }) test.app.log.generateId() test.app.log.generateId() let client = await connectClient(test.app) await sendTo(client, [ 'sync', 2, { type: 'BAD' }, { id: [1, '10:uuid', 0], status: 'processed', time: 1 }, { type: 'GOOD' }, { id: [2, '10:uuid', 0], subprotocol: 1, time: 3 } ]) expect(test.app.log.actions()).toEqual([ { type: 'GOOD' }, { action: { type: 'BAD' }, id: '1 10:uuid 0', reason: 'denied', type: 'logux/undo' }, { id: '2 10:uuid 0', type: 'logux/processed' } ]) expect(test.names).toEqual([ 'connect', 'authenticated', 'denied', 'add', 'add', 'add' ]) expect(test.reports[2][1].actionId).toEqual('1 10:uuid 0') expect(test.reports[4][1].meta.id).toEqual('2 10:uuid 0') }) it('ignores unknown action types', async () => { let test = createReporter() let client = await connectClient(test.app) await sendTo(client, [ 'sync', 2, { type: 'UNKNOWN' }, { id: [1, '10:uuid', 0], time: 1 } ]) expect(test.app.log.actions()).toEqual([ { action: { type: 'UNKNOWN' }, id: '1 10:uuid 0', reason: 'unknownType', type: 'logux/undo' } ]) expect(test.names).toEqual(['connect', 'authenticated', 'unknownType', 'add']) expect(test.reports[2]).toEqual([ 'unknownType', { actionId: '1 10:uuid 0', type: 'UNKNOWN' } ]) }) it('checks user access for action', async () => { let test = createReporter({ env: 'development' }) type FooAction = { bar: boolean type: 'FOO' } test.app.type('FOO', { async access(ctx, action, meta) { expect(ctx.userId).toEqual('10') expect(ctx.subprotocol).toEqual(1) expect(meta.id).toBeDefined() return action.bar } }) let client = await connectClient(test.app) await sendTo(client, [ 'sync', 2, { bar: true, type: 'FOO' }, { id: [1, '10:uuid', 0], time: 1 }, { type: 'FOO' }, { id: [1, '10:uuid', 1], time: 1 } ]) await setTimeout(50) expect(test.app.log.actions()).toEqual([ { bar: true, type: 'FOO' }, { id: '1 10:uuid 0', type: 'logux/processed' }, { action: { type: 'FOO' }, id: '1 10:uuid 1', reason: 'denied', type: 'logux/undo' } ]) expect(test.names).toEqual([ 'connect', 'authenticated', 'add', 'denied', 'add', 'add' ]) expect(test.reports.find(i => i[0] === 'denied')![1].actionId).toEqual( '1 10:uuid 1' ) expect(sent(client).find(i => i[0] === 'debug')).toEqual([ 'debug', 'error', 'Action "1 10:uuid 1" was denied' ]) }) it('takes subprotocol from action meta', async () => { let app = createServer() let subprotocols: number[] = [] app.type('FOO', { access: () => true, process(ctx) { subprotocols.push(ctx.subprotocol) } }) let client = await connectClient(app) app.log.add({ type: 'FOO' }, { id: `1 ${client.nodeId} 0`, subprotocol: 1 }) await setTimeout(1) expect(subprotocols).toEqual([1]) }) it('reports about errors in access callback', async () => { let err = new Error('test') let test = createReporter() let finalled = 0 test.app.type('FOO', { access() { throw err }, finally() { finalled += 1 } }) let throwed test.app.on('error', e => { throwed = e }) let client = await connectClient(test.app) await sendTo(client, [ 'sync', 2, { bar: true, type: 'FOO' }, { id: [1, '10:uuid', 0], time: 1 } ]) expect(test.app.log.actions()).toEqual([ { action: { bar: true, type: 'FOO' }, id: '1 10:uuid 0', reason: 'error', type: 'logux/undo' } ]) expect(test.names).toEqual(['connect', 'authenticated', 'error', 'add']) expect(test.reports[2]).toEqual([ 'error', { actionId: '1 10:uuid 0', err } ]) expect(throwed).toEqual(err) expect(finalled).toEqual(1) }) it('adds resend keys', async () => { let test = createReporter() test.app.type('FOO', { access: () => true, resend(ctx, action, meta) { expect(ctx.nodeId).toEqual('10:uuid') expect(action.type).toEqual('FOO') expect(meta.id).toEqual('1 10:uuid 0') return { channels: ['a'], clients: ['1:client'], nodes: ['1:client:other'], users: ['1'] } } }) test.app.type('EMPTY', { access: () => true }) test.app.log.generateId() test.app.log.generateId() let client = await connectClient(test.app) await sendTo(client, [ 'sync', 2, { type: 'FOO' }, { id: [1, '10:uuid', 0], time: 1 }, { type: 'EMPTY' }, { id: [2, '10:uuid', 0], time: 2 } ]) expect(test.app.log.actions()).toEqual([ { type: 'FOO' }, { type: 'EMPTY' }, { id: '1 10:uuid 0', type: 'logux/processed' }, { id: '2 10:uuid 0', type: 'logux/processed' } ]) expect(test.names).toEqual([ 'connect', 'authenticated', 'add', 'add', 'add', 'add' ]) expect(test.reports[2][1].action.type).toEqual('FOO') expect(test.reports[2][1].meta.nodes).toEqual(['1:client:other']) expect(test.reports[2][1].meta.clients).toEqual(['1:client']) expect(test.reports[2][1].meta.channels).toEqual(['a']) expect(test.reports[2][1].meta.users).toEqual(['1']) expect(test.reports[4][1].action.type).toEqual('EMPTY') expect(test.reports[4][1].meta.users).not.toBeDefined() }) it('has channel resend shortcut', async () => { let app = createServer() app.type('FOO', { access: () => true, resend() { return 'bar' } }) app.type('FOOS', { access: () => true, resend() { return ['bar1', 'bar2'] } }) let client = await connectClient(app) await sendTo(client, [ 'sync', 2, { type: 'FOO' }, { id: [1, '10:uuid', 0], time: 1 }, { type: 'FOOS' }, { id: [2, '10:uuid', 0], time: 2 } ]) expect(app.log.actions()).toEqual([ { type: 'FOO' }, { id: '1 10:uuid 0', type: 'logux/processed' }, { type: 'FOOS' }, { id: '2 10:uuid 0', type: 'logux/processed' } ]) expect(app.log.entries()[0][1].channels).toEqual(['bar']) expect(app.log.entries()[2][1].channels).toEqual(['bar1', 'bar2']) }) it('sends old actions by node ID', async () => { let app = createServer() app.type('A', { access: () => true }) await app.log.add({ type: 'A' }, { id: '1 server:x 0' }) await app.log.add({ type: 'A' }, { id: '2 server:x 0', nodes: ['10:uuid'] }) let client = await connectClient(app) sendTo(client, ['synced', 2]) await client.node.waitFor('synchronized') expect(sentNames(client)).toEqual(['connected', 'sync']) expect(sent(client)[1]).toEqual([ 'sync', 2, { type: 'A' }, { id: [2, 'server:x', 0], time: 2 } ]) }) it('sends new actions by node ID', async () => { let app = createServer() app.type('A', { access: () => true }) let client = await connectClient(app) await app.log.add({ type: 'A' }, { id: '1 server:x 0' }) await app.log.add({ type: 'A' }, { id: '2 server:x 0', nodes: ['10:uuid'] }) sendTo(client, ['synced', 2]) await setTimeout(10) expect(sentNames(client)).toEqual(['connected', 'sync']) expect(sent(client)[1]).toEqual([ 'sync', 2, { type: 'A' }, { id: [2, 'server:x', 0], time: 2 } ]) }) it('sends old actions by client ID', async () => { let app = createServer() app.type('A', { access: () => true }) await app.log.add({ type: 'A' }, { id: '1 server:x 0' }) await app.log.add( { type: 'A' }, { clients: ['10:client'], id: '2 server:x 0' } ) let client = await connectClient(app, '10:client:uuid') sendTo(client, ['synced', 2]) await client.node.waitFor('synchronized') expect(sentNames(client)).toEqual(['connected', 'sync']) expect(sent(client)[1]).toEqual([ 'sync', 2, { type: 'A' }, { id: [2, 'server:x', 0], time: 2 } ]) }) it('sends new actions by client ID', async () => { let app = createServer() app.type('A', { access: () => true }) let client = await connectClient(app, '10:client:uuid') await app.log.add({ type: 'A' }, { id: '1 server:x 0' }) await app.log.add( { type: 'A' }, { clients: ['10:client'], id: '2 server:x 0' } ) sendTo(client, ['synced', 2]) await setTimeout(1) expect(sentNames(client)).toEqual(['connected', 'sync']) expect(sent(client)[1]).toEqual([ 'sync', 2, { type: 'A' }, { id: [2, 'server:x', 0], time: 2 } ]) }) it('does not send old action on client excluding', async () => { let app = createServer() app.type('A', { access: () => true }) await app.log.add({ type: 'A' }, { id: '1 server:x 0' }) await app.log.add( { type: 'A' }, { excludeClients: ['10:client'], id: '2 server:x 0', users: ['10'] } ) let client = await connectClient(app, '10:client:uuid') sendTo(client, ['synced', 2]) await client.node.waitFor('synchronized') expect(sentNames(client)).toEqual(['connected']) }) it('sends old actions by user', async () => { let app = createServer() app.type('A', { access: () => true }) await app.log.add({ type: 'A' }, { id: '1 server:x 0' }) await app.log.add({ type: 'A' }, { id: '2 server:x 0', users: ['10'] }) let client = await connectClient(app) sendTo(client, ['synced', 2]) await client.node.waitFor('synchronized') expect(sentNames(client)).toEqual(['connected', 'sync']) expect(sent(client)[1]).toEqual([ 'sync', 2, { type: 'A' }, { id: [2, 'server:x', 0], time: 2 } ]) }) it('sends new actions by user', async () => { let app = createServer() app.type('A', { access: () => true }) let client = await connectClient(app) await app.log.add({ type: 'A' }, { id: '1 server:x 0' }) await app.log.add({ type: 'A' }, { id: '2 server:x 0', users: ['10'] }) sendTo(client, ['synced', 2]) await setTimeout(10) expect(sentNames(client)).toEqual(['connected', 'sync']) expect(sent(client)[1]).toEqual([ 'sync', 2, { type: 'A' }, { id: [2, 'server:x', 0], time: 2 } ]) }) it('sends new actions by channel', async () => { let app = createServer() app.type('FOO', { access: () => true }) app.type('BAR', { access: () => true }) let client = await connectClient(app) app.subscribers.foo = { '10:uuid': { filters: { '{}': true } } } app.subscribers.bar = { '10:uuid': { filters: { '{}': (ctx, action, meta) => { expect(meta.id).toContain(' server:x ') expect(ctx.isServer).toBe(true) return privateMethods(action).secret !== true } } } } await app.log.add({ type: 'FOO' }, { id: '1 server:x 0' }) await app.log.add({ type: 'FOO' }, { channels: ['foo'], id: '2 server:x 0' }) await app.log.add( { secret: true, type: 'BAR' }, { channels: ['bar'], id: '3 server:x 0' } ) await app.log.add({ type: 'BAR' }, { channels: ['bar'], id: '4 server:x 0' }) sendTo(client, ['synced', 2]) sendTo(client, ['synced', 4]) await client.node.waitFor('synchronized') await setTimeout(1) expect(sentNames(client)).toEqual(['connected', 'sync', 'sync']) expect(sent(client)[1]).toEqual([ 'sync', 2, { type: 'FOO' }, { id: [2, 'server:x', 0], time: 2 } ]) expect(sent(client)[2]).toEqual([ 'sync', 4, { type: 'BAR' }, { id: [4, 'server:x', 0], time: 4 } ]) }) it('excludes client from channel', async () => { let app = createServer() app.type('FOO', { access: () => true }) let client1 = await connectClient(app, '10:1:uuid') let client2 = await connectClient(app, '10:2:uuid') app.subscribers.foo = { '10:1:uuid': { filters: { '{}': true } }, '10:2:uuid': { filters: { '{}': true } } } await app.log.add( { type: 'FOO' }, { channels: ['foo'], excludeClients: ['10:1'], id: '2 server:x 0' } ) await setTimeout(10) expect(sentNames(client1)).toEqual(['connected']) expect(sentNames(client2)).toEqual(['connected', 'sync']) expect(sent(client2)[1]).toEqual([ 'sync', 1, { type: 'FOO' }, { id: [2, 'server:x', 0], time: 2 } ]) }) it('works with channel according client ID', async () => { let app = createServer() app.type('FOO', { access: () => true }) app.type('BAR', { access: () => true }) let client = await connectClient(app, '10:uuid:a') app.subscribers.foo = { '10:uuid:b': { filters: { '{}': true } }, '10:uuid:c': { filters: { '{}': true } } } await app.log.add({ type: 'FOO' }, { channels: ['foo'], id: '2 server:x 0' }) sendTo(client, ['synced', 1]) await setTimeout(10) expect(sentNames(client)).toEqual(['connected', 'sync']) expect(sent(client)[1]).toEqual([ 'sync', 1, { type: 'FOO' }, { id: [2, 'server:x', 0], time: 2 } ]) }) it('sends old action only once', async () => { let app = createServer() app.type('FOO', { access: () => true }) await app.log.add( { type: 'FOO' }, { clients: ['10:uuid', '10:uuid'], id: '1 server:x 0', nodes: ['10:uuid', '10:uuid'], users: ['10', '10'] } ) let client = await connectClient(app) sendTo(client, ['synced', 2]) await client.node.waitFor('synchronized') expect(sentNames(client)).toEqual(['connected', 'sync']) expect(sent(client)[1]).toEqual([ 'sync', 1, { type: 'FOO' }, { id: [1, 'server:x', 0], time: 1 } ]) }) it('sends debug back on unknown type', async () => { let app = createServer({ env: 'development' }) let client1 = await connectClient(app) let client2 = await connectClient(app, '20:uuid') app.log.add({ type: 'UNKNOWN' }, { id: '1 server:x 0' }) app.log.add({ type: 'UNKNOWN' }, { id: '2 10:uuid 0' }) await getPair(client1).wait('right') expect(sent(client1).find(i => i[0] === 'debug')).toEqual([ 'debug', 'error', 'Action with unknown type UNKNOWN' ]) expect(sentNames(client2)).toEqual(['headers', 'connected']) }) it('does not send debug back on unknown type in production', async () => { let app = createServer({ env: 'production' }) let client = await connectClient(app) await app.log.add({ type: 'U' }, { id: '1 10:uuid 0' }) await getPair(client).wait('right') expect(sentNames(client)).toEqual(['connected', 'sync']) }) it('decompress subprotocol', async () => { let app = createServer({ env: 'production' }) app.type('A', { access: () => true }) app.log.generateId() app.log.generateId() let client = await connectClient(app) await sendTo(client, [ 'sync', 2, { type: 'A' }, { id: [1, '10:uuid', 0], time: 1 }, { type: 'A' }, { id: [2, '10:uuid', 0], subprotocol: 2, time: 2 } ]) expect(app.log.entries()[0][1].subprotocol).toEqual(1) expect(app.log.entries()[1][1].subprotocol).toEqual(2) }) it('has custom processor for unknown type', async () => { let test = createReporter() let calls: string[] = [] test.app.otherType({ access() { calls.push('access') return true }, process() { calls.push('process') } }) let client = await connectClient(test.app) await sendTo(client, [ 'sync', 1, { type: 'UNKOWN' }, { id: [1, '10:uuid', 0], time: 1 } ]) expect(test.names).toEqual(['connect', 'authenticated', 'add', 'add']) expect(calls).toEqual(['access', 'process']) }) it('allows to reports about unknown type in custom processor', async () => { let test = createReporter() let calls: string[] = [] test.app.otherType({ access(ctx, action, meta) { calls.push('access') test.app.unknownType(action, meta) return true }, process() { calls.push('process') } }) let client = await connectClient(test.app) await sendTo(client, [ 'sync', 1, { type: 'UNKOWN' }, { id: [1, '10:uuid', 0], time: 1 } ]) expect(test.names).toEqual(['connect', 'authenticated', 'unknownType', 'add']) expect(calls).toEqual(['access']) }) it('allows to use different node ID', async () => { let app = createServer() let calls = 0 app.type('A', { access(ctx, action, meta) { expect(ctx.nodeId).toEqual('10:client:other') expect(meta.id).toEqual('1 10:client:other 0') calls += 1 return true } }) let client = await connectClient(app, '10:client:uuid') await sendTo(client, [ 'sync', 1, { type: 'A' }, { id: [1, '10:client:other', 0], time: 1 } ]) expect(calls).toEqual(1) expect(app.log.entries()[1][0].type).toEqual('logux/processed') expect(app.log.entries()[1][1].clients).toEqual(['10:client']) }) it('allows to use different node ID only with same client ID', async () => { let test = createReporter() let client = await connectClient(test.app, '10:client:uuid') await sendTo(client, [ 'sync', 1, { type: 'A' }, { id: [1, '10:clnt:uuid', 0], time: 1 } ]) expect(test.names).toEqual(['connect', 'authenticated', 'denied', 'add']) }) it('has finally callback', async () => { let app = createServer() let calls: string[] = [] let errors: string[] = [] app.on('error', e => { errors.push(e.message) }) app.type( 'A', { access: () => true, finally() { calls.push('A') } }, { queue: 'A' } ) app.type( 'B', { access: () => true, finally() { calls.push('B') }, process: () => {} }, { queue: 'B' } ) app.type( 'C', { access: () => true, finally() { calls.push('C') }, resend() { throw new Error('C') } }, { queue: 'C' } ) app.type( 'D', { access() { throw new Error('D') }, finally() { calls.push('D') } }, { queue: 'D' } ) app.type( 'E', { access: () => true, finally() { calls.push('E') throw new Error('EE') }, process() { throw new Error('E') } }, { queue: 'E' } ) let client = await connectClient(app, '10:client:uuid') await sendTo(client, [ 'sync', 5, { type: 'A' }, { id: [1, '10:client:other', 0], time: 1 }, { type: 'B' }, { id: [2, '10:client:other', 0], time: 1 }, { type: 'C' }, { id: [3, '10:client:other', 0], time: 1 }, { type: 'D' }, { id: [4, '10:client:other', 0], time: 1 }, { type: 'E' }, { id: [5, '10:client:other', 0], time: 1 } ]) expect(calls).toEqual(['D', 'C', 'A', 'E', 'B']) expect(errors).toEqual(['D', 'C', 'E', 'EE']) }) it('sends error to author', async () => { let app = createServer() app.type('A', { access: () => true }) let client1 = await connectClient(app, '10:1:uuid') let client2 = await connectClient(app, '10:2:uuid') await sendTo(client2, [ 'sync', 1, { type: 'A' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await setTimeout(1) expect(sent(client1)).toHaveLength(1) expect(sent(client2)).toHaveLength(3) }) it('does not resend actions back', async () => { let app = createServer() app.type('A', { access: () => true, resend: () => ({ users: ['10'] }) }) app.type('B', { access: () => true, resend: () => ({ channels: ['all'] }) }) app.channel('all', { access: () => true }) let client1 = await connectClient(app, '10:1:uuid') let client2 = await connectClient(app, '10:2:uuid') await sendTo(client1, [ 'sync', 1, { channel: 'all', type: 'logux/subscribe' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await sendTo(client2, [ 'sync', 1, { channel: 'all', type: 'logux/subscribe' }, { id: [1, '10:2:uuid', 0], time: 1 } ]) await sendTo(client1, [ 'sync', 4, { type: 'A' }, { id: [2, '10:1:uuid', 0], time: 2 }, { type: 'B' }, { id: [3, '10:1:uuid', 0], time: 3 } ]) await setTimeout(10) expect(actions(client1)).toEqual([]) expect(actions(client2)).toEqual([{ type: 'A' }, { type: 'B' }]) }) it('keeps context', async () => { let app = createServer() app.type('A', { access(ctx) { ctx.data.a = 1 return true }, finally(ctx) { expect(ctx.data.a).toEqual(1) }, process(ctx) { expect(ctx.data.a).toEqual(1) } }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 1, { type: 'A' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await setTimeout(1) expect(sent(client)[2][2].type).toEqual('logux/processed') }) it('uses resend for own actions', async () => { let app = createServer() app.type('FOO', { access: () => false, resend: () => ({ channel: 'foo' }) }) app.channel('foo', { access: () => true }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 1, { channel: 'foo', type: 'logux/subscribe' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await setTimeout(10) app.log.add({ type: 'FOO' }) await setTimeout(10) expect(app.log.entries()[2][1].channels).toEqual(['foo']) expect(sent(client)[3][2]).toEqual({ type: 'FOO' }) app.log.add({ type: 'FOO' }, { status: 'processed' }) await setTimeout(10) expect(app.log.entries()[3][1].channels).not.toBeDefined() }) it('does not duplicate channel load actions', async () => { let app = createServer() app.type('FOO', { access: () => true, resend: () => ({ channel: 'foo' }) }) app.channel('foo', { access: () => true, async load(ctx) { await ctx.sendBack({ type: 'FOO' }) } }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 1, { channel: 'foo', type: 'logux/subscribe' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await setTimeout(10) function meta(time: number): object { return { id: time, subprotocol: 1, time } } expect(sent(client).slice(1)).toEqual([ ['synced', 1], ['sync', 2, { type: 'FOO' }, meta(1)], ['sync', 3, { id: '1 10:1:uuid 0', type: 'logux/processed' }, meta(2)] ]) }) it('allows to return actions', async () => { let app = createServer() app.channel('a', { access: () => true, load() { return { type: 'A' } } }) app.channel('b', { access: () => true, load() { return [{ type: 'B' }] } }) app.channel('c', { access: () => true, load() { return [[{ type: 'C' }, { time: 100 }]] } }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 1, { channel: 'a', type: 'logux/subscribe' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await sendTo(client, [ 'sync', 2, { channel: 'b', type: 'logux/subscribe' }, { id: [2, '10:1:uuid', 0], time: 2 } ]) await sendTo(client, [ 'sync', 3, { channel: 'c', type: 'logux/subscribe' }, { id: [3, '10:1:uuid', 0], time: 3 } ]) await setTimeout(50) function meta(time: number): object { return { id: time, subprotocol: 1, time } } expect(sent(client).slice(1)).toEqual([ ['synced', 1], ['sync', 2, { type: 'A' }, meta(1)], ['sync', 3, { id: '1 10:1:uuid 0', type: 'logux/processed' }, meta(2)], ['synced', 2], ['sync', 5, { type: 'B' }, meta(3)], ['sync', 6, { id: '2 10:1:uuid 0', type: 'logux/processed' }, meta(4)], ['synced', 3], ['sync', 8, { type: 'C' }, { ...meta(5), time: 100 }], ['sync', 9, { id: '3 10:1:uuid 0', type: 'logux/processed' }, meta(6)] ]) }) it('does not process send-back actions', async () => { let app = createServer() app.channel('a', { access: () => true, load() { return { data: 'load', type: 'A' } } }) let processed: string[] = [] let resended: string[] = [] app.type('A', { access: () => true, process(ctx, action) { processed.push(action.data) }, resend(ctx, action) { resended.push(action.data) return {} } }) app.log.add({ data: 'server', type: 'A' }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 1, { data: 'client', type: 'A' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await sendTo(client, [ 'sync', 2, { channel: 'a', type: 'logux/subscribe' }, { id: [2, '10:1:uuid', 0], time: 2 } ]) await setTimeout(10) expect(resended).toEqual(['server', 'client']) expect(processed).toEqual(['server', 'client']) }) it('restores actions with old ID from history', async () => { let app = createServer() app.on('preadd', (action, meta) => { meta.reasons = [] }) let history: [Action, ServerMeta][] = [] app.channel('a', { access: () => true, load() { return history } }) app.type('A', { access: () => true, process(ctx, action, meta) { history.push([action, meta]) } }) let client1 = await connectClient(app, '10:1:uuid') await sendTo(client1, [ 'sync', 1, { type: 'A' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) let client2 = await connectClient(app, '10:1:other') await sendTo(client2, [ 'sync', 2, { channel: 'a', type: 'logux/subscribe' }, { id: [2, '10:1:uuid', 0], time: 2 } ]) await setTimeout(10) expect(actions(client2)).toEqual([{ type: 'A' }]) }) it('has shortcut to access and process in one callback', async () => { let app = createServer() app.log.keepActions() app.type('FOO', { async accessAndProcess(ctx, action, meta) { expect(typeof meta.id).toEqual('string') expect(action.type).toEqual('FOO') await ctx.sendBack({ type: 'REFOO' }) } }) app.otherType({ async accessAndProcess(ctx, action, meta) { expect(typeof meta.id).toEqual('string') expect(typeof action.type).toEqual('string') if (action.type === 'BAR') { await ctx.sendBack({ type: 'REBAR' }) } } }) app.channel('foo', { async accessAndLoad(ctx, action, meta) { expect(typeof meta.id).toEqual('string') expect(action.type).toEqual('logux/subscribe') return { type: 'FOO:load' } } }) app.otherChannel({ accessAndLoad(ctx, action, meta) { expect(typeof meta.id).toEqual('string') expect(action.type).toEqual('logux/subscribe') return [{ type: 'OTHER:load' }] } }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 1, { type: 'FOO' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) await sendTo(client, [ 'sync', 2, { type: 'BAR' }, { id: [2, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) await sendTo(client, [ 'sync', 3, { channel: 'foo', type: 'logux/subscribe' }, { id: [3, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) await sendTo(client, [ 'sync', 4, { channel: 'bar', type: 'logux/subscribe' }, { id: [4, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) expect(app.log.actions()).toEqual([ { type: 'FOO' }, { type: 'BAR' }, { channel: 'foo', type: 'logux/subscribe' }, { channel: 'bar', type: 'logux/subscribe' }, { type: 'REFOO' }, { id: '1 10:1:uuid 0', type: 'logux/processed' }, { type: 'REBAR' }, { id: '2 10:1:uuid 0', type: 'logux/processed' }, { type: 'FOO:load' }, { id: '3 10:1:uuid 0', type: 'logux/processed' }, { type: 'OTHER:load' }, { id: '4 10:1:uuid 0', type: 'logux/processed' } ]) }) it('process action exactly once with accessAndProcess callback', async () => { let app = createServer() app.log.keepActions() app.type('FOO', { async accessAndProcess(ctx) { await ctx.sendBack({ type: 'REFOO' }) } }) app.otherType({ async accessAndProcess(ctx, action) { if (action.type === 'BAR') { await ctx.sendBack({ type: 'REBAR' }) } } }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 1, { type: 'FOO' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) await sendTo(client, [ 'sync', 2, { type: 'BAR' }, { id: [2, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) expect(app.log.actions()).toEqual([ { type: 'FOO' }, { type: 'BAR' }, { type: 'REFOO' }, { id: '1 10:1:uuid 0', type: 'logux/processed' }, { type: 'REBAR' }, { id: '2 10:1:uuid 0', type: 'logux/processed' } ]) }) it('denies access on 403 error', async () => { let app = createServer() app.log.keepActions() let error404 = new ResponseError(404, '/a') let error403 = new ResponseError(403, '/a') let error = new Error('test') let catched: Error[] = [] app.on('error', e => { catched.push(e) }) app.type('E404', { accessAndProcess() { throw error404 } }) app.type('E403', { accessAndProcess() { throw error403 } }) app.type('ERROR', { async accessAndProcess() { throw error } }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 2, { type: 'E404' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) await sendTo(client, [ 'sync', 2, { type: 'E403' }, { id: [2, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) await sendTo(client, [ 'sync', 2, { type: 'ERROR' }, { id: [3, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) expect(app.log.actions()).toEqual([ { action: { type: 'E404' }, id: '1 10:1:uuid 0', reason: 'error', type: 'logux/undo' }, { action: { type: 'E403' }, id: '2 10:1:uuid 0', reason: 'denied', type: 'logux/undo' }, { action: { type: 'ERROR' }, id: '3 10:1:uuid 0', reason: 'error', type: 'logux/undo' } ]) expect(catched).toEqual([error404, error]) }) it('undoes action with notFound on 404 error', async () => { let app = createServer() app.log.keepActions() let error500 = new ResponseError(500, '/a') let error404 = new ResponseError(404, '/a') let error403 = new ResponseError(403, '/a') let error = new Error('test') let catched: Error[] = [] app.on('error', e => { catched.push(e) }) app.channel('e500', { accessAndLoad() { throw error500 } }) app.channel('e404', { accessAndLoad() { throw error404 } }) app.channel('e403', { accessAndLoad() { throw error403 } }) app.channel('error', { accessAndLoad() { throw error } }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 2, { channel: 'e500', type: 'logux/subscribe' }, { id: [1, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) await sendTo(client, [ 'sync', 2, { channel: 'e404', type: 'logux/subscribe' }, { id: [2, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) await sendTo(client, [ 'sync', 2, { channel: 'e403', type: 'logux/subscribe' }, { id: [3, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) await sendTo(client, [ 'sync', 2, { channel: 'error', type: 'logux/subscribe' }, { id: [4, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) expect(app.log.actions()).toEqual([ { channel: 'e500', type: 'logux/subscribe' }, { channel: 'e404', type: 'logux/subscribe' }, { channel: 'e403', type: 'logux/subscribe' }, { channel: 'error', type: 'logux/subscribe' }, { action: { channel: 'e500', type: 'logux/subscribe' }, id: '1 10:1:uuid 0', reason: 'error', type: 'logux/undo' }, { action: { channel: 'e404', type: 'logux/subscribe' }, id: '2 10:1:uuid 0', reason: 'notFound', type: 'logux/undo' }, { action: { channel: 'e403', type: 'logux/subscribe' }, id: '3 10:1:uuid 0', reason: 'denied', type: 'logux/undo' }, { action: { channel: 'error', type: 'logux/subscribe' }, id: '4 10:1:uuid 0', reason: 'error', type: 'logux/undo' } ]) expect(catched).toEqual([error500, error]) }) it('allows to throws LoguxNotFoundError', async () => { let app = createServer() app.log.keepActions() let catched: Error[] = [] app.on('error', e => { catched.push(e) }) app.channel('notFound', { accessAndLoad() { throw new LoguxNotFoundError() } }) let client = await connectClient(app, '10:1:uuid') await sendTo(client, [ 'sync', 2, { channel: 'notFound', type: 'logux/subscribe' }, { id: [2, '10:1:uuid', 0], time: 1 } ]) await setTimeout(100) expect(app.log.actions()).toEqual([ { channel: 'notFound', type: 'logux/subscribe' }, { action: { channel: 'notFound', type: 'logux/subscribe' }, id: '2 10:1:uuid 0', reason: 'notFound', type: 'logux/undo' } ]) }) it('undoes all other actions in a queue if error in one action occurs', async () => { let app = createServer() let calls: string[] = [] let errors: string[] = [] app.on('error', e => { errors.push(e.message) }) app.type( 'GOOD 0', { access: () => true, process() { calls.push('GOOD 0') } }, { queue: '1' } ) app.type( 'BAD', { access: () => true, async process() { await setTimeout(50) calls.push('BAD') throw new Error('BAD') } }, { queue: '1' } ) app.type( 'GOOD 1', { access: () => true, process() { calls.push('GOOD 1') } }, { queue: '1' } ) app.type( 'GOOD 2', { access: () => true, process() { calls.push('GOOD 2') } }, { queue: '1' } ) let client = await connectClient(app, '10:client:uuid') await sendTo(client, [ 'sync', 3, { type: 'GOOD 0' }, { id: [1, '10:client:other', 0], time: 1 }, { type: 'BAD' }, { id: [2, '10:client:other', 0], time: 1 }, { type: 'GOOD 1' }, { id: [3, '10:client:other', 0], time: 1 }, { type: 'GOOD 2' }, { id: [4, '10:client:other', 0], time: 1 } ]) await setTimeout(50) expect(errors).toEqual(['BAD']) expect(calls).toEqual(['GOOD 0', 'BAD']) }) it('does not add action with same ID to the queue', async () => { let app = createServer() let errors: string[] = [] let calls: string[] = [] app.on('error', e => { errors.push(e.message) }) app.type( 'FOO', { access: () => true, process: () => { calls.push('FOO') } }, { queue: '1' } ) app.type( 'BAR', { access: () => true, process: () => { calls.push('BAR') } }, { queue: '1' } ) app.type( 'BAZ', { access: () => true, process: () => { calls.push('BAZ') } }, { queue: '2' } ) app.type( 'BOM', { access: () => true, process: () => { calls.push('BOM') } }, { queue: '2' } ) let client = await connectClient(app, '10:client:uuid') await sendTo(client, [ 'sync', 4, { type: 'FOO' }, { id: [1, '10:client:other', 0], time: 1 }, { type: 'BAR' }, { id: [1, '10:client:other', 0], time: 1 }, { type: 'BAZ' }, { id: [1, '10:client:other', 0], time: 1 }, { type: 'BOM' }, { id: [2, '10:client:other', 0], time: 1 } ]) expect(errors).toEqual([]) expect(calls).toEqual(['FOO', 'BOM']) }) it('does not undo actions in one queue if error occurs in another queue', async () => { let app = createServer() let calls: string[] = [] let errors: string[] = [] app.on('error', e => { errors.push(e.message) }) app.type( 'BAD', { access: () => true, process() { calls.push('BAD') throw new Error('BAD') } }, { queue: '1' } ) app.type( 'GOOD 1', { access: () => true, async process() { await setTimeout(30) calls.push('GOOD 1') } }, { queue: '2' } ) app.type( 'GOOD 2', { access: () => true, process() { calls.push('GOOD 2') } }, { queue: '2' } ) let client = await connectClient(app, '10:client:uuid') await sendTo(client, [ 'sync', 3, { type: 'BAD' }, { id: [1, '10:client:other', 0], time: 1 }, { type: 'GOOD 1' }, { id: [2, '10:client:other', 0], time: 1 }, { type: 'GOOD 2' }, { id: [3, '10:client:other', 0], time: 1 } ]) await setTimeout(50) expect(errors).toEqual(['BAD']) expect(calls).toEqual(['BAD', 'GOOD 1', 'GOOD 2']) }) it('calls access, resend and process in a queue', async () => { let app = createServer() let calls: string[] = [] app.type('FOO', { async access() { await setTimeout(50) calls.push('FOO ACCESS') return true }, async process() { await setTimeout(50) calls.push('FOO PROCESS') }, async resend() { await setTimeout(50) calls.push('FOO RESEND') return '' } }) app.type('BAR', { async access() { calls.push('BAR ACCESS') return true }, async process() { calls.push('BAR PROCESS') }, async resend() { calls.push('BAR RESEND') return '' } }) let client = await connectClient(app, '10:client:uuid') await sendTo(client, [ 'sync', 2, { type: 'FOO' }, { id: [1, '10:client:other', 0], time: 1 }, { type: 'BAR' }, { id: [2, '10:client:other', 0], time: 1 } ]) await setTimeout(200) expect(calls).toEqual([ 'FOO ACCESS', 'FOO RESEND', 'FOO PROCESS', 'BAR ACCESS', 'BAR RESEND', 'BAR PROCESS' ]) }) it('undoes all other actions in a queue if some action should be undone', async () => { let test = createReporter() test.app.type('FOO', { access: () => false }) test.app.type('BAR', { access: () => true }) let client = await connectClient(test.app) await sendTo(client, [ 'sync', 3, { type: 'FOO' }, { id: [1, '10:uuid', 0], time: 1 }, { type: 'BAR' }, { id: [2, '10:uuid', 0], time: 1 } ]) expect(test.names).toEqual([ 'connect', 'authenticated', 'denied', 'add', 'add' ]) expect(test.app.log.actions()).toEqual([ { action: { type: 'FOO' }, id: '1 10:uuid 0', reason: 'denied', type: 'logux/undo' }, { action: { type: 'BAR' }, id: '2 10:uuid 0', reason: 'error', type: 'logux/undo' } ]) }) it('all actions are processed before destroy', async () => { let app = createServer() let calls: string[] = [] app.type( 'queue 1 task 1', { async access() { return true }, async process() { await setTimeout(30) calls.push('queue 1 task 1') } }, { queue: '1' } ) app.type( 'queue 1 task 2', { async access() { return true }, async process() { await setTimeout(30) calls.push('queue 1 task 2') } }, { queue: '1' } ) app.type( 'queue 2 task 1', { async access() { await setTimeout(50) return true }, async process() { calls.push('queue 2 task 1') } }, { queue: '2' } ) app.type( 'queue 2 task 2', { async access() { return true }, async process() { calls.push('queue 2 task 2') } }, { queue: '2' } ) app.type('during destroy', { async access() { return true }, async process() { calls.push('during destroy') } }) let client = await connectClient(app, '10:client:uuid') sendTo(client, [ 'sync', 4, { type: 'queue 1 task 1' }, { id: [1, client.nodeId!, 0], time: 1 }, { type: 'queue 1 task 2' }, { id: [2, client.nodeId!, 0], time: 1 }, { type: 'queue 2 task 1' }, { id: [3, client.nodeId!, 0], time: 1 }, { type: 'queue 2 task 2' }, { id: [4, client.nodeId!, 0], time: 1 } ]) await setTimeout(10) await app.destroy() expect(calls).toEqual([ 'queue 1 task 1', 'queue 2 task 1', 'queue 2 task 2', 'queue 1 task 2' ]) }) it('recognizes channel regex', async () => { let app = createServer() let calls: string[] = [] app.channel(/ba./, { access: () => true, load: (_, action) => { calls.push(action.channel) } }) let client = await connectClient(app, '10:client:uuid') await sendTo(client, [ 'sync', 3, { channel: 'bar', type: 'logux/subscribe' }, { id: [1, client.nodeId || '', 0], time: 1 }, { channel: 'baz', type: 'logux/subscribe' }, { id: [2, client.nodeId || '', 0], time: 1 }, { channel: 'bom', type: 'logux/subscribe' }, { id: [3, client.nodeId || '', 0], time: 1 } ]) expect(calls).toEqual(['bar', 'baz']) }) it('recognizes channel pattern', async () => { let app = createServer() let calls: string[] = [] app.channel('/api/users/:id', { access: () => true, load: (_, action) => { calls.push(action.channel) } }) let client = await connectClient(app, '10:client:uuid') await sendTo(client, [ 'sync', 3, { channel: '/api/users/5', type: 'logux/subscribe' }, { id: [1, client.nodeId || '', 0], time: 1 }, { channel: '/api/users/10', type: 'logux/subscribe' }, { id: [2, client.nodeId || '', 0], time: 1 }, { channel: '/api/users/10/9/8', type: 'logux/subscribe' }, { id: [3, client.nodeId || '', 0], time: 1 } ]) expect(calls).toEqual(['/api/users/5', '/api/users/10']) }) it('removes empty queues', async () => { let app = createServer() app.type('FOO', { access: () => true, process: async () => { await setTimeout(50) } }) app.type('BAR', { access: () => true }) let client = await connectClient(app, '10:client:uuid') sendTo(client, [ 'sync', 2, { type: 'FOO' }, { id: [1, '10:client:uuid', 0], time: 1 }, { type: 'BAR' }, { id: [2, '10:client:uuid', 0], time: 1 } ]) await setTimeout(10) expect(privateMethods(app).queues.size).toEqual(1) await setTimeout(50) expect(privateMethods(app).queues.size).toEqual(0) }) it('replaces Node class if necessary', async () => { class OtherNode extends FilteredNode { syncSinceQuery(): { added: number; entries: [Action, Meta][] } { return { added: 0, entries: [ [ { type: 'FOO' }, { added: 0, id: '1 server:uuid 0', reasons: [], time: 1 } ] ] } } } let app = createServer({ Node: OtherNode }) let client = await connectClient(app, '10:client:uuid') await setTimeout(10) expect(actions(client)).toEqual([{ type: 'FOO' }]) }) it('allows to change how server loads initial actions', async () => { let app = createServer({}) app.sendOnConnect(async (ctx, lastSync) => { expect(ctx.clientId).toEqual('10:client') expect(ctx.subprotocol).toEqual(1) expect(lastSync).toEqual(0) return [ [ { type: 'FOO' }, { added: 0, id: '1 server:uuid 0', reasons: [], server: '', time: 2 } ], [ { type: 'BAR' }, { added: 0, id: '1 server:uuid 0', reasons: [], server: '', time: 1 } ] ] }) let client = await connectClient(app, '10:client:uuid') await setTimeout(10) expect(actions(client)).toEqual([{ type: 'BAR' }, { type: 'FOO' }]) }) ================================================ FILE: test/fixtures/cert.pem ================================================ -----BEGIN CERTIFICATE----- MIID7jCCAtagAwIBAgIUO6cuMpNPGoG1mUO5WcSQMzvNaHkwDQYJKoZIhvcNAQEL BQAwZjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAk5ZMREwDwYDVQQHEwhOZXcgWW9y azEOMAwGA1UEChMFTG9ndXgxEzARBgNVBAsTCk9wZXJhdGlvbnMxEjAQBgNVBAMT CWxvZ3V4Lm9yZzAeFw0xODA1MTAyMDQ0MDBaFw0yMzA1MDkyMDQ0MDBaMEwxCzAJ BgNVBAYTAlJVMQ8wDQYDVQQIEwZNb3Njb3cxDDAKBgNVBAcTA01TSzEeMBwGA1UE AxMVbG9jYWxob3N0LmFtcGxpZnIuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A MIIBCgKCAQEAzD7FPEH7miQ8CZDPI2OWgPF5CXzqojns1bPkEhhEPrGEBtRkYETh Fpa0+Z1XD61t++t2yoAY1V09V7yV8EGyFZzeVL3IRiDUm0IE8wiuhvG5pCRwOxL2 W2d8+jU4Xu7lmo2IGuZbQyCc80NcNS+fnY8uH7aYPKN09KaJQ2l5LnE3czncB6CW LiNA3rbQKgufjgODl1NsNmgS7yNhFwMJcl09mpdEY/wDpXshLYd0phAi0rz0ypcJ UlaCvoU7dRT3k/8jBKa6hZHSxnaXercwdb/bHhBm8qpDRfcr1sGeM9rxwi4fgp8L U/pdNQ1ALfDqCGv8sOCVEZsaTgrowRVxIwIDAQABo4GtMIGqMA4GA1UdDwEB/wQE AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW BBT15Q7OhWASNHp2ZXD0MEVm6NKurzAfBgNVHSMEGDAWgBT1qRLU7Q0DOb2ewaOL P0ZpcRS8SzA1BgNVHREELjAsghVsb2NhbGhvc3QuYW1wbGlmci5jb22CDSouYW1w bGlmci5jb22HBH8AAAEwDQYJKoZIhvcNAQELBQADggEBALKi2D2BDIYIeqlfctnx PKTbwZCeWOLjCdD8sgcLXOL6QVidWHOnlfU5J2mtoThcbAr8eJ+DXRm4ps/pLSkG Esd1H6Ki/M3EPzAm1aALkPeBram3TFKpBLaZproIVgKiL1WQeCqCrn7ZAck1A/Lj CVVtn8hC02SA5JpMs39PYCa7X9F5bGeYbGJqh0z87HuG6Tfae/B9LHPiormCUj6N BvR10juL1f6ai7dUjTz704OmrFHqCMo+Bdf+pzTsLZqmmSB4B//bmh/mFMuTfm1X ocHvKwcTf4EYdHZroh0/kuTMXgww+cujNND6W70lYfOkfIJ8T22kwb/uqCPtgRRd 1i8= -----END CERTIFICATE----- ================================================ FILE: test/fixtures/key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAzD7FPEH7miQ8CZDPI2OWgPF5CXzqojns1bPkEhhEPrGEBtRk YEThFpa0+Z1XD61t++t2yoAY1V09V7yV8EGyFZzeVL3IRiDUm0IE8wiuhvG5pCRw OxL2W2d8+jU4Xu7lmo2IGuZbQyCc80NcNS+fnY8uH7aYPKN09KaJQ2l5LnE3cznc B6CWLiNA3rbQKgufjgODl1NsNmgS7yNhFwMJcl09mpdEY/wDpXshLYd0phAi0rz0 ypcJUlaCvoU7dRT3k/8jBKa6hZHSxnaXercwdb/bHhBm8qpDRfcr1sGeM9rxwi4f gp8LU/pdNQ1ALfDqCGv8sOCVEZsaTgrowRVxIwIDAQABAoIBAQCkM5K97w4nzhm2 VwUwnk/ROlDkn9jCs28EH6usIHY9MNnD490OyFFtp5u3Uhc8M2HItnS6OGG+p0c5 0hN5JFfXqFXWKv1n490JNPplqQUm2A83N1RDKeuFcJ25SjAXolhU+JQDjE6ymPWV XQI0gCUCtqmONW4O0hqk1X5lA9a4zjuZkDWCUNuuLR/udoEJmgf26Hf8uLlvRiOD RCOzOTN+xHzUJhFMGx7VsL9cBxyKL55VnXfXAxJUwbUSJmMFuEz+1FEkXYiudYfn 6mPbicmM36JJkZCbwT4Z9Icx62WO9vR7PocFParb/iiBG1cEBbNdAfBDJUrkHiOT Ws3nj0uBAoGBANIciSOFlPePzkJ3InogJG1A4kyYeIAjjh4dZssHCYjHj9/MRfkD zQ+QYd0Jw3/yxw8+oNjxx6Fd7D5opTB6SfD6b74dW8DRSC5FkvJ0cums1CrTXNUw mhEDrR8UlgmsNNGHspyspWm2BCFUwkPY5aDYwo0B3HzoWf4AzipwnUYFAoGBAPja P3UETswZ+YCQXK0bkv7o/2crZajKAq3L3nJ/x98VWIsqHX4hOxsGy+PBx+EGkHqP O4dDQwntoc3DH3RCajm380qB2QWeN88eJb6mbIV9O/+ysyGXX+kM8kZpJQN+Ql4t VGWCYZHxuPPQakaT397mywiTKGinneVBCjSzrhsHAoGAHIovvpl4gKAR/kk8b6ZK DGUR2CGlzJIHzeNkgRN1ohSpYFbY8lgn1INiJ6oZ2hlaHKH/Kzi8Sxj87AU+2vTh clAyOXq3aduDmHVu3mwe58rIDwEizPqLEuCS9XPQZYP0sLlj85An79H/gZ+Hu7uM hWqsEoc8MeNFxhDJ8E3XrxkCgYAWSQ03wHwCAS172vHBut9uHpWIurUu6XBV+hTg shrHGpVEWTAs9HLjl7c4nUj2GO1lXGBbW6WsRPChiaDOe4ghxRxvhrNVsnaTAMMm kKbVSYLPAkTSdEjtiPBFZ/Mdnff5kRumv4dXV4tVoktyKJn6zzZNfUg4HxKfzjRI xfKIjwKBgCVZHfS0UL9gM0wROa7axzifRuUNn2UzESmfnZlyMOhiTWQ2zb+avRQ4 dK16NACGe+ZbYivra+wUIv7x/iSsi37Rz7rl8WFC9Q9GiylLqeUlezedekTsrdwB VVVEAxq6meQEY6dcgPOnwamfm86diHCeAesmCtB4AJAQfkKm59Ku -----END RSA PRIVATE KEY----- ================================================ FILE: test/force-colors.js ================================================ process.env.FORCE_COLOR = '1' ================================================ FILE: test/servers/autoload-error-modules.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server({ minSubprotocol: 1, root: import.meta.dirname, subprotocol: 1 }) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) await app.listen().then(async () => { await app.autoloadModules('error-modules/*/index.js') }) ================================================ FILE: test/servers/autoload-modules.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server({ minSubprotocol: 1, root: import.meta.dirname, subprotocol: 1 }) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) await app.autoloadModules().then(async () => { await app.listen() }) ================================================ FILE: test/servers/destroy.js ================================================ #!/usr/bin/env node import { setTimeout } from 'node:timers/promises' import { Server } from '../../index.js' let app = new Server({ minSubprotocol: 1, port: 2000, subprotocol: 1 }) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) app.unbind.push(async () => { await setTimeout(10) app.logger.info('Custom destroy task finished') }) await app.listen() process.on('message', async msg => { if (msg === 'close') { console.error('close') await app.destroy() } }) ================================================ FILE: test/servers/eacces.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server({ minSubprotocol: 1, port: 1000, subprotocol: 1 }) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) await app.listen() ================================================ FILE: test/servers/eaddrinuse.js ================================================ #!/usr/bin/env node import os from 'node:os' import { Server } from '../../index.js' os.platform = () => 'linux' let app = new Server({ minSubprotocol: 1, port: 2001, subprotocol: 1 }) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) await app.listen() ================================================ FILE: test/servers/error-modules/wrond-export/index.js ================================================ export default 'wrong module export' ================================================ FILE: test/servers/json.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server({ logger: 'json', minSubprotocol: 1, port: 2000, subprotocol: 1 }) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) await app.listen() ================================================ FILE: test/servers/logger.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server( Server.loadOptions(process, { host: '127.0.0.1', minSubprotocol: 1, subprotocol: 1 }) ) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) app.logger.info({ field: 1 }, 'Hi from custom logger') app.logger.debug('Debug message') await app.listen() ================================================ FILE: test/servers/missed.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server( Server.loadOptions(process, { host: '127.0.0.1', subprotocol: 1 }) ) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) await app.listen() ================================================ FILE: test/servers/modules/child/index.foo.js ================================================ setTimeout(() => { let error = new Error('Test Error') error.stack = `${error.stack.split('\n')[0]}\nfake stacktrace` throw error }, 10) ================================================ FILE: test/servers/modules/child/index.js ================================================ import { setTimeout } from 'node:timers/promises' export default async server => { await setTimeout(100) console.log(`Child path module: ${server.options.subprotocol}`) } ================================================ FILE: test/servers/modules/child/lib/lib.js ================================================ setTimeout(() => { let error = new Error('Test Error') error.stack = `${error.stack.split('\n')[0]}\nfake stacktrace` throw error }, 50) ================================================ FILE: test/servers/modules/root.js ================================================ export default server => { console.log(`Root path module: ${server.options.subprotocol}`) } ================================================ FILE: test/servers/modules/root.test.js ================================================ throw new Error('No load') ================================================ FILE: test/servers/options.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server( Server.loadOptions(process, { host: '127.0.0.1', minSubprotocol: 1, subprotocol: 1 }) ) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) await app.listen() ================================================ FILE: test/servers/root.js ================================================ #!/usr/bin/env node import { join } from 'node:path' import { Server } from '../../index.js' let app = new Server( Server.loadOptions(process, { host: '127.0.0.1', minSubprotocol: 1, root: join(import.meta.dirname, '..', 'fixtures'), subprotocol: 1 }) ) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) await app.listen() ================================================ FILE: test/servers/throw.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server({ minSubprotocol: 1, subprotocol: 1 }) app.nodeId = 'server:FnXaqDxY' app.on('fatal', e => app.logger.info(`Fatal event: ${e.message}`)) setTimeout(() => { let error = new Error('Test Error') error.stack = `${error.stack.split('\n')[0]}\nfake stacktrace` throw error }, 10) ================================================ FILE: test/servers/unbind.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server({ minSubprotocol: 1, subprotocol: 1 }) app.nodeId = 'server:FnXaqDxY' await app.destroy() setTimeout(() => {}, 10000) ================================================ FILE: test/servers/uncatch.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server({ minSubprotocol: 1, subprotocol: 1 }) app.nodeId = 'server:FnXaqDxY' app.on('fatal', e => app.logger.info(`Fatal event: ${e.message}`)) await new Promise((resolve, reject) => { setTimeout(() => { let error = new Error('Test Error') error.stack = `${error.stack.split('\n')[0]}\nfake stacktrace` reject(error) }, 50) }) ================================================ FILE: test/servers/unknown.js ================================================ #!/usr/bin/env node import { Server } from '../../index.js' let app = new Server( Server.loadOptions(process, { host: '127.0.0.1', maxSubprotocol: 1, subprotocol: 1 }) ) app.nodeId = 'server:FnXaqDxY' app.auth(async () => true) await app.listen() ================================================ FILE: test-client/index.d.ts ================================================ import type { LoguxSubscribeAction, LoguxUnsubscribeAction } from '@logux/actions' import type { Action, AnyAction, ClientNode, TestLog, TestPair } from '@logux/core' import type { ServerMeta } from '../base-server/index.js' import type { TestServer } from '../test-server/index.js' export class LoguxActionError extends Error { action: Action } export interface TestClientOptions { cookie?: object headers?: object httpHeaders?: { [key: string]: string } subprotocol?: number token?: string } /** * Client to test server. * * ```js * import { TestServer } from '@logux/server' * import postsModule from './posts.js' * import authModule from './auth.js' * * let destroyable * afterEach(() => { * if (destroyable) destroyable.destroy() * }) * * function createServer () { * destroyable = new TestServer() * return destroyable * } * * it('check auth', () => { * let server = createServer() * authModule(server) * await server.connect('1', { token: 'good' }) * expect(() => { * await server.connect('2', { token: 'bad' }) * }).rejects.toEqual({ * error: 'Wrong credentials' * }) * }) * * it('creates and loads posts', () => { * let server = createServer() * postsModule(server) * let client1 = await server.connect('1') * await client1.process({ type: 'posts/add', post }) * let client1 = await server.connect('2') * expect(await client2.subscribe('posts')).toEqual([ * { type: 'posts/add', post } * ]) * }) * ``` */ export class TestClient { /** * Client’s ID. * * ```js * let client = new TestClient(server, '10') * client.clientId //=> '10:1' * ``` */ clientId: string /** * Client’s log with extra methods to check actions inside. * * ```js * console.log(client.log.entries()) * ``` */ log: TestLog /** * Logux node. */ node: ClientNode> /** * Client’s node ID. * * ```js * let client = new TestClient(server, '10') * client.nodeId //=> '10:1:1' * ``` */ nodeId: string /** * Connection channel between client and server to track sent messages. * * ```js * console.log(client.pair.leftSent) * ``` */ pair: TestPair /** * User ID. * * ```js * let client = new TestClient(server, '10') * client.userId //=> '10' * ``` */ userId: string /** * @param server Test server. * @param userId User ID. * @param opts Other options. */ constructor(server: TestServer, userId: string, opts?: TestClientOptions) /** * Collect actions added by server and other clients during the `test` call. * * ```js * let answers = await client.collect(async () => { * client.log.add({ type: 'pay' }) * await delay(10) * }) * expect(actions).toEqual([{ type: 'paid' }]) * ``` * * @param test Function, where do you expect action will be received * @returns Promise with all received actions */ collect(test: () => Promise): Promise /** * Connect to test server. * * ```js * let client = new TestClient(server, '10') * await client.connect() * ``` * * @params Connection credentials. * @returns Promise until the authorization. */ connect(opts?: { token: string }): Promise /** * Disconnect from test server. * * ```js * await client.disconnect() * ``` * * @returns Promise until connection close. */ disconnect(): Promise /** * Send action to the sever and collect all response actions. * * ```js * await client.process({ type: 'posts/add', post }) * let posts = await client.subscribe('posts') * expect(posts).toHaveLength(1) * ``` * * @param action New action. * @param meta Optional action’s meta. * @returns Promise until `logux/processed` answer. */ process(action: AnyAction, meta?: Partial): Promise /** * Collect actions received from server during the `test` call. * * ```js * let answers = await client1.received(async () => { * await client2.process({ type: 'resend' }) * }) * expect(actions).toEqual([{ type: 'local' }]) * ``` * * @param test Function, where do you expect action will be received * @returns Promise with all received actions */ received(test: () => unknown): Promise /** * Subscribe to the channel and collect all actions during the subscription. * * ```js * let posts = await client.subscribe('posts') * expect(posts).toEqual([ * { type: 'posts/add', post } * ]) * ``` * * @param channel Channel name or `logux/subscribe` action. * @param filter Optional filter for subscription. * @param since Optional time from last data. * @returns Promise with all actions from the server. */ subscribe( channel: LoguxSubscribeAction | string, filter?: object, since?: { id: string; time: number } ): Promise /** * Unsubscribe client from the channel. * * ```js * await client.unsubscribe('posts') * ``` * * @param channel Channel name or `logux/subscribe` action. * @param filter Optional filter for subscription. * @returns Promise until server will remove client from subscribers. */ unsubscribe( channel: LoguxUnsubscribeAction | string, filter?: object ): Promise } ================================================ FILE: test-client/index.js ================================================ import { ClientNode, TestPair } from '@logux/core' import cookie from 'cookie' import { setTimeout } from 'node:timers/promises' import { filterMeta } from '../filter-meta/index.js' export class TestClient { constructor(server, userId, opts = {}) { this.server = server this.pair = new TestPair() let clientId = server.testUsers[userId] || 0 clientId += 1 server.testUsers[userId] = clientId this.userId = userId this.clientId = `${userId}:${clientId}` this.nodeId = `${this.clientId}:1` this.log = server.options.time.nextLog({ nodeId: this.nodeId }) this.node = new ClientNode(this.nodeId, this.log, this.pair.left, { ...opts, fixTime: false, onSend(action, meta) { return [action, filterMeta(meta)] } }) this.pair.right.ws = { _socket: { remoteAddress: '127.0.0.1' }, upgradeReq: { headers: opts.httpHeaders || {} } } if (opts.headers) { this.node.setLocalHeaders(opts.headers) } if (opts.cookie) { this.pair.right.ws.upgradeReq.headers.cookie = Object.keys(opts.cookie) .map(i => cookie.serialize(i, opts.cookie[i])) .join('; ') } server.unbind.push(() => { this.node.destroy() }) } async collect(test) { let added = [] let unbind = this.node.log.on('add', (action, meta) => { if (!meta.id.includes(` ${this.nodeId} `)) { added.push(action) } }) await test() unbind() return added } connect() { return new Promise((resolve, reject) => { this.node.throwsError = false let unbind = this.node.on('error', e => { if (e.name === 'LoguxError' && e.type === 'wrong-credentials') { reject(new Error('Wrong credentials')) } else { reject(e) } }) this.server.addClient(this.pair.right) void this.node.connection.connect() void this.node.waitFor('synchronized').then(() => { this.node.throwsError = true unbind() resolve() }) }) } disconnect() { this.node.connection.disconnect() return this.pair.wait('right') } process(action, meta) { return this.collect(async () => { return new Promise((resolve, reject) => { let id let lastError let unbindError = this.server.on('error', e => { lastError = e }) let unbindProcessed = this.log.type('logux/processed', other => { if (other.id === id) { unbindProcessed() unbindUndo() unbindError() resolve() } }) let unbindUndo = this.log.type('logux/undo', other => { if (other.id === id) { unbindProcessed() unbindUndo() unbindError() let error if (other.reason === 'denied') { error = new Error('Action was denied') } else if (other.reason === 'unknownType') { error = new Error( `Server does not have callbacks for ${action.type} actions` ) } else if (other.reason === 'wrongChannel') { error = new Error( `Server does not have callbacks for ${action.channel} channel` ) } else if (lastError) { error = lastError } else { error = new Error('Server undid action') } error.action = other reject(error) } }) this.log.add(action, meta).then(newMeta => { if (newMeta) { id = newMeta.id } else { reject(new Error(`Action ${meta.id} was already in log`)) } }) }) }) } async received(test) { let actions = [] let unbind = this.log.on('add', (action, meta) => { if (!meta.id.includes(` ${this.nodeId} `)) { actions.push(action) } }) await test() await setTimeout(1) unbind() return actions } async subscribe(channel, filter, since) { let action = channel if (typeof channel === 'string') { action = { channel, type: 'logux/subscribe' } } if (filter) { action.filter = filter } if (since) { action.since = since } let actions = await this.process(action) return actions.filter(i => i.type !== 'logux/processed') } unsubscribe(channel, filter) { let action = channel if (typeof channel === 'string') { action = { channel, type: 'logux/unsubscribe' } } if (filter) { action.filter = filter } return this.process(action) } } ================================================ FILE: test-client/index.test.ts ================================================ import { TestTime } from '@logux/core' import { restoreAll, type Spy, spyOn } from 'nanospy' import { setTimeout } from 'node:timers/promises' import { afterEach, expect, it } from 'vitest' import { type LoguxActionError, TestClient, TestServer } from '../index.js' let server: TestServer afterEach(() => { restoreAll() server.destroy() }) async function catchError(cb: () => Promise): Promise { let err: LoguxActionError | undefined try { await cb() } catch (e) { err = e as LoguxActionError } if (!err) throw new Error('Error was no thrown') return err } function privateMethods(obj: object): any { return obj } it('connects and disconnect', async () => { server = new TestServer() let client1 = new TestClient(server, '10') let client2 = new TestClient(server, '10') expect(client1.nodeId).toEqual('10:1:1') expect(client1.clientId).toEqual('10:1') expect(client1.userId).toEqual('10') expect(client2.nodeId).toEqual('10:2:1') await Promise.all([client1.connect(), client2.connect()]) expect(Array.from(server.clientIds.keys())).toEqual(['10:1', '10:2']) await client1.disconnect() expect(Array.from(server.clientIds.keys())).toEqual(['10:2']) }) it('sends and collect actions', async () => { server = new TestServer() server.type('FOO', { access: () => true, process(ctx) { ctx.sendBack({ type: 'BAR' }) } }) server.type('RESEND', { access: () => true, resend: () => ({ user: '10' }) }) let [client1, client2] = await Promise.all([ server.connect('10'), server.connect('11') ]) client1.log.keepActions() let received = await client1.collect(async () => { await client1.log.add({ type: 'FOO' }) await setTimeout(10) await client2.log.add({ type: 'RESEND' }) await setTimeout(10) }) expect(received).toEqual([ { type: 'BAR' }, { id: '1 10:1:1 0', type: 'logux/processed' }, { type: 'RESEND' } ]) expect(client1.log.actions()).toEqual([ { type: 'FOO' }, { type: 'BAR' }, { id: '1 10:1:1 0', type: 'logux/processed' }, { type: 'RESEND' } ]) }) it('allows to change time', () => { let time = new TestTime() let server1 = new TestServer({ time }) let server2 = new TestServer({ time }) expect(server1.options.time).toBe(time) expect(server2.options.time).toBe(time) expect(server1.nodeId).not.toEqual(server2.nodeId) }) it('tracks action processing', async () => { server = new TestServer() server.type('FOO', { access: () => true }) server.type('ERR', { access: () => true, process() { throw new Error('test') } }) server.type('DENIED', { access: () => false }) server.type('UNDO', { access: () => true, process(ctx, action, meta) { server.undo(action, meta) } }) let client = await server.connect('10') let processed = await client.process({ type: 'FOO' }) expect(processed).toEqual([{ id: '1 10:1:1 0', type: 'logux/processed' }]) let notDenied = await catchError(async () => { await server.expectDenied(() => client.process({ type: 'FOO' })) }) expect(notDenied.message).toEqual('Actions passed without error') let serverError = await catchError(() => client.process({ type: 'ERR' })) expect(serverError.message).toEqual('test') expect(serverError.action).toEqual({ action: { type: 'ERR' }, id: '5 10:1:1 0', reason: 'error', type: 'logux/undo' }) let accessError = await catchError(() => client.process({ type: 'DENIED' })) expect(accessError.message).toEqual('Action was denied') await server.expectDenied(() => client.process({ type: 'DENIED' })) let unknownError = await catchError(() => client.process({ type: 'UNKNOWN' })) expect(unknownError.message).toEqual( 'Server does not have callbacks for UNKNOWN actions' ) let customError1 = await catchError(() => client.process({ type: 'UNDO' })) expect(customError1.message).toEqual('Server undid action') let customError2 = await catchError(async () => { await server.expectDenied(() => client.process({ type: 'UNDO' })) }) expect(customError2.message).toEqual('Undo was with error reason, not denied') await server.expectUndo('error', () => client.process({ type: 'UNDO' })) let reasonError = await catchError(async () => { await server.expectUndo('another', () => client.process({ type: 'UNDO' })) }) expect(reasonError.message).toEqual('Undo was with error reason, not another') let noReasonError = await catchError(async () => { await server.expectUndo('error', () => client.process({ type: 'UNKNOWN' })) }) expect(noReasonError.message).toEqual( 'Server does not have callbacks for UNKNOWN actions' ) await server.expectError('test', async () => { await client.process({ type: 'ERR' }) }) await server.expectError(/te/, async () => { await client.process({ type: 'ERR' }) }) let wrongMessageError = await catchError(async () => { await server.expectError('te', async () => { await client.process({ type: 'ERR' }) }) }) expect(wrongMessageError.message).toEqual('test') let noErrorError = await catchError(async () => { await server.expectError('te', async () => { await client.process({ type: 'FOO' }) }) }) expect(noErrorError.message).toEqual('Actions passed without error') }) it('detects action ID duplicate', async () => { server = new TestServer() server.type('FOO', { access: () => true }) let client = await server.connect('10') client.log.keepActions() let processed = await client.process({ type: 'FOO' }, { id: '1 10:1:1 0' }) expect(processed).toEqual([{ id: '1 10:1:1 0', type: 'logux/processed' }]) let err = await catchError(async () => { await client.process({ type: 'FOO' }, { id: '1 10:1:1 0' }) }) expect(err.message).toEqual('Action 1 10:1:1 0 was already in log') }) it('tracks subscriptions', async () => { server = new TestServer() server.channel('foo', { access: () => true, load(ctx, action) { ctx.sendBack({ a: action.filter?.a, since: action.since, type: 'FOO' }) } }) let client = await server.connect('10') let actions1 = await client.subscribe('foo') expect(actions1).toEqual([{ a: undefined, type: 'FOO' }]) await client.unsubscribe('foo') expect(privateMethods(server).subscribers).toEqual({}) let actions2 = await client.subscribe('foo', { a: 1 }) expect(actions2).toEqual([{ a: 1, type: 'FOO' }]) let actions3 = await client.subscribe('foo', undefined, { id: '1 1:0:0', time: 1 }) expect(actions3).toEqual([{ since: { id: '1 1:0:0', time: 1 }, type: 'FOO' }]) await client.unsubscribe('foo', { a: 1 }) expect(privateMethods(server).subscribers).toEqual({ foo: { '10:1:1': { filters: { '{}': true } } } }) await client.unsubscribe('foo') expect(privateMethods(server).subscribers).toEqual({}) let actions4 = await client.subscribe({ channel: 'foo', filter: { a: 2 }, type: 'logux/subscribe' }) expect(actions4).toEqual([{ a: 2, type: 'FOO' }]) let unknownError = await catchError(() => client.subscribe('unknown')) expect(unknownError.message).toEqual( 'Server does not have callbacks for unknown channel' ) }) it('prints server log', async () => { let reporterStream = { write() {} } spyOn(reporterStream, 'write', () => {}) server = new TestServer({ logger: { stream: reporterStream } }) await server.connect('10:uuid') expect((reporterStream.write as any as Spy).callCount).toEqual(2) }) it('tests authentication', async () => { server = new TestServer() server.options.minSubprotocol = 1 server.auth(({ token, userId }) => userId === '10' && token === 'good') let wrong = await catchError(async () => { await server.connect('10', { subprotocol: 1, token: 'bad' }) }) expect(wrong.message).toEqual('Wrong credentials') await server.expectWrongCredentials('10', { subprotocol: 1, token: 'bad' }) let error1 = await catchError(async () => { await server.connect('10', { subprotocol: 0 }) }) expect(error1.message).toContain('wrong-subprotocol') await server.connect('10', { subprotocol: 1, token: 'good' }) let notWrong = await catchError(async () => { await server.expectWrongCredentials('10', { subprotocol: 1, token: 'good' }) }) expect(notWrong.message).toEqual('Credentials passed') }) it('disables build-in auth', () => { server = new TestServer({ auth: false }) expect(privateMethods(server).authenticator).not.toBeDefined() }) it('sets client headers', async () => { server = new TestServer() await server.connect('10', { headers: { locale: 'fr' } }) let node = server.clientIds.get('10:1')?.node expect(node?.remoteHeaders).toEqual({ locale: 'fr' }) }) it('sets client cookie', async () => { server = new TestServer() server.auth(({ cookie }) => cookie.token === 'good') await server.connect('10', { cookie: { token: 'good' } }) await server.expectWrongCredentials('10', { cookie: { token: 'bad' } }) }) it('sets custom HTTP headers', async () => { server = new TestServer() server.auth(({ client }) => client.httpHeaders.authorization === 'good') await server.connect('10', { httpHeaders: { authorization: 'good' } }) await server.expectWrongCredentials('10', { httpHeaders: { authorization: 'bad' } }) await server.expectWrongCredentials('10') }) it('collects received actions', async () => { server = new TestServer() server.type('foo', { access: () => true, process(ctx) { ctx.sendBack({ type: 'bar' }) } }) let client = await server.connect('10') let actions = await client.received(async () => { await client.process({ type: 'foo' }) }) expect(actions).toEqual([ { type: 'bar' }, { id: '1 10:1:1 0', type: 'logux/processed' } ]) }) it('receives HTTP requests', async () => { server = new TestServer() server.http('GET', '/a', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end(String(req.headers['x-test'] ?? 'empty')) }) let response1 = await server.fetch('/a') expect(response1.headers.get('Content-Type')).toEqual('text/plain') expect(await response1.text()).toEqual('empty') let response2 = await server.fetch('/a', { headers: [['X-Test', '1']] }) expect(await response2.text()).toEqual('1') let response3 = await server.fetch('/b') expect(response3.status).toEqual(404) let response4 = await server.fetch('/a', { method: 'POST' }) expect(response4.status).toEqual(404) }) it('does not block login because of bruteforce', async () => { server = new TestServer() server.auth(({ userId }) => { return userId === 'good' }) await server.expectWrongCredentials('bad1') await server.expectWrongCredentials('bad2') await server.expectWrongCredentials('bad3') await server.expectWrongCredentials('bad4') await server.expectWrongCredentials('bad5') await server.connect('good') }) it('destroys on fatal', () => { server = new TestServer() // @ts-expect-error server.emitter.emit('fatal') // @ts-expect-error expect(server.destroying).toBe(true) }) ================================================ FILE: test-server/index.d.ts ================================================ import type { TestLog, TestTime } from '@logux/core' import { BaseServer } from '../base-server/index.js' import type { BaseServerOptions, Logger, ServerMeta } from '../base-server/index.js' import type { LoggerOptions } from '../server/index.js' import type { TestClient, TestClientOptions } from '../test-client/index.js' export interface TestServerOptions extends Omit< BaseServerOptions, 'minSubprotocol' | 'subprotocol' > { /** * Disable built-in auth. */ auth?: false /** * Logger with custom settings. */ logger?: Logger | LoggerOptions minSubprotocol?: number subprotocol?: number } /** * Server to be used in test. * * ```js * import { TestServer } from '@logux/server' * import usersModule from './users.js' * * let server * afterEach(() => { * if (server) server.destroy() * }) * * it('connects to the server', () => { * server = new TestServer() * usersModule(server) * let client = await server.connect('10') * }) * ``` */ export class TestServer< Headers extends object = unknown > extends BaseServer { /** * fetch() compatible API to test HTTP endpoints. * * ```js * server.http('GET', '/version', (req, res) => { * res.end('1.0.0') * }) * let res = await server.fetch() * expect(await res.text()).toEqual('1.0.0') * ``` */ fetch: typeof fetch /** * Server actions log, with methods to check actions inside. * * ```js * server.log.actions() //=> […] * ``` */ log: TestLog /** * Time replacement without variable parts like current timestamp. */ time: TestTime /** * @param opts The limit subset of server options. */ constructor(opts?: TestServerOptions) /** * Create and connect client. * * ```js * server = new TestServer() * let client = await server.connect('10') * ``` * * @param userId User ID. * @param opts Other options. * @returns Promise with new client. */ connect(userId: string, opts?: TestClientOptions): Promise /** * Call callback and throw an error if there was no `Action was denied` * during callback. * * ```js * await server.expectDenied(async () => { * client.subscribe('secrets') * }) * ``` * * @param test Callback with subscripting or action sending. */ expectDenied(test: () => unknown): Promise /** * Call callback and throw an error if there was no error during * server processing. * * @param text RegExp or string of error message. * @param test Callback with subscripting or action sending. */ expectError(text: RegExp | string, test: () => unknown): Promise /** * Call callback and throw an error if there was no `logux/undo` in return * with specific reason. * * ```js * await server.expectUndo('notFound', async () => { * client.subscribe('projects/nothing') * }) * ``` * * @param reason The reason in undo action. * @param test Callback with subscripting or action sending. */ expectUndo(reason: string, test: () => unknown): Promise /** * Try to connect client and throw an error is client didn’t received * `Wrong Cregentials` message from the server. * * ```js * server = new TestServer() * await server.expectWrongCredentials('10') * ``` * * @param userId User ID. * @param opts Other options. * @returns Promise until check. */ expectWrongCredentials( userId: string, opts?: TestClientOptions ): Promise } ================================================ FILE: test-server/index.js ================================================ import { TestTime } from '@logux/core' import { createServer } from 'node:http' import { BaseServer } from '../base-server/index.js' import { createReporter } from '../create-reporter/index.js' import { TestClient } from '../test-client/index.js' export class TestServer extends BaseServer { constructor(opts = {}) { if (!opts.time) { opts.time = new TestTime() } opts.time.lastId += 1 super({ id: `${opts.time.lastId}`, minSubprotocol: 0, subprotocol: 0, ...opts }) if (opts.logger) { this.on('report', createReporter(opts)) } else { this.logger = { debug: () => {}, error: () => {}, fatal: () => {}, info: () => {}, warn: () => {} } } if (opts.auth !== false) this.auth(() => true) this.testUsers = {} this.fetch = this.fetch.bind(this) this.on('fatal', async () => { await this.destroy() }) } async connect(userId, opts = {}) { let client = new TestClient(this, userId, opts) await client.connect() return client } async expectDenied(test) { await this.expectUndo('denied', test) } async expectError(text, test) { try { await test() throw new Error('Actions passed without error') } catch (e) { if ( (typeof text === 'string' && e.message !== text) || (text instanceof RegExp && !text.test(e.message)) ) { throw e } } } async expectUndo(reason, test) { try { await test() throw new Error('Actions passed without error') } catch (e) { if (reason === 'denied' && e.message === 'Action was denied') return if (e.message === 'Server undid action') { if (e.action.reason !== reason) { throw new Error( `Undo was with ${e.action.reason} reason, not ${reason}`, { cause: e } ) } } else { throw e } } } async expectWrongCredentials(userId, opts = {}) { try { await this.connect(userId, opts) throw new Error('Credentials passed') } catch (e) { if (e.message !== 'Wrong credentials') { throw e } } } async fetch(path, init) { let server = createServer(async (req, res) => { await this.processHttp(req, res) server.close() }) await new Promise(resolve => { server.listen(0, resolve) }) let { port } = server.address() return fetch(`http://localhost:${port}${path}`, init) } isBruteforce() { return false } } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2024", "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, "skipLibCheck": true, "allowJs": true, "strict": true, "noEmit": true }, "exclude": ["**/errors.ts"] } ================================================ FILE: vite.config.ts ================================================ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { coverage: { exclude: [ 'node_modules', 'server/index.js', 'test/*', '**/*.d.ts', '**/*.test.ts', '*/errors.ts', '*/types.ts', '*.config.*', 'human-formatter' ], provider: 'v8', thresholds: { lines: 100 } }, environment: 'node', exclude: ['node_modules', 'test/servers'] } })