Showing preview only (380K chars total). Download the full file or copy to clipboard to get everything.
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<<EOF" >> $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 <andrey@sitnik.es>
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]
<img align="right" width="95" height="148" title="Logux logotype"
src="https://logux.org/branding/logotype.svg">
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.
<a href="https://evilmartians.com/?utm_source=logux-server">
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg"
alt="Sponsored by Evil Martians" width="236" height="54">
</a>
[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
================================================
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Logux Server</title>
<style>
html {
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
margin: 0;
}
svg {
height: 25vh;
}
</style>
</head>
<body>
<a href="https://logux.org/">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75 117">
<g fill="none" fill-rule="evenodd">
<path d="M-1 0h78v117H-1z" />
<g fill-rule="nonzero">
<path
fill="#1F1F1F"
d="M2.239 40.258a12.83 12.83 0 00-.237.351C-.01 43.745-.515 46.634.71 50.4c1.484 4.565 3.865 6.346 9.045 7.98 5.705 1.8 7.064 2.346 10.02 4.481a23.387 23.387 0 013.3 2.885l4.198-1.394a28.632 28.632 0 00-1.907-2.072 27.455 27.455 0 00-3.196-2.697c-3.434-2.48-5.054-3.132-11.182-5.065-4.052-1.279-5.4-2.287-6.4-5.364-.69-2.121-.605-3.62.245-5.313L2.24 40.259zm4.91-7.089a22.975 22.975 0 001.72-3.977c1.038-3.18 1.217-5.458 1.078-9.74-.107-3.276-.06-4.527.422-6.218.648-2.273 2.042-4.164 4.406-5.87 2.658-1.918 5.15-2.604 7.865-2.292a14.85 14.85 0 012.38.491L22.371 9.12a22.74 22.74 0 01-.2-.022c-1.679-.193-3.155.214-5.002 1.546-2.923 2.11-3.317 3.494-3.149 8.678.153 4.713-.057 7.394-1.276 11.123a27.342 27.342 0 01-2.995 6.317l-2.6-3.592zm21.16 39.381c2.508 3.086 5.035 4.444 9.219 4.45 4.565.007 7.157-1.607 9.898-5.325.343-.465 1.837-2.598 2.27-3.184.928-1.257 1.795-2.288 2.783-3.27a23.18 23.18 0 012.784-2.36c1.364-.986 2.405-1.636 3.689-2.22l.023-4.394c-2.399.892-3.9 1.74-6.107 3.335a27.246 27.246 0 00-3.27 2.772c-1.15 1.143-2.145 2.328-3.187 3.74-.472.639-1.968 2.773-2.271 3.185-2.037 2.762-3.501 3.674-6.606 3.669-2.252-.003-3.644-.484-5.016-1.796L28.31 72.55zm38.808-14.716c3.919-1.472 5.9-3.358 7.234-7.431 1.29-3.939.765-6.885-1.353-10.092-.589-.892-2.979-4.007-3.421-4.634-1.48-2.097-2.532-4.081-3.317-6.485-.492-1.506-.799-2.81-.98-4.225l-4.217-1.334c.15 2.408.528 4.384 1.32 6.811.922 2.82 2.16 5.154 3.858 7.56.52.738 2.858 3.785 3.35 4.53 1.47 2.226 1.77 3.91.885 6.614-.716 2.185-1.57 3.35-3.336 4.265l-.023 4.421zm-2.09-41.45c-.22-4.341-1.311-6.541-4.74-9.019-2.339-1.69-4.597-2.387-7.035-2.254-1.101.06-2.206.28-3.518.666-.497.145-1.005.309-1.688.537.011-.003-1.32.446-1.709.575-3.303 1.09-5.743 1.588-8.806 1.588-1.515 0-2.87-.126-4.248-.39l-2.646 3.552c2.286.6 4.406.89 6.894.89 3.57 0 6.408-.579 10.09-1.794 4.765-1.573 4.538-1.506 5.854-1.578 1.481-.08 2.82.332 4.416 1.485 1.811 1.31 2.611 2.37 2.927 4.41l4.21 1.331z"
/>
<path
fill="#F5A623"
d="M72.859 36.742c.095-.137.175-.256.236-.351 2.014-3.136 2.517-6.025 1.294-9.79-1.484-4.565-3.865-6.346-9.046-7.98-5.705-1.8-7.063-2.346-10.019-4.481a23.387 23.387 0 01-3.3-2.885l-4.198 1.394a28.632 28.632 0 001.907 2.072c.975.96 2.03 1.855 3.196 2.697 3.434 2.48 5.054 3.132 11.182 5.065 4.052 1.279 5.4 2.287 6.4 5.364.69 2.121.605 3.62-.245 5.313l2.593 3.582zm-4.91 7.089a22.975 22.975 0 00-1.72 3.977c-1.039 3.18-1.217 5.458-1.078 9.74.106 3.276.06 4.527-.423 6.218-.647 2.273-2.042 4.164-4.406 5.87-2.657 1.918-5.15 2.604-7.864 2.292a14.85 14.85 0 01-2.38-.491l2.649-3.556.2.022c1.678.193 3.155-.214 5.002-1.546 2.923-2.11 3.317-3.494 3.149-8.678-.154-4.713.057-7.394 1.276-11.123a27.342 27.342 0 012.994-6.317l2.6 3.592zM46.788 4.45C44.28 1.364 41.754.006 37.57 0c-4.566-.007-7.157 1.607-9.899 5.325-.343.465-1.837 2.598-2.27 3.184-.928 1.257-1.794 2.288-2.783 3.27a23.18 23.18 0 01-2.784 2.36c-1.363.986-2.404 1.636-3.688 2.22l-.023 4.394c2.398-.892 3.899-1.74 6.107-3.335a27.246 27.246 0 003.27-2.772c1.15-1.143 2.145-2.328 3.187-3.74.472-.639 1.967-2.773 2.271-3.185 2.036-2.762 3.5-3.674 6.606-3.669 2.252.003 3.644.484 5.015 1.796l4.209-1.398zM7.98 19.166c-3.918 1.472-5.899 3.358-7.233 7.431-1.29 3.939-.765 6.885 1.352 10.092.59.892 2.98 4.007 3.422 4.634C7 43.42 8.052 45.404 8.838 47.808c.492 1.506.798 2.81.979 4.225l4.218 1.334c-.15-2.408-.528-4.384-1.321-6.811-.922-2.82-2.16-5.154-3.857-7.56-.521-.738-2.859-3.785-3.351-4.53-1.47-2.226-1.77-3.91-.884-6.614.715-2.185 1.57-3.35 3.335-4.265l.023-4.421zm2.09 41.45c.22 4.341 1.312 6.541 4.74 9.019 2.34 1.69 4.598 2.387 7.036 2.254 1.1-.06 2.205-.28 3.518-.666a45.704 45.704 0 001.687-.537c-.01.003 1.32-.446 1.71-.575 3.303-1.09 5.743-1.588 8.806-1.588 1.515 0 2.87.126 4.248.39l2.646-3.552c-2.286-.6-4.406-.89-6.894-.89-3.57 0-6.409.579-10.09 1.794-4.766 1.573-4.538 1.506-5.854 1.578-1.482.08-2.82-.332-4.417-1.485-1.81-1.31-2.61-2.37-2.926-4.41l-4.21-1.331z"
/>
</g>
<path
fill="#1F1F1F"
fill-rule="nonzero"
d="M10.889 117v-4H6.944V89H3v28h7.889zm11.833-14c0 .547-.194 1.017-.582 1.41-.387.393-.85.59-1.39.59-.54 0-1.003-.197-1.39-.59a1.938 1.938 0 01-.582-1.41c0-.547.194-1.017.582-1.41.387-.393.85-.59 1.39-.59.54 0 1.003.197 1.39.59.388.393.582.863.582 1.41zm-7.889 0c0 1.653.579 3.067 1.736 4.24s2.55 1.76 4.181 1.76c1.63 0 3.024-.587 4.181-1.76s1.736-2.587 1.736-4.24-.579-3.067-1.736-4.24S22.381 97 20.75 97c-1.63 0-3.024.587-4.181 1.76s-1.736 2.587-1.736 4.24zm11.834-14H14.833v4h11.834v-4zm0 24H14.833v4h11.834v-4zM38.5 95c0 .547-.194 1.017-.582 1.41-.388.393-.851.59-1.39.59-.54 0-1.003-.197-1.39-.59a1.938 1.938 0 01-.582-1.41c0-.547.193-1.017.581-1.41.388-.393.852-.59 1.39-.59.54 0 1.003.197 1.391.59.388.393.582.863.582 1.41zm-3.944 16v-2.32c.618.213 1.275.32 1.972.32 1.63 0 3.024-.587 4.18-1.76 1.158-1.173 1.736-2.587 1.736-4.24 0-1.547-.506-2.88-1.518-4 1.012-1.12 1.518-2.453 1.518-4 0-1.653-.578-3.067-1.735-4.24S38.159 89 36.528 89c-1.63 0-3.024.587-4.181 1.76S30.61 93.347 30.61 95s.579 3.067 1.736 4.24 2.55 1.76 4.18 1.76c.54 0 1.003.197 1.391.59.388.393.582.863.582 1.41 0 .547-.194 1.017-.582 1.41-.388.393-.851.59-1.39.59-.54 0-1.003-.197-1.39-.59a1.938 1.938 0 01-.582-1.41H30.61v8c0 1.653.579 3.067 1.736 4.24s2.55 1.76 4.18 1.76c1.631 0 3.025-.587 4.182-1.76 1.157-1.173 1.735-2.587 1.735-4.24H38.5c0 .547-.194 1.017-.582 1.41-.388.393-.851.59-1.39.59-.54 0-1.003-.197-1.39-.59a1.938 1.938 0 01-.582-1.41zm11.833 0c0 1.653.578 3.067 1.735 4.24s2.551 1.76 4.182 1.76c1.63 0 3.024-.587 4.18-1.76 1.158-1.173 1.736-2.587 1.736-4.24V89h-3.944v22c0 .547-.194 1.017-.582 1.41-.388.393-.851.59-1.39.59-.54 0-1.003-.197-1.39-.59a1.938 1.938 0 01-.583-1.41V89H46.39v22zM74 89H62.167v4H74v-4zm-3.116 14l2.78-2.82-2.8-2.82-2.78 2.82-2.782-2.82-2.8 2.82 2.8 2.82-2.8 2.84 2.8 2.82 2.781-2.82 2.781 2.82 2.8-2.82-2.78-2.84zM74 113H62.167v4H74v-4z"
/>
</g>
</svg>
</a>
</body>
</html>
================================================
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<BaseServerOptions> = {}
): BaseServer<object, TestLog<ServerMeta>> {
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<object, TestLog<ServerMeta>>(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<HttpResponse> {
return new Promise<HttpResponse>((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<RequestError> {
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('<svg ')
})
it('disables HTTP on request', async () => {
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<Value extends SyncMapTypes | SyncMapTypes[]> = {
time: number
value: Value
[WITH_TIME]: true
}
export type WithoutTime<Value extends SyncMapTypes | SyncMapTypes[]> = {
time: undefined
value: Value
[WITH_TIME]: false
}
export type SyncMapData<Value extends SyncMapValues> = {
[Key in keyof Value]: WithoutTime<Value[Key]> | WithTime<Value[Key]>
} & { 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 extends SyncMapTypes | SyncMapTypes[]>(
value: Value,
time: number
): WithTime<Value>
/**
* 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<Value>
interface SyncMapActionFilter<Value extends SyncMapValues> {
(
ctx: Context,
action:
| SyncMapChangedAction<Value>
| SyncMapCreatedAction<Value>
| SyncMapDeletedAction,
meta: ServerMeta
): boolean | Promise<boolean>
}
interface SyncMapOperations<Value extends SyncMapValues> {
access(
ctx: Context,
id: string,
action:
| LoguxSubscribeAction
| SyncMapChangeAction
| SyncMapCreateAction
| SyncMapDeleteAction,
meta: ServerMeta
): boolean | Promise<boolean>
change?(
ctx: Context,
id: string,
fields: Partial<Value>,
time: number,
action: SyncMapChangeAction<Value>,
meta: ServerMeta
): boolean | Promise<boolean | void> | void
create?(
ctx: Context,
id: string,
fields: Value,
time: number,
action: SyncMapCreateAction<Value>,
meta: ServerMeta
): boolean | Promise<boolean | void> | void
delete?(
ctx: Context,
id: string,
action: SyncMapDeleteAction,
meta: ServerMeta
): boolean | Promise<boolean | void> | void
load?(
ctx: Context,
id: string,
since: number | undefined,
action: LoguxSubscribeAction,
meta: ServerMeta
): false | Promise<false | SyncMapData<Value>> | SyncMapData<Value>
}
interface SyncMapFilterOperations<Value extends SyncMapValues> {
access?(
ctx: Context,
filter: Partial<Value> | undefined,
action: LoguxSubscribeAction,
meta: ServerMeta
): boolean | Promise<boolean>
actions?(
ctx: Context,
filter: Partial<Value> | undefined,
action: LoguxSubscribeAction,
meta: ServerMeta
): Promise<SyncMapActionFilter<Value>> | SyncMapActionFilter<Value> | void
initial(
ctx: Context,
filter: Partial<Value> | undefined,
since: number | undefined,
action: LoguxSubscribeAction,
meta: ServerMeta
): Promise<SyncMapData<Value>[]> | SyncMapData<Value>[]
}
/**
* 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<Values extends SyncMapValues>(
server: BaseServer,
plural: string,
operations: SyncMapOperations<Values>
): 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<Values extends SyncMapValues>(
server: BaseServer,
plural: string,
operations: SyncMapFilterOperations<Values>
): 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<string, TaskRecord>()
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<TaskValue>(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<TaskValue>(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<TaskValue>[] = []
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<CommentValue>(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<CommentValue>(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<CommentValue>(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<CommentValue>(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<CommentValue>(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<CommentValue>(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<Headers extends object = unknown> {
(
ctx: ConnectContext<Headers>,
lastSynced: number
):
| [Action, ServerMeta][]
| Promise<
[
Action,
Partial<Pick<ServerMeta, 'subprotocol'>> &
Pick<ServerMeta, 'id' | 'time'>
][]
>
}
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<Headers extends object> {
client: ServerClient
cookie: Record<string, string>
headers: Headers
token: string
userId: string
}
export type SendBackActions =
| [Action, Partial<Meta>][]
| 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<Headers extends object> {
(user: AuthenticatorOptions<Headers>): boolean | Promise<boolean>
}
/**
* 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<Data, Headers>,
action: Readonly<TypeAction>,
meta: Readonly<ServerMeta>
): boolean | Promise<boolean>
}
/**
* 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<Data, Headers>,
action: Readonly<TypeAction>,
meta: Readonly<ServerMeta>
): Promise<Resend> | 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<Data, Headers>,
action: Readonly<TypeAction>,
meta: Readonly<ServerMeta>
): Promise<void> | 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<Data, Headers>,
action: Readonly<TypeAction>,
meta: Readonly<ServerMeta>
): 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<Headers extends object> {
(
ctx: Context<unknown, Headers>,
action: Readonly<Action>,
meta: Readonly<ServerMeta>
): boolean | Promise<boolean>
}
/**
* 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<Data, ChannelParams, Headers>,
action: Readonly<SubscribeAction>,
meta: Readonly<ServerMeta>
): boolean | Promise<boolean>
}
/**
* 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<Data, ChannelParams, Headers>,
action: Readonly<SubscribeAction>,
meta: Readonly<ServerMeta>
): ChannelFilter<Headers> | Promise<ChannelFilter<Headers>> | 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<Data, ChannelParams, Headers>,
action: Readonly<SubscribeAction>,
meta: Readonly<ServerMeta>
): Promise<SendBackActions> | 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<Data, ChannelParams, Headers>,
action: Readonly<SubscribeAction>,
meta: Readonly<ServerMeta>
): 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<Data, ChannelParams, Headers>,
action: LoguxUnsubscribeAction,
meta: Readonly<ServerMeta>
): void
}
type ActionCallbacks<
TypeAction extends Action,
Data extends object,
Headers extends object
> = (
| {
access: Authorizer<TypeAction, Data, Headers>
process?: Processor<TypeAction, Data, Headers>
}
| {
accessAndProcess: Processor<TypeAction, Data, Headers>
}
) & {
finally?: ActionFinally<TypeAction, Data, Headers>
resend?: Resender<TypeAction, Data, Headers>
}
type ChannelCallbacks<
SubscribeAction extends Action,
Data extends object,
ChannelParams extends object | string[],
Headers extends object
> = (
| {
access: ChannelAuthorizer<SubscribeAction, Data, ChannelParams, Headers>
load?: ChannelLoader<SubscribeAction, Data, ChannelParams, Headers>
}
| {
accessAndLoad: ChannelLoader<
SubscribeAction,
Data,
ChannelParams,
Headers
>
}
) & {
filter?: FilterCreator<SubscribeAction, Data, ChannelParams, Headers>
finally?: ChannelFinally<SubscribeAction, Data, ChannelParams, Headers>
unsubscribe?: ChannelUnsubscribe<Data, ChannelParams, Headers>
}
interface ActionReporter {
action: Readonly<Action>
meta: Readonly<ServerMeta>
}
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 extends keyof ReportersArguments>(
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<void>): Promise<boolean>
/**
* Base server class to extend.
*/
export class BaseServer<
Headers extends object = unknown,
ServerLog extends Log = Log<ServerMeta>
> {
/**
* 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<string, ServerClient>
/**
* Connected clients.
*
* ```js
* for (let client of server.connected.values()) {
* console.log(client.remoteAddress)
* }
* ```
*/
connected: Map<string, ServerClient>
/**
* 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<string, ServerClient>
/**
* 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<string, ChannelFilter<unknown> | 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<string, ServerClient[]>
/**
* @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<Headers>): 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<SubscribeAction, Data, ChannelParams, Headers>,
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<SubscribeAction, Data, ChannelParams, Headers>,
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<void>
/**
* 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
): void
http(
listener: (
req: IncomingMessage,
res: ServerResponse
) => boolean | Promise<boolean>
): void
/**
* Start WebSocket server and listen for clients.
*
* @returns When the server has been bound.
*/
listen(): Promise<void>
/**
* @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<ServerMeta>) => void
): Unsubscribe
/**
* @param event The event name.
* @param listener Processing listener.
*/
on(
event: 'processed',
listener: (
action: Action,
meta: Readonly<ServerMeta>,
latencyMilliseconds: number
) => void
): Unsubscribe
/**
* @param event The event name.
* @param listener Action listener.
*/
on(
event: 'add' | 'clean',
listener: (action: Action, meta: Readonly<ServerMeta>) => 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<ServerMeta>) => 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<ServerMeta>,
latencyMilliseconds: number
) => void
): Unsubscribe
/**
* @param event The event name.
* @param listener Subscription listener.
*/
on(
event: 'unsubscribed',
listener: (
action: LoguxUnsubscribeAction,
meta: Readonly<ServerMeta>,
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<Data extends object = unknown>(
callbacks: ChannelCallbacks<LoguxSubscribeAction, Data, [string], Headers>
): 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<Data extends object = unknown>(
callbacks: ActionCallbacks<Action, Data, Headers>
): 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<ServerMeta>
): Promise<Readonly<ServerMeta>>
/**
* 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> | 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<Headers>): 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<Creator extends AbstractActionCreator, Data extends object = unknown>(
actionCreator: Creator,
callbacks: ActionCallbacks<ReturnType<Creator>, 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<TypeAction extends Action = AnyAction, Data extends object = unknown>(
name: RegExp | TypeAction['type'],
callbacks: ActionCallbacks<TypeAction, Data, Headers>,
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<void>
/**
* 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<BaseServerOptions> = {}
): BaseServer<object, TestLog<ServerMeta>> {
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<object, TestLog<ServerMeta>>(opts)
created.auth(() => true)
destroyable = created
return created
}
let destroyable: BaseServer | undefined
let httpServer: http.Server | undefined
function createReporter(opts: Partial<BaseServerOptions> = {}): {
app: BaseServer<object, TestLog<ServerMeta>>
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<any>): Promise<Error> {
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<void>(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<ActionA>('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<Headers extends object = unknown> {
/**
* 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<ServerMeta>): Promise<void>
}
/**
* Action context.
* ```
*/
export class Context<
Data extends object = unknown,
Headers extends object = unknown
> extends ConnectContext<Headers> {
/**
* 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<Data, Headers> {
/**
* 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<ServerMeta> = { 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 - \`<username>\`\\n$ npm start -p 80","msg":"You are not allowed to run server on port \`80\`"}
FLUSH"
`;
exports[`reports EACCES error 2`] = `
"[41m[37m FATAL [39m[49m [1m[31mYou are not allowed to run server on port [33m80[39m[39m[22m [2mat 1970-01-01 00:00:00[22m
[90mNon-privileged users can't start a listening socket on ports below 1024.[39m
[90mTry to change user or take another port.[39m
[90m[39m
[90m$ su - [1m<username>[22m[39m
[90m$ npm start -p 80[39m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mAction was added[39m[22m [2mat 1970-01-01 00:00:00[22m
Action:
id: [1m100[22m
name: "[1mJohn[22m"
type: "[1mADD_USER[22m"
Meta:
excludeClients: ["[1m1[22m:[33m-lC[39m[35mr7e[39m[31m9s[39m","[1m2[22m:[33mwv0[39m[31mr_O[39m[34m5C[39m"]
id: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
reasons: []
server: [1mserver[22m:[34mH1f[39m[35m8LA[39m[36myzl[39m
subprotocol: [1m1[22m
time: [1m1487805099387[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mAction was added[39m[22m [2mat 1970-01-01 00:00:00[22m
Action:
id: [1m100[22m
name: "[1mJohn[22m"
type: "[1mADD_USER[22m"
Meta:
clients: ["[1m1[22m:[33m-lC[39m[35mr7e[39m[31m9s[39m","[1m2[22m:[33mwv0[39m[31mr_O[39m[34m5C[39m"]
id: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
reasons: []
server: [1mserver[22m:[34mH1f[39m[35m8LA[39m[36myzl[39m
subprotocol: [1m1[22m
time: [1m1487805099387[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mAction was added[39m[22m [2mat 1970-01-01 00:00:00[22m
Action:
data:
array: [[1m1[22m, [[1m2[22m], { a: "[1m1[22m", b: { c: [1m2[22m }, d: [], e: [1mnull[22m }, [1mnull[22m]
name: "[1mJohn[22m"
role: [1mnull[22m
id: [1m100[22m
type: "[1mCHANGE_USER[22m"
Meta:
id: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
reasons: ["[1mlastValue[22m", "[1mdebug[22m"]
server: [1mserver[22m:[34mH1f[39m[35m8LA[39m[36myzl[39m
subprotocol: [1m1[22m
time: [1m1487805099387[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mAction was added and cleaned[39m[22m [2mat 1970-01-01 00:00:00[22m
Action:
data:
array: [[1m1[22m, [[1m2[22m], { a: "[1m1[22m", b: { c: [1m2[22m }, d: [], e: [1mnull[22m }, [1mnull[22m]
name: "[1mJohn[22m"
role: [1mnull[22m
id: [1m100[22m
type: "[1mCHANGE_USER[22m"
Meta:
id: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
reasons: ["[1mlastValue[22m", "[1mdebug[22m"]
server: [1mserver[22m:[34mH1f[39m[35m8LA[39m[36myzl[39m
subprotocol: [1m1[22m
time: [1m1487805099387[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mUser was authenticated[39m[22m [2mat 1970-01-01 00:00:00[22m
Connection ID: [1m670[22m
Node ID: [1madmin[22m:[31m100[39m
Subprotocol: [1m1[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mUser was authenticated[39m[22m [2mat 1970-01-01 00:00:00[22m
Connection ID: [1m670[22m
Node ID: uImkcF4z
Subprotocol: [1m1[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mAction was cleaned[39m[22m [2mat 1970-01-01 00:00:00[22m
Action ID: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mClient was connected[39m[22m [2mat 1970-01-01 00:00:00[22m
Connection ID: [1m670[22m
IP address: [1m10.110.6.56[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mAction was denied[39m[22m [2mat 1970-01-01 00:00:00[22m
Action ID: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
"
`;
exports[`reports destroy 1`] = `
"{"level":30,"time":"1970-01-01T00:00:00.000Z","pid":21384,"msg":"Shutting down Logux server"}
"
`;
exports[`reports destroy 2`] = `
"[42m[30m INFO [39m[49m [1m[32mShutting down Logux server[39m[22m [2mat 1970-01-01 00:00:00[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mClient was disconnected[39m[22m [2mat 1970-01-01 00:00:00[22m
Node ID: [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mClient was disconnected[39m[22m [2mat 1970-01-01 00:00:00[22m
Connection ID: [1m670[22m
"
`;
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.<anonymous> (/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`] = `
"[41m[37m FATAL [39m[49m [1m[31mSome mistake[39m[22m [2mat 1970-01-01 00:00:00[22m
[90m at Object.<anonymous> (/dev/app/index.js:28:13)[39m
[90m at Module._compile (module.js:573:32)[39m
[90m at at runTest (/dev/app/node_modules/jest/index.js:50:10)[39m
[90m at process._tickCallback (internal/process/next_tick.js:103:7)[39m
"
`;
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.<anonymous> (/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`] = `
"[41m[37m ERROR [39m[49m [1m[31mSome mistake[39m[22m [2mat 1970-01-01 00:00:00[22m
[90m at Object.<anonymous> (/dev/app/index.js:28:13)[39m
[90m at Module._compile (module.js:573:32)[39m
[90m at at runTest (/dev/app/node_modules/jest/index.js:50:10)[39m
[90m at process._tickCallback (internal/process/next_tick.js:103:7)[39m
Action ID: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mClient error: A timeout was reached (5000 ms)[39m[22m [2mat 1970-01-01 00:00:00[22m
Connection ID: [1m670[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mSync error: A timeout was reached (5000 ms)[39m[22m [2mat 1970-01-01 00:00:00[22m
Node ID: [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m
"
`;
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.<anonymous> (/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`] = `
"[41m[37m ERROR [39m[49m [1m[31m{"Authorization":"[SECRET]"}[39m[22m [2mat 1970-01-01 00:00:00[22m
[90m at Object.<anonymous> (/dev/app/index.js:28:13)[39m
[90m at Module._compile (module.js:573:32)[39m
[90m at at runTest (/dev/app/node_modules/jest/index.js:50:10)[39m
[90m at process._tickCallback (internal/process/next_tick.js:103:7)[39m
Action ID: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mLogux server is listening[39m[22m [2mat 1970-01-01 00:00:00[22m
PID: [1m21384[22m
Environment: [1mdevelopment[22m
Logux server: [1m0.0.0[22m
Min subprotocol: [1m0[22m
Node ID: [1mserver[22m:[31mFnX[39m[35maqD[39m[34mxY[39m
Subprotocol: [1m0[22m
Health check: [1mhttp://127.0.0.1:31337/health[22m
Listen: [1mws://127.0.0.1:31337/[22m
[90mServer was started in non-secure development mode[39m
[90mPress Ctrl-C to shutdown server[39m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mLogux server is listening[39m[22m [2mat 1970-01-01 00:00:00[22m
PID: [1m21384[22m
Environment: [1mdevelopment[22m
Logux server: [1m0.0.0[22m
Min subprotocol: [1m0[22m
Node ID: [1mserver[22m:[31mFnX[39m[35maqD[39m[34mxY[39m
Subprotocol: [1m0[22m
Prometheus: [1mhttp://127.0.0.1:31338/prometheus[22m
Listen: [1mCustom HTTP server[22m
[90mServer was started in non-secure development mode[39m
[90mPress Ctrl-C to shutdown server[39m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mLogux server is listening[39m[22m [2mat 1970-01-01 00:00:00[22m
PID: [1m21384[22m
Environment: [1mproduction[22m
Logux server: [1m0.0.0[22m
Min subprotocol: [1m0[22m
Node ID: [1mserver[22m:[31mFnX[39m[35maqD[39m[34mxY[39m
Subprotocol: [1m0[22m
Health check: [1mhttps://127.0.0.1:31337/health[22m
Redis: [1m//localhost[22m
Listen: [1mwss://127.0.0.1:31337/[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mClient was subscribed[39m[22m [2mat 1970-01-01 00:00:00[22m
Action ID: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
Channel: [1muser/100[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mBad authentication[39m[22m [2mat 1970-01-01 00:00:00[22m
Connection ID: [1m670[22m
Node ID: [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m
Subprotocol: [1m1[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mAction with unknown type[39m[22m [2mat 1970-01-01 00:00:00[22m
Action ID: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[34mvAA[39m[32mpgN[39m[31mT9[39m [1m0[22m
Type: [1mCHANGE_SER[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mAction with unknown type[39m[22m [2mat 1970-01-01 00:00:00[22m
Action ID: [1m[34m1650[39m[35m2690[39m[31m2170[39m[31m0[39m[22m [1mserver[22m:[31mFnX[39m[35maqD[39m[34mxY[39m [1m0[22m
Type: [1mCHANGE_SER[22m
"
`;
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`] = `
"[42m[30m INFO [39m[49m [1m[32mClient was unsubscribed[39m[22m [2mat 1970-01-01 00:00:00[22m
Action ID: [1m[36m1650[39m[34m2719[39m[35m4090[39m[35m0[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
Channel: [1muser/100[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mUseless action[39m[22m [2mat 1970-01-01 00:00:00[22m
Action:
id: [1m100[22m
name: "[1mJohn[22m"
type: "[1mADD_USER[22m"
Meta:
id: [1m[32m1487[39m[35m8050[39m[33m9938[39m[33m7[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
reasons: []
server: [1mserver[22m:[34mH1f[39m[35m8LA[39m[36myzl[39m
subprotocol: [1m1[22m
time: [1m1487805099387[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mWrong channel name[39m[22m [2mat 1970-01-01 00:00:00[22m
Action ID: [1m[32m1650[39m[34m2690[39m[33m4580[39m[33m0[39m[22m [1m100[22m:[31mIsv[39m[33mVzq[39m[34mWx[39m [1m0[22m
Channel: [1mser/100[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mWrong channel name[39m[22m [2mat 1970-01-01 00:00:00[22m
Action ID: [1m[36m1650[39m[31m2690[39m[34m5660[39m[34m0[39m[22m [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m [1m0[22m
Channel: [1mundefined[22m
"
`;
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`] = `
"[43m[30m WARN [39m[49m [1m[33mZombie client was disconnected[39m[22m [2mat 1970-01-01 00:00:00[22m
Node ID: [1m100[22m:[32muIm[39m[36mkcF[39m[33m4z[39m
"
`;
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`] = `
"[41m[37m FATAL [39m[49m [1m[31mPort [33m31337[31m already in use[39m[22m [2mat 1970-01-01 00:00:00[22m
[90mAnother Logux server or other app already running on this port.[39m
[90mProbably you haven’t stopped server from other project or previous[39m
[90mversion of this server was not killed.[39m
[90m[39m
[90m$ su - root[39m
[90m# netstat -nlp | grep 31337[39m
[90mProto Local Address State PID/Program name[39m
[90mtcp 0.0.0.0:31337 LISTEN [1m777[22m/node[39m
[90m# sudo kill -9 [1m777[22m[39m
"
`;
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`] = `
"[41m[37m FATAL [39m[49m [1m[31mUnknown option [33msuprotocol[31m in server constructor[39m[22m [2mat 1970-01-01 00:00:00[22m
[90mMaybe there is a mistake in option name or this version of Logux Server[39m
[90mdoesn’t support this option[39m
"
`;
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`] = `
"[41m[37m ERROR [39m[49m [1m[31mLogux received unknown-message error (Unknown message [33mbad[31m type)[39m[22m [2mat 1970-01-01 00:00:00[22m
Connection ID: [1m670[22m
"
`;
================================================
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 - \`<username>\`\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 \`<processid>\``,
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 `<processid>`'
}
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.<anonymous> (/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],
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
SYMBOL INDEX (415 symbols across 38 files)
FILE: add-http-pages/index.js
function readHello (line 5) | async function readHello() {
function addHttpPages (line 12) | function addHttpPages(server) {
FILE: add-http-pages/index.test.ts
constant DEFAULT_OPTIONS (line 12) | const DEFAULT_OPTIONS = {
function createServer (line 19) | function createServer(
class RequestError (line 45) | class RequestError extends Error {
method constructor (line 48) | constructor(statusCode: number | undefined, body: string) {
type HttpResponse (line 55) | interface HttpResponse {
function request (line 60) | function request(
function requestError (line 93) | async function requestError(
FILE: add-sync-map/index.d.ts
type WithTime (line 18) | type WithTime<Value extends SyncMapTypes | SyncMapTypes[]> = {
type WithoutTime (line 24) | type WithoutTime<Value extends SyncMapTypes | SyncMapTypes[]> = {
type SyncMapData (line 30) | type SyncMapData<Value extends SyncMapValues> = {
type SyncMapActionFilter (line 59) | interface SyncMapActionFilter<Value extends SyncMapValues> {
type SyncMapOperations (line 70) | interface SyncMapOperations<Value extends SyncMapValues> {
type SyncMapFilterOperations (line 116) | interface SyncMapFilterOperations<Value extends SyncMapValues> {
FILE: add-sync-map/index.js
constant WITH_TIME (line 1) | const WITH_TIME = Symbol('WITH_TIME')
function ChangedAt (line 3) | function ChangedAt(value, time) {
function NoConflictResolution (line 7) | function NoConflictResolution(value) {
function addFinished (line 11) | async function addFinished(server, ctx, type, action, meta) {
function resendFinished (line 18) | function resendFinished(server, plural, type, all = true) {
function buildFilter (line 40) | function buildFilter(filter) {
function sendMap (line 58) | async function sendMap(server, changedType, data, since) {
function addSyncMap (line 90) | function addSyncMap(server, plural, operations) {
function addSyncMapFilter (line 177) | function addSyncMapFilter(server, plural, operations) {
FILE: add-sync-map/index.test.ts
type TaskValue (line 20) | type TaskValue = {
type TaskRecord (line 25) | type TaskRecord = {
type CommentValue (line 39) | type CommentValue = {
function getTime (line 56) | function getTime(client: TestClient, creator: { type: string }): number[] {
function getServer (line 63) | function getServer(): TestServer {
method access (line 333) | access() {
method load (line 336) | load(ctx, id, since) {
method access (line 352) | access() {
method initial (line 355) | initial() {
method access (line 383) | access() {
method change (line 386) | change(ctx, id) {
method create (line 389) | create(ctx, id) {
method delete (line 392) | delete(ctx, id) {
method load (line 395) | load(ctx, id) {
method access (line 400) | access() {
method initial (line 403) | initial() {
method access (line 434) | access() {
method load (line 437) | load(ctx, id) {
method access (line 464) | access() {
method load (line 468) | load(ctx, id) {
FILE: allowed-meta/index.d.ts
constant ALLOWED_META (line 17) | const ALLOWED_META: string[]
FILE: allowed-meta/index.js
constant ALLOWED_META (line 1) | const ALLOWED_META = ['id', 'time', 'subprotocol']
FILE: base-server/index.d.ts
type LogFn (line 31) | interface LogFn {
type TypeOptions (line 35) | interface TypeOptions {
type ChannelOptions (line 43) | interface ChannelOptions {
type ConnectLoader (line 51) | interface ConnectLoader<Headers extends object = unknown> {
type ServerNodeConstructor (line 66) | type ServerNodeConstructor = new (...args: unknown[]) => ServerNode
type ServerMeta (line 68) | interface ServerMeta extends Meta {
type BaseServerOptions (line 125) | interface BaseServerOptions {
type AuthenticatorOptions (line 253) | interface AuthenticatorOptions<Headers extends object> {
type SendBackActions (line 261) | type SendBackActions =
type ServerAuthenticator (line 275) | interface ServerAuthenticator<Headers extends object> {
type Authorizer (line 287) | interface Authorizer<
type Resender (line 307) | interface Resender<
type Processor (line 327) | interface Processor<
type ActionFinally (line 347) | interface ActionFinally<
type ChannelFilter (line 367) | interface ChannelFilter<Headers extends object> {
type ChannelAuthorizer (line 383) | interface ChannelAuthorizer<
type FilterCreator (line 404) | interface FilterCreator<
type ChannelLoader (line 425) | interface ChannelLoader<
type ChannelFinally (line 446) | interface ChannelFinally<
type ChannelUnsubscribe (line 467) | interface ChannelUnsubscribe<
type ActionCallbacks (line 479) | type ActionCallbacks<
type ChannelCallbacks (line 496) | type ChannelCallbacks<
type ActionReporter (line 520) | interface ActionReporter {
type SubscriptionReporter (line 525) | interface SubscriptionReporter {
type CleanReporter (line 530) | interface CleanReporter {
type AuthenticationReporter (line 534) | interface AuthenticationReporter {
type ReportersArguments (line 540) | interface ReportersArguments {
type Reporter (line 598) | interface Reporter {
type Resend (line 605) | type Resend =
type Logger (line 620) | interface Logger {
class BaseServer (line 648) | class BaseServer<
FILE: base-server/index.js
constant SKIP_PROCESS (line 16) | const SKIP_PROCESS = Symbol('skipProcess')
constant RESEND_META (line 17) | const RESEND_META = ['channels', 'users', 'clients', 'nodes']
function optionError (line 19) | function optionError(msg) {
function wasNot403 (line 26) | async function wasNot403(cb) {
function normalizeTypeCallbacks (line 38) | function normalizeTypeCallbacks(name, callbacks) {
function normalizeChannelCallbacks (line 55) | function normalizeChannelCallbacks(pattern, callbacks) {
function subscriberFilterId (line 85) | function subscriberFilterId(action) {
class BaseServer (line 89) | class BaseServer {
method constructor (line 90) | constructor(opts = {}) {
method addClient (line 410) | addClient(connection) {
method auth (line 418) | auth(authenticator) {
method buildUndo (line 422) | buildUndo(action, meta, reason, extra) {
method channel (line 444) | channel(pattern, callbacks, options = {}) {
method createContext (line 459) | createContext(action, meta) {
method debugActionError (line 468) | debugActionError(meta, msg) {
method debugError (line 477) | debugError(error) {
method denyAction (line 487) | denyAction(action, meta) {
method destroy (line 493) | destroy() {
method finally (line 499) | finally(processor, ctx, action, meta) {
method getProcessor (line 510) | getProcessor(type) {
method getRegexProcessor (line 516) | getRegexProcessor(type) {
method handleClient (line 525) | handleClient(ws, req) {
method http (line 530) | http(method, url, listener) {
method internalUnknownType (line 543) | internalUnknownType(action, meta) {
method internalWrongChannel (line 556) | internalWrongChannel(action, meta) {
method isBruteforce (line 566) | isBruteforce(ip) {
method isUseless (line 571) | isUseless(action, meta) {
method listen (line 585) | async listen() {
method markAsProcessed (line 648) | markAsProcessed(meta) {
method on (line 659) | on(event, listener) {
method otherChannel (line 667) | otherChannel(callbacks) {
method otherType (line 681) | otherType(callbacks) {
method performUnsubscribe (line 689) | performUnsubscribe(clientNodeId, action, meta) {
method process (line 715) | process(action, meta = {}) {
method processAction (line 735) | async processAction(processor, action, meta, start) {
method processHttp (line 756) | async processHttp(req, res) {
method rememberBadAuth (line 782) | rememberBadAuth(ip) {
method replaceResendShortcuts (line 793) | replaceResendShortcuts(meta) {
method sendAction (line 812) | async sendAction(action, meta) {
method sendOnConnect (line 879) | sendOnConnect(loader) {
method setTimeout (line 883) | setTimeout(callback, ms) {
method subscribe (line 892) | subscribe(nodeId, channel) {
method subscribeAction (line 903) | async subscribeAction(action, meta, start) {
method type (line 1008) | type(name, callbacks, options = {}) {
method undo (line 1025) | undo(action, meta, reason = 'error', extra = {}) {
method unknownType (line 1032) | unknownType(action, meta) {
method unsubscribe (line 1037) | unsubscribe(action, meta) {
method unsubscribeAction (line 1042) | unsubscribeAction(action, meta) {
method wrongChannel (line 1054) | wrongChannel(action, meta) {
FILE: base-server/index.test.ts
constant ROOT (line 24) | const ROOT = join(import.meta.dirname, '..')
constant DEFAULT_OPTIONS (line 26) | const DEFAULT_OPTIONS = {
constant CERT (line 30) | const CERT = join(ROOT, 'test/fixtures/cert.pem')
constant KEY (line 31) | const KEY = join(ROOT, 'test/fixtures/key.pem')
function createServer (line 35) | function createServer(
function createReporter (line 62) | function createReporter(opts: Partial<BaseServerOptions> = {}): {
function privateMethods (line 83) | function privateMethods(obj: object): any {
function emit (line 87) | function emit(obj: any, event: string, ...args: any): void {
function catchError (line 91) | async function catchError(cb: () => Promise<any>): Promise<Error> {
function calls (line 100) | function calls(fn: Function | undefined): any[][] {
function called (line 104) | function called(fn: Function | undefined): boolean {
function callCount (line 108) | function callCount(fn: Function | undefined): number {
method process (line 594) | async process(ctx, action, meta) {
method process (line 623) | async process(ctx, action, meta) {
method process (line 669) | process() {
method process (line 702) | process() {
method onAdd (line 817) | onAdd() {}
method access (line 874) | access(ctx, action, meta) {
method send (line 881) | send() {}
method onAdd (line 882) | onAdd() {}
method access (line 909) | async access(ctx) {
method finally (line 913) | finally() {
method access (line 946) | access() {
method access (line 979) | access(ctx, action, meta) {
method access (line 991) | access() {
method filter (line 994) | async filter() {
method access (line 1086) | access() {
method filter (line 1089) | async filter() {
method access (line 1151) | access() {
method filter (line 1156) | filter() {
method load (line 1159) | load() {
method load (line 1184) | load() {
method load (line 1228) | load(ctx, action, meta) {
method access (line 1285) | access(ctx) {
method process (line 1444) | process() {
type ActionA (line 1463) | type ActionA = { aValue: string; type: 'A' }
method process (line 1470) | process(ctx, action) {
FILE: context/index.d.ts
class ConnectContext (line 7) | class ConnectContext<Headers extends object = unknown> {
class Context (line 82) | class Context<
class ChannelContext (line 126) | class ChannelContext<
FILE: context/index.js
class Context (line 3) | class Context {
method constructor (line 4) | constructor(server, meta) {
method sendBack (line 36) | sendBack(action, meta = {}) {
FILE: context/index.test.ts
constant FAKE_SERVER (line 8) | const FAKE_SERVER: any = {
method add (line 17) | add(action: Action, meta: ServerMeta) {
function createContext (line 28) | function createContext(
FILE: create-http-server/index.js
constant PEM_PREAMBLE (line 6) | const PEM_PREAMBLE = '-----BEGIN'
function isPem (line 8) | function isPem(content) {
function readFrom (line 16) | function readFrom(root, file) {
function createHttpServer (line 22) | async function createHttpServer(opts) {
FILE: create-reporter/index.js
constant ERROR_CODES (line 6) | const ERROR_CODES = {
constant REPORTERS (line 48) | const REPORTERS = {
function cleanFromKeys (line 175) | function cleanFromKeys(obj, regexp, seen) {
function createRecord (line 195) | function createRecord(level, details, msg) {
function createReporter (line 210) | function createReporter(options) {
FILE: create-reporter/index.test.ts
class MemoryStream (line 8) | class MemoryStream {
method constructor (line 13) | constructor(flushSync: boolean) {
method write (line 22) | write(chunk: string): void {
function clean (line 27) | function clean(str: string): string {
function check (line 38) | function check(type: string, details?: object): void {
function createError (line 54) | function createError(name: string, message: string): Error {
method info (line 391) | info(details: object, msg: string) {
FILE: filter-meta/index.js
function filterMeta (line 3) | function filterMeta(meta) {
FILE: filtered-node/index.js
function has (line 3) | function has(array, item) {
class FilteredNode (line 7) | class FilteredNode extends ServerNode {
method constructor (line 8) | constructor(client, nodeId, log, connection, options) {
method syncFilter (line 19) | syncFilter(action, meta) {
FILE: filtered-node/index.test.ts
type Test (line 6) | type Test = {
function createTest (line 11) | function createTest(): Test {
FILE: human-formatter/index.js
constant INDENT (line 6) | const INDENT = ' '
constant PADDING (line 7) | const PADDING = ' '
constant SEPARATOR (line 8) | const SEPARATOR = os.EOL + os.EOL
constant NEXT_LINE (line 9) | const NEXT_LINE = os.EOL === '\n' ? '\r\v' : os.EOL
constant PARAMS_BLACKLIST (line 11) | const PARAMS_BLACKLIST = {
constant LABELS (line 27) | const LABELS = {
constant COLORS (line 35) | const COLORS = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan']
function formatNow (line 37) | function formatNow() {
function rightPag (line 48) | function rightPag(str, length) {
function label (line 54) | function label(type, color, labelBg, labelText, message) {
function formatName (line 61) | function formatName(key) {
function shuffledColors (line 70) | function shuffledColors(str) {
function splitAndColorize (line 87) | function splitAndColorize(partLength, str) {
function formatNodeId (line 109) | function formatNodeId(nodeId) {
function formatValue (line 121) | function formatValue(value) {
function formatObject (line 133) | function formatObject(obj) {
function formatArray (line 138) | function formatArray(array) {
function formatActionId (line 143) | function formatActionId(id) {
function formatParams (line 154) | function formatParams(params, parent) {
function splitByLength (line 197) | function splitByLength(string, max) {
function prettyStackTrace (line 211) | function prettyStackTrace(stack, basepath) {
function humanFormatter (line 233) | function humanFormatter(options) {
FILE: human-formatter/utils.js
function onceXmur3 (line 1) | function onceXmur3(str) {
function mulberry32 (line 12) | function mulberry32(a) {
FILE: options-loader/index.js
function loadOptions (line 5) | function loadOptions(spec, process, env) {
function gatherCliArgs (line 39) | function gatherCliArgs(argv) {
function parseValues (line 67) | function parseValues(spec, args) {
function loadEnv (line 84) | function loadEnv(file) {
function mapArgs (line 110) | function mapArgs(parsedCliArgs, argsSpec) {
function composeHelp (line 121) | function composeHelp(spec, argv) {
function composeEnvName (line 160) | function composeEnvName(prefix, name) {
function composeCliFullName (line 167) | function composeCliFullName(name) {
function composeCliAliasName (line 171) | function composeCliAliasName(name) {
function toKebabCase (line 175) | function toKebabCase(word) {
function oneOf (line 179) | function oneOf(options, rawValue) {
function number (line 192) | function number(rawValue) {
function string (line 205) | function string(rawValue) {
FILE: options-loader/index.test.js
function fakeProcess (line 8) | function fakeProcess(argv, env = {}) {
FILE: request/index.d.ts
class ResponseError (line 5) | class ResponseError extends Error {
FILE: request/index.js
class ResponseError (line 1) | class ResponseError extends Error {
method constructor (line 2) | constructor(statusCode, url) {
FILE: server-client/index.d.ts
class ServerClient (line 12) | class ServerClient {
FILE: server-client/index.js
function onSend (line 10) | async function onSend(action, meta) {
function reportDetails (line 14) | function reportDetails(client) {
function denyBack (line 22) | function denyBack(app, clientId, action, meta) {
function queueWorker (line 30) | async function queueWorker(task, next) {
class ServerClient (line 67) | class ServerClient {
method constructor (line 68) | constructor(app, connection, key) {
method auth (line 128) | async auth(nodeId, token) {
method destroy (line 209) | destroy() {
method onReceive (line 242) | onReceive(action, meta) {
FILE: server-client/index.test.ts
function privateMethods (line 27) | function privateMethods(obj: object): any {
function getPair (line 31) | function getPair(client: ServerClient): TestPair {
function sendTo (line 35) | async function sendTo(client: ServerClient, msg: Message): Promise<void> {
function connect (line 41) | async function connect(
function createConnection (line 57) | function createConnection(): ServerConnection {
function createServer (line 70) | function createServer(
function createReporter (line 93) | function createReporter(opts: Partial<BaseServerOptions> = {}): {
function createClient (line 109) | function createClient(app: BaseServer): ServerClient {
function connectClient (line 117) | async function connectClient(
function sent (line 127) | function sent(client: ServerClient): Message[] {
function sentNames (line 131) | function sentNames(client: ServerClient): string[] {
function actions (line 135) | function actions(client: ServerClient): Action[] {
function connectNext (line 376) | async function connectNext(num: number): Promise<void> {
method finally (line 564) | finally() {
type FooAction (line 723) | type FooAction = {
method access (line 728) | async access(ctx, action, meta) {
method process (line 779) | process(ctx) {
method access (line 797) | access() {
method finally (line 800) | finally() {
method resend (line 842) | resend(ctx, action, meta) {
method resend (line 898) | resend() {
method resend (line 904) | resend() {
method access (line 1240) | access() {
method process (line 1244) | process() {
method access (line 1264) | access(ctx, action, meta) {
method process (line 1269) | process() {
method access (line 1289) | access(ctx, action, meta) {
method finally (line 1333) | finally() {
method finally (line 1343) | finally() {
method finally (line 1354) | finally() {
method resend (line 1357) | resend() {
method access (line 1366) | access() {
method finally (line 1369) | finally() {
method finally (line 1379) | finally() {
method process (line 1383) | process() {
method access (line 1473) | access(ctx) {
method finally (line 1477) | finally(ctx) {
method process (line 1480) | process(ctx) {
method load (line 1533) | async load(ctx) {
function meta (line 1546) | function meta(time: number): object {
method load (line 1561) | load() {
method load (line 1567) | load() {
method load (line 1573) | load() {
function meta (line 1598) | function meta(time: number): object {
method load (line 1619) | load() {
method process (line 1628) | process(ctx, action) {
method resend (line 1631) | resend(ctx, action) {
method load (line 1665) | load() {
method process (line 1671) | process(ctx, action, meta) {
method accessAndProcess (line 1700) | async accessAndProcess(ctx, action, meta) {
method accessAndProcess (line 1707) | async accessAndProcess(ctx, action, meta) {
method accessAndLoad (line 1716) | async accessAndLoad(ctx, action, meta) {
method accessAndLoad (line 1723) | accessAndLoad(ctx, action, meta) {
method accessAndProcess (line 1781) | async accessAndProcess(ctx) {
method accessAndProcess (line 1786) | async accessAndProcess(ctx, action) {
method accessAndProcess (line 1833) | accessAndProcess() {
method accessAndProcess (line 1838) | accessAndProcess() {
method accessAndProcess (line 1843) | async accessAndProcess() {
method accessAndLoad (line 1908) | accessAndLoad() {
method accessAndLoad (line 1913) | accessAndLoad() {
method accessAndLoad (line 1918) | accessAndLoad() {
method accessAndLoad (line 1923) | accessAndLoad() {
method accessAndLoad (line 2000) | accessAndLoad() {
method process (line 2035) | process() {
method process (line 2045) | async process() {
method process (line 2057) | process() {
method process (line 2067) | process() {
method process (line 2170) | process() {
method process (line 2181) | async process() {
method process (line 2192) | process() {
method access (line 2220) | async access() {
method process (line 2225) | async process() {
method resend (line 2229) | async resend() {
method access (line 2236) | async access() {
method process (line 2240) | async process() {
method resend (line 2243) | async resend() {
method access (line 2318) | async access() {
method process (line 2321) | async process() {
method access (line 2331) | async access() {
method process (line 2334) | async process() {
method access (line 2344) | async access() {
method process (line 2348) | async process() {
method access (line 2357) | async access() {
method process (line 2360) | async process() {
method access (line 2367) | async access() {
method process (line 2370) | async process() {
class OtherNode (line 2478) | class OtherNode extends FilteredNode {
method syncSinceQuery (line 2479) | syncSinceQuery(): { added: number; entries: [Action, Meta][] } {
FILE: server/errors.ts
class User (line 19) | class User {
method constructor (line 23) | constructor(id: string) {
method save (line 28) | async save(): Promise<void> {}
type UserRenameAction (line 31) | type UserRenameAction = Action & {
type UserSubscribeAction (line 37) | type UserSubscribeAction = LoguxSubscribeAction & {
type UserData (line 41) | type UserData = {
type UserParams (line 45) | type UserParams = {
type BadParams (line 49) | type BadParams = number
method access (line 52) | access(ctx, action) {
method resend (line 58) | resend(_, action) {
method process (line 64) | async process(ctx, action) {
method process (line 76) | async process(_, action) {
method access (line 85) | access() {
method access (line 91) | access() {
method filter (line 95) | async filter(_, action) {
method load (line 106) | async load(ctx) {
method access (line 116) | access(ctx, action, meta) {
method access (line 125) | access() {
method access (line 135) | access(ctx, action) {
FILE: server/index.d.ts
type LogStream (line 7) | interface LogStream {
type LoggerOptions (line 16) | interface LoggerOptions {
type ServerOptions (line 33) | interface ServerOptions extends BaseServerOptions {
class Server (line 64) | class Server<
FILE: server/index.js
class Server (line 48) | class Server extends BaseServer {
method constructor (line 49) | constructor(opts = {}) {
method loadOptions (line 96) | static loadOptions(process, defaults) {
method autoloadModules (line 119) | async autoloadModules(
method listen (line 148) | async listen(...args) {
FILE: server/index.test.ts
constant ROOT (line 8) | const ROOT = join(import.meta.dirname, '..')
constant DATE (line 9) | const DATE = /\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/g
function start (line 13) | function start(name: string, args?: string[]): Promise<void> {
function check (line 28) | function check(
function fakeProcess (line 68) | function fakeProcess(argv: string[] = [], env: object = {}): any {
function checkOut (line 72) | async function checkOut(
function checkError (line 86) | async function checkError(
FILE: server/types.ts
class User (line 17) | class User {
method constructor (line 21) | constructor(id: string) {
method save (line 26) | async save(): Promise<void> {}
type UserRenameAction (line 29) | type UserRenameAction = {
type UserSubscribeAction (line 35) | type UserSubscribeAction = {
type UserData (line 39) | type UserData = {
type UserParams (line 43) | type UserParams = {
method access (line 48) | access(ctx, action, meta) {
method process (line 54) | async process(ctx, action) {
method resend (line 59) | resend(ctx, action) {
method access (line 67) | access(ctx, action, meta) {
method filter (line 72) | filter(ctx, action) {
method load (line 83) | async load(ctx) {
method access (line 98) | access(ctx, action, meta) {
method access (line 113) | access(ctx, action) {
FILE: test-client/index.d.ts
class LoguxActionError (line 16) | class LoguxActionError extends Error {
type TestClientOptions (line 20) | interface TestClientOptions {
class TestClient (line 69) | class TestClient {
FILE: test-client/index.js
class TestClient (line 7) | class TestClient {
method constructor (line 8) | constructor(server, userId, opts = {}) {
method collect (line 46) | async collect(test) {
method connect (line 58) | connect() {
method disconnect (line 79) | disconnect() {
method process (line 84) | process(action, meta) {
method received (line 136) | async received(test) {
method subscribe (line 149) | async subscribe(channel, filter, since) {
method unsubscribe (line 164) | unsubscribe(channel, filter) {
FILE: test-client/index.test.ts
function catchError (line 14) | async function catchError(cb: () => Promise<any>): Promise<LoguxActionEr...
function privateMethods (line 25) | function privateMethods(obj: object): any {
method process (line 47) | process(ctx) {
method process (line 95) | process() {
method process (line 104) | process(ctx, action, meta) {
method load (line 200) | load(ctx, action) {
method write (line 249) | write() {}
method process (line 317) | process(ctx) {
FILE: test-server/index.d.ts
type TestServerOptions (line 12) | interface TestServerOptions extends Omit<
class TestServer (line 50) | class TestServer<
FILE: test-server/index.js
class TestServer (line 8) | class TestServer extends BaseServer {
method constructor (line 9) | constructor(opts = {}) {
method connect (line 42) | async connect(userId, opts = {}) {
method expectDenied (line 48) | async expectDenied(test) {
method expectError (line 52) | async expectError(text, test) {
method expectUndo (line 66) | async expectUndo(reason, test) {
method expectWrongCredentials (line 85) | async expectWrongCredentials(userId, opts = {}) {
method fetch (line 96) | async fetch(path, init) {
method isBruteforce (line 108) | isBruteforce() {
Condensed preview — 86 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (390K chars).
[
{
"path": ".editorconfig",
"chars": 147,
"preview": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ni"
},
{
"path": ".github/workflows/api.yml",
"chars": 443,
"preview": "name: Update API\non:\n create:\n tags:\n - '*.*.*'\npermissions: {}\njobs:\n api:\n runs-on: ubuntu-latest\n ste"
},
{
"path": ".github/workflows/release.yml",
"chars": 1479,
"preview": "name: Release\non:\n push:\n tags:\n - '*'\npermissions:\n contents: write\njobs:\n release:\n name: Release On Tag"
},
{
"path": ".github/workflows/test.yml",
"chars": 1561,
"preview": "name: Test\non:\n push:\n branches:\n - main\n - next\n pull_request:\npermissions:\n contents: read\njobs:\n ful"
},
{
"path": ".gitignore",
"chars": 25,
"preview": "node_modules/\n\ncoverage/\n"
},
{
"path": ".npmignore",
"chars": 83,
"preview": "test/\ncoverage/\ntsconfig.json\n**/*.test.ts\n**/types.ts\n**/errors.ts\n\n__snapshots__\n"
},
{
"path": ".prettierrc.js",
"chars": 87,
"preview": "import loguxOxfmtConfig from '@logux/oxc-configs/fmt'\n\nexport default loguxOxfmtConfig\n"
},
{
"path": "CHANGELOG.md",
"chars": 10427,
"preview": "# Change Log\n\nThis project adheres to [Semantic Versioning](http://semver.org/).\n\n## 0.14 “Sliver of Straw”\n\n- Removed N"
},
{
"path": "LICENSE",
"chars": 1095,
"preview": "The MIT License (MIT)\n\nCopyright 2016 Andrey Sitnik <andrey@sitnik.es>\n\nPermission is hereby granted, free of charge, to"
},
{
"path": "README.md",
"chars": 2158,
"preview": "# Logux Server [![Cult Of Martians][cult-img]][cult]\n\n<img align=\"right\" width=\"95\" height=\"148\" title=\"Logux logotype\"\n"
},
{
"path": "add-http-pages/hello.html",
"chars": 6644,
"preview": "<!doctype html>\n<html>\n <head>\n <meta charset=\"UTF-8\" />\n <title>Logux Server</title>\n <style>\n html {\n "
},
{
"path": "add-http-pages/index.js",
"chars": 633,
"preview": "import { readFile } from 'node:fs/promises'\nimport { join } from 'node:path'\n\nlet hello\nasync function readHello() {\n i"
},
{
"path": "add-http-pages/index.test.ts",
"chars": 5758,
"preview": "import { type TestLog, TestTime } from '@logux/core'\nimport http from 'node:http'\nimport { setTimeout } from 'node:timer"
},
{
"path": "add-sync-map/index.d.ts",
"chars": 6156,
"preview": "import type {\n LoguxSubscribeAction,\n SyncMapChangeAction,\n SyncMapChangedAction,\n SyncMapCreateAction,\n SyncMapCre"
},
{
"path": "add-sync-map/index.js",
"chars": 5444,
"preview": "const WITH_TIME = Symbol('WITH_TIME')\n\nexport function ChangedAt(value, time) {\n return { time, value, [WITH_TIME]: tru"
},
{
"path": "add-sync-map/index.test.ts",
"chars": 13271,
"preview": "import {\n defineSyncMapActions,\n LoguxNotFoundError,\n loguxProcessed,\n loguxSubscribed\n} from '@logux/actions'\nimpor"
},
{
"path": "allowed-meta/index.d.ts",
"chars": 373,
"preview": "/**\n * List of meta keys permitted for clients.\n *\n *```js\n * import { ALLOWED_META } from '@logux/server'\n * async func"
},
{
"path": "allowed-meta/index.js",
"chars": 58,
"preview": "export const ALLOWED_META = ['id', 'time', 'subprotocol']\n"
},
{
"path": "allowed-meta/index.test.ts",
"chars": 204,
"preview": "import { expect, it } from 'vitest'\n\nimport { ALLOWED_META } from '../index.js'\n\nit('has allowed meta keys list', () => "
},
{
"path": "base-server/index.d.ts",
"chars": 30257,
"preview": "import type {\n AbstractActionCreator,\n LoguxSubscribeAction,\n LoguxUnsubscribeAction\n} from '@logux/actions'\nimport t"
},
{
"path": "base-server/index.js",
"chars": 30175,
"preview": "import { LoguxNotFoundError } from '@logux/actions'\nimport { Log, MemoryStore, parseId, ServerConnection } from '@logux/"
},
{
"path": "base-server/index.test.ts",
"chars": 38656,
"preview": "import { defineAction } from '@logux/actions'\nimport {\n type Action,\n Log,\n MemoryStore,\n type TestLog,\n TestTime\n}"
},
{
"path": "context/index.d.ts",
"chars": 3077,
"preview": "import type { AnyAction } from '@logux/core'\n\nimport type { ServerMeta } from '../base-server/index.js'\nimport type { Se"
},
{
"path": "context/index.js",
"chars": 1052,
"preview": "import { parseId } from '@logux/core'\n\nexport class Context {\n constructor(server, meta) {\n this.server = server\n "
},
{
"path": "context/index.test.ts",
"chars": 2143,
"preview": "import type { Action } from '@logux/core'\nimport { beforeEach, expect, it } from 'vitest'\n\nimport { Context, type Server"
},
{
"path": "create-http-server/index.js",
"chars": 1024,
"preview": "import { promises as fs } from 'node:fs'\nimport http from 'node:http'\nimport https from 'node:https'\nimport { isAbsolute"
},
{
"path": "create-reporter/__snapshots__/index.test.ts.snap",
"chars": 23386,
"preview": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`allows custom loggers 1`] = `\"{\"details\":{\"conne"
},
{
"path": "create-reporter/index.js",
"chars": 7047,
"preview": "import os from 'node:os'\nimport { sep } from 'node:path'\n\nimport humanFormatter from '../human-formatter/index.js'\n\ncons"
},
{
"path": "create-reporter/index.test.ts",
"chars": 9083,
"preview": "import '../test/force-colors.js'\n\nimport { LoguxError } from '@logux/core'\nimport { describe, expect, it } from 'vitest'"
},
{
"path": "filter-meta/index.d.ts",
"chars": 241,
"preview": "import type { ServerMeta } from '../base-server/index.js'\n\n/**\n * Remove all non-allowed keys from meta.\n *\n * @param me"
},
{
"path": "filter-meta/index.js",
"chars": 224,
"preview": "import { ALLOWED_META } from '../allowed-meta/index.js'\n\nexport function filterMeta(meta) {\n let result = {}\n for (let"
},
{
"path": "filter-meta/index.test.ts",
"chars": 509,
"preview": "import { expect, it } from 'vitest'\n\nimport { filterMeta, type ServerMeta } from '../index.js'\n\nit('filters meta', () =>"
},
{
"path": "filtered-node/index.js",
"chars": 654,
"preview": "import { ServerNode } from '@logux/core'\n\nfunction has(array, item) {\n return array && array.includes(item)\n}\n\nexport c"
},
{
"path": "filtered-node/index.test.ts",
"chars": 2790,
"preview": "import { ClientNode, type TestLog, TestPair, TestTime } from '@logux/core'\nimport { afterEach, expect, it } from 'vitest"
},
{
"path": "human-formatter/index.js",
"chars": 7699,
"preview": "import os from 'node:os'\nimport { stripVTControlCharacters, styleText } from 'node:util'\n\nimport { mulberry32, onceXmur3"
},
{
"path": "human-formatter/utils.js",
"chars": 565,
"preview": "export function onceXmur3(str) {\n let h = 1779033703 ^ str.length\n for (let i = 0; i < str.length; i++) {\n h = Math"
},
{
"path": "index.d.ts",
"chars": 848,
"preview": "export {\n addSyncMap,\n addSyncMapFilter,\n ChangedAt,\n NoConflictResolution,\n SyncMapData,\n WithoutTime,\n WithTime"
},
{
"path": "index.js",
"chars": 525,
"preview": "export {\n addSyncMap,\n addSyncMapFilter,\n ChangedAt,\n NoConflictResolution\n} from './add-sync-map/index.js'\nexport {"
},
{
"path": "options-loader/__snapshots__/index.test.js.snap",
"chars": 890,
"preview": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`loadOptions > returns help 1`] = `\n\"Start Logux "
},
{
"path": "options-loader/index.js",
"chars": 5294,
"preview": "import { existsSync, readFileSync } from 'node:fs'\nimport { join } from 'node:path'\nimport { styleText } from 'node:util"
},
{
"path": "options-loader/index.test.js",
"chars": 5501,
"preview": "import '../test/force-colors.js'\n\nimport { resolve } from 'node:path'\nimport { describe, expect, it } from 'vitest'\n\nimp"
},
{
"path": "options-loader/test.env",
"chars": 70,
"preview": "# Comment\n\nDATABASE_URL=postgresql://localhost/logux\nLOGUX_PORT=31337\n"
},
{
"path": "oxfmt.config.ts",
"chars": 87,
"preview": "import loguxOxfmtConfig from '@logux/oxc-configs/fmt'\n\nexport default loguxOxfmtConfig\n"
},
{
"path": "oxlint.config.ts",
"chars": 671,
"preview": "import loguxOxlintConfig from '@logux/oxc-configs/lint'\nimport { defineConfig } from 'oxlint'\n\nexport default defineConf"
},
{
"path": "package.json",
"chars": 1555,
"preview": "{\n \"name\": \"@logux/server\",\n \"version\": \"0.14.0\",\n \"description\": \"Build own Logux server\",\n \"keywords\": [\n \"coll"
},
{
"path": "request/index.d.ts",
"chars": 235,
"preview": "/**\n * Throwing this error in `accessAndProcess` or `accessAndLoad`\n * will deny the action.\n */\nexport class ResponseEr"
},
{
"path": "request/index.js",
"chars": 193,
"preview": "export class ResponseError extends Error {\n constructor(statusCode, url) {\n super(`${statusCode} response on ${url}`"
},
{
"path": "server/__snapshots__/index.test.ts.snap",
"chars": 7477,
"preview": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`destroys everything on exit 1`] = `\n\" INFO Log"
},
{
"path": "server/errors.ts",
"chars": 3088,
"preview": "import { LoguxSubscribeAction, defineAction } from '@logux/actions'\n\nimport { Server, Action } from '../index.js'\n\nlet s"
},
{
"path": "server/index.d.ts",
"chars": 2365,
"preview": "import {\n BaseServer,\n type BaseServerOptions,\n type Logger\n} from '../base-server/index.js'\n\nexport interface LogStr"
},
{
"path": "server/index.js",
"chars": 3749,
"preview": "import { join, relative } from 'node:path'\nimport { styleText } from 'node:util'\nimport { glob } from 'tinyglobby'\n\nimpo"
},
{
"path": "server/index.test.ts",
"chars": 6585,
"preview": "import spawn from 'cross-spawn'\nimport type { ChildProcess, SpawnOptions } from 'node:child_process'\nimport { join } fro"
},
{
"path": "server/types.ts",
"chars": 2293,
"preview": "import { defineAction, type LoguxSubscribeAction } from '@logux/actions'\n\nimport { type Action, Server } from '../index."
},
{
"path": "server-client/index.d.ts",
"chars": 1974,
"preview": "import type { ServerConnection, ServerNode } from '@logux/core'\n\nimport type { BaseServer } from '../base-server/index.j"
},
{
"path": "server-client/index.js",
"chars": 8912,
"preview": "import { LoguxError, parseId } from '@logux/core'\nimport cookie from 'cookie'\nimport fastq from 'fastq'\n\nimport { ALLOWE"
},
{
"path": "server-client/index.test.ts",
"chars": 61516,
"preview": "import { LoguxNotFoundError } from '@logux/actions'\nimport {\n type Action,\n LoguxError,\n type Message,\n type Meta,\n "
},
{
"path": "test/fixtures/cert.pem",
"chars": 1424,
"preview": "-----BEGIN CERTIFICATE-----\nMIID7jCCAtagAwIBAgIUO6cuMpNPGoG1mUO5WcSQMzvNaHkwDQYJKoZIhvcNAQEL\nBQAwZjELMAkGA1UEBhMCVVMxCzA"
},
{
"path": "test/fixtures/key.pem",
"chars": 1675,
"preview": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAzD7FPEH7miQ8CZDPI2OWgPF5CXzqojns1bPkEhhEPrGEBtRk\nYEThFpa0+Z1XD61t++t2yoA"
},
{
"path": "test/force-colors.js",
"chars": 30,
"preview": "process.env.FORCE_COLOR = '1'\n"
},
{
"path": "test/servers/autoload-error-modules.js",
"chars": 312,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server({\n minSubprotocol: 1,\n root: import"
},
{
"path": "test/servers/autoload-modules.js",
"chars": 286,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server({\n minSubprotocol: 1,\n root: import"
},
{
"path": "test/servers/destroy.js",
"chars": 498,
"preview": "#!/usr/bin/env node\n\nimport { setTimeout } from 'node:timers/promises'\n\nimport { Server } from '../../index.js'\n\nlet app"
},
{
"path": "test/servers/eacces.js",
"chars": 219,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server({\n minSubprotocol: 1,\n port: 1000,\n"
},
{
"path": "test/servers/eaddrinuse.js",
"chars": 274,
"preview": "#!/usr/bin/env node\n\nimport os from 'node:os'\n\nimport { Server } from '../../index.js'\n\nos.platform = () => 'linux'\n\nlet"
},
{
"path": "test/servers/error-modules/wrond-export/index.js",
"chars": 37,
"preview": "export default 'wrong module export'\n"
},
{
"path": "test/servers/json.js",
"chars": 237,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server({\n logger: 'json',\n minSubprotocol:"
},
{
"path": "test/servers/logger.js",
"chars": 357,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server(\n Server.loadOptions(process, {\n "
},
{
"path": "test/servers/missed.js",
"chars": 244,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server(\n Server.loadOptions(process, {\n "
},
{
"path": "test/servers/modules/child/index.foo.js",
"chars": 143,
"preview": "setTimeout(() => {\n let error = new Error('Test Error')\n error.stack = `${error.stack.split('\\n')[0]}\\nfake stacktrace"
},
{
"path": "test/servers/modules/child/index.js",
"chars": 176,
"preview": "import { setTimeout } from 'node:timers/promises'\n\nexport default async server => {\n await setTimeout(100)\n console.lo"
},
{
"path": "test/servers/modules/child/lib/lib.js",
"chars": 143,
"preview": "setTimeout(() => {\n let error = new Error('Test Error')\n error.stack = `${error.stack.split('\\n')[0]}\\nfake stacktrace"
},
{
"path": "test/servers/modules/root.js",
"chars": 94,
"preview": "export default server => {\n console.log(`Root path module: ${server.options.subprotocol}`)\n}\n"
},
{
"path": "test/servers/modules/root.test.js",
"chars": 27,
"preview": "throw new Error('No load')\n"
},
{
"path": "test/servers/options.js",
"chars": 267,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server(\n Server.loadOptions(process, {\n "
},
{
"path": "test/servers/root.js",
"chars": 356,
"preview": "#!/usr/bin/env node\n\nimport { join } from 'node:path'\n\nimport { Server } from '../../index.js'\n\nlet app = new Server(\n "
},
{
"path": "test/servers/throw.js",
"chars": 369,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server({\n minSubprotocol: 1,\n subprotocol:"
},
{
"path": "test/servers/unbind.js",
"chars": 207,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server({\n minSubprotocol: 1,\n subprotocol:"
},
{
"path": "test/servers/uncatch.js",
"chars": 425,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server({\n minSubprotocol: 1,\n subprotocol:"
},
{
"path": "test/servers/unknown.js",
"chars": 267,
"preview": "#!/usr/bin/env node\n\nimport { Server } from '../../index.js'\n\nlet app = new Server(\n Server.loadOptions(process, {\n "
},
{
"path": "test-client/index.d.ts",
"chars": 5447,
"preview": "import type {\n LoguxSubscribeAction,\n LoguxUnsubscribeAction\n} from '@logux/actions'\nimport type {\n Action,\n AnyActi"
},
{
"path": "test-client/index.js",
"chars": 4703,
"preview": "import { ClientNode, TestPair } from '@logux/core'\nimport cookie from 'cookie'\nimport { setTimeout } from 'node:timers/p"
},
{
"path": "test-client/index.test.ts",
"chars": 11221,
"preview": "import { TestTime } from '@logux/core'\nimport { restoreAll, type Spy, spyOn } from 'nanospy'\nimport { setTimeout } from "
},
{
"path": "test-server/index.d.ts",
"chars": 3563,
"preview": "import type { TestLog, TestTime } from '@logux/core'\n\nimport { BaseServer } from '../base-server/index.js'\nimport type {"
},
{
"path": "test-server/index.js",
"chars": 2579,
"preview": "import { TestTime } from '@logux/core'\nimport { createServer } from 'node:http'\n\nimport { BaseServer } from '../base-ser"
},
{
"path": "tsconfig.json",
"chars": 263,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2024\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"esMo"
},
{
"path": "vite.config.ts",
"chars": 489,
"preview": "import { defineConfig } from 'vitest/config'\n\nexport default defineConfig({\n test: {\n coverage: {\n exclude: [\n "
}
]
About this extraction
This page contains the full source code of the logux/logux-server GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 86 files (356.2 KB), approximately 110.6k tokens, and a symbol index with 415 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.