├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ ├── node.js.yml │ └── npm-publish.yml ├── .gitignore ├── .loaderrc.js ├── .prettierignore ├── .prettierrc.yaml ├── LICENSE ├── README.md ├── TODO.txt ├── author-test-manual.ts ├── author.ts ├── build.js ├── callback-replayer-test.ts ├── callback-replayer.ts ├── collect.ts ├── event-cache-test.ts ├── event-cache.ts ├── event-demultiplexer-test.ts ├── event-demultiplexer.ts ├── event-persister.ts ├── event.ts ├── examples └── simple-nodejs │ ├── README.md │ ├── index.js │ ├── package-lock.json │ ├── package.json │ └── pnpm-lock.yaml ├── fakejson.test.ts ├── fakejson.ts ├── group-filters-by-relay.ts ├── in-memory-relay-server.ts ├── index.ts ├── jest.config.cjs ├── merge-similar-filters-test.ts ├── merge-similar-filters.ts ├── metadata-cache.ts ├── newest-event-cache.ts ├── on-event-filters.ts ├── package.json ├── relay-pool-worker.ts ├── relay-pool.test.ts ├── relay-pool.ts ├── relay-pool.worker.js ├── relay-pool.worker.ts ├── relay.test.ts ├── relay.ts ├── subscription-filter-state-cache.ts ├── tsconfig.json ├── write-relays.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | end_of_line = lf 8 | 9 | [*.{js,json,ts,tsx}] 10 | indent_style = space 11 | indent_size = 2 12 | max_line_length = 80 13 | 14 | [*.{md,markdown}] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | 4 | "parser": "@typescript-eslint/parser", 5 | "plugins": ["@typescript-eslint"], 6 | 7 | "parserOptions": { 8 | "ecmaVersion": 9, 9 | "ecmaFeatures": { 10 | "jsx": true 11 | }, 12 | "sourceType": "module", 13 | "allowImportExportEverywhere": false 14 | }, 15 | 16 | "env": { 17 | "es6": true, 18 | "node": true 19 | }, 20 | 21 | "plugins": ["babel"], 22 | "extends": ["prettier"], 23 | 24 | "globals": { 25 | "document": false, 26 | "navigator": false, 27 | "window": false, 28 | "crypto": false, 29 | "location": false, 30 | "URL": false, 31 | "URLSearchParams": false, 32 | "fetch": false, 33 | "EventSource": false, 34 | "localStorage": false, 35 | "sessionStorage": false 36 | }, 37 | 38 | "rules": { 39 | "accessor-pairs": 2, 40 | "arrow-spacing": [2, {"before": true, "after": true}], 41 | "block-spacing": [2, "always"], 42 | "brace-style": [2, "1tbs", {"allowSingleLine": true}], 43 | "comma-dangle": 0, 44 | "comma-spacing": [2, {"before": false, "after": true}], 45 | "comma-style": [2, "last"], 46 | "constructor-super": 2, 47 | "curly": [0, "multi-line"], 48 | "dot-location": [2, "property"], 49 | "eol-last": 2, 50 | "eqeqeq": [2, "allow-null"], 51 | "generator-star-spacing": [2, {"before": true, "after": true}], 52 | "handle-callback-err": [2, "^(err|error)$"], 53 | "indent": 0, 54 | "jsx-quotes": [2, "prefer-double"], 55 | "key-spacing": [2, {"beforeColon": false, "afterColon": true}], 56 | "keyword-spacing": [2, {"before": true, "after": true}], 57 | "new-cap": 0, 58 | "new-parens": 0, 59 | "no-array-constructor": 2, 60 | "no-caller": 2, 61 | "no-class-assign": 2, 62 | "no-cond-assign": 2, 63 | "no-const-assign": 2, 64 | "no-control-regex": 0, 65 | "no-debugger": 0, 66 | "no-delete-var": 2, 67 | "no-dupe-args": 2, 68 | "no-dupe-class-members": 2, 69 | "no-dupe-keys": 2, 70 | "no-duplicate-case": 2, 71 | "no-empty-character-class": 2, 72 | "no-empty-pattern": 2, 73 | "no-eval": 0, 74 | "no-ex-assign": 2, 75 | "no-extend-native": 2, 76 | "no-extra-bind": 2, 77 | "no-extra-boolean-cast": 2, 78 | "no-extra-parens": [2, "functions"], 79 | "no-fallthrough": 2, 80 | "no-floating-decimal": 2, 81 | "no-func-assign": 2, 82 | "no-implied-eval": 2, 83 | "no-inner-declarations": [0, "functions"], 84 | "no-invalid-regexp": 2, 85 | "no-irregular-whitespace": 2, 86 | "no-iterator": 2, 87 | "no-label-var": 2, 88 | "no-labels": [2, {"allowLoop": false, "allowSwitch": false}], 89 | "no-lone-blocks": 2, 90 | "no-mixed-spaces-and-tabs": 2, 91 | "no-multi-spaces": 2, 92 | "no-multi-str": 2, 93 | "no-multiple-empty-lines": [2, {"max": 2}], 94 | "no-native-reassign": 2, 95 | "no-negated-in-lhs": 2, 96 | "no-new": 0, 97 | "no-new-func": 2, 98 | "no-new-object": 2, 99 | "no-new-require": 2, 100 | "no-new-symbol": 2, 101 | "no-new-wrappers": 2, 102 | "no-obj-calls": 2, 103 | "no-octal": 2, 104 | "no-octal-escape": 2, 105 | "no-path-concat": 0, 106 | "no-proto": 2, 107 | "no-redeclare": 2, 108 | "no-regex-spaces": 2, 109 | "no-return-assign": 0, 110 | "no-self-assign": 2, 111 | "no-self-compare": 2, 112 | "no-sequences": 2, 113 | "no-shadow-restricted-names": 2, 114 | "no-spaced-func": 2, 115 | "no-sparse-arrays": 2, 116 | "no-this-before-super": 2, 117 | "no-throw-literal": 2, 118 | // "no-trailing-spaces": 2, // Fixed on save 119 | "no-undef": 2, 120 | "no-undef-init": 2, 121 | "no-unexpected-multiline": 2, 122 | "no-unneeded-ternary": [2, {"defaultAssignment": false}], 123 | "no-unreachable": 2, 124 | "no-unused-vars": [ 125 | 2, 126 | {"vars": "local", "args": "none", "varsIgnorePattern": "^_"} 127 | ], 128 | "no-useless-call": 2, 129 | "no-useless-constructor": 2, 130 | "no-with": 2, 131 | "one-var": [0, {"initialized": "never"}], 132 | "operator-linebreak": [ 133 | 2, 134 | "after", 135 | {"overrides": {"?": "before", ":": "before"}} 136 | ], 137 | // "padded-blocks": [2, "never"], 138 | "semi-spacing": [2, {"before": false, "after": true}], 139 | "space-before-blocks": [2, "always"], 140 | "space-before-function-paren": 0, 141 | "space-in-parens": [2, "never"], 142 | "space-infix-ops": 2, 143 | "space-unary-ops": [2, {"words": true, "nonwords": false}], 144 | "spaced-comment": 0, 145 | "template-curly-spacing": [2, "never"], 146 | "use-isnan": 2, 147 | "valid-typeof": 2, 148 | "wrap-iife": [2, "any"], 149 | "yield-star-spacing": [2, "both"], 150 | "yoda": [0] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [19.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm install -g yarn 29 | - run: yarn install --frozen-lockfile 30 | - run: npm run type-check 31 | - run: npm run build --if-present 32 | - run: npm test -- --testTimeout 5000 33 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: 16 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .envrc 4 | lib 5 | test.html 6 | -------------------------------------------------------------------------------- /.loaderrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | loaders: [ 3 | 'esm-loader-typescript', 4 | { 5 | loader: 'esm-loader-import-relative-extension', 6 | options: { 7 | extensions: { 8 | '.ts': { 9 | '': '.ts', 10 | '.js': '.ts' 11 | } 12 | } 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | bracketSpacing: false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 adamritter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nostr-relaypool-ts 2 | 3 | A Nostr RelayPool implementation in TypeScript using https://github.com/nbd-wtf/nostr-tools library as a dependency. 4 | 5 | Its main goal is to make it simpler to build a client on top of it than just a dumb RelayPool implementation. 6 | 7 | It's used by: 8 | 9 | - https://iris.to/ 10 | - https://rbr.bio/ 11 | - https://nostrbounties.com/ 12 | - To be part of NDK 13 | 14 | (Add yourself here :) ) 15 | 16 | Features: 17 | 18 | - Merging filters: separate filters with the same type of query (like asking for different authors with the same 19 | kinds) are automatically merged for every subscription request to decrease the number of filters, 20 | as the server usually handles it better. 21 | - Deleting empty filters: filters with no possible match are deleted, and subscription is not even created if there 22 | would be no valid event. 23 | - Duplicate events from cache / different relays are parsed / verified / emitted only once 24 | - If an event with kind 0 / 3 is emitted, older events with the same author and kind are not emitted. The last 25 | emitted event with that kind and author is always the freshest. 26 | - Delayed subscriptions allow clients to request data from different components, and let the RelayPool implementation 27 | merge, deduplicate, prioritize, cache these requests and split the replies to send to each subscription 28 | from the clients. It needs lots of testing and optimizations to get it to production level, but prototyping is important 29 | at this stage. 30 | - Compute smallest created_at (for continuing subscriptions on relays). When continuing a subscription, just pass 31 | the specific relay. 32 | - Run RelayPool in Web worker for smooth scrolling 33 | - Delete signatures (off by default) 34 | - Caching events (off by default): every event searched by id or event of Metadata or Contacts kind are cached in memory. 35 | Returning cached data can be turned off for each filter 36 | - Automatically close subscriptions (optional) 37 | - Automatically select relays by getting write relays from indexing servers 38 | - Getting metadata / contacts from indexing servers and cache them. 39 | - Automatically reconnect to servers (optional) 40 | 41 | # Installation: 42 | 43 | ```bash 44 | npm i nostr-relaypool 45 | ``` 46 | 47 | # Installation for use in NodeJS 48 | 49 | ```bash 50 | npm i nostr-relaypool ws 51 | ``` 52 | 53 | # Usage: 54 | 55 | ```typescript 56 | import {RelayPool} from "nostr-relaypool"; 57 | 58 | let relays = [ 59 | "wss://relay.damus.io", 60 | "wss://nostr.fmt.wiz.biz", 61 | "wss://nostr.bongbong.com", 62 | ]; 63 | 64 | let relayPool = new RelayPool(relays); 65 | 66 | let unsub = relayPool.subscribe( 67 | [ 68 | { 69 | authors: [ 70 | "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", 71 | ], 72 | }, 73 | { 74 | kinds: [0], 75 | authors: [ 76 | "0000000035450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", 77 | ], 78 | relay: "wss://nostr.sandwich.farm", 79 | }, 80 | ], 81 | relays, 82 | (event, isAfterEose, relayURL) => { 83 | console.log(event, isAfterEose, relayURL); 84 | }, 85 | undefined, 86 | (events, relayURL) => { 87 | console.log(events, relayURL); 88 | } 89 | ); 90 | 91 | relayPool.onerror((err, relayUrl) => { 92 | console.log("RelayPool error", err, " from relay ", relayUrl); 93 | }); 94 | relayPool.onnotice((relayUrl, notice) => { 95 | console.log("RelayPool notice", notice, " from relay ", relayUrl); 96 | }); 97 | ``` 98 | 99 | # Web worker support (not all functionality is supported yet, but feel free to add, it's easy): 100 | 101 | ``` 102 | import { RelayPoolWorker } from 'nostr-relaypool'; 103 | 104 | const worker = new Worker( 105 | new URL('./node_modules/nostr-relaypool/lib/nostr-relaypool.worker.js', document.location.href) 106 | ); 107 | const relayPool = new RelayPoolWorker(worker); 108 | ``` 109 | 110 |
111 | 112 | # API documentation: 113 | 114 | ```typescript 115 | RelayPool(relays:string[] = [], options:{useEventCache?: boolean, logSubscriptions?: boolean, 116 | deleteSignatures?: boolean, skipVerification?: boolean, 117 | autoReconnect?: boolean} = {}) 118 | ``` 119 | 120 | RelayPool constructor connects to the given relays, but it doesn't determine which relays are used for specific subscriptions. 121 | 122 | It caches all events and returns filtering id and 0 / 3 kinds with requested pubkeys from cache. 123 | 124 | options: 125 | 126 | - useEventCache: turns on caching of all events. WARNING: This makes memory grow without limit. 127 | - logSubscriptions: turns on logging subscriptions. 128 | 1 entry per RelayPool subscription, and it can help clients significantly 129 | - deleteSignatures: delete signatures for events 130 | - skipVerification: skip event signature verification 131 | - autoReconnect: aggressively reconnect automatically (not suggested for clients) 132 | 133 |
134 | 135 | ```typescript 136 | RelayPool::subscribe(filters: Filter & {relay?: string, noCache?: boolean}, 137 | relays: string[] | undefined, 138 | onEvent: (event: Event, isAfterEose: boolean, 139 | relayURL: string | undefined) => void, 140 | maxDelayms?: number, 141 | onEose?: (relayURL, minCreatedAt) => void, 142 | options: { 143 | allowDuplicateEvents?: boolean, 144 | allowOlderEvents?: boolean, 145 | logAllEvents?: boolean, 146 | unsubscribeOnEose: boolean, 147 | defaultRelays?: string[]} = {} 148 | ) : () => void 149 | ``` 150 | 151 | Creates a subscription to a list of filters and sends them to a pool of relays if 152 | new data is required from those relays. 153 | 154 | - filters: 155 | 156 | If the relay property of a filter is set, that filter will be requested only from that relay. 157 | Filters that don't have relay set will be sent to the relays passed inside 158 | the relays parameter. There will be at most 1 subscription created for each relay 159 | even if it's passed multiple times in relay / relays. 160 | 161 | The implementation finds filters in the subscriptions that only differ in 1 key and 162 | merges them both on RelayPool level and Relay level. 163 | The merging algorithm is linear in input size (not accidental O(n^2)). 164 | It also removes empty filters that are guaranteed to not match any events. 165 | 166 | noCache inside a filter instructs relayPool to never return 167 | cached results for that specific filter, but get them from a subscription. 168 | (maybe refresh or revalidate would be a better name, but noCache was selected 169 | as it's defined in the Cache-Control header of the HTTP standard). 170 | It may only be useful if kinds 0, 3 are requested. 171 | 172 | If no real work would be done by a relay (all filters are satisfied from cache or empty) the subscription will not be sent 173 | (but the cached events will be provided instantly using the onEvent callback). 174 | 175 | - relays: Events for filters that have no relay field set will be requested from 176 | all the specified relays. 177 | 178 | If relays is not set and authors is set for all filters, RelayPool will get write relays for all authors in the subscription 179 | and send the subscription to those relays. 180 | 181 | - onEvent: 182 | 183 | Other RelayPool implementations allow calling onevent multiple times 184 | on a Subscription class, which was the original design in this library as well, but 185 | since caching is implemented, it's safer API design to pass onevent inside the 186 | subscribe call. 187 | 188 | Events that are read from the cache will be called back immediately 189 | with relayURL === undefined. 190 | 191 | isAfterEose is true if the event was recieved from a relay after the EOSE message. 192 | isAfterEose is always false for cached events. 193 | 194 | - maxDelayms: Adding maxDelay option delays sending subscription requests, batching them and matching them after the events come back. 195 | 196 | It's called maxDelay instead of delay, as subscribing with a maxDelay of 100ms and later subscribing with infinity time will reset the timer to 100ms delay. 197 | 198 | The first implementation uses matchFilter (O(n^2)) for redistributing events that can be easily optimized if the abstraction is successful. 199 | 200 | If it's used, the returned function doesn't do anything. It can't be used together with onEose. 201 | 202 | - onEose: called for each EOSE event received from a relay. Can't be used together with maxDelayms 203 | 204 | minCreatedAt contains the smallest created_at seen or Infinity if there were no events in the subscription. 205 | 206 | - options: 207 | 208 | - allowDuplicateEvents: by default duplicate events with the same id are filtered out. 209 | This option removes duplicate event filtering. 210 | 211 | - allowOlderEvents: if a subscription emitted an event with kind 0 or 3 (metadata / contacts), 212 | it doesn't allow emitting older events by default. This option overrides that filter. 213 | 214 | - logAllEvents: Log all events that are sent back to separate subscriptions. It can be a lot of events, 215 | so this option is only advised for application development, especially debugging. 216 | 217 | - unsubscribeOnEose: The most important option to use for past events with different queries (for example subscribing when the ids are known). 218 | the number of subscriptions on relays are limited, this option closes subscription on each relay when the relay sends EOSE event. 219 | 220 | - defaultRelays: if relays is undefined, sometimes no writeRelays can be found for the authors in the filters. 221 | In that case the subscription falls back to these passed defaultRelays 222 | 223 | - dontSendOtherFilters: other filters may have timeouts, but this subscription may be sent instantly, 224 | in this case this option can be used (for example for rbr.bio that doesn't have limits). 225 | 226 | Return value: 227 | 228 | Returns a function that stops sending more data with the onEvent callback. When all virtual subscriptions are unsubscribed, 229 | an unsubscribe request is sent to all relays. 230 | 231 | ```typescript 232 | RelayPool::sendSubscriptions(onEose?: (events, relayURL) => void) : () => void 233 | ``` 234 | 235 | Sends subscriptions queued up with delayed subscriptions. It can be used after all subscriptions are requested (with some delay or Infinite delay). 236 | 237 | ```typescript 238 | async getEventById(id: string, relays: string[], maxDelayms: number) : Promise 239 | ``` 240 | 241 | Gets one event by event id. Many similar subscriptions should be batched together. It is useful inside a component when many components are rendered. 242 | 243 | Other API functions: 244 | 245 | ```typescript 246 | RelayPool::publish(event: Event, relays: string[]) 247 | 248 | RelayPool::onnotice(cb: (url: string, msg: string) => void) 249 | 250 | RelayPool::onerror(cb: (url: string, msg: string) => void) 251 | 252 | RelayPool::setWriteRelaysForPubKey(pubkey: string, writeRelays: string[]) 253 | 254 | RelayPool::subscribeReferencedEvents( 255 | event: Event, 256 | onEvent: OnEvent, 257 | maxDelayms?: number, 258 | onEose?: OnEose, 259 | options: SubscriptionOptions = {} 260 | ): () => void 261 | 262 | RelayPool::fetchAndCacheMetadata(pubkey: string): Promise 263 | 264 | RelayPool::subscribeReferencedEventsAndPrefetchMetadata( 265 | event: Event, 266 | onEvent: OnEvent, 267 | maxDelayms?: number, 268 | onEose?: OnEose, 269 | options: SubscriptionOptions = {} 270 | ): () => void 271 | 272 | RelayPool::setCachedMetadata(pubkey: string, metadata: Event) 273 | 274 | ``` 275 | 276 | # Support: 277 | 278 | Telegram: @AdamRitter 279 | 280 | npub1dcl4zejwr8sg9h6jzl75fy4mj6g8gpdqkfczseca6lef0d5gvzxqvux5ey 281 | 282 | # DEPRECIATED: Experimental API wrapper for RelayPool 283 | 284 | All these are depreciated as they are not too useful abstractions in practice. 285 | 286 | This is the first taste of an API wrapper that makes RelayPool easier to use. It's experimental (many methods haven't been tested at all) and subject to change significantly. 287 | 288 | The first parameter is OnEvent, last parameter is always maxDelayms, and the middle parameter is limit if it's needed. 289 | 290 | An unsubscribe function is returned, although it's not implemented yet. 291 | 292 | ```typescript 293 | RelayPool::subscribeEventObject(filters: Filter & {relay?: string, noCache?: boolean}, 294 | relays: string[] | undefined, 295 | onEventObject: (eventObject: EventObject, isAfterEose: boolean, 296 | relayURL: string | undefined) => void, 297 | maxDelayms?: number, 298 | onEose?: (relayURL, minCreatedAt) => void, 299 | options: {allowDuplicateEvents?: boolean, allowOlderEvents?: boolean, 300 | logAllEvents?: boolean, unsubscribeOnEose: boolean} = {} 301 | ) : () => void 302 | 303 | const pubkey = 304 | "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"; 305 | const relays = [ 306 | "wss://relay.damus.io", 307 | "wss://nostr.fmt.wiz.biz", 308 | "wss://relay.snort.social", 309 | ]; 310 | const relayPool = new RelayPool(); 311 | const author = new Author(relayPool, relays, pubkey); 312 | author.metaData(console.log, 0); 313 | author.follows(console.log, 0); 314 | author.followers(console.log, 0); 315 | author.subscribe([{kinds: [Kind.Contacts]}], console.log, 0); 316 | author.allEvents(console.log, 5, 0); 317 | author.referenced(console.log, 5, 0); 318 | author.followers(console.log, 50, 0); 319 | author.sentAndRecievedDMs(console.log, 50, 0); 320 | author.text(console.log, 10, 0); 321 | 322 | new Author(relayPool: RelayPool, relays: string[], pubkey: string) 323 | 324 | Author::metaData(cb: (event: EventObject) => void, maxDelayms: number): () => void 325 | 326 | Author::subscribe(filters: Filter[], cb: OnEvent, maxDelayms: number): () => void 327 | 328 | Author::followsPubkeys(cb: (pubkeys: string[]) => void, maxDelayms: number): () => void 329 | 330 | Author::follows(cb: (authors: Author[]) => void, maxDelayms: number): () => void 331 | 332 | Author::allEvents(cb: OnEventObject, limit = 100, maxDelayms: number): () => void 333 | 334 | Author::referenced(cb: OnEventObject, limit = 100, maxDelayms: number): () => void 335 | 336 | Author::followers(cb: OnEventObject, limit = 100, maxDelayms: number): () => void 337 | 338 | Author::sentAndRecievedDMs(cb: OnEventObject, limit = 100, maxDelayms: number): () => void 339 | 340 | Author::text(cb: OnEventObject, limit = 100, maxDelayms: number): () => void 341 | 342 | collect(onEvents: (events: Event[]) => void): OnEvent // Keeps events array sorted by created_at 343 | 344 | ``` 345 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | It's critical to be able to continue a subscription after a restart. Also it must be in a 2 | subscription cache that uses an event cache. 3 | 4 | It can be dangorous to create 2 subscriptions instead of 1 original though. Probably it's the best to start with 5 | the newest data first. Anyways for infinite scrolling continueing is a must have. 6 | 7 | - Another continue interface would be 8 | ,,continueUntil'' or something similar. A class would handle the translation from the callback to 9 | continueUntil higher level abstraction. countinueUntil can have a minimum element number as well that must 10 | be retrieved after continueUntil. 11 | - onEose must be implemented well for batched subscriptions to be able to continue anything, though it's 12 | not super important if only ,,new data'' is asked for... in that case onEose is only for error handling 13 | 14 | -------------------------------------------------------------------------------- /author-test-manual.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import {RelayPool} from "./relay-pool"; 4 | import {Author} from "./author"; 5 | import {Kind} from "nostr-tools"; 6 | 7 | jest.setTimeout(5000); 8 | const pubkey = 9 | "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"; 10 | const relays = [ 11 | "wss://relay.damus.io", 12 | "wss://nostr.fmt.wiz.biz", 13 | "wss://relay.snort.social", 14 | ]; 15 | const _events = [ 16 | { 17 | id: "bd72d02cbc5e4eca98cef001d9850e816c31d2bf9287f7eb433026c8f81c551f", 18 | pubkey: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", 19 | created_at: 1673549387, 20 | kind: 0, 21 | tags: [], 22 | content: 23 | '{"banner":"https:\\/\\/pbs.twimg.com\\/profile_banners\\/9918032\\/1531711830\\/600x200","website":"https:\\/\\/jb55.com","lud06":"lnurl1dp68gurn8ghj7um9dej8xct5wvhxcmmv9uh8wetvdskkkmn0wahz7mrww4excup0df3r2dg3mj444","nip05":"jb55@jb55.com","picture":"https:\\/\\/cdn.jb55.com\\/img\\/red-me.jpg","display_name":"Will 🔮⚡️","about":"damus.io author. bitcoin and nostr dev","name":"jb55"}', 24 | sig: "fcc1826acf94d57c2eaebbd8a810240a83a172bb9853f2b90784ab0f2722355716204d4e7fe5a0447594fbde3d708484477eec6a544a11b21cc91dead9343c3d", 25 | }, 26 | ]; 27 | 28 | test("metaData", () => { 29 | const relayPool = new RelayPool(); 30 | const author = new Author(relayPool, relays, pubkey); 31 | return new Promise((resolve) => { 32 | const unsubscribe = author.metaData((event) => { 33 | unsubscribe(); 34 | expect(event.kind).toBe(Kind.Metadata); 35 | expect(event.pubkey).toBe(pubkey); 36 | expect(JSON.parse(event.content).nip05).toBe("jb55@jb55.com"); 37 | resolve(event); 38 | }, 0); 39 | }); 40 | }); 41 | 42 | test("followsPubkeys", () => { 43 | const relayPool = new RelayPool(); 44 | const author = new Author(relayPool, relays, pubkey); 45 | author.subscribe([{kinds: [Kind.Contacts]}], console.log, 0); 46 | return new Promise((resolve) => { 47 | const unsubscribe = author.followsPubkeys((pubkeys) => { 48 | unsubscribe(); 49 | expect( 50 | pubkeys.includes( 51 | "6cad545430904b84a8101c5783b65043f19ae29d2da1076b8fc3e64892736f03" 52 | ) 53 | ).toBe(true); 54 | resolve(true); 55 | }, 0); 56 | }); 57 | }); 58 | 59 | test("followsAuthors", () => { 60 | const relayPool = new RelayPool(); 61 | const author = new Author(relayPool, relays, pubkey); 62 | author.subscribe([{kinds: [Kind.Contacts]}], console.log, 0); 63 | return new Promise((resolve) => { 64 | const unsubscribe = author.follows((authors) => { 65 | unsubscribe(); 66 | expect( 67 | authors 68 | .map((author) => author.pubkey) 69 | .includes( 70 | "6cad545430904b84a8101c5783b65043f19ae29d2da1076b8fc3e64892736f03" 71 | ) 72 | ).toBe(true); 73 | resolve(true); 74 | }, 0); 75 | }); 76 | }); 77 | 78 | test("allEvents", () => { 79 | const relayPool = new RelayPool(); 80 | const author = new Author(relayPool, relays, pubkey); 81 | author.subscribe([{kinds: [Kind.Contacts]}], console.log, 0); 82 | return new Promise((resolve) => { 83 | const unsubscribe = author.allEvents( 84 | (events) => { 85 | unsubscribe(); 86 | expect(events.pubkey).toBe(pubkey); 87 | resolve(true); 88 | }, 89 | 2, 90 | 0 91 | ); 92 | }); 93 | }); 94 | 95 | test("referenced", () => { 96 | const relayPool = new RelayPool(); 97 | const author = new Author(relayPool, relays, pubkey); 98 | return new Promise((resolve) => { 99 | const unsubscribe = author.referenced( 100 | (events) => { 101 | unsubscribe(); 102 | expect(events.tags.map((tag) => tag[1]).includes(pubkey)).toBe(true); 103 | resolve(true); 104 | }, 105 | 2, 106 | 0 107 | ); 108 | }); 109 | }); 110 | 111 | test("followers", () => { 112 | const relayPool = new RelayPool(); 113 | const author = new Author(relayPool, relays, pubkey); 114 | return new Promise((resolve) => { 115 | const unsubscribe = author.referenced( 116 | (events) => { 117 | unsubscribe(); 118 | expect(events.pubkey !== pubkey).toBe(true); 119 | expect(events.kind).toBe(Kind.Contacts); 120 | expect(events.tags.map((tag) => tag[1]).includes(pubkey)).toBe(true); 121 | resolve(true); 122 | }, 123 | 2, 124 | 0 125 | ); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /author.ts: -------------------------------------------------------------------------------- 1 | import type {OnEvent, OnEventObject, RelayPool} from "./relay-pool"; 2 | import {type Filter, Kind} from "nostr-tools"; 3 | import type {EventObject} from "./event"; 4 | 5 | export class Author { 6 | pubkey: string; 7 | relayPool: RelayPool; 8 | relays: string[] | undefined; 9 | constructor( 10 | relayPool: RelayPool, 11 | relays: string[] | undefined, 12 | pubkey: string 13 | ) { 14 | this.pubkey = pubkey; 15 | this.relayPool = relayPool; 16 | this.relays = relays; 17 | } 18 | 19 | metaData(cb: (event: EventObject) => void, maxDelayms: number): () => void { 20 | return this.relayPool.subscribeEventObject( 21 | [ 22 | { 23 | authors: [this.pubkey], 24 | kinds: [Kind.Metadata], 25 | }, 26 | ], 27 | this.relays, 28 | cb, 29 | maxDelayms 30 | ); 31 | } 32 | subscribe( 33 | filters: Filter[], 34 | cb: OnEventObject, 35 | maxDelayms: number 36 | ): () => void { 37 | return this.relayPool.subscribeEventObject( 38 | filters.map((filter) => ({ 39 | authors: [this.pubkey], 40 | ...filter, 41 | })), 42 | this.relays, 43 | cb, 44 | maxDelayms 45 | ); 46 | } 47 | 48 | followsPubkeys( 49 | cb: (pubkeys: string[]) => void, 50 | maxDelayms: number 51 | ): () => void { 52 | return this.relayPool.subscribeEventObject( 53 | [ 54 | { 55 | authors: [this.pubkey], 56 | kinds: [Kind.Contacts], 57 | }, 58 | ], 59 | this.relays, 60 | (event: EventObject) => { 61 | let r: string[] = []; 62 | for (const tag of event.tags) { 63 | if (tag[0] === "p") { 64 | r.push(tag[1]); 65 | } 66 | } 67 | cb(r); 68 | }, 69 | maxDelayms 70 | ); 71 | } 72 | 73 | // TODO: prioritize relay over other relays for specific authors 74 | follows(cb: (authors: Author[]) => void, maxDelayms: number): () => void { 75 | return this.relayPool.subscribeEventObject( 76 | [ 77 | { 78 | authors: [this.pubkey], 79 | kinds: [Kind.Contacts], 80 | }, 81 | ], 82 | this.relays, 83 | (event: EventObject) => { 84 | let r: Author[] = []; 85 | for (const tag of event.tags) { 86 | if (tag[0] === "p") { 87 | let relays = this.relays; 88 | if (tag[1]) { 89 | relays = [tag[1], ...(this.relays || [])]; 90 | } 91 | r.push(new Author(this.relayPool, relays, tag[1])); 92 | } 93 | } 94 | cb(r); 95 | }, 96 | maxDelayms 97 | ); 98 | } 99 | 100 | secondFollows( 101 | cb: (pubkeysWithWeight: [string, number][]) => void, 102 | maxDelayms: number, 103 | removeDirectFollows = true 104 | ): () => void { 105 | return this.followsPubkeys((pubkeys) => { 106 | let sfollows = new Map(); 107 | for (const pubkey of pubkeys) { 108 | this.relayPool.subscribeEventObject( 109 | [ 110 | { 111 | authors: [pubkey], 112 | kinds: [Kind.Contacts], 113 | }, 114 | ], 115 | this.relays, 116 | (event: EventObject) => { 117 | let dweight = 1.0 / event.tags.length; 118 | for (const tag of event.tags) { 119 | if (tag[0] === "p") { 120 | let weight = sfollows.get(tag[1]); 121 | if (weight) { 122 | weight += dweight; 123 | } else { 124 | weight = dweight; 125 | } 126 | sfollows.set(tag[1], weight); 127 | } 128 | } 129 | if (removeDirectFollows) { 130 | for (const pubkey of pubkeys) { 131 | sfollows.delete(pubkey); 132 | } 133 | } 134 | cb(Array.from(sfollows.entries()).sort((a, b) => b[1] - a[1])); 135 | }, 136 | maxDelayms 137 | ); 138 | } 139 | }, maxDelayms); 140 | } 141 | 142 | allEvents(cb: OnEvent, limit = 100, maxDelayms: number): () => void { 143 | return this.relayPool.subscribe( 144 | [ 145 | { 146 | authors: [this.pubkey], 147 | limit, 148 | }, 149 | ], 150 | this.relays, 151 | cb, 152 | maxDelayms 153 | ); 154 | } 155 | 156 | referenced(cb: OnEvent, limit = 100, maxDelayms: number): () => void { 157 | return this.relayPool.subscribe( 158 | [ 159 | { 160 | "#p": [this.pubkey], 161 | limit, 162 | }, 163 | ], 164 | this.relays, 165 | cb, 166 | maxDelayms 167 | ); 168 | } 169 | 170 | followers(cb: OnEvent, limit = 100, maxDelayms: number): () => void { 171 | return this.relayPool.subscribe( 172 | [ 173 | { 174 | "#p": [this.pubkey], 175 | kinds: [Kind.Contacts], 176 | limit, 177 | }, 178 | ], 179 | this.relays, 180 | cb, 181 | maxDelayms 182 | ); 183 | } 184 | 185 | sentAndRecievedDMs(cb: OnEvent, limit = 100, maxDelayms: number): () => void { 186 | return this.relayPool.subscribe( 187 | [ 188 | { 189 | authors: [this.pubkey], 190 | kinds: [Kind.EncryptedDirectMessage], 191 | limit, 192 | }, 193 | { 194 | "#p": [this.pubkey], 195 | kinds: [Kind.EncryptedDirectMessage], 196 | limit, 197 | }, 198 | ], 199 | this.relays, 200 | cb, 201 | maxDelayms 202 | ); 203 | } 204 | text(cb: OnEvent, limit = 100, maxDelayms: number): () => void { 205 | return this.relayPool.subscribe( 206 | [ 207 | { 208 | authors: [this.pubkey], 209 | kinds: [Kind.Text], 210 | limit, 211 | }, 212 | ], 213 | this.relays, 214 | cb, 215 | maxDelayms 216 | ); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {build} from "esbuild"; 4 | 5 | let common = { 6 | entryPoints: ["index.ts"], 7 | bundle: true, 8 | sourcemap: "external", 9 | }; 10 | 11 | build({ 12 | ...common, 13 | outfile: "lib/nostr-relaypool.esm.js", 14 | format: "esm", 15 | packages: "external", 16 | }).then(() => console.log("esm build success.")); 17 | 18 | build({ 19 | ...common, 20 | outfile: "lib/nostr-relaypool.cjs", 21 | format: "cjs", 22 | packages: "external", 23 | }).then(() => console.log("cjs build success.")); 24 | 25 | build({ 26 | ...common, 27 | outfile: "lib/nostr-relaypool.bundle.js", 28 | format: "iife", 29 | globalName: "NostrRelayPool", 30 | define: { 31 | window: "self", 32 | global: "self", 33 | process: '{"env": {}}', 34 | }, 35 | }).then(() => console.log("standalone build success.")); 36 | 37 | // build worker 38 | build({ 39 | ...common, 40 | outfile: "lib/nostr-relaypool.worker.js", 41 | format: "esm", 42 | target: "es2018", 43 | loader: { 44 | ".ts": "ts", 45 | }, 46 | entryPoints: ["relay-pool.worker.ts"], 47 | external: ["@nostr/core"], 48 | }).then(() => console.log("worker build success.")); 49 | -------------------------------------------------------------------------------- /callback-replayer-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { 4 | CallbackReplayer, 5 | CancellableCallbackReplayer, 6 | } from "./callback-replayer"; 7 | 8 | describe("CallbackReplayer", () => { 9 | test("should call callback with all events", () => { 10 | const unsub = jest.fn(); 11 | const replayer = new CallbackReplayer(unsub); 12 | const unsub1 = replayer.sub((a, b) => { 13 | expect(a).toBe(1); 14 | expect(b).toBe(2); 15 | }); 16 | const unsub2 = replayer.sub((a, b) => { 17 | expect(a).toBe(1); 18 | expect(b).toBe(2); 19 | }); 20 | replayer.event(1, 2); 21 | unsub1(); 22 | expect(unsub).not.toBeCalled(); 23 | unsub2(); 24 | expect(unsub).toBeCalled(); 25 | }); 26 | }); 27 | 28 | describe("CancellableCallbackReplayer", () => { 29 | test("should call callback with all events", () => { 30 | let unsub = jest.fn(); 31 | let onevent: ((a: number, b: number) => void) | undefined; 32 | const cc = (onevent_: (a: number, b: number) => void) => { 33 | onevent = onevent_; 34 | return unsub; 35 | }; 36 | const replayer: CancellableCallbackReplayer<[number, number]> = 37 | new CancellableCallbackReplayer(cc); 38 | expect(onevent).toBeDefined(); 39 | const unsub1 = replayer.sub()((a, b) => { 40 | expect(a).toBe(1); 41 | expect(b).toBe(2); 42 | }); 43 | const unsub2 = replayer.sub()((a, b) => { 44 | expect(a).toBe(1); 45 | expect(b).toBe(2); 46 | }); 47 | onevent?.(1, 2); 48 | unsub1(); 49 | expect(unsub).not.toBeCalled(); 50 | unsub2(); 51 | expect(unsub).toBeCalled(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /callback-replayer.ts: -------------------------------------------------------------------------------- 1 | // Cancellable is perfect for this. 2 | export type Cancellable = (process: Process) => () => void; 3 | export type Callback = (...args: Args) => void; 4 | 5 | export class CancellableCallbackReplayer { 6 | events: Args[] = []; 7 | unsubAll?: () => void; 8 | subs: Set> = new Set(); 9 | constructor(cancellableCallback: Cancellable>) { 10 | this.unsubAll = cancellableCallback((...args: Args) => { 11 | this.events.push(args); 12 | for (let sub of this.subs) { 13 | sub(...args); 14 | } 15 | }); 16 | } 17 | sub(): Cancellable> { 18 | return (callback: Callback) => { 19 | this.subs.add(callback); 20 | this.events.forEach((arg) => callback(...arg)); 21 | return () => { 22 | this.subs.delete(callback); 23 | if (this.subs.size === 0) { 24 | this.unsubAll?.(); 25 | this.unsubAll = undefined; 26 | } 27 | }; 28 | }; 29 | } 30 | } 31 | export class CallbackReplayer< 32 | Args extends any[], 33 | T extends (...args: Args) => void 34 | > { 35 | subs: T[] = []; 36 | events: Args[] = []; 37 | onunsub: (() => void) | undefined; 38 | 39 | constructor(onunsub: (() => void) | undefined) { 40 | this.onunsub = onunsub; 41 | } 42 | 43 | event(...args: Args) { 44 | this.events.push(args); 45 | this.subs.forEach((sub) => sub(...args)); 46 | } 47 | 48 | sub(callback: T) { 49 | this.events.forEach((event) => callback(...event)); 50 | this.subs.push(callback); 51 | return () => { 52 | this.subs = this.subs.filter((sub) => sub !== callback); 53 | if (this.subs.length === 0) { 54 | this.onunsub?.(); 55 | this.onunsub = undefined; 56 | } 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /collect.ts: -------------------------------------------------------------------------------- 1 | import type {Event} from "nostr-tools"; 2 | import type {OnEvent} from "./on-event-filters"; 3 | 4 | const binarySearch = function (a: Event[], target: Event) { 5 | var l = 0, 6 | h = a.length - 1, 7 | m, 8 | comparison; 9 | let comparator = function (a: Event, b: Event) { 10 | return a.created_at - b.created_at; 11 | }; 12 | while (l <= h) { 13 | m = (l + h) >>> 1; /* equivalent to Math.floor((l + h) / 2) but faster */ 14 | comparison = comparator(a[m], target); 15 | if (comparison < 0) { 16 | l = m + 1; 17 | } else if (comparison > 0) { 18 | h = m - 1; 19 | } else { 20 | return m; 21 | } 22 | } 23 | return ~l; 24 | }; 25 | 26 | const binaryInsert = function (a: Event[], target: Event) { 27 | const duplicate = true; // it's OK to have the same created_at multiple times 28 | var i = binarySearch(a, target); 29 | if (i >= 0) { 30 | /* if the binarySearch return value was zero or positive, a matching object was found */ 31 | if (!duplicate) { 32 | return i; 33 | } 34 | } else { 35 | /* if the return value was negative, the bitwise complement of the return value is the correct index for this object */ 36 | i = ~i; 37 | } 38 | a.splice(i, 0, target); 39 | return i; 40 | }; 41 | 42 | export function collect( 43 | onEvents: (events: Event[]) => void, 44 | skipSort: boolean = false 45 | ): OnEvent { 46 | let events: Event[] = []; 47 | return (event: Event, afterEose: boolean, url: string | undefined) => { 48 | if (skipSort) { 49 | events.push(event); 50 | } else { 51 | binaryInsert(events, event); 52 | } 53 | onEvents(events); 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /event-cache-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import {Kind, type Event} from "nostr-tools"; 4 | import {EventCache} from "./event-cache"; 5 | 6 | describe("EventCache", () => { 7 | let eventCache: EventCache; 8 | let event: Event; 9 | 10 | beforeEach(() => { 11 | eventCache = new EventCache(); 12 | event = { 13 | id: "1", 14 | pubkey: "pk", 15 | kind: Kind.Metadata, 16 | created_at: 0, 17 | tags: [], 18 | content: "", 19 | sig: "", 20 | }; 21 | }); 22 | 23 | test("addEvent should add event to eventsById", () => { 24 | eventCache.addEvent(event); 25 | expect(eventCache.eventsById.get(event.id)).toBe(event); 26 | }); 27 | 28 | test("addEvent should add event to metadataByPubKey if event is metadata", () => { 29 | eventCache.addEvent(event); 30 | expect(eventCache.metadataByPubKey.get(event.pubkey)).toBe(event); 31 | }); 32 | 33 | test("addEvent should add event to contactsByPubKey if event is contact", () => { 34 | event.kind = Kind.Contacts; 35 | eventCache.addEvent(event); 36 | expect(eventCache.contactsByPubKey.get(event.pubkey)).toBe(event); 37 | }); 38 | 39 | test("getEventById should return event for given id", () => { 40 | eventCache.addEvent(event); 41 | expect(eventCache.getEventById(event.id)).toBe(event); 42 | }); 43 | 44 | test("hasEventById should return true if event with given id exists", () => { 45 | eventCache.addEvent(event); 46 | expect(eventCache.hasEventById(event.id)).toBe(true); 47 | }); 48 | 49 | test("getCachedEventsByPubKeyWithUpdatedFilter should return events and filter if all conditions met", () => { 50 | eventCache.addEvent(event); 51 | const filter = { 52 | authors: [event.pubkey], 53 | kinds: [Kind.Metadata], 54 | noCache: false, 55 | }; 56 | const result = eventCache.getCachedEventsWithUpdatedFilters([filter], []); 57 | expect(result).toEqual({ 58 | filters: [filter], 59 | events: [event], 60 | }); 61 | }); 62 | 63 | test("getCachedEventsByPubKeyWithUpdatedFilter should return undefined if noCache is true", () => { 64 | const filter = { 65 | authors: [event.pubkey], 66 | kinds: [Kind.Metadata], 67 | noCache: true, 68 | }; 69 | const result = eventCache.getCachedEventsWithUpdatedFilters([filter], []); 70 | expect(result).toStrictEqual({filters: [filter], events: []}); 71 | }); 72 | 73 | test("getCachedEventsByPubKeyWithUpdatedFilter should return undefined if authors not specified", () => { 74 | const filter = {kinds: [Kind.Metadata], noCache: false}; 75 | const result = eventCache.getCachedEventsWithUpdatedFilters([filter], []); 76 | expect(result).toStrictEqual({filters: [filter], events: []}); 77 | }); 78 | 79 | // test('getCachedEventsByPubKeyWithUpdatedFilter should return undefined if kinds not specified', () => { 80 | // const filter = { authors: [event.pubkey], noCache: false }; 81 | // const result = eventCache.#getCachedEventsByPubKey 82 | 83 | test("getCachedEventsByPubKeyWithUpdatedFilter should return events by pubkey", () => { 84 | eventCache.addEvent(event); 85 | const filter = { 86 | authors: ["pk"], 87 | kinds: [Kind.Metadata, Kind.Contacts], 88 | noCache: false, 89 | }; 90 | const result = eventCache.getCachedEventsWithUpdatedFilters([filter], []); 91 | expect(result).toEqual({events: [event], filters: [filter]}); 92 | }); 93 | 94 | test("tags", () => { 95 | event.tags = [["p", "pk2"]]; 96 | eventCache.addEvent(event); 97 | const filter = { 98 | "#p": ["pk2"], 99 | }; 100 | // console.log("tags", eventCache.eventsByTags); 101 | const result = eventCache.getCachedEventsWithUpdatedFilters([filter], []); 102 | expect(result).toEqual({events: [event], filters: [filter]}); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /event-cache.ts: -------------------------------------------------------------------------------- 1 | import {type Filter, Kind, type Event} from "nostr-tools"; 2 | 3 | export class EventCache { 4 | eventsById: Map = new Map(); 5 | metadataByPubKey: Map = new Map(); 6 | contactsByPubKey: Map = new Map(); 7 | authorsKindsByPubKey: Map> = new Map(); 8 | eventsByTags: Map = new Map(); 9 | 10 | #addEventToAuthorKindsByPubKey(event: Event) { 11 | const kindsByPubKey = this.authorsKindsByPubKey.get(event.pubkey); 12 | if (!kindsByPubKey) { 13 | this.authorsKindsByPubKey.set( 14 | event.pubkey, 15 | new Map([[event.kind, [event]]]) 16 | ); 17 | } else { 18 | const events = kindsByPubKey.get(event.kind); 19 | if (!events) { 20 | kindsByPubKey.set(event.kind, [event]); 21 | } else { 22 | if (event.kind === Kind.Metadata || event.kind === Kind.Contacts) { 23 | if (event.created_at > events[0].created_at) { 24 | events[0] = event; 25 | } 26 | } else { 27 | events.push(event); 28 | } 29 | } 30 | } 31 | } 32 | 33 | #addEventToEventsByTags(event: Event) { 34 | for (const tag of event.tags) { 35 | let tag2 = tag[0] + ":" + tag[1]; 36 | const events = this.eventsByTags.get(tag2); 37 | if (events) { 38 | events.push(event); 39 | } else { 40 | this.eventsByTags.set(tag2, [event]); 41 | } 42 | } 43 | } 44 | 45 | addEvent(event: Event) { 46 | if (this.getEventById(event.id)) { 47 | return; 48 | } 49 | this.eventsById.set(event.id, event); 50 | if (event.kind === Kind.Metadata) { 51 | this.metadataByPubKey.set(event.pubkey, event); 52 | } 53 | if (event.kind === Kind.Contacts) { 54 | this.contactsByPubKey.set(event.pubkey, event); 55 | } 56 | this.#addEventToAuthorKindsByPubKey(event); 57 | this.#addEventToEventsByTags(event); 58 | } 59 | 60 | getEventById(id: string): Event | undefined { 61 | return this.eventsById.get(id); 62 | } 63 | 64 | hasEventById(id: string): boolean { 65 | return this.eventsById.has(id); 66 | } 67 | 68 | #getCachedEventsByPubKeyWithUpdatedFilter( 69 | filter: Filter & { 70 | relay?: string; 71 | noCache?: boolean; 72 | } 73 | ): {filter: Filter & {relay?: string}; events: Set} | undefined { 74 | if ( 75 | filter.noCache || 76 | !filter.authors || 77 | !filter.kinds || 78 | filter.kinds.find( 79 | (kind) => kind !== Kind.Contacts && kind !== Kind.Metadata 80 | ) !== undefined 81 | ) { 82 | return undefined; 83 | } 84 | const authors: string[] = []; 85 | const events = new Set(); 86 | for (const author of filter.authors) { 87 | let contactEvent; 88 | if (filter.kinds.includes(Kind.Contacts)) { 89 | contactEvent = this.contactsByPubKey.get(author); 90 | if (!contactEvent) { 91 | authors.push(author); 92 | continue; 93 | } 94 | } 95 | let metadataEvent; 96 | if (filter.kinds.includes(Kind.Metadata)) { 97 | metadataEvent = this.metadataByPubKey.get(author); 98 | if (!metadataEvent) { 99 | authors.push(author); 100 | continue; 101 | } 102 | } 103 | if (contactEvent) { 104 | events.add(contactEvent); 105 | } 106 | if (metadataEvent) { 107 | events.add(metadataEvent); 108 | } 109 | } 110 | return {filter: {...filter, authors}, events}; 111 | } 112 | 113 | #getCachedEventsByPubKeyWithUpdatedFilter2( 114 | filter: Filter & { 115 | relay?: string; 116 | noCache?: boolean; 117 | } 118 | ): {filter: Filter & {relay?: string}; events: Set} | undefined { 119 | if (filter.noCache || !filter.authors) { 120 | return undefined; 121 | } 122 | const events = new Set(); 123 | for (const author of filter.authors) { 124 | if (filter.kinds) { 125 | const kindsByPubKey = this.authorsKindsByPubKey.get(author); 126 | if (kindsByPubKey) { 127 | for (const kind of filter.kinds) { 128 | const events2 = kindsByPubKey.get(kind); 129 | if (events2) { 130 | for (const event of events2) { 131 | events.add(event); 132 | } 133 | } 134 | } 135 | } 136 | } else { 137 | const kindsByPubKey = this.authorsKindsByPubKey.get(author); 138 | if (kindsByPubKey) { 139 | for (const events2 of kindsByPubKey.values()) { 140 | for (const event3 of events2) { 141 | events.add(event3); 142 | } 143 | } 144 | } 145 | } 146 | } 147 | return {filter, events}; 148 | } 149 | 150 | #getCachedEventsByTagsWithUpdatedFilter( 151 | filter: Filter & { 152 | relay?: string; 153 | noCache?: boolean; 154 | } 155 | ): {filter: Filter & {relay?: string}; events: Set} | undefined { 156 | if (filter.noCache) { 157 | return undefined; 158 | } 159 | const events = new Set(); 160 | for (const tag in filter) { 161 | if (tag[0] !== "#") { 162 | continue; 163 | } 164 | // @ts-ignore 165 | let tag2 = tag.slice(1) + ":" + filter[tag][0]; 166 | const events2 = this.eventsByTags.get(tag2); 167 | if (events2) { 168 | for (const event of events2) { 169 | events.add(event); 170 | } 171 | } 172 | } 173 | return {filter, events}; 174 | } 175 | 176 | #getCachedEventsByIdWithUpdatedFilter( 177 | filter: Filter & {relay?: string; noCache?: boolean} 178 | ): {filter: Filter & {relay?: string}; events: Set} | undefined { 179 | if (!filter.ids) { 180 | return undefined; 181 | } 182 | 183 | const events = new Set(); 184 | const ids: string[] = []; 185 | for (const id of filter.ids) { 186 | const event = this.getEventById(id); 187 | if (event) { 188 | events.add(event); 189 | } else { 190 | ids.push(id); 191 | } 192 | } 193 | return {filter: {...filter, ids}, events}; 194 | } 195 | 196 | getCachedEventsWithUpdatedFilters( 197 | filters: (Filter & {relay?: string; noCache?: boolean})[], 198 | relays: string[] 199 | ): { 200 | filters: (Filter & {relay?: string})[]; 201 | events: Event[]; 202 | } { 203 | const events: Set = new Set(); 204 | const new_filters: (Filter & {relay?: string})[] = []; 205 | for (const filter of filters) { 206 | const new_data = this.#getCachedEventsByIdWithUpdatedFilter(filter) || 207 | // this.#getCachedEventsByPubKeyWithUpdatedFilter(filter) || 208 | this.#getCachedEventsByPubKeyWithUpdatedFilter2(filter) || 209 | this.#getCachedEventsByTagsWithUpdatedFilter(filter) || { 210 | filter, 211 | events: [], 212 | }; 213 | for (const event of new_data.events) { 214 | events.add(event); 215 | } 216 | new_filters.push(new_data.filter); 217 | } 218 | return {filters: new_filters, events: [...events]}; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /event-demultiplexer-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import {type Filter, matchFilter, type Event, type UnsignedEvent} from "nostr-tools"; 4 | import {EventDemultiplexer} from "./event-demultiplexer"; 5 | 6 | let eventFrom = (event: UnsignedEvent & {id: string}) => { 7 | return { 8 | id: event.id, 9 | kind: event.kind, 10 | pubkey: event.pubkey, 11 | tags: event.tags, 12 | content: event.content, 13 | created_at: event.created_at, 14 | sig: "", 15 | }; 16 | }; 17 | describe("EventDemultiplexer", () => { 18 | let demultiplexer: EventDemultiplexer; 19 | 20 | beforeEach(() => { 21 | demultiplexer = new EventDemultiplexer(); 22 | }); 23 | 24 | test("subscribe method should add filter and OnEvent to filterAndOnEventByEvent map", () => { 25 | const filters = [{ids: ["123"]}]; 26 | const onEvent = jest.fn(); 27 | demultiplexer.subscribe(filters, onEvent); 28 | expect(demultiplexer.filterAndOnEventByEvent.get("ids:123")).toEqual([ 29 | [filters[0], onEvent], 30 | ]); 31 | }); 32 | 33 | test("onEvent method should call OnEvent callback for event that matches subscribed filter", () => { 34 | const filters = [{ids: ["123"]}]; 35 | const onEvent = jest.fn(); 36 | demultiplexer.subscribe(filters, onEvent); 37 | const event: Event = { 38 | id: "123", 39 | kind: 1, 40 | pubkey: "abc", 41 | tags: [], 42 | content: "", 43 | created_at: 0, 44 | sig: "", 45 | }; 46 | demultiplexer.onEvent(event, true, "https://example.com"); 47 | expect(onEvent).toHaveBeenCalledWith(event, true, "https://example.com"); 48 | }); 49 | 50 | test("onEvent method should not call OnEvent callback for event that does not match any subscribed filters", () => { 51 | const filters = [{ids: ["456"]}]; 52 | const onEvent = jest.fn(); 53 | demultiplexer.subscribe(filters, onEvent); 54 | const event: Event = { 55 | id: "123", 56 | kind: 1, 57 | pubkey: "abc", 58 | tags: [], 59 | content: "", 60 | created_at: 0, 61 | sig: "", 62 | }; 63 | demultiplexer.onEvent(event, true, "https://example.com"); 64 | expect(onEvent).not.toHaveBeenCalled(); 65 | }); 66 | 67 | test("subscribe method should handle edge cases", () => { 68 | const filters = [{ids: [""]}]; 69 | const onEvent = jest.fn(); 70 | demultiplexer.subscribe(filters, onEvent); 71 | const event: Event = eventFrom({ 72 | id: "", 73 | kind: 0, 74 | pubkey: "", 75 | tags: [], 76 | content: "", 77 | created_at: 0, 78 | }); 79 | demultiplexer.onEvent(event, true, ""); 80 | expect(onEvent).toHaveBeenCalledWith(event, true, ""); 81 | }); 82 | 83 | test.skip("many filters", () => { 84 | const time = new Date().getTime(); 85 | let counter = 0; 86 | const onEvent = () => { 87 | counter++; 88 | }; 89 | for (let i = 0; i < 2000; i++) { 90 | demultiplexer.subscribe([{ids: ["" + i * 3]}], onEvent); 91 | } 92 | 93 | const event: Event = eventFrom({ 94 | id: "123", 95 | kind: 1, 96 | pubkey: "abc", 97 | tags: [], 98 | content: "", 99 | created_at: 0, 100 | }); 101 | for (let i = 0; i < 2000; i++) { 102 | demultiplexer.onEvent( 103 | eventFrom({...event, id: "" + i * 2}), 104 | true, 105 | "https://example.com" 106 | ); 107 | } 108 | expect(new Date().getTime() - time).toBeLessThan(5); 109 | expect(counter).toBe(667); 110 | }); 111 | 112 | test.skip("many filters using matchFilter", () => { 113 | const time = new Date().getTime(); 114 | let counter = 0; 115 | const filters: Filter[] = []; 116 | for (let i = 0; i < 2000; i++) { 117 | filters.push({ids: ["" + i * 3]}); 118 | } 119 | const event: Event = eventFrom({ 120 | id: "123", 121 | kind: 1, 122 | pubkey: "abc", 123 | tags: [], 124 | content: "", 125 | created_at: 0, 126 | }); 127 | for (let i = 0; i < 2000; i++) { 128 | const e = {...event, id: "" + i * 2}; 129 | for (const filter of filters) { 130 | // Doesn't use sig 131 | if (matchFilter(filter, e)) { 132 | counter++; 133 | } 134 | } 135 | } 136 | expect(new Date().getTime() - time).toBeGreaterThan(20); 137 | expect(counter).toBe(667); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /event-demultiplexer.ts: -------------------------------------------------------------------------------- 1 | import {type Filter, matchFilter, type Event} from "nostr-tools"; 2 | import type {OnEvent} from "./on-event-filters"; 3 | 4 | export class EventDemultiplexer { 5 | filterAndOnEventByEvent: Map = new Map(); 6 | #addEventUsingEventKey( 7 | event: Event, 8 | afterEose: boolean, 9 | url: string | undefined, 10 | eventKey: string 11 | ) { 12 | const filterAndOnEvent = this.filterAndOnEventByEvent.get(eventKey); 13 | if (filterAndOnEvent) { 14 | for (const [filter, onEvent] of filterAndOnEvent) { 15 | if (matchFilter(filter, event)) { 16 | onEvent(event, afterEose, url); 17 | } 18 | } 19 | } 20 | } 21 | 22 | onEvent(event: Event, afterEose: boolean, url: string | undefined) { 23 | this.#addEventUsingEventKey(event, afterEose, url, `ids:${event.id}`); 24 | this.#addEventUsingEventKey( 25 | event, 26 | afterEose, 27 | url, 28 | `authors:${event.pubkey}` 29 | ); 30 | for (const tag of event.tags) { 31 | this.#addEventUsingEventKey( 32 | event, 33 | afterEose, 34 | url, 35 | `#${tag[0]}:${tag[1]}` 36 | ); 37 | } 38 | this.#addEventUsingEventKey(event, afterEose, url, `kinds:${event.kind}`); 39 | this.#addEventUsingEventKey(event, afterEose, url, ""); 40 | } 41 | 42 | subscribe(filters: Filter[], onEvent: OnEvent) { 43 | for (const filter of filters) { 44 | let added = false; 45 | for (const key of ["ids", "authors", ...filterTags(filter), "kinds"]) { 46 | if (key in filter) { 47 | // @ts-ignore 48 | for (const value of filter[key]) { 49 | const eventKey = `${key}:${value}`; 50 | const filterAndOnEvent = this.filterAndOnEventByEvent.get(eventKey); 51 | if (filterAndOnEvent) { 52 | filterAndOnEvent.push([filter, onEvent]); 53 | } else { 54 | this.filterAndOnEventByEvent.set(eventKey, [[filter, onEvent]]); 55 | } 56 | } 57 | added = true; 58 | break; 59 | } 60 | } 61 | if (!added) { 62 | const eventKey = ""; 63 | const filterAndOnEvent = this.filterAndOnEventByEvent.get(eventKey); 64 | if (filterAndOnEvent) { 65 | filterAndOnEvent.push([filter, onEvent]); 66 | } else { 67 | this.filterAndOnEventByEvent.set(eventKey, [[filter, onEvent]]); 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | const filterTags = (filter: Filter): string[] => 75 | Object.keys(filter).filter((key) => key.startsWith("#")); 76 | -------------------------------------------------------------------------------- /event-persister.ts: -------------------------------------------------------------------------------- 1 | // import {Event} from "./event"; 2 | // import {EventCache} from "./event-cache"; 3 | 4 | // export function persistCache( 5 | // cache: EventCache, 6 | // pubkey: string, 7 | // follows: string[], 8 | // capacity: number = 4000000 9 | // ) { 10 | // let events: Event[] = []; 11 | // let kindsEvents = cache.authorsKindsByPubKey.get(pubkey); 12 | // if (kindsEvents) { 13 | // let mycapacity = capacity * 0.4; 14 | // let sizes: [number, number][] = Array.from(kindsEvents).map((e, i) => [ 15 | // i, 16 | // JSON.stringify(e).length, 17 | // ]); 18 | // sizes.sort((a, b) => b[1] - a[1]); 19 | // let s = sizes.length; 20 | // for (const [i, l] of sizes) { 21 | // let allowedCapacity = mycapacity / s; 22 | // let kindEvents: Event = kindsEvents.get(i); 23 | // if (kindEvents) { 24 | // let ke: Event[] = kindEvents; 25 | // if (l < allowedCapacity) { 26 | // events = events.concat(kindEvents); 27 | // mycapacity -= l; 28 | // } else { 29 | // let partialEvents = kindEvents.slice( 30 | // 0, 31 | // kindEvents.length * Math.floor(allowedCapacity / l) 32 | // ); 33 | // events = events.concat(partialEvents); 34 | // } 35 | // } 36 | // } 37 | // } 38 | // } 39 | 40 | // Needed for isolatedModules to not complain 41 | export const __unused = true; 42 | -------------------------------------------------------------------------------- /event.ts: -------------------------------------------------------------------------------- 1 | import type {Kind, Event} from "nostr-tools"; 2 | import {Author} from "./author"; 3 | import type {RelayPool} from "./relay-pool"; 4 | 5 | import type {OnEvent} from "./on-event-filters"; 6 | export class EventObject implements Event { 7 | id: string; 8 | kind: Kind; 9 | pubkey: string; 10 | tags: string[][]; 11 | created_at: number; 12 | content: string; 13 | relayPool: RelayPool; 14 | relays: string[] | undefined; 15 | sig: string; 16 | 17 | constructor( 18 | event: Event, 19 | relayPool: RelayPool, 20 | relays: string[] | undefined 21 | ) { 22 | this.id = event.id; 23 | this.kind = event.kind; 24 | this.pubkey = event.pubkey; 25 | this.tags = event.tags; 26 | this.created_at = event.created_at; 27 | this.content = event.content; 28 | this.relayPool = relayPool; 29 | this.relays = relays; 30 | this.sig = event.sig; 31 | } 32 | 33 | referencedAuthors(): Author[] { 34 | const r: Author[] = []; 35 | for (const tag of this.tags) { 36 | if (tag[0] === "p") { 37 | r.push(new Author(this.relayPool, undefined, tag[1])); 38 | } 39 | } 40 | return r; 41 | } 42 | referencedEvents(maxDelayms: number): Promise[] { 43 | const r: Promise[] = []; 44 | for (const tag of this.tags) { 45 | if (tag[0] === "e") { 46 | let relays = this.relays; 47 | if (tag[2]) { 48 | relays = [tag[2], ...(relays || [])]; 49 | } 50 | r.push( 51 | this.relayPool 52 | // @ts-ignore 53 | .getEventById(tag[1], relays, maxDelayms) 54 | .then((e) => new EventObject(e, this.relayPool, this.relays)) 55 | ); 56 | } 57 | } 58 | return r; 59 | } 60 | 61 | thread(cb: OnEvent, maxDelayms: number): () => void { 62 | let relays = this.relays; 63 | let ids: string[] = []; 64 | for (const tag of this.tags) { 65 | if (tag[0] === "e") { 66 | if (tag[2]) { 67 | relays = [tag[2], ...(relays || [])]; 68 | } 69 | ids.push(tag[1]); 70 | } 71 | } 72 | 73 | return this.relayPool.subscribe( 74 | [{ids}, {"#e": ids}], 75 | relays, 76 | cb, 77 | maxDelayms 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /examples/simple-nodejs/README.md: -------------------------------------------------------------------------------- 1 | Start with 2 | 3 | ``` 4 | npm run start 5 | ``` 6 | -------------------------------------------------------------------------------- /examples/simple-nodejs/index.js: -------------------------------------------------------------------------------- 1 | let NostrRelayPool = require("nostr-relaypool"); 2 | let RelayPool = NostrRelayPool.RelayPool; 3 | 4 | let relayPool = new RelayPool(); 5 | relayPool.subscribe( 6 | [ 7 | { 8 | authors: [ 9 | "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", 10 | ], 11 | limit: 20, 12 | }, 13 | ], 14 | [ 15 | "wss://relay.damus.io", 16 | "wss://nostr.fmt.wiz.biz", 17 | "wss://relay.snort.social", 18 | ], 19 | (event) => console.log(event.id) 20 | ); 21 | -------------------------------------------------------------------------------- /examples/simple-nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nostr-relaypool-simple-nodejs-example", 3 | "version": "0.0.1", 4 | "description": "Example.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/adamritter/nostr-relaypool-ts.git" 8 | }, 9 | "dependencies": { 10 | "@types/node": "^18.11.18", 11 | "nostr-relaypool": "0.3.10", 12 | "nostr-tools": "^1.1.0", 13 | "ws": "^5.0.0" 14 | }, 15 | "keywords": [ 16 | "decentralization", 17 | "social", 18 | "censorship-resistance", 19 | "client", 20 | "nostr" 21 | ], 22 | "devDependencies": { 23 | "ts-node": "^10.9.1", 24 | "tsd": "^0.22.0", 25 | "typescript": "^4.9.4" 26 | }, 27 | "scripts": { 28 | "start": "node index" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /fakejson.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import * as fj from "./fakejson"; 4 | 5 | test("match id", () => { 6 | expect( 7 | fj.matchEventId( 8 | `["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`, 9 | "fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146" 10 | ) 11 | ).toBeTruthy(); 12 | 13 | expect( 14 | fj.matchEventId( 15 | `["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`, 16 | "fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146" 17 | ) 18 | ).toBeFalsy(); 19 | }); 20 | 21 | test("match kind", () => { 22 | expect( 23 | fj.matchEventKind( 24 | `["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`, 25 | 1 26 | ) 27 | ).toBeTruthy(); 28 | 29 | expect( 30 | fj.matchEventKind( 31 | `["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`, 32 | 12720 33 | ) 34 | ).toBeTruthy(); 35 | 36 | expect( 37 | fj.getSubName( 38 | `["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]` 39 | ) 40 | ).toBe("nostril-query"); 41 | }); 42 | -------------------------------------------------------------------------------- /fakejson.ts: -------------------------------------------------------------------------------- 1 | export function getHex64(json: string, field: string): string { 2 | let len = field.length + 3; 3 | let idx = json.indexOf(`"${field}":`) + len; 4 | let s = json.slice(idx).indexOf(`"`) + idx + 1; 5 | return json.slice(s, s + 64); 6 | } 7 | 8 | export function getSubName(json: string): string { 9 | let idx = json.indexOf(`"EVENT"`) + 7; 10 | let sliced = json.slice(idx); 11 | let idx2 = sliced.indexOf(`"`) + 1; 12 | let sliced2 = sliced.slice(idx2); 13 | return sliced2.slice(0, sliced2.indexOf(`"`)); 14 | } 15 | 16 | export function getInt(json: string, field: string): number { 17 | let len = field.length; 18 | let idx = json.indexOf(`"${field}":`) + len + 3; 19 | let sliced = json.slice(idx); 20 | let end = Math.min(sliced.indexOf(","), sliced.indexOf("}")); 21 | return parseInt(sliced.slice(0, end), 10); 22 | } 23 | 24 | export function matchEventId(json: string, id: string): boolean { 25 | return id === getHex64(json, "id"); 26 | } 27 | 28 | export function matchEventPubkey(json: string, pubkey: string): boolean { 29 | return pubkey === getHex64(json, "pubkey"); 30 | } 31 | 32 | export function matchEventKind(json: string, kind: number): boolean { 33 | return kind === getInt(json, "kind"); 34 | } 35 | -------------------------------------------------------------------------------- /group-filters-by-relay.ts: -------------------------------------------------------------------------------- 1 | import type {Filter, Event} from "nostr-tools"; 2 | import {mergeSimilarAndRemoveEmptyFilters} from "./merge-similar-filters"; 3 | import { 4 | doNotEmitDuplicateEvents, 5 | doNotEmitOlderEvents, 6 | matchOnEventFilters, 7 | type OnEvent, 8 | } from "./on-event-filters"; 9 | import type {EventCache} from "./event-cache"; 10 | import type {FilterToSubscribe} from "./relay-pool"; 11 | import {CallbackReplayer} from "./callback-replayer"; 12 | 13 | const unique = (arr: string[]) => [...new Set(arr)]; 14 | 15 | export function groupFiltersByRelayAndEmitCacheHits( 16 | filters: (Filter & {relay?: string; noCache?: boolean})[], 17 | relays: string[], 18 | onEvent: OnEvent, 19 | options: { 20 | allowDuplicateEvents?: boolean; 21 | allowOlderEvents?: boolean; 22 | logAllEvents?: boolean; 23 | } = {}, 24 | eventCache?: EventCache, 25 | ): [OnEvent, Map] { 26 | let events: Event[] = []; 27 | if (eventCache) { 28 | const cachedEventsWithUpdatedFilters = 29 | eventCache.getCachedEventsWithUpdatedFilters(filters, relays); 30 | filters = cachedEventsWithUpdatedFilters.filters; 31 | events = cachedEventsWithUpdatedFilters.events; 32 | } 33 | if (options.logAllEvents) { 34 | const onEventNow = onEvent; // Nasty bug without introducing a new variable 35 | onEvent = (event, afterEose, url) => { 36 | onEventNow(event, afterEose, url); 37 | }; 38 | } 39 | if (!options.allowDuplicateEvents) { 40 | onEvent = doNotEmitDuplicateEvents(onEvent); 41 | } 42 | if (!options.allowOlderEvents) { 43 | onEvent = doNotEmitOlderEvents(onEvent); 44 | } 45 | for (const event of events) { 46 | onEvent(event, false, undefined); 47 | } 48 | filters = mergeSimilarAndRemoveEmptyFilters(filters); 49 | onEvent = matchOnEventFilters(onEvent, filters); 50 | relays = unique(relays); 51 | const filtersByRelay = getFiltersByRelay(filters, relays); 52 | return [onEvent, filtersByRelay]; 53 | } 54 | 55 | function getFiltersByRelay( 56 | filters: (Filter & {relay?: string})[], 57 | relays: string[], 58 | ): Map { 59 | const filtersByRelay = new Map(); 60 | const filtersWithoutRelay: Filter[] = []; 61 | for (const filter of filters) { 62 | const relay = filter.relay; 63 | if (relay) { 64 | const relayFilters = filtersByRelay.get(relay); 65 | if (relayFilters) { 66 | relayFilters.push(withoutRelay(filter)); 67 | } else { 68 | filtersByRelay.set(relay, [withoutRelay(filter)]); 69 | } 70 | } else { 71 | filtersWithoutRelay.push(filter); 72 | } 73 | } 74 | if (filtersWithoutRelay.length > 0) { 75 | for (const relay of relays) { 76 | const filters = filtersByRelay.get(relay); 77 | if (filters) { 78 | filtersByRelay.set(relay, filters.concat(filtersWithoutRelay)); 79 | } else { 80 | filtersByRelay.set(relay, filtersWithoutRelay); 81 | } 82 | } 83 | } 84 | return filtersByRelay; 85 | } 86 | 87 | function withoutRelay(filter: Filter & {relay?: string}): Filter { 88 | filter = {...filter}; 89 | delete filter.relay; 90 | return filter; 91 | } 92 | 93 | export function batchFiltersByRelay( 94 | subscribedFilters: FilterToSubscribe[], 95 | subscriptionCache?: Map< 96 | string, 97 | CallbackReplayer<[Event, boolean, string | undefined], OnEvent> 98 | >, 99 | ): [OnEvent, Map, {unsubcb?: () => void}] { 100 | const filtersByRelay = new Map(); 101 | const onEvents: OnEvent[] = []; 102 | let counter = 0; 103 | let unsubOnEoseCounter = 0; 104 | let allUnsub = {unsubcb: () => {}, unsuboneosecb: () => {}}; 105 | let unsubVirtualSubscription = () => { 106 | counter--; 107 | 108 | if (counter === 0) { 109 | allUnsub.unsubcb(); 110 | } else if (unsubOnEoseCounter === 0) { 111 | allUnsub.unsuboneosecb(); 112 | } 113 | }; 114 | for (const [ 115 | onEvent, 116 | filtersByRelayBySub, 117 | unsub, 118 | unsubscribeOnEose, 119 | subscriptionCacheKey, 120 | ] of subscribedFilters) { 121 | if (!unsub.unsubcb) { 122 | continue; 123 | } 124 | for (const [relay, filters] of filtersByRelayBySub) { 125 | const filtersByRelayFilters = filtersByRelay.get(relay); 126 | if (filtersByRelayFilters) { 127 | filtersByRelay.set(relay, filtersByRelayFilters.concat(filters)); 128 | } else { 129 | filtersByRelay.set(relay, filters); 130 | } 131 | } 132 | let onEventWithUnsub: OnEvent = (event, afterEose, url) => { 133 | if (unsub.unsubcb) { 134 | onEvent(event, afterEose, url); 135 | } 136 | }; 137 | 138 | if (subscriptionCache && subscriptionCacheKey) { 139 | const callbackReplayer: CallbackReplayer< 140 | [Event, boolean, string | undefined], 141 | OnEvent 142 | > = new CallbackReplayer(unsubVirtualSubscription); 143 | onEvents.push((event, afterEose, url) => { 144 | callbackReplayer.event(event, afterEose, url); 145 | }); 146 | let unsubReplayerVirtualSubscription = 147 | callbackReplayer.sub(onEventWithUnsub); 148 | subscriptionCache.set(subscriptionCacheKey, callbackReplayer); 149 | unsub.unsubcb = () => { 150 | unsub.unsubcb = undefined; 151 | unsubReplayerVirtualSubscription(); 152 | if (!unsubscribeOnEose) { 153 | unsubOnEoseCounter--; 154 | } 155 | }; 156 | } else { 157 | onEvents.push(onEventWithUnsub); 158 | unsub.unsubcb = () => { 159 | unsub.unsubcb = undefined; 160 | unsubVirtualSubscription(); 161 | if (!unsubscribeOnEose) { 162 | unsubOnEoseCounter--; 163 | } 164 | }; 165 | } 166 | counter++; 167 | if (!unsubscribeOnEose) { 168 | unsubOnEoseCounter++; 169 | } 170 | } 171 | 172 | if (unsubOnEoseCounter === 0) { 173 | setTimeout(() => { 174 | allUnsub.unsuboneosecb(); 175 | }, 0); 176 | } else { 177 | // console.log("NO unsuboneosecb for ", subscribedFilters); 178 | } 179 | const onEvent: OnEvent = (event, afterEose, url) => { 180 | for (const onEvent of onEvents) { 181 | onEvent(event, afterEose, url); 182 | } 183 | }; 184 | subscribedFilters.length = 0; 185 | return [onEvent, filtersByRelay, allUnsub]; 186 | } 187 | -------------------------------------------------------------------------------- /in-memory-relay-server.ts: -------------------------------------------------------------------------------- 1 | import {type Event, type Filter, matchFilters, matchFilter} from "nostr-tools"; 2 | import {WebSocket, WebSocketServer} from "isomorphic-ws"; 3 | 4 | const _ = WebSocket; // Importing WebSocket is needed for WebSocketServer to work 5 | 6 | export class InMemoryRelayServer { 7 | events: (Event & {id: string})[] = []; 8 | auth?: string; 9 | wss: WebSocketServer; 10 | subs: Map = new Map(); 11 | connections: Set = new Set(); 12 | totalSubscriptions = 0; 13 | constructor(port = 8081, host = "localhost") { 14 | this.wss = new WebSocketServer({port, host}); 15 | this.wss.on("connection", (ws) => { 16 | this.connections.add(ws); 17 | // console.log('connected') 18 | ws.on("message", (message) => { 19 | const data = JSON.parse(message.toString()); 20 | // console.log('received: %s', JSON.stringify(data)) 21 | if (data && data[0] === "REQ") { 22 | const sub = data[1]; 23 | const filters = data.slice(2); 24 | this.totalSubscriptions++; 25 | this.subs.set(sub, filters); 26 | // Go through events in reverse order, look at limits 27 | const counts = filters.map(() => 0); 28 | // console.log("data", data, "events", this.events) 29 | for (let i = this.events.length - 1; i >= 0; i--) { 30 | const event = this.events[i]; 31 | // console.log("event", event) 32 | let matched = false; 33 | for (let j = 0; j < filters.length; j++) { 34 | let filter = filters[j]; 35 | // console.log("filter", filter, "event", event) 36 | if (matchFilter(filter, event)) { 37 | counts[j]++; 38 | // console.log("j", j, "count", counts[j], "limit", filter.limit) 39 | if (!filter.limit || counts[j] <= filter.limit) { 40 | // console.log("matched j", j, "count", counts[j], "limit", filter.limit) 41 | matched = true; 42 | } 43 | } 44 | } 45 | if (matched) { 46 | // console.log('sending event to sub %s', sub, JSON.stringify(['EVENT', sub, event])) 47 | ws.send(JSON.stringify(["EVENT", sub, event])); 48 | } 49 | } 50 | // console.log('sending eose to sub %s', sub, JSON.stringify(['EOSE', sub])) 51 | ws.send(JSON.stringify(["EOSE", sub])); 52 | } else if (data && data[0] === "EVENT") { 53 | // console.log('received event', data[1], data[2]) 54 | const event = data[1]; 55 | this.events.push(event); 56 | // Reply with OK 57 | ws.send(JSON.stringify(["OK", event.id, true, ""])); 58 | for (const [sub, filters] of this.subs) { 59 | if (matchFilters(filters, event)) { 60 | // console.log('sending event to sub %s', sub, JSON.stringify(['EVENT', sub, event])) 61 | ws.send(JSON.stringify(["EVENT", sub, event])); 62 | } 63 | } 64 | } else if (data && data[0] === "CLOSE") { 65 | const sub = data[1]; 66 | this.subs.delete(sub); 67 | } 68 | }); 69 | if (this.auth) { 70 | ws.send(JSON.stringify(["AUTH", this.auth])); 71 | } 72 | }); 73 | } 74 | async close(): Promise { 75 | new Promise((resolve) => this.wss.close(resolve)); 76 | } 77 | clear() { 78 | this.events = []; 79 | this.subs = new Map(); 80 | this.totalSubscriptions = 0; 81 | this.auth = undefined; 82 | } 83 | disconnectAll() { 84 | for (const ws of this.connections) { 85 | ws.close(); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from "./relay-pool"; 2 | export * from "./author"; 3 | export * from "./collect"; 4 | export {emitEventsOnNextTick} from "./on-event-filters"; 5 | export * from "./relay-pool-worker"; 6 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | const config = { 3 | testMatch: ["**/*test.[jt]s"], 4 | expand: true, 5 | silent: false, 6 | preset: "ts-jest", 7 | testEnvironment: "node", 8 | testTimeout: 200, 9 | }; 10 | 11 | module.exports = config; 12 | // export default config 13 | -------------------------------------------------------------------------------- /merge-similar-filters-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import {mergeSimilarAndRemoveEmptyFilters} from "./merge-similar-filters"; 4 | import type {Filter} from "nostr-tools"; 5 | 6 | test("Merge filters automatically", () => { 7 | let filters: Filter[] = [ 8 | {authors: ["pub1"], kinds: [0, 2]}, 9 | {ids: ["1"]}, 10 | {"#p": ["p1", "p2"]}, 11 | {authors: ["pub2"], kinds: [0, 2]}, 12 | {ids: ["5"]}, 13 | {"#p": ["p2", "p3"]}, 14 | ]; 15 | 16 | let result = mergeSimilarAndRemoveEmptyFilters(filters); 17 | expect(result).toEqual([ 18 | {authors: ["pub1", "pub2"], kinds: [0, 2]}, 19 | {ids: ["1", "5"]}, 20 | {"#p": ["p1", "p2", "p3"]}, 21 | ]); 22 | }); 23 | 24 | test("Don't merge filters using different relays and different ids", () => { 25 | let filters: (Filter & {relay?: string})[] = [ 26 | {ids: ["1"]}, 27 | {ids: ["2"], relay: "wss://nostr-dev.wellorder.net/"}, 28 | ]; 29 | 30 | let result = mergeSimilarAndRemoveEmptyFilters(filters); 31 | expect(result).toEqual(filters); 32 | }); 33 | 34 | test("Remove empty filters", () => { 35 | let filters: (Filter & {relay?: string})[] = [ 36 | {ids: []}, 37 | {authors: [], relay: "wss://nostr-dev.wellorder.net/"}, 38 | ]; 39 | 40 | let result = mergeSimilarAndRemoveEmptyFilters(filters); 41 | expect(result).toEqual([]); 42 | }); 43 | 44 | test("concat error", () => { 45 | let filters: (Filter & {relay?: string})[] = [ 46 | { 47 | authors: [ 48 | "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", 49 | ], 50 | kinds: [0, 3], 51 | }, 52 | { 53 | kinds: [1, 6, 7], 54 | "#e": [ 55 | "692e7e4b3aa182c35c4346932e4daeb3554fa7e5854244222a0688a405cba107", 56 | ], 57 | }, 58 | { 59 | authors: [ 60 | "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", 61 | ], 62 | kinds: [0, 3], 63 | }, 64 | { 65 | kinds: [1, 6, 7], 66 | "#e": [ 67 | "8914e1ea4774091b9bb5439d6eef2e4eb95064d618ded5ef473d2e976b782a22", 68 | ], 69 | }, 70 | { 71 | authors: [ 72 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", 73 | ], 74 | kinds: [0, 3], 75 | }, 76 | { 77 | kinds: [1, 6, 7], 78 | "#e": [ 79 | "7b91e4aa186780422ffa16671f8e19af7d58281a4a78bd74cfdb56742dae4945", 80 | ], 81 | }, 82 | { 83 | authors: [ 84 | "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", 85 | ], 86 | kinds: [0, 3], 87 | }, 88 | { 89 | kinds: [1, 6, 7], 90 | "#e": [ 91 | "984acbcbdd9fba397d4537a20d0f2a4dd0bc9a6278e35465f2e3b109087d7ece", 92 | ], 93 | }, 94 | { 95 | authors: [ 96 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", 97 | ], 98 | kinds: [0, 3], 99 | }, 100 | { 101 | kinds: [1, 6, 7], 102 | "#e": [ 103 | "7e0e1feef76aa793be725fe5f0f2534c13ea8b8b40c995ea91f70a638aad0eab", 104 | ], 105 | }, 106 | { 107 | authors: [ 108 | "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", 109 | ], 110 | kinds: [0, 3], 111 | }, 112 | { 113 | kinds: [1, 6, 7], 114 | "#e": [ 115 | "0e329a10e7c2cbce3a206a76a4b4c932426902b3fbb548ae042ca0f38e011ef2", 116 | ], 117 | }, 118 | { 119 | authors: [ 120 | "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", 121 | ], 122 | kinds: [0, 3], 123 | }, 124 | { 125 | kinds: [1, 6, 7], 126 | "#e": [ 127 | "18f19a4e982bc1058aab98692ad1bb2eb3ac214a21bcd37676ad5d588fc0dd5d", 128 | ], 129 | }, 130 | { 131 | authors: [ 132 | "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", 133 | ], 134 | kinds: [0, 3], 135 | }, 136 | { 137 | kinds: [1, 6, 7], 138 | "#e": [ 139 | "39b9e6dc5a539debac289c36cb887d90d13d85ed3371d2f1233b9c34c025c4a5", 140 | ], 141 | }, 142 | { 143 | authors: [ 144 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", 145 | ], 146 | kinds: [0, 3], 147 | }, 148 | { 149 | kinds: [1, 6, 7], 150 | "#e": [ 151 | "b48d5c8ebac25830779d22f654213f993d939c8159fa53248d3ec69a48a7f20b", 152 | ], 153 | }, 154 | { 155 | authors: [ 156 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", 157 | ], 158 | kinds: [0, 3], 159 | }, 160 | { 161 | kinds: [1, 6, 7], 162 | "#e": [ 163 | "04a2825b574a818ee48701f70bd875f8a0c8f7cb07a7286baab0e96577b107f1", 164 | ], 165 | }, 166 | { 167 | authors: [ 168 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", 169 | ], 170 | kinds: [0, 3], 171 | }, 172 | { 173 | kinds: [1, 6, 7], 174 | "#e": [ 175 | "d72af132bad9d332c469195cc80b084da54be085cabc33d911d371d64894c033", 176 | ], 177 | }, 178 | { 179 | authors: [ 180 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", 181 | ], 182 | kinds: [0, 3], 183 | }, 184 | { 185 | kinds: [1, 6, 7], 186 | "#e": [ 187 | "09c646ebfdcba8f31701ec9a74af65bd230d2c6aeff8388b3cf3171797af2e95", 188 | ], 189 | }, 190 | { 191 | authors: [ 192 | "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", 193 | ], 194 | kinds: [0, 3], 195 | }, 196 | { 197 | kinds: [1, 6, 7], 198 | "#e": [ 199 | "8fda8ee4d719dfeab389ba13f4e46addcb65dc9ff7ffdd48875ad5889dceb118", 200 | ], 201 | }, 202 | { 203 | authors: [ 204 | "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", 205 | ], 206 | kinds: [0, 3], 207 | }, 208 | { 209 | kinds: [1, 6, 7], 210 | "#e": [ 211 | "467ea9860198b370b6867102685fb3accf55a592a710806ad938a0fed0879b44", 212 | ], 213 | }, 214 | { 215 | kinds: [1, 6, 7], 216 | "#e": [ 217 | "fb2f0cd0b53557bcc85be2b3430d4c443d907a064968a1485aaa3d39bb010196", 218 | ], 219 | }, 220 | { 221 | authors: [ 222 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", 223 | ], 224 | kinds: [0, 3], 225 | }, 226 | { 227 | kinds: [1, 6, 7], 228 | "#e": [ 229 | "7e0e1feef76aa793be725fe5f0f2534c13ea8b8b40c995ea91f70a638aad0eab", 230 | ], 231 | }, 232 | { 233 | authors: [ 234 | "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", 235 | ], 236 | kinds: [0, 3], 237 | }, 238 | { 239 | authors: [ 240 | "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", 241 | ], 242 | kinds: [0, 3], 243 | }, 244 | { 245 | authors: [ 246 | "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", 247 | ], 248 | kinds: [0, 3], 249 | }, 250 | { 251 | authors: [ 252 | "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", 253 | ], 254 | kinds: [0, 3], 255 | }, 256 | ]; 257 | let _result = mergeSimilarAndRemoveEmptyFilters(filters); 258 | expect(_result.length).toBe(2); 259 | }); 260 | 261 | test("benchmark_merge", () => { 262 | let filters: Filter[] = []; 263 | for (let i = 0; i < 3000; i++) { 264 | filters.push({authors: [i.toString()], kinds: [0, 2]}); 265 | filters.push({ids: [i.toString()]}); 266 | filters.push({"#p": [i.toString(), (i + 1).toString()]}); 267 | } 268 | let start = Date.now(); 269 | let _result = mergeSimilarAndRemoveEmptyFilters(filters); 270 | let time = Date.now() - start; 271 | // console.log("mergeSimilarAndRemoveEmptyFilters benchmark", time); 272 | expect(time).toBeLessThan(100); 273 | expect(_result.length).toBe(3); 274 | }); 275 | 276 | test("merge3", () => { 277 | let filters: Filter[] = [ 278 | {authors: ["pub1"], kinds: [0]}, 279 | {authors: ["pub2"], kinds: [0]}, 280 | {authors: ["pub3"], kinds: [0]}, 281 | ]; 282 | let result = mergeSimilarAndRemoveEmptyFilters(filters); 283 | expect(result.length).toBe(1); 284 | expect(JSON.stringify(result)).toBe( 285 | JSON.stringify([{authors: ["pub1", "pub2", "pub3"], kinds: [0]}]) 286 | ); 287 | }); 288 | -------------------------------------------------------------------------------- /merge-similar-filters.ts: -------------------------------------------------------------------------------- 1 | import {stringify} from "safe-stable-stringify"; 2 | import type {Filter} from "nostr-tools"; 3 | 4 | function indexForFilter(filter: Filter, key: string): string { 5 | let new_filter = {...filter}; 6 | // @ts-ignore 7 | delete new_filter[key]; 8 | return key + stringify(new_filter); 9 | } 10 | 11 | // Combines filters that are similar, and removes empty filters 12 | // Similarity is defined as having the same values for all keys except one 13 | export function mergeSimilarAndRemoveEmptyFilters(filters: Filter[]): Filter[] { 14 | let r = []; 15 | let indexByFilter = new Map(); 16 | let sets = []; 17 | for (let filter of filters) { 18 | let added = false; 19 | for (let key in filter) { 20 | if ( 21 | // @ts-ignore 22 | filter[key] && 23 | (["ids", "authors", "kinds"].includes(key) || key.startsWith("#")) 24 | ) { 25 | // @ts-ignore 26 | if (filter[key].length === 0) { 27 | added = true; 28 | break; 29 | } 30 | let index_by = indexForFilter(filter, key); 31 | let index = indexByFilter.get(index_by); 32 | if (index !== undefined) { 33 | // @ts-ignore 34 | let extendedFilter = r[index]; 35 | // remove all other groupings for r[index] 36 | for (let key2 in extendedFilter) { 37 | if (key2 !== key) { 38 | let index_by2 = indexForFilter(extendedFilter, key2); 39 | indexByFilter.delete(index_by2); 40 | } 41 | } 42 | // // @ts-ignore 43 | // if (!r[index][key]?.includes(filter[key])) { 44 | // // @ts-ignore 45 | // r[index][key].push(filter[key]); 46 | // } 47 | if (r[index][key] instanceof Set) { 48 | // @ts-ignore 49 | for (let v of filter[key]) { 50 | // @ts-ignore 51 | r[index][key].add(v); 52 | } 53 | } else { 54 | // @ts-ignore 55 | r[index][key] = new Set(r[index][key].concat(filter[key])); 56 | sets.push([index, key]); 57 | } 58 | added = true; 59 | break; 60 | } 61 | } 62 | } 63 | if (!added) { 64 | for (let key in filter) { 65 | if ( 66 | // @ts-ignore 67 | filter[key] && 68 | (["ids", "authors", "kinds"].includes(key) || key.startsWith("#")) 69 | ) { 70 | let index_by = indexForFilter(filter, key); 71 | indexByFilter.set(index_by, r.length); 72 | } 73 | } 74 | r.push({...filter}); 75 | } 76 | } 77 | for (let [index, key] of sets) { 78 | // @ts-ignore 79 | r[index][key] = Array.from(r[index][key]); 80 | } 81 | return r; 82 | } 83 | -------------------------------------------------------------------------------- /metadata-cache.ts: -------------------------------------------------------------------------------- 1 | import type {Event} from "nostr-tools"; 2 | 3 | export class MetadataCache { 4 | data: Map; 5 | promises: Map>; 6 | servers: string[]; 7 | constructor(servers?: string[]) { 8 | this.data = new Map(); 9 | this.promises = new Map(); 10 | this.servers = servers || ["https://us.rbr.bio", "https://eu.rbr.bio"]; 11 | } 12 | 13 | async get(pubkey: string): Promise { 14 | let value = this.data.get(pubkey); 15 | if (value) { 16 | return Promise.resolve(value); 17 | } 18 | const promise = this.promises.get(pubkey); 19 | if (promise) { 20 | return promise; 21 | } 22 | const rs = []; 23 | for (let server of this.servers) { 24 | rs.push(fetchMetadata(server, pubkey)); 25 | } 26 | const r = firstGoodPromise(rs); 27 | r.then((x) => { 28 | this.data.set(pubkey, x); 29 | this.promises.delete(pubkey); 30 | }); 31 | this.promises.set(pubkey, r); 32 | return r; 33 | } 34 | } 35 | 36 | async function fetchJSON(url: string) { 37 | return fetch(url) 38 | .then((response) => response.json()) 39 | .catch((e) => { 40 | throw new Error("error fetching " + url + " " + e); 41 | }); 42 | } 43 | 44 | function firstGoodPromise(promises: Promise[]): Promise { 45 | return new Promise((resolve, reject) => { 46 | let rejects: any[] = []; 47 | promises.forEach((p) => { 48 | p.then(resolve).catch((rej) => { 49 | rejects.push(rej); 50 | if (rejects.length === promises.length) { 51 | reject(rejects); 52 | } 53 | }); 54 | }); 55 | }); 56 | } 57 | 58 | function fetchMetadata(server: string, pubkey: string) { 59 | const url = `${server}/${pubkey}/metadata.json`; 60 | return fetchJSON(url); 61 | } 62 | -------------------------------------------------------------------------------- /newest-event-cache.ts: -------------------------------------------------------------------------------- 1 | import type {Event, Filter} from "nostr-tools"; 2 | import type {RelayPool} from "./relay-pool"; 3 | 4 | export class NewestEventCache { 5 | data: Map; 6 | promises: Map>; 7 | relays: string[]; 8 | kind: number; 9 | relayPool: RelayPool; 10 | useps: boolean; 11 | constructor( 12 | kind: number, 13 | relayPool: RelayPool, 14 | relays?: string[], 15 | useps?: boolean 16 | ) { 17 | this.data = new Map(); 18 | this.promises = new Map(); 19 | this.kind = kind; 20 | this.relayPool = relayPool; 21 | this.relays = relays || ["wss://us.rbr.bio", "wss://eu.rbr.bio"]; 22 | this.useps = useps || false; 23 | } 24 | 25 | async get(pubkey: string): Promise { 26 | let value = this.data.get(pubkey); 27 | if (value) { 28 | return Promise.resolve(value); 29 | } 30 | const promise = this.promises.get(pubkey); 31 | if (promise) { 32 | return promise; 33 | } 34 | return new Promise((resolve, reject) => { 35 | let tries = 0; 36 | const filter: Filter = this.useps 37 | ? {kinds: [this.kind], "#p": [pubkey]} 38 | : {kinds: [this.kind], authors: [pubkey]}; 39 | // Don't log this instant sending of subscriptions 40 | const logSubscriptions = this.relayPool.logSubscriptions; 41 | this.relayPool.logSubscriptions = false; 42 | this.relayPool.subscribe( 43 | [filter], 44 | this.relays, 45 | (event) => { 46 | this.data.set(pubkey, event); 47 | this.promises.delete(pubkey); 48 | resolve(event); 49 | }, 50 | undefined, 51 | (relayUrl) => { 52 | if (this.relays.includes(relayUrl)) { 53 | tries++; 54 | } 55 | if (tries === this.relays.length) { 56 | this.promises.delete(pubkey); 57 | reject( 58 | `Can't find data2 for ${pubkey} with kind ${ 59 | this.kind 60 | } on RelayInfoServers ${this.relays.join(",")}, ${tries} tries` 61 | ); 62 | } 63 | }, 64 | {dontSendOtherFilters: true} 65 | ); 66 | this.relayPool.logSubscriptions = logSubscriptions; 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /on-event-filters.ts: -------------------------------------------------------------------------------- 1 | import {type Filter, Kind, matchFilter, type Event} from "nostr-tools"; 2 | import type {EventObject} from "./event"; 3 | export type OnEventArgs = [ 4 | event: Event, 5 | afterEose: boolean, 6 | url: string | undefined 7 | ]; 8 | 9 | export type OnEvent = ( 10 | event: Event, 11 | afterEose: boolean, 12 | url: string | undefined 13 | ) => void; 14 | 15 | export type OnEventObject = ( 16 | eventObject: EventObject, 17 | afterEose: boolean, 18 | url: string | undefined 19 | ) => void; 20 | 21 | export function doNotEmitDuplicateEvents(onEvent: OnEvent): OnEvent { 22 | let event_ids = new Set(); 23 | return (event: Event, afterEose: boolean, url: string | undefined) => { 24 | if (event_ids.has(event.id)) return; 25 | event_ids.add(event.id); 26 | onEvent(event, afterEose, url); 27 | }; 28 | } 29 | 30 | export function doNotEmitOlderEvents(onEvent: OnEvent): OnEvent { 31 | let created_at_by_events_kinds = new Map(); 32 | return (event: Event, afterEose: boolean, url: string | undefined) => { 33 | if (event.kind === Kind.Metadata || event.kind === Kind.Contacts) { 34 | let event_kind = event.pubkey + " " + event.kind; 35 | if ((created_at_by_events_kinds.get(event_kind) || 0) > event.created_at) 36 | return; 37 | created_at_by_events_kinds.set(event_kind, event.created_at); 38 | } 39 | onEvent(event, afterEose, url); 40 | }; 41 | } 42 | 43 | export function matchOnEventFilters( 44 | onEvent: OnEvent, 45 | filters: Filter[] 46 | ): OnEvent { 47 | return (event: Event, afterEose: boolean, url: string | undefined) => { 48 | for (let filter of filters) { 49 | if (matchFilter(filter, event)) { 50 | onEvent(event, afterEose, url); 51 | break; 52 | } 53 | } 54 | }; 55 | } 56 | 57 | export function emitEventsOnNextTick(onEvent: OnEvent): OnEvent { 58 | return (event: Event, afterEose: boolean, url: string | undefined) => { 59 | setTimeout(() => { 60 | onEvent(event, afterEose, url); 61 | }, 0); 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nostr-relaypool", 3 | "version": "0.6.30", 4 | "description": "A Nostr RelayPool implementation in TypeScript using only nostr-tools library as a dependency.", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/adamritter/nostr-relaypool-ts.git" 9 | }, 10 | "main": "lib/nostr-relaypool.cjs", 11 | "module": "lib/nostr-relaypool.esm.js", 12 | "exports": { 13 | "import": "./lib/nostr-relaypool.esm.js", 14 | "require": "./lib/nostr-relaypool.cjs" 15 | }, 16 | "dependencies": { 17 | "isomorphic-ws": "^5.0.0", 18 | "nostr-tools": "^1.17.0", 19 | "safe-stable-stringify": "^2.4.2" 20 | }, 21 | "keywords": [ 22 | "decentralization", 23 | "social", 24 | "censorship-resistance", 25 | "client", 26 | "nostr" 27 | ], 28 | "devDependencies": { 29 | "@babel/core": "^7.20.7", 30 | "@babel/preset-typescript": "^7.18.6", 31 | "@jest/globals": "^29.3.1", 32 | "@jest/source-map": "^29.4.3", 33 | "@noble/hashes": "^1.3.2", 34 | "@noble/secp256k1": "^2.0.0", 35 | "@scure/base": "^1.1.1", 36 | "@scure/bip32": "^1.1.1", 37 | "@scure/bip39": "^1.1.0", 38 | "@types/jest": "^29.2.5", 39 | "@types/node": "^20.9.2", 40 | "@types/ws": "^8.5.4", 41 | "@typescript-eslint/eslint-plugin": "^6.11.0", 42 | "@typescript-eslint/parser": "^6.11.0", 43 | "esbuild": "0.19.6", 44 | "esbuild-plugin-alias": "^0.2.1", 45 | "eslint": "^8.30.0", 46 | "eslint-config-prettier": "^9.0.0", 47 | "eslint-plugin-babel": "^5.3.1", 48 | "esm-loader-import-relative-extension": "^1.0.8", 49 | "esm-loader-typescript": "^1.0.3", 50 | "events": "^3.3.0", 51 | "jest": "^29.3.1", 52 | "node-esm-loader": "^0.2.5", 53 | "node-fetch": "3.3.2", 54 | "prettier": "3.1.0", 55 | "ts-jest": "^29.0.3", 56 | "ts-node": "^10.9.1", 57 | "tsd": "^0.29.0", 58 | "typescript": "^5.2.2", 59 | "ws": "^8.12.0" 60 | }, 61 | "scripts": { 62 | "build": "node build.js", 63 | "pretest": "node build.js", 64 | "test": "NODE_OPTIONS=--experimental-vm-modules npx jest", 65 | "publish": "yarn install && npm run build && npm run test -- --testTimeout 5000 && npm publish", 66 | "type-check": "tsc" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /relay-pool-worker.ts: -------------------------------------------------------------------------------- 1 | import type {Event} from "nostr-tools"; 2 | import type {OnEose, OnEvent, SubscriptionOptions} from "./relay-pool"; 3 | 4 | export class RelayPoolWorker { 5 | // eslint-disable-next-line no-undef 6 | private worker: Worker; 7 | private subscriptionCallbacks = new Map< 8 | number | string, 9 | {onEvent: OnEvent; onEose?: OnEose} 10 | >(); 11 | 12 | constructor( 13 | // eslint-disable-next-line no-undef 14 | worker: Worker, 15 | relays: string[] = [], 16 | options: { 17 | useEventCache?: boolean; 18 | logSubscriptions?: boolean; 19 | deleteSignatures?: boolean; 20 | skipVerification?: boolean; 21 | autoReconnect?: boolean; 22 | } = {} 23 | ) { 24 | this.worker = worker; 25 | this.worker.onmessage = this.handleWorkerMessage.bind(this); 26 | this.worker.postMessage({ 27 | action: "create_relay_pool", 28 | data: {relays, options}, 29 | }); 30 | } 31 | 32 | private handleWorkerMessage(event: MessageEvent) { 33 | const {type, subscriptionId, ...rest} = event.data; 34 | 35 | if (type === "event" || type === "eose") { 36 | const callbacks = this.subscriptionCallbacks.get(subscriptionId); 37 | 38 | if (callbacks) { 39 | if (type === "event") { 40 | callbacks.onEvent(rest.event, rest.isAfterEose, rest.relayURL); 41 | } else if (type === "eose" && callbacks.onEose) { 42 | callbacks.onEose(rest.relayURL, rest.minCreatedAt); 43 | } 44 | } 45 | } else if (type === "subscribed") { 46 | // Do nothing 47 | } else if (type === "metadata") { 48 | // Need better handling 49 | } else if (type === "contactList") { 50 | // Need better handling 51 | } else { 52 | console.warn("Unhandled message from worker:", event.data); 53 | } 54 | } 55 | 56 | subscribe( 57 | filters: any, 58 | relays: string[] | undefined, 59 | onEvent: OnEvent, 60 | maxDelayms?: number, 61 | onEose?: OnEose, 62 | options: SubscriptionOptions = {} 63 | ): () => void { 64 | const subscriptionId = Math.random().toString(36).slice(2, 9); 65 | 66 | this.subscriptionCallbacks.set(subscriptionId, {onEvent, onEose}); 67 | 68 | this.worker.postMessage({ 69 | action: "subscribe", 70 | data: { 71 | filters, 72 | relays, 73 | maxDelayms, 74 | onEose: !!onEose, 75 | options, 76 | subscriptionId, 77 | }, 78 | }); 79 | 80 | return () => { 81 | this.subscriptionCallbacks.delete(subscriptionId); 82 | this.worker.postMessage({action: "unsubscribe", data: {subscriptionId}}); 83 | }; 84 | } 85 | 86 | publish(event: Event, relays: string[]) { 87 | this.worker.postMessage({action: "publish", data: {event, relays}}); 88 | } 89 | 90 | setWriteRelaysForPubKey(pubkey: string, writeRelays: string[]) { 91 | this.worker.postMessage({ 92 | action: "set_write_relays_for_pub_key", 93 | data: {pubkey, writeRelays}, 94 | }); 95 | } 96 | 97 | subscribeReferencedEvents( 98 | event: Event, 99 | onEvent: OnEvent, 100 | maxDelayms?: number, 101 | onEose?: OnEose, 102 | options: SubscriptionOptions = {} 103 | ): () => void { 104 | const subscriptionId = Math.random().toString(36).slice(2, 9); 105 | 106 | this.subscriptionCallbacks.set(subscriptionId, {onEvent, onEose}); 107 | 108 | this.worker.postMessage({ 109 | action: "subscribe_referenced_events", 110 | data: {event, maxDelayms, onEose: !!onEose, options, subscriptionId}, 111 | }); 112 | 113 | return () => { 114 | this.subscriptionCallbacks.delete(subscriptionId); 115 | this.worker.postMessage({action: "unsubscribe", data: {subscriptionId}}); 116 | }; 117 | } 118 | 119 | fetchAndCacheMetadata(pubkey: string): Promise { 120 | return new Promise((resolve) => { 121 | const listener = (event: MessageEvent) => { 122 | if (event.data.type === "metadata" && event.data.pubkey === pubkey) { 123 | this.worker.removeEventListener("message", listener); 124 | resolve(event.data.metadata); 125 | } 126 | }; 127 | 128 | this.worker.addEventListener("message", listener); 129 | this.worker.postMessage({ 130 | action: "fetch_and_cache_metadata", 131 | data: {pubkey}, 132 | }); 133 | }); 134 | } 135 | 136 | fetchAndCacheContactList(pubkey: string): Promise { 137 | return new Promise((resolve) => { 138 | const listener = (event: MessageEvent) => { 139 | if (event.data.type === "contactList" && event.data.pubkey === pubkey) { 140 | this.worker.removeEventListener("message", listener); 141 | resolve(event.data.contactList); 142 | } 143 | }; 144 | 145 | this.worker.addEventListener("message", listener); 146 | this.worker.postMessage({ 147 | action: "fetch_and_cache_contact_list", 148 | data: {pubkey}, 149 | }); 150 | }); 151 | } 152 | 153 | subscribeReferencedEventsAndPrefetchMetadata( 154 | event: Event, 155 | onEvent: OnEvent, 156 | maxDelayms?: number, 157 | onEose?: OnEose, 158 | options: SubscriptionOptions = {} 159 | ): () => void { 160 | const subscriptionId = Math.random().toString(36).slice(2, 9); 161 | 162 | this.subscriptionCallbacks.set(subscriptionId, {onEvent, onEose}); 163 | 164 | this.worker.postMessage({ 165 | action: "subscribe_referenced_events_and_prefetch_metadata", 166 | data: {event, maxDelayms, onEose: !!onEose, options, subscriptionId}, 167 | }); 168 | 169 | return () => { 170 | this.subscriptionCallbacks.delete(subscriptionId); 171 | this.worker.postMessage({action: "unsubscribe", data: {subscriptionId}}); 172 | }; 173 | } 174 | 175 | setCachedMetadata(pubkey: string, metadata: Event) { 176 | this.worker.postMessage({ 177 | action: "set_cached_metadata", 178 | data: {pubkey, metadata}, 179 | }); 180 | } 181 | 182 | close() { 183 | this.worker.postMessage({action: "close"}); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /relay-pool.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { 4 | getSignature, 5 | generatePrivateKey, 6 | getEventHash, 7 | getPublicKey, 8 | type Event, 9 | } from "nostr-tools"; 10 | import {RelayPool} from "./relay-pool"; 11 | import {InMemoryRelayServer} from "./in-memory-relay-server"; 12 | import {SubscriptionFilterStateCache} from "./subscription-filter-state-cache"; 13 | 14 | let relaypool: RelayPool; 15 | 16 | // const relayurls = ['wss://nostr-dev.wellorder.net/'] 17 | // const relayurls2 = ['wss://nostr.v0l.io/'] 18 | 19 | const relayurls = ["ws://localhost:8083/"]; 20 | const relayurls2 = ["ws://localhost:8084/"]; 21 | 22 | let _relayServer: InMemoryRelayServer; 23 | let _relayServer2: InMemoryRelayServer; 24 | 25 | beforeAll(() => { 26 | _relayServer = new InMemoryRelayServer(8083); 27 | _relayServer2 = new InMemoryRelayServer(8084); 28 | }); 29 | 30 | beforeEach(() => { 31 | relaypool = new RelayPool([], { 32 | subscriptionCache: true, 33 | useEventCache: true, 34 | }); 35 | _relayServer.clear(); 36 | _relayServer2.clear(); 37 | }); 38 | 39 | afterEach(async () => { 40 | await relaypool.close(); 41 | }); 42 | 43 | afterAll(async () => { 44 | await _relayServer.close(); 45 | await _relayServer2.close(); 46 | }); 47 | 48 | function createSignedEvent( 49 | kind = 27572, 50 | content = "nostr-tools test suite", 51 | created_at = Math.floor(Date.now() / 1000), 52 | ): Event & {id: string} { 53 | const sk = generatePrivateKey(); 54 | const pk = getPublicKey(sk); 55 | const unsignedEvent = { 56 | kind, 57 | pubkey: pk, 58 | created_at, 59 | tags: [], 60 | content, 61 | }; 62 | const eventId = getEventHash(unsignedEvent); 63 | return {id: eventId, ...unsignedEvent, sig: getSignature(unsignedEvent, sk)}; 64 | } 65 | 66 | async function publishAndGetEvent( 67 | relays: string[], 68 | kind = 27572, 69 | content = "nostr-tools test suite", 70 | ): Promise { 71 | const event = createSignedEvent(kind, content); 72 | relaypool.publish(event, relays); 73 | const a = relaypool.getEventById(event.id, relays, Infinity); 74 | relaypool.sendSubscriptions(); 75 | await a; 76 | return event; 77 | } 78 | test("external geteventbyid", async () => { 79 | const event = await publishAndGetEvent(relayurls); 80 | var resolve1: (success: boolean) => void; 81 | var resolve2: (success: boolean) => void; 82 | const promise1 = new Promise((resolve) => { 83 | resolve1 = resolve; 84 | }); 85 | const promise2 = new Promise((resolve) => { 86 | resolve2 = resolve; 87 | }); 88 | 89 | const promiseAll = Promise.all([promise1, promise2]); 90 | relaypool.close(); 91 | relaypool = new RelayPool(relayurls, { 92 | externalGetEventById: (id) => { 93 | if (id === event.id) { 94 | resolve2(true); 95 | return event; 96 | } 97 | }, 98 | }); 99 | expect(event.kind).toEqual(27572); 100 | 101 | relaypool.subscribe( 102 | [ 103 | { 104 | kinds: [27572], // Force no caching 105 | }, 106 | ], 107 | relayurls, 108 | (event, afterEose, url) => { 109 | expect(event).toHaveProperty("id", event.id); 110 | expect(afterEose).toBe(false); 111 | // expect(url).toBe(relayurls[0]) 112 | resolve1(true); 113 | }, 114 | undefined, 115 | undefined, 116 | ); 117 | 118 | return expect(promiseAll).resolves.toEqual([true, true]); 119 | }); 120 | 121 | test("empty", async () => { 122 | var resolve2: (success: boolean) => void; 123 | const promiseAll = Promise.all([ 124 | new Promise((resolve) => { 125 | resolve2 = resolve; 126 | }), 127 | ]); 128 | relaypool.subscribe( 129 | [ 130 | { 131 | kinds: [27572], // Force no caching 132 | }, 133 | ], 134 | relayurls, 135 | (event, afterEose, url) => {}, 136 | undefined, 137 | (url, minCreatedAt) => { 138 | expect(minCreatedAt).toBe(Infinity); 139 | expect(url).toBe(relayurls[0]); 140 | resolve2(true); 141 | }, 142 | ); 143 | 144 | return expect(promiseAll).resolves.toEqual([true]); 145 | }); 146 | 147 | test("querying relaypool", async () => { 148 | const event = await publishAndGetEvent(relayurls); 149 | expect(event.kind).toEqual(27572); 150 | var resolve1: (success: boolean) => void; 151 | var resolve2: (success: boolean) => void; 152 | const promiseAll = Promise.all([ 153 | new Promise((resolve) => { 154 | resolve1 = resolve; 155 | }), 156 | new Promise((resolve) => { 157 | resolve2 = resolve; 158 | }), 159 | ]); 160 | relaypool.subscribe( 161 | [ 162 | { 163 | kinds: [27572], // Force no caching 164 | }, 165 | ], 166 | relayurls, 167 | (event, afterEose, url) => { 168 | expect(event).toHaveProperty("id", event.id); 169 | expect(afterEose).toBe(false); 170 | // expect(url).toBe(relayurls[0]) 171 | resolve1(true); 172 | }, 173 | undefined, 174 | (url, minCreatedAt) => { 175 | expect(minCreatedAt).toBe(event.created_at); 176 | expect(url).toBe(relayurls[0]); 177 | resolve2(true); 178 | }, 179 | ); 180 | 181 | return expect(promiseAll).resolves.toEqual([true, true]); 182 | }); 183 | 184 | test("listening and publishing", async () => { 185 | const event = createSignedEvent(); 186 | 187 | let resolve2: (success: boolean) => void; 188 | 189 | relaypool.subscribe( 190 | [ 191 | { 192 | kinds: [27572], 193 | authors: [event.pubkey], 194 | }, 195 | ], 196 | relayurls, 197 | (event) => { 198 | expect(event).toHaveProperty("pubkey", event.pubkey); 199 | expect(event).toHaveProperty("kind", 27572); 200 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 201 | resolve2(true); 202 | }, 203 | ); 204 | 205 | relaypool.publish(event, relayurls); 206 | return expect( 207 | new Promise((resolve) => { 208 | resolve2 = resolve; 209 | }), 210 | ).resolves.toEqual(true); 211 | }); 212 | 213 | test("relay option in filter", async () => { 214 | const event = await publishAndGetEvent(relayurls); 215 | 216 | var resolve1: (success: boolean) => void; 217 | var resolve2: (success: boolean) => void; 218 | const promiseAll = Promise.all([ 219 | new Promise((resolve) => { 220 | resolve1 = resolve; 221 | }), 222 | new Promise((resolve) => { 223 | resolve2 = resolve; 224 | }), 225 | ]); 226 | 227 | relaypool.subscribe( 228 | [ 229 | { 230 | kinds: [event.kind], 231 | relay: relayurls[0], 232 | }, 233 | ], 234 | [], 235 | (event, afterEose, url) => { 236 | expect(event).toHaveProperty("id", event.id); 237 | expect(afterEose).toBe(false); 238 | resolve1(true); 239 | }, 240 | undefined, 241 | (url, minCreatedAt) => { 242 | expect(minCreatedAt).toBe(event.created_at); 243 | expect(url).toBe(relayurls[0]); 244 | resolve2(true); 245 | }, 246 | ); 247 | 248 | return expect(promiseAll).resolves.toEqual([true, true]); 249 | }); 250 | 251 | test("cached result", async () => { 252 | const event = createSignedEvent(); 253 | relaypool.publish(event, relayurls); 254 | 255 | await expect( 256 | new Promise((resolve) => { 257 | relaypool.subscribe( 258 | [ 259 | { 260 | kinds: [27572], 261 | authors: [event.pubkey], 262 | }, 263 | ], 264 | relayurls, 265 | (event) => { 266 | expect(event).toHaveProperty("pubkey", event.pubkey); 267 | expect(event).toHaveProperty("kind", 27572); 268 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 269 | resolve(true); 270 | }, 271 | ); 272 | }), 273 | ).resolves.toEqual(true); 274 | 275 | const secondOnEvent = new Promise((resolve) => { 276 | relaypool.subscribe( 277 | [ 278 | { 279 | ids: [event.id], 280 | }, 281 | ], 282 | [], 283 | (event) => { 284 | expect(event).toHaveProperty("pubkey", event.pubkey); 285 | expect(event).toHaveProperty("kind", 27572); 286 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 287 | resolve(true); 288 | }, 289 | ); 290 | }); 291 | 292 | return expect(secondOnEvent).resolves.toEqual(true); 293 | }); 294 | 295 | test("remove duplicates", async () => { 296 | const event = await publishAndGetEvent(relayurls); 297 | 298 | await expect( 299 | new Promise((resolve) => { 300 | relaypool.subscribe( 301 | [ 302 | { 303 | kinds: [27572], 304 | authors: [event.pubkey], 305 | }, 306 | ], 307 | relayurls, 308 | (event, afterEose, url) => { 309 | expect(event).toHaveProperty("pubkey", event.pubkey); 310 | expect(event).toHaveProperty("kind", 27572); 311 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 312 | resolve(true); 313 | }, 314 | ); 315 | }), 316 | ).resolves.toEqual(true); 317 | 318 | await expect( 319 | new Promise((resolve) => { 320 | relaypool.subscribe( 321 | [ 322 | { 323 | kinds: [27572], 324 | authors: [event.pubkey], 325 | }, 326 | ], 327 | relayurls, 328 | (event, afterEose, url) => { 329 | expect(event).toHaveProperty("pubkey", event.pubkey); 330 | expect(event).toHaveProperty("kind", 27572); 331 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 332 | resolve(true); 333 | }, 334 | ); 335 | relaypool.publish(event, relayurls); 336 | }), 337 | ).resolves.toEqual(true); 338 | 339 | let counter = 0; 340 | await expect( 341 | new Promise((resolve) => { 342 | relaypool.subscribe( 343 | [ 344 | { 345 | kinds: [27572], 346 | authors: [event.pubkey], 347 | noCache: true, 348 | }, 349 | ], 350 | [...relayurls, ...relayurls2], 351 | (event, afterEose, url) => { 352 | expect(event).toHaveProperty("pubkey", event.pubkey); 353 | expect(event).toHaveProperty("kind", 27572); 354 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 355 | counter += 1; 356 | if (counter === 2) { 357 | resolve(true); 358 | } 359 | }, 360 | undefined, 361 | undefined, 362 | {allowDuplicateEvents: true}, 363 | ); 364 | relaypool.publish(event, relayurls); 365 | relaypool.publish(event, relayurls2); 366 | }), 367 | ).resolves.toEqual(true); 368 | 369 | let counter2 = 0; 370 | const thirdOnEvent = new Promise((resolve) => { 371 | relaypool.subscribe( 372 | [ 373 | { 374 | authors: [event.pubkey], 375 | }, 376 | ], 377 | [...relayurls, ...relayurls2], 378 | (event, afterEose, url) => { 379 | expect(event).toHaveProperty("pubkey", event.pubkey); 380 | expect(event).toHaveProperty("kind", 27572); 381 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 382 | counter2 += 1; 383 | if (counter2 === 2) { 384 | resolve(true); 385 | } 386 | }, 387 | ); 388 | relaypool.publish(event, relayurls); 389 | relaypool.publish(event, relayurls2); 390 | }); 391 | 392 | await expect( 393 | Promise.race([ 394 | thirdOnEvent, 395 | new Promise((resolve) => setTimeout(() => resolve(-1), 50)), 396 | ]), 397 | ).resolves.toEqual(-1); 398 | }); 399 | 400 | test("cache authors", async () => { 401 | let event = createSignedEvent(); 402 | let pk = event.pubkey; 403 | 404 | await expect( 405 | new Promise((resolve) => { 406 | relaypool.subscribe( 407 | [ 408 | { 409 | kinds: [27572], 410 | authors: [pk], 411 | }, 412 | ], 413 | relayurls2, 414 | (event, afterEose, url) => { 415 | expect(event).toHaveProperty("pubkey", pk); 416 | expect(event).toHaveProperty("kind", 27572); 417 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 418 | resolve(true); 419 | }, 420 | ); 421 | relaypool.publish(event, relayurls2); 422 | }), 423 | ).resolves.toEqual(true); 424 | 425 | return expect( 426 | new Promise((resolve) => { 427 | relaypool.subscribe( 428 | [ 429 | { 430 | kinds: [27572], 431 | authors: [pk], 432 | }, 433 | ], 434 | relayurls2, 435 | (event, afterEose, url) => { 436 | expect(event).toHaveProperty("pubkey", pk); 437 | expect(event).toHaveProperty("kind", 27572); 438 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 439 | expect(url).toEqual(undefined); 440 | resolve(true); 441 | }, 442 | ); 443 | }), 444 | ).resolves.toEqual(true); 445 | }); 446 | 447 | test("kind3", async () => { 448 | let event = createSignedEvent(3); 449 | relaypool.publish(event, relayurls); 450 | let pk = event.pubkey; 451 | 452 | await expect( 453 | new Promise((resolve) => { 454 | relaypool.subscribe( 455 | [ 456 | { 457 | kinds: [3], 458 | authors: [pk], 459 | }, 460 | ], 461 | relayurls, 462 | (event) => { 463 | expect(event).toHaveProperty("pubkey", pk); 464 | expect(event).toHaveProperty("kind", 3); 465 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 466 | resolve(true); 467 | }, 468 | ); 469 | }), 470 | ).resolves.toEqual(true); 471 | 472 | const secondOnEvent = new Promise((resolve) => { 473 | relaypool.subscribe( 474 | [ 475 | { 476 | ids: [event.id], 477 | }, 478 | ], 479 | [], 480 | (event) => { 481 | expect(event).toHaveProperty("pubkey", pk); 482 | expect(event).toHaveProperty("kind", 3); 483 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 484 | resolve(true); 485 | }, 486 | ); 487 | }); 488 | 489 | return expect(secondOnEvent).resolves.toEqual(true); 490 | }); 491 | 492 | test("kind0", async () => { 493 | let event = createSignedEvent(0); 494 | relaypool.publish(event, relayurls); 495 | let pk = event.pubkey; 496 | await expect( 497 | new Promise((resolve) => { 498 | relaypool.subscribe( 499 | [ 500 | { 501 | kinds: [0], 502 | authors: [pk], 503 | }, 504 | ], 505 | relayurls, 506 | (event) => { 507 | expect(event).toHaveProperty("pubkey", pk); 508 | expect(event).toHaveProperty("kind", 0); 509 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 510 | resolve(true); 511 | }, 512 | undefined, 513 | undefined, 514 | {logAllEvents: true}, 515 | ); 516 | }), 517 | ).resolves.toEqual(true); 518 | console.log("first on event done"); 519 | 520 | const secondOnEvent = new Promise((resolve) => { 521 | relaypool.subscribe( 522 | [ 523 | { 524 | ids: [event.id], 525 | }, 526 | ], 527 | [], 528 | (event) => { 529 | expect(event).toHaveProperty("pubkey", pk); 530 | expect(event).toHaveProperty("kind", 0); 531 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 532 | resolve(true); 533 | }, 534 | ); 535 | }); 536 | 537 | await expect(secondOnEvent).resolves.toEqual(true); 538 | 539 | const thirdOnEvent = new Promise((resolve) => { 540 | relaypool.subscribe( 541 | [ 542 | { 543 | kinds: [0], 544 | authors: [pk], 545 | }, 546 | ], 547 | [], 548 | (event) => { 549 | expect(event).toHaveProperty("pubkey", pk); 550 | expect(event).toHaveProperty("kind", 0); 551 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 552 | resolve(true); 553 | }, 554 | ); 555 | }); 556 | 557 | return expect(thirdOnEvent).resolves.toEqual(true); 558 | }); 559 | 560 | test("getRelayStatuses", async () => { 561 | let event = createSignedEvent(0); 562 | relaypool.publish(event, relayurls); 563 | expect(relaypool.getRelayStatuses()).toEqual([[relayurls[0], 0]]); 564 | }); 565 | 566 | test("nounsub", async () => { 567 | let event = createSignedEvent(0); 568 | relaypool.publish(event, relayurls); 569 | 570 | let p2 = new Promise((resolve2) => { 571 | new Promise((resolve1) => { 572 | let counter = 0; 573 | let _sub1 = relaypool.subscribe( 574 | filtersByKind(event), 575 | relayurls, 576 | (event) => { 577 | expect(event).toHaveProperty("kind", 0); 578 | counter++; 579 | if (counter === 1) { 580 | let event2 = createSignedEvent(0); 581 | relaypool.publish(event2, relayurls); 582 | } else if (counter === 2) { 583 | resolve2(true); 584 | } 585 | }, 586 | ); 587 | }); 588 | }); 589 | await expect(p2).resolves.toEqual(true); 590 | }); 591 | 592 | test("unsub", async () => { 593 | let event = createSignedEvent(0); 594 | relaypool.publish(event, relayurls); 595 | 596 | let p2 = new Promise((resolve2) => { 597 | new Promise((resolve1) => { 598 | let counter = 0; 599 | let sub1 = relaypool.subscribe( 600 | filtersByKind(event), 601 | relayurls, 602 | (event) => { 603 | expect(event).toHaveProperty("kind", 0); 604 | counter++; 605 | if (counter === 1) { 606 | sub1(); 607 | let event2 = createSignedEvent(0); 608 | relaypool.publish(event2, relayurls); 609 | } else if (counter === 2) { 610 | resolve2(true); 611 | } 612 | }, 613 | ); 614 | }); 615 | }); 616 | await expect( 617 | Promise.race([ 618 | p2, 619 | new Promise((resolve) => { 620 | setTimeout(() => resolve(false), 50); 621 | }), 622 | ]), 623 | ).resolves.toEqual(false); 624 | }); 625 | 626 | test("delay_nounsub", async () => { 627 | let event = createSignedEvent(0); 628 | relaypool.publish(event, relayurls); 629 | 630 | let p2 = new Promise((resolve2) => { 631 | new Promise((resolve1) => { 632 | let counter = 0; 633 | let _sub1 = relaypool.subscribe( 634 | filtersByKind(event), 635 | relayurls, 636 | (event) => { 637 | expect(event).toHaveProperty("kind", 0); 638 | counter++; 639 | if (counter === 1) { 640 | let event2 = createSignedEvent(0); 641 | relaypool.publish(event2, relayurls); 642 | } else if (counter === 2) { 643 | resolve2(true); 644 | } 645 | }, 646 | 0, 647 | ); 648 | }); 649 | }); 650 | await expect( 651 | Promise.race([ 652 | p2, 653 | new Promise((resolve) => { 654 | setTimeout(() => resolve(false), 50); 655 | }), 656 | ]), 657 | ).resolves.toEqual(true); 658 | }); 659 | 660 | test("delay_unsub", async () => { 661 | let event = createSignedEvent(0); 662 | relaypool.publish(event, relayurls); 663 | 664 | let p2 = new Promise((resolve2) => { 665 | new Promise((resolve1) => { 666 | let counter = 0; 667 | let sub1 = relaypool.subscribe( 668 | filtersByKind(event), 669 | relayurls, 670 | (event) => { 671 | expect(event).toHaveProperty("kind", 0); 672 | counter++; 673 | if (counter === 1) { 674 | sub1(); 675 | let event2 = createSignedEvent(0); 676 | relaypool.publish(event2, relayurls); 677 | } else if (counter === 2) { 678 | resolve2(true); 679 | } 680 | }, 681 | 0, 682 | ); 683 | }); 684 | }); 685 | await expect( 686 | Promise.race([ 687 | p2, 688 | new Promise((resolve) => { 689 | setTimeout(() => resolve(false), 50); 690 | }), 691 | ]), 692 | ).resolves.toEqual(false); 693 | }); 694 | 695 | test("unsubscribeOnEose", async () => { 696 | let relayServer = new InMemoryRelayServer(8099); 697 | let event = createSignedEvent(); 698 | relaypool.close(); 699 | relaypool = new RelayPool([]); 700 | relaypool.publish(event, ["ws://localhost:8099/"]); 701 | expect(relayServer.subs.size).toEqual(0); 702 | 703 | await new Promise((resolve) => { 704 | let sub = relaypool.subscribe( 705 | filtersByKind(event), 706 | ["ws://localhost:8099/"], 707 | (event) => { 708 | expect(event).toHaveProperty("kind", event.kind); 709 | sub(); 710 | setTimeout(() => resolve(true), 50); 711 | }, 712 | ); 713 | }); 714 | 715 | expect(_relayServer.subs.size).toEqual(0); 716 | 717 | let found = false; 718 | let p2; 719 | let p = new Promise((resolve) => { 720 | p2 = new Promise((resolve2) => { 721 | let _sub = relaypool.subscribe( 722 | filtersByKind(event), 723 | ["ws://localhost:8099/"], 724 | (event) => { 725 | expect(event).toHaveProperty("kind", event.kind); 726 | found = true; 727 | resolve(true); 728 | }, 729 | undefined, 730 | () => resolve2(true), 731 | {unsubscribeOnEose: true}, 732 | ); 733 | }); 734 | }); 735 | await expect(p).resolves.toEqual(true); 736 | await expect(p2).resolves.toEqual(true); 737 | expect(found).toEqual(true); 738 | await sleepms(50); 739 | expect(relayServer.subs.size).toEqual(0); 740 | relayServer.close(); 741 | }); 742 | 743 | const filtersByAuthor = (event: Event) => [{authors: [event.pubkey]}]; 744 | const filtersByKind = (event: Event) => [{kinds: [event.kind]}]; 745 | const _filtersById = (event: Event & {id: string}) => [{ids: [event.id]}]; 746 | const sleepms = (timeoutMs: number) => 747 | new Promise((resolve) => setTimeout(() => resolve(true), timeoutMs)); 748 | 749 | // const subscribePromise = ( 750 | // relaypool: RelayPool, 751 | // filters: Filter[], 752 | // relays: string[], 753 | // onEventPromise: (resolve: (value: any) => void) => OnEvent, 754 | // maxDelayms?: number | undefined, 755 | // onEosePromise?: (resolve: (value: any) => void) => OnEose, 756 | // options?: SubscriptionOptions 757 | // ) => 758 | // new Promise((resolve) => 759 | // relaypool.subscribe( 760 | // filters, 761 | // relays, 762 | // onEventPromise(resolve), 763 | // maxDelayms, 764 | // onEosePromise?.(resolve), 765 | // options 766 | // ) 767 | // ); 768 | 769 | test("subscriptionCache", async () => { 770 | let event = createSignedEvent(); 771 | relaypool.publish(event, relayurls); 772 | expect(_relayServer.totalSubscriptions).toEqual(0); 773 | 774 | await new Promise((resolve) => { 775 | relaypool.subscribe(filtersByKind(event), relayurls, (event) => { 776 | resolve(true); 777 | }); 778 | }); 779 | 780 | await new Promise((resolve) => { 781 | relaypool.subscribe( 782 | filtersByAuthor(event), 783 | relayurls, 784 | (event) => { 785 | resolve(true); 786 | }, 787 | undefined, 788 | undefined, 789 | {unsubscribeOnEose: true}, 790 | ); 791 | }); 792 | await new Promise((resolve) => { 793 | relaypool.subscribe( 794 | filtersByAuthor(event), 795 | relayurls, 796 | (event) => { 797 | resolve(true); 798 | }, 799 | undefined, 800 | undefined, 801 | {unsubscribeOnEose: true}, 802 | ); 803 | }); 804 | await sleepms(10); 805 | expect(_relayServer.totalSubscriptions).toEqual(2); 806 | }); 807 | 808 | // jest -t 'pool memory' --testTimeout 1000000 --logHeapUsage 809 | // PASS ./relay-pool.test.ts (92.9 s, 348 MB heap size) 810 | test.skip("pool memory usage", async () => { 811 | console.log("creating new relaypool"); 812 | relaypool.close(); 813 | relaypool = new RelayPool(relayurls); 814 | relaypool.relayByUrl.forEach((relay) => { 815 | // @ts-ignore 816 | relay.relay.logging = false; 817 | }); 818 | await publishAndGetEvent(relayurls, 100, "x".repeat(20 * 1024 * 1024)); 819 | 820 | for (let i = 0; i < 300; i++) { 821 | await new Promise((resolve) => { 822 | const unsub = relaypool.subscribe([{}], relayurls, (event) => { 823 | unsub(); 824 | resolve(true); 825 | }); 826 | }); 827 | } 828 | }); 829 | 830 | test("delayfiltering", async () => { 831 | let event = createSignedEvent(); 832 | relaypool.publish(event, relayurls); 833 | expect(_relayServer.totalSubscriptions).toEqual(0); 834 | 835 | await new Promise((resolve) => { 836 | relaypool.subscribe(filtersByKind(event), relayurls, (event) => { 837 | resolve(true); 838 | }); 839 | }); 840 | expect(_relayServer.totalSubscriptions).toEqual(1); 841 | relaypool.close(); 842 | relaypool = new RelayPool([], {}); 843 | 844 | const p2 = new Promise((resolve) => { 845 | relaypool.subscribe( 846 | [{ids: ["notfound"], authors: [event.pubkey]}], 847 | relayurls, 848 | (event) => { 849 | resolve(false); 850 | }, 851 | 1, 852 | undefined, 853 | {unsubscribeOnEose: true}, 854 | ); 855 | }); 856 | 857 | const p1 = new Promise((resolve) => { 858 | relaypool.subscribe( 859 | [{ids: [event.id], authors: [event.pubkey]}], 860 | relayurls, 861 | (event) => { 862 | resolve(true); 863 | }, 864 | 1, 865 | undefined, 866 | {unsubscribeOnEose: true}, 867 | ); 868 | }); 869 | await p1; 870 | expect(await p1).toEqual(true); 871 | expect(await Promise.race([p2, sleepms(10).then(() => true)])).toEqual(true); 872 | await sleepms(10); 873 | expect(_relayServer.totalSubscriptions).toEqual(2); 874 | }); 875 | 876 | test("auth", async () => { 877 | _relayServer.auth = "123"; 878 | relaypool.close(); 879 | const relaypool2 = new RelayPool(relayurls, { 880 | subscriptionCache: true, 881 | useEventCache: true, 882 | }); 883 | await expect( 884 | new Promise((resolve) => { 885 | relaypool2.onauth(() => resolve(true)); 886 | }), 887 | ).resolves.toBe(true); 888 | await relaypool2.close(); 889 | }); 890 | 891 | test("dontSendOtherFilters", async () => { 892 | const event = createSignedEvent(); 893 | relaypool.publish(event, relayurls); 894 | 895 | const never = new Promise((resolve) => { 896 | relaypool.subscribe( 897 | filtersByKind(event), 898 | relayurls, 899 | (_event) => { 900 | resolve(true); 901 | }, 902 | Infinity, 903 | undefined, 904 | {unsubscribeOnEose: true}, 905 | ); 906 | }); 907 | 908 | await new Promise((resolve) => { 909 | relaypool.subscribe( 910 | filtersByKind(event), 911 | relayurls, 912 | (_event) => { 913 | resolve(true); 914 | }, 915 | undefined, 916 | undefined, 917 | {unsubscribeOnEose: false, dontSendOtherFilters: true}, 918 | ); 919 | }); 920 | 921 | const neverResult = await Promise.race([ 922 | never, 923 | sleepms(10).then(() => false), 924 | ]); 925 | expect(neverResult).toEqual(false); 926 | }); 927 | 928 | // Test SubscriptionFilterStateCache 929 | test("SubscriptionFilterStateCache", async () => { 930 | const event = createSignedEvent(); 931 | relaypool.publish(event, relayurls); 932 | expect(_relayServer.totalSubscriptions).toEqual(0); 933 | 934 | await new Promise((resolve) => { 935 | relaypool.subscribe(filtersByKind(event), relayurls, (event) => { 936 | // console.log("event", event); 937 | resolve(true); 938 | }); 939 | }); 940 | let subscriptionFilterStateCache = new SubscriptionFilterStateCache(); 941 | let filter = filtersByKind(event)[0]; 942 | await new Promise((resolve) => { 943 | relaypool.subscribe( 944 | [filter], 945 | relayurls, 946 | (event) => { 947 | // console.log("event", event); 948 | }, 949 | undefined, 950 | (url, minCreatedAt) => { 951 | // console.log("onEose", url, minCreatedAt); 952 | resolve(true); 953 | }, 954 | {unsubscribeOnEose: true, subscriptionFilterStateCache}, 955 | ); 956 | }); 957 | let filterInfoForFilter = subscriptionFilterStateCache.filterInfo.get( 958 | JSON.stringify(filter), 959 | ); 960 | expect(filterInfoForFilter).toBeTruthy(); 961 | let filterInfoForFilterAndHost = filterInfoForFilter?.get(relayurls[0]); 962 | expect(filterInfoForFilterAndHost).toBeTruthy(); 963 | expect(Math.round(filterInfoForFilterAndHost![0] / 10)).toEqual( 964 | Math.round(event.created_at / 10), 965 | ); 966 | expect(filterInfoForFilterAndHost?.[1]).toEqual(event.created_at); 967 | }); 968 | 969 | // Test limit 970 | test("limit", async () => { 971 | const event1 = createSignedEvent(10, "event1", 1000); 972 | relaypool.publish(event1, relayurls); 973 | const event2 = createSignedEvent(10, "event2", 2000); 974 | relaypool.publish(event2, relayurls); 975 | 976 | expect(_relayServer.totalSubscriptions).toEqual(0); 977 | while (_relayServer.events.length < 2) { 978 | await sleepms(3); 979 | } 980 | 981 | let events = 0; 982 | let filters = filtersByKind(event1).map((filter) => { 983 | return {...filter, limit: 1}; 984 | }); 985 | console.log({filters}); 986 | await new Promise((resolve) => { 987 | relaypool.subscribe( 988 | filtersByKind(event1).map((filter) => { 989 | return {...filter, limit: 1}; 990 | }), 991 | relayurls, 992 | (event) => { 993 | console.log("event", event); 994 | expect(event).toHaveProperty("content", "event2"); 995 | events++; 996 | }, 997 | undefined, 998 | (url, minCreatedAt) => { 999 | expect(minCreatedAt).toBe(event2.created_at); 1000 | expect(events).toBe(1); 1001 | resolve(true); 1002 | }, 1003 | ); 1004 | }); 1005 | }); 1006 | 1007 | // Test _continue 1008 | test("_continue", async () => { 1009 | const event1 = createSignedEvent(10, "event1", 1000); 1010 | relaypool.publish(event1, relayurls); 1011 | const event2 = createSignedEvent(10, "event2", 2000); 1012 | relaypool.publish(event2, relayurls); 1013 | 1014 | expect(_relayServer.totalSubscriptions).toEqual(0); 1015 | while (_relayServer.events.length < 2) { 1016 | await sleepms(3); 1017 | } 1018 | 1019 | // Test _continue by using limit 1 in filter 1020 | let expectedContent = event2.content; 1021 | let events = 0; 1022 | await new Promise((resolve) => { 1023 | relaypool.subscribe( 1024 | filtersByKind(event1).map((filter) => { 1025 | return {...filter, limit: 1}; 1026 | }), 1027 | relayurls, 1028 | (event) => { 1029 | expect(event).toHaveProperty("content", expectedContent); 1030 | events++; 1031 | }, 1032 | undefined, 1033 | (url, minCreatedAt, _continue) => { 1034 | // console.log("onEose", url, minCreatedAt); 1035 | expect(minCreatedAt).toBe(event2.created_at); 1036 | expect(events).toBe(1); 1037 | expectedContent = event1.content; 1038 | _continue!((relayUrl, minCreatedAt) => { 1039 | expect(minCreatedAt).toBe(event1.created_at); 1040 | expect(events).toBe(2); 1041 | resolve(true); 1042 | }); 1043 | }, 1044 | ); 1045 | }); 1046 | }); 1047 | -------------------------------------------------------------------------------- /relay-pool.ts: -------------------------------------------------------------------------------- 1 | import type {Filter, Event} from "nostr-tools"; 2 | import {mergeSimilarAndRemoveEmptyFilters} from "./merge-similar-filters"; 3 | import {type Relay, relayInit, type Sub} from "./relay"; 4 | import type {OnEventObject, OnEvent} from "./on-event-filters"; 5 | import {EventCache} from "./event-cache"; 6 | import {EventObject} from "./event"; 7 | import { 8 | batchFiltersByRelay, 9 | groupFiltersByRelayAndEmitCacheHits, 10 | } from "./group-filters-by-relay"; 11 | import type {CallbackReplayer} from "./callback-replayer"; 12 | import {NewestEventCache} from "./newest-event-cache"; 13 | import {SubscriptionFilterStateCache} from "./subscription-filter-state-cache"; 14 | 15 | const unique = (arr: string[]) => [...new Set(arr)]; 16 | 17 | export {type OnEvent, type OnEventObject} from "./on-event-filters"; 18 | export type OnEose = (relayUrl: string, minCreatedAt: number, 19 | _continue?: (onEose: OnEose)=>void 20 | ) => void; 21 | export type OnEventAndMetadata = (event: Event, metadata: Event) => void; 22 | export type FilterToSubscribe = [ 23 | onEvent: OnEvent, 24 | filtersByRelay: Map, 25 | unsub: {unsubcb?: () => void}, 26 | unsubscribeOnEose?: boolean, 27 | subscriptionCacheKey?: string, 28 | maxDelayms?: number, 29 | ]; 30 | 31 | export type SubscriptionOptions = { 32 | allowDuplicateEvents?: boolean; 33 | allowOlderEvents?: boolean; 34 | logAllEvents?: boolean; 35 | unsubscribeOnEose?: boolean; 36 | defaultRelays?: string[]; 37 | dontSendOtherFilters?: boolean; 38 | subscriptionFilterStateCache?: SubscriptionFilterStateCache; 39 | }; 40 | 41 | function parseJSON(json: string | undefined) { 42 | if (json) { 43 | return JSON.parse(json); 44 | } 45 | } 46 | 47 | function registerSubscriptionFilterStateCache( 48 | filters: (Filter & { 49 | relay?: string | undefined; 50 | noCache?: boolean | undefined; 51 | })[], 52 | relays: string[], 53 | SubscriptionFilterStateCache: SubscriptionFilterStateCache, 54 | onEose: OnEose | undefined, 55 | ) : OnEose | undefined { 56 | let start = Math.round(new Date().getTime() / 1000) 57 | 58 | for (const filter of filters) { 59 | let strippedFilter = {...filter, relay: undefined, noCache: undefined}; 60 | SubscriptionFilterStateCache.addFilter(strippedFilter); 61 | } 62 | if (onEose) { 63 | return (relay, minCreatedAt, _continue) => { 64 | for (const filter of filters) { 65 | let strippedFilter = {...filter, relay: undefined, noCache: undefined}; 66 | if (filter.relay) { 67 | SubscriptionFilterStateCache.updateFilter( 68 | strippedFilter, 69 | filter.until ? filter.until : start, 70 | minCreatedAt, 71 | filter.relay, 72 | ); 73 | } else { 74 | for (const relay of relays) { 75 | SubscriptionFilterStateCache.updateFilter( 76 | strippedFilter, 77 | filter.until ? filter.until : start, 78 | minCreatedAt, 79 | relay, 80 | ); 81 | } 82 | } 83 | } 84 | onEose(relay, minCreatedAt, _continue); 85 | } 86 | } else { 87 | return undefined 88 | } 89 | } 90 | 91 | export class RelayPool { 92 | relayByUrl: Map = new Map(); 93 | noticecbs: Array<(url: string, msg: string) => void> = []; 94 | errorcbs: Array<(url: string, err: string) => void> = []; 95 | authcbs: Array<(relay: Relay, challenge: string) => void> = []; 96 | eventCache?: EventCache; 97 | minMaxDelayms: number = Infinity; 98 | filtersToSubscribe: FilterToSubscribe[] = []; 99 | timer?: ReturnType; 100 | externalGetEventById?: (id: string) => Event | undefined; 101 | logSubscriptions?: boolean = false; 102 | autoReconnect?: boolean = false; 103 | startTime: number = new Date().getTime(); 104 | deleteSignatures?: boolean; 105 | subscriptionCache?: Map< 106 | string, 107 | CallbackReplayer<[Event, boolean, string | undefined], OnEvent> 108 | >; 109 | skipVerification?: boolean; 110 | writeRelays: NewestEventCache; 111 | metadataCache: NewestEventCache; 112 | contactListCache: NewestEventCache; 113 | logErrorsAndNotices?: boolean; 114 | errorsAndNoticesInterval: any; 115 | 116 | constructor( 117 | relays?: string[], 118 | options: { 119 | useEventCache?: boolean; 120 | externalGetEventById?: (id: string) => Event | undefined; 121 | logSubscriptions?: boolean; 122 | autoReconnect?: boolean; 123 | subscriptionCache?: boolean; 124 | deleteSignatures?: boolean; 125 | skipVerification?: boolean; 126 | logErrorsAndNotices?: boolean; 127 | } = {}, 128 | ) { 129 | this.externalGetEventById = options.externalGetEventById; 130 | this.logSubscriptions = options.logSubscriptions; 131 | this.autoReconnect = options.autoReconnect; 132 | this.deleteSignatures = options.deleteSignatures; 133 | this.skipVerification = options.skipVerification; 134 | this.writeRelays = new NewestEventCache(10003, this, undefined, true); 135 | this.metadataCache = new NewestEventCache(0, this); 136 | this.contactListCache = new NewestEventCache(3, this); 137 | if (options.useEventCache) { 138 | this.eventCache = new EventCache(); 139 | } 140 | if (options.subscriptionCache) { 141 | this.subscriptionCache = new Map(); 142 | } 143 | if (relays) { 144 | for (const relay of unique(relays)) { 145 | this.addOrGetRelay(relay); 146 | } 147 | } 148 | 149 | this.logErrorsAndNotices = options.logErrorsAndNotices ?? true; 150 | 151 | this.onnotice((url, msg) => { 152 | this.errorsAndNotices.push({ 153 | type: "notice", 154 | url, 155 | msg, 156 | time: Date.now() - this.startTime, 157 | }); 158 | }); 159 | this.onerror((url, msg) => { 160 | this.errorsAndNotices.push({ 161 | type: "error", 162 | url, 163 | msg, 164 | time: Date.now() - this.startTime, 165 | }); 166 | }); 167 | this.errorsAndNoticesInterval = setInterval( 168 | () => this.#maybeLogErrorsAndNotices(), 169 | 1000 * 10, 170 | ); 171 | } 172 | errorsAndNotices: { 173 | type: string; 174 | url: string; 175 | msg: string | Error; 176 | time: number; 177 | }[] = []; 178 | 179 | #maybeLogErrorsAndNotices() { 180 | if (!this.errorsAndNotices.length) { 181 | return; 182 | } 183 | if (!this.logErrorsAndNotices) { 184 | this.errorsAndNotices = []; 185 | return; 186 | } 187 | if (this.errorsAndNotices.length > 5) { 188 | console.groupCollapsed( 189 | "RelayPool errors and notices with " + 190 | this.errorsAndNotices.length + 191 | " entries", 192 | ); 193 | } else { 194 | console.group("RelayPool errors and notices"); 195 | } 196 | console.table(this.errorsAndNotices.map((e) => ({...e, msg: e.msg}))); 197 | console.groupEnd(); 198 | this.errorsAndNotices = []; 199 | } 200 | 201 | addOrGetRelay(relay: string): Relay { 202 | const origRelayInstance = this.relayByUrl.get(relay); 203 | if (origRelayInstance) { 204 | return origRelayInstance; 205 | } 206 | const relayInstance = relayInit( 207 | relay, 208 | this.externalGetEventById 209 | ? this.externalGetEventById 210 | : this.eventCache 211 | ? (id) => this.eventCache?.getEventById(id) 212 | : undefined, 213 | this.autoReconnect, 214 | ); 215 | this.relayByUrl.set(relay, relayInstance); 216 | relayInstance.connect().then( 217 | (onfulfilled) => { 218 | relayInstance?.on("notice", (msg: string) => { 219 | this.noticecbs.forEach((cb) => cb(relay, msg)); 220 | }); 221 | relayInstance?.on("auth", (msg: string) => { 222 | this.authcbs.forEach((cb) => cb(relayInstance, msg)); 223 | }); 224 | }, 225 | (onrejected) => { 226 | this.errorcbs.forEach((cb) => cb(relay, onrejected)); 227 | }, 228 | ); 229 | return relayInstance; 230 | } 231 | 232 | async close() { 233 | const promises = []; 234 | for (const relayInstance of this.relayByUrl.values()) { 235 | promises.push(relayInstance.close()); 236 | } 237 | this.relayByUrl.clear(); 238 | clearInterval(this.errorsAndNoticesInterval); 239 | return Promise.all(promises); 240 | } 241 | 242 | removeRelay(url: string) { 243 | const relay = this.relayByUrl.get(url); 244 | if (relay) { 245 | relay.close(); 246 | this.relayByUrl.delete(url); 247 | } 248 | } 249 | 250 | #subscribeRelay( 251 | relay: string, 252 | filters: Filter[], 253 | onEvent: OnEvent, 254 | onEose?: OnEose, 255 | eventIds?: Set, 256 | ): Sub | undefined { 257 | const mergedAndRemovedEmptyFilters = 258 | mergeSimilarAndRemoveEmptyFilters(filters); 259 | if (mergedAndRemovedEmptyFilters.length === 0) { 260 | return; 261 | } 262 | const instance = this.addOrGetRelay(relay); 263 | const sub = instance.sub(mergedAndRemovedEmptyFilters, { 264 | skipVerification: this.skipVerification, 265 | eventIds, 266 | }); 267 | let afterEose = false; 268 | let minCreatedAt = Infinity; 269 | sub.on("event", (nostrEvent: Event) => { 270 | if (nostrEvent.created_at < minCreatedAt) { 271 | minCreatedAt = nostrEvent.created_at; 272 | } 273 | let event = nostrEvent; 274 | if (!this.deleteSignatures) { 275 | event.sig = nostrEvent.sig; 276 | } 277 | this.eventCache?.addEvent(event); 278 | onEvent(event, afterEose, relay); 279 | }); 280 | sub.on("eose", () => { 281 | onEose?.(relay, minCreatedAt); 282 | afterEose = true; 283 | }); 284 | 285 | return sub; 286 | } 287 | 288 | #mergeAndRemoveEmptyFiltersByRelay( 289 | filtersByRelay: Map, 290 | ): Map { 291 | const mergedAndRemovedEmptyFiltersByRelay = new Map(); 292 | for (const [relay, filters] of filtersByRelay) { 293 | const mergedAndRemovedEmptyFilters = mergeSimilarAndRemoveEmptyFilters( 294 | mergeSimilarAndRemoveEmptyFilters(filters), 295 | ); 296 | if (mergedAndRemovedEmptyFilters.length > 0) { 297 | mergedAndRemovedEmptyFiltersByRelay.set( 298 | relay, 299 | mergedAndRemovedEmptyFilters, 300 | ); 301 | } 302 | } 303 | return mergedAndRemovedEmptyFiltersByRelay; 304 | } 305 | 306 | #subscribeRelays( 307 | filtersByRelay: Map, 308 | onEvent: OnEvent, 309 | onEose?: OnEose, 310 | unsub: {unsubcb?: () => void; unsuboneosecb?: () => void} = {}, 311 | minMaxDelayms?: number, 312 | ): () => void { 313 | if (filtersByRelay.size === 0) { 314 | return () => {}; 315 | } 316 | // Merging here is done to make logging more readable. 317 | filtersByRelay = this.#mergeAndRemoveEmptyFiltersByRelay(filtersByRelay); 318 | if (this.logSubscriptions) { 319 | // console.log( 320 | // "RelayPool subscribeRelays: at time", 321 | // new Date().getTime() - this.startTime, 322 | // "ms, minMaxDelayms=", 323 | // minMaxDelayms, 324 | // ", filtersByRelay: ", 325 | // filtersByRelay 326 | // ); 327 | console.group("RelayPool subscribeRelays"); 328 | console.log( 329 | "at time", 330 | new Date().getTime() - this.startTime, 331 | "ms, minMaxDelayms=", 332 | minMaxDelayms, 333 | ); 334 | const flattenedFilters: any = {}; 335 | for (const [relay, filters] of filtersByRelay) { 336 | let i = 0; 337 | for (const filter of filters) { 338 | const filter2: any = {...filter}; 339 | if (filter2.authors) { 340 | filter2.authors = filter2.authors.join(); 341 | filter2["authors.length"] = filter?.authors?.length; 342 | } 343 | if (filter2.kinds) { 344 | filter2.kinds = filter2.kinds.join(); 345 | } 346 | if (filter2.ids) { 347 | filter2.ids = filter2.ids.join(); 348 | filter2["ids.length"] = filter?.ids?.length; 349 | } 350 | if (filter2["#e"]) { 351 | filter2["#e"] = filter2["#e"].join(); 352 | filter2["#e.length"] = filter["#e"]!.length; 353 | } 354 | if (filter2["#p"]) { 355 | filter2["#p"] = filter2["#p"].join(); 356 | filter2["#p.length"] = filter["#p"]!.length; 357 | } 358 | flattenedFilters[relay + " " + i] = filter2; 359 | i++; 360 | } 361 | } 362 | 363 | if (Object.keys(flattenedFilters).length > 3) { 364 | console.groupCollapsed( 365 | Object.keys(flattenedFilters).length + 366 | " filters to " + 367 | filtersByRelay.size + 368 | " relays", 369 | ); 370 | } 371 | console.table(flattenedFilters); 372 | if (Object.keys(flattenedFilters).length > 3) { 373 | console.groupEnd(); 374 | } 375 | console.groupEnd(); 376 | } 377 | const subs: Sub[] = []; 378 | let unsuboneosecbcalled = false; 379 | let eoseSubs: Sub[] = []; 380 | unsub.unsuboneosecb = () => { 381 | unsuboneosecbcalled = true; 382 | eoseSubs.forEach((sub) => sub.unsub()); 383 | }; 384 | for (const [relay, filters] of filtersByRelay) { 385 | let subHolder: {sub?: Sub} = {}; 386 | const subOnEose: OnEose = (url, minCreatedAt, _continue) => { 387 | if (onEose) { 388 | onEose(url, minCreatedAt, _continue); 389 | } 390 | if (unsuboneosecbcalled) { 391 | subHolder.sub?.unsub(); 392 | } else { 393 | if (subHolder.sub) { 394 | eoseSubs.push(subHolder.sub); 395 | } 396 | } 397 | }; 398 | 399 | const eventIds = new Set(); 400 | 401 | const sub = this.#subscribeRelay( 402 | relay, 403 | filters, 404 | onEvent, 405 | subOnEose, 406 | eventIds, 407 | ); 408 | if (sub) { 409 | subHolder.sub = sub; 410 | subs.push(sub); 411 | } 412 | } 413 | const allUnsub = () => subs.forEach((sub) => sub.unsub()); 414 | unsub.unsubcb = () => { 415 | allUnsub(); 416 | delete unsub.unsubcb; 417 | }; 418 | return allUnsub; 419 | } 420 | 421 | sendSubscriptions(onEose?: OnEose, filtersToSubscribe?: FilterToSubscribe[]) { 422 | clearTimeout(this.timer); 423 | this.timer = undefined; 424 | let minMaxDelayms = this.minMaxDelayms; 425 | this.minMaxDelayms = Infinity; 426 | 427 | const [onEvent, filtersByRelay, unsub]: [ 428 | OnEvent, 429 | Map, 430 | {unsubcb?: () => void; unsuboneosecb?: () => void}, 431 | ] = batchFiltersByRelay( 432 | filtersToSubscribe || this.filtersToSubscribe, 433 | this.subscriptionCache, 434 | ); 435 | 436 | let allUnsub = this.#subscribeRelays( 437 | filtersByRelay, 438 | onEvent, 439 | onEose, 440 | unsub, 441 | minMaxDelayms, // For logging 442 | ); 443 | 444 | return allUnsub; 445 | } 446 | 447 | #resetTimer(maxDelayms: number) { 448 | if (this.minMaxDelayms > maxDelayms) { 449 | this.minMaxDelayms = maxDelayms; 450 | } 451 | 452 | clearTimeout(this.timer); 453 | this.timer = undefined; 454 | 455 | if (this.minMaxDelayms !== Infinity) { 456 | this.timer = setTimeout(() => { 457 | this.sendSubscriptions(); 458 | }, this.minMaxDelayms); 459 | } 460 | } 461 | 462 | async #getRelaysAndSubscribe( 463 | filters: (Filter & {relay?: string; noCache?: boolean})[], 464 | onEvent: OnEvent, 465 | maxDelayms?: number, 466 | onEose?: OnEose, 467 | options: SubscriptionOptions = {}, 468 | ) { 469 | const allAuthors: Set = new Set(); 470 | for (const filter of filters) { 471 | if (filter.authors) { 472 | for (const author of filter.authors) { 473 | allAuthors.add(author); 474 | } 475 | } else { 476 | if (!options.defaultRelays) { 477 | throw new Error( 478 | "Authors must be specified if no relays are subscribed and no default relays are specified.", 479 | ); 480 | } 481 | } 482 | } 483 | const promises = []; 484 | const allAuthorsArray = []; 485 | for (const author of allAuthors) { 486 | promises.push( 487 | this.writeRelays 488 | ?.get(author) 489 | .then((event) => parseJSON(event?.content)) 490 | .catch(() => options?.defaultRelays || []), 491 | ); 492 | allAuthorsArray.push(author); 493 | } 494 | const allRelays: Set = new Set(); 495 | let i = 0; 496 | for (const promise of promises) { 497 | const author = allAuthorsArray[i]; 498 | i += 1; 499 | let relays = await promise; 500 | if (!Array.isArray(relays)) { 501 | console.error("Couldn't load relays for author ", author); 502 | continue; 503 | } 504 | for (let relay of relays) { 505 | allRelays.add(relay); 506 | } 507 | } 508 | let allRelaysArray = Array.from(allRelays); 509 | if (allRelaysArray.length === 0) { 510 | if (options.defaultRelays) { 511 | allRelaysArray = options.defaultRelays; 512 | } 513 | } 514 | // if (this.logSubscriptions) { 515 | // console.log( 516 | // "getRelaysAndSubscribe", 517 | // "filters=", 518 | // filters, 519 | // "allRelaysArray=", 520 | // allRelaysArray, 521 | // "maxDelayms=", 522 | // maxDelayms, 523 | // "options=", 524 | // options 525 | // ); 526 | // } 527 | 528 | return this.subscribe( 529 | filters, 530 | allRelaysArray, 531 | onEvent, 532 | maxDelayms, 533 | onEose, 534 | options, 535 | ); 536 | } 537 | 538 | subscribeEventObject( 539 | filters: (Filter & {relay?: string; noCache?: boolean})[], 540 | relays: string[] | undefined, 541 | onEventObject: OnEventObject, 542 | maxDelayms?: number, 543 | onEose?: OnEose, 544 | options: SubscriptionOptions = {}, 545 | ): () => void { 546 | return this.subscribe(filters, relays, (event, afterEose, url) => 547 | onEventObject(new EventObject(event, this, relays), afterEose, url), 548 | ); 549 | } 550 | 551 | subscribe( 552 | filters: (Filter & {relay?: string; noCache?: boolean})[], 553 | relays: string[] | undefined, 554 | onEvent: OnEvent, 555 | maxDelayms?: number, 556 | onEose?: OnEose, 557 | options: SubscriptionOptions = {}, 558 | ): () => void { 559 | if (maxDelayms !== undefined && onEose) { 560 | throw new Error("maxDelayms and onEose cannot be used together"); 561 | } 562 | if (relays === undefined) { 563 | const promise = this.#getRelaysAndSubscribe( 564 | filters, 565 | onEvent, 566 | maxDelayms, 567 | onEose, 568 | options, 569 | ); 570 | return () => { 571 | promise.then((x) => { 572 | x(); 573 | }); 574 | }; 575 | } 576 | let subscriptionCacheKey: string | undefined; 577 | if (options.unsubscribeOnEose && !onEose) { 578 | subscriptionCacheKey = JSON.stringify([filters, relays]); 579 | const cachedSubscription = 580 | this.subscriptionCache?.get(subscriptionCacheKey); 581 | if (cachedSubscription) { 582 | return cachedSubscription.sub(onEvent); 583 | } 584 | } 585 | // continue_ 586 | if (onEose) { 587 | let oldOnEose = onEose!; 588 | onEose = (relayUrl: string, minCreatedAt: number) => { 589 | oldOnEose(relayUrl, minCreatedAt, (onEose: OnEose) => { 590 | this.subscribe( 591 | filters.map( 592 | (filter) => 593 | ({ 594 | ...filter, 595 | until: minCreatedAt - 1, 596 | } as Filter), 597 | ), 598 | [relayUrl], 599 | onEvent, 600 | maxDelayms, 601 | onEose, 602 | options, 603 | ); 604 | }); 605 | } 606 | } 607 | // Register SubscriptionFilterStateCache 608 | if (options.subscriptionFilterStateCache) { 609 | onEose = registerSubscriptionFilterStateCache( 610 | filters, 611 | relays, 612 | options.subscriptionFilterStateCache, 613 | onEose, 614 | ); 615 | } 616 | 617 | const [dedupedOnEvent, filtersByRelay] = 618 | groupFiltersByRelayAndEmitCacheHits( 619 | filters, 620 | relays, 621 | onEvent, 622 | options, 623 | this.eventCache, 624 | ); 625 | let unsub: {unsubcb?: () => void} = {unsubcb: () => {}}; 626 | if ( 627 | maxDelayms === undefined && 628 | onEose && 629 | this.filtersToSubscribe.length > 0 && 630 | !options.dontSendOtherFilters 631 | ) { 632 | this.sendSubscriptions(); // onEose is not yet supported for batched subscriptions 633 | } 634 | const newFilters: FilterToSubscribe = [ 635 | dedupedOnEvent, 636 | filtersByRelay, 637 | unsub, 638 | options.unsubscribeOnEose, 639 | subscriptionCacheKey, 640 | maxDelayms, 641 | ]; 642 | if (options.dontSendOtherFilters) { 643 | return this.sendSubscriptions(onEose, [newFilters]); 644 | } 645 | 646 | this.filtersToSubscribe.push(newFilters); 647 | if (maxDelayms === undefined) { 648 | return this.sendSubscriptions(onEose); 649 | } else { 650 | this.#resetTimer(maxDelayms); 651 | return () => { 652 | unsub.unsubcb?.(); 653 | delete unsub.unsubcb; 654 | }; 655 | } 656 | } 657 | 658 | async getEventObjectById( 659 | id: string, 660 | relays: string[], 661 | maxDelayms: number, 662 | ): Promise { 663 | return this.getEventById(id, relays, maxDelayms).then( 664 | (event) => new EventObject(event, this, relays), 665 | ); 666 | } 667 | 668 | async getEventById( 669 | id: string, 670 | relays: string[], 671 | maxDelayms: number, 672 | ): Promise { 673 | return new Promise((resolve, reject) => { 674 | this.subscribe( 675 | [{ids: [id]}], 676 | relays, 677 | (event) => { 678 | resolve(event); 679 | }, 680 | maxDelayms, 681 | undefined, 682 | // {unsubscribeOnEose: true} 683 | ); 684 | }); 685 | } 686 | 687 | publish(event: Event, relays: string[]) { 688 | for (const relay of unique(relays)) { 689 | const instance = this.addOrGetRelay(relay); 690 | instance.publish(event); 691 | } 692 | } 693 | 694 | onnotice(cb: (url: string, msg: string) => void) { 695 | this.noticecbs.push(cb); 696 | } 697 | 698 | onerror(cb: (url: string, msg: string) => void) { 699 | this.relayByUrl.forEach((relay: Relay, url: string) => 700 | relay.on("error", (msg: string) => cb(url, msg)), 701 | ); 702 | this.errorcbs.push(cb); 703 | } 704 | ondisconnect(cb: (url: string, msg: string) => void) { 705 | this.relayByUrl.forEach((relay: Relay, url: string) => 706 | relay.on("disconnect", (msg: string) => cb(url, msg)), 707 | ); 708 | } 709 | onauth(cb: (relay: Relay, challenge: string) => void) { 710 | this.authcbs.push(cb); 711 | } 712 | getRelayStatuses(): [url: string, staus: number][] { 713 | return Array.from(this.relayByUrl.entries()) 714 | .map( 715 | ([url, relay]: [string, Relay]) => 716 | [url, relay.status] as [string, number], 717 | ) 718 | .sort(); 719 | } 720 | setWriteRelaysForPubKey( 721 | pubkey: string, 722 | writeRelays: string[], 723 | created_at: number, 724 | ) { 725 | const event: Event = { 726 | created_at, 727 | pubkey: "", 728 | id: "", 729 | sig: "", 730 | content: JSON.stringify(writeRelays), 731 | // @ts-ignore 732 | kind: 10003, 733 | tags: [["p", pubkey]], 734 | }; 735 | this.writeRelays.data.set(pubkey, event); 736 | } 737 | setCachedMetadata(pubkey: string, metadata: Event) { 738 | this.metadataCache.data.set(pubkey, metadata); 739 | } 740 | setCachedContactList(pubkey: string, contactList: Event) { 741 | this.contactListCache.data.set(pubkey, contactList); 742 | } 743 | 744 | subscribeReferencedEvents( 745 | event: Event, 746 | onEvent: OnEvent, 747 | maxDelayms?: number, 748 | onEose?: OnEose, 749 | options: SubscriptionOptions = {}, 750 | ): () => void { 751 | let ids: string[] = []; 752 | let authors: string[] = []; 753 | 754 | for (const tag of event.tags) { 755 | if (tag[0] === "p") { 756 | const pubkey = tag[1]; 757 | if (pubkey.length !== 64) { 758 | console.log("bad pubkey", pubkey, tag); 759 | continue; 760 | } 761 | authors.push(pubkey); 762 | } 763 | if (tag[0] === "e") { 764 | const id = tag[1]; 765 | ids.push(id); 766 | } 767 | } 768 | if (ids.length === 0) { 769 | return () => {}; 770 | } 771 | if (authors.length === 0) { 772 | if (options.defaultRelays) { 773 | return this.subscribe( 774 | [{ids}], 775 | options.defaultRelays, 776 | onEvent, 777 | maxDelayms, 778 | onEose, 779 | options, 780 | ); 781 | } else { 782 | console.error("No authors for ids in event", event); 783 | return () => {}; 784 | } 785 | } 786 | if (this.logSubscriptions) { 787 | // console.log("subscribeReferencedEvents0: ", ids, authors); 788 | } 789 | return this.subscribe( 790 | [{ids, authors}], 791 | undefined, 792 | onEvent, 793 | maxDelayms, 794 | onEose, 795 | options, 796 | ); 797 | } 798 | 799 | fetchAndCacheMetadata(pubkey: string): Promise { 800 | return this.metadataCache.get(pubkey).catch((e) => { 801 | this.errorsAndNotices.push({ 802 | type: "error", 803 | msg: `Error fetching metadata for ${pubkey}: ${e}`, 804 | time: Date.now() - this.startTime, 805 | url: "", 806 | }); 807 | 808 | throw new Error(`Error fetching metadata for ${pubkey}: ${e}`); 809 | }); 810 | } 811 | 812 | fetchAndCacheContactList(pubkey: string): Promise { 813 | return this.contactListCache.get(pubkey).catch((e) => { 814 | this.errorsAndNotices.push({ 815 | type: "error", 816 | msg: `Error fetching contact list for ${pubkey}: ${e}`, 817 | time: Date.now() - this.startTime, 818 | url: "", 819 | }); 820 | throw new Error(`Error fetching contact list for ${pubkey}: ${e}`); 821 | }); 822 | } 823 | 824 | subscribeReferencedEventsAndPrefetchMetadata( 825 | event: Event, 826 | onEvent: OnEvent, 827 | maxDelayms?: number, 828 | onEose?: OnEose, 829 | options: SubscriptionOptions = {}, 830 | ): () => void { 831 | for (const tag of event.tags) { 832 | if (tag[0] === "p") { 833 | const pubkey = tag[1]; 834 | if (pubkey.length !== 64) { 835 | console.log("bad pubkey", pubkey, tag); 836 | continue; 837 | } 838 | this.fetchAndCacheMetadata(pubkey).catch((e) => { 839 | this.errorsAndNotices.push({ 840 | type: "error", 841 | url: "", 842 | msg: `Error fetching metadata for ${pubkey}: ${e}`, 843 | time: Date.now() - this.startTime, 844 | }); 845 | }); 846 | } 847 | } 848 | return this.subscribeReferencedEvents( 849 | event, 850 | onEvent, 851 | maxDelayms, 852 | onEose, 853 | options, 854 | ); 855 | } 856 | 857 | reconnect() { 858 | this.relayByUrl.forEach((relay: Relay) => { 859 | relay.connect().catch((e) => { 860 | this.errorsAndNotices.push({ 861 | type: "error", 862 | msg: `Error reconnecting to ${relay.url}: ${e}`, 863 | time: Date.now() - this.startTime, 864 | url: relay.url, 865 | }); 866 | }); 867 | }); 868 | } 869 | } 870 | -------------------------------------------------------------------------------- /relay-pool.worker.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamritter/nostr-relaypool-ts/9005e9469bee680a81008c31ae814e8d71c2c9bc/relay-pool.worker.js -------------------------------------------------------------------------------- /relay-pool.worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | // worker.ts 3 | import {RelayPool, type SubscriptionOptions} from "./relay-pool"; 4 | import type {Event} from "nostr-tools"; 5 | 6 | let relayPool: RelayPool; 7 | // eslint-disable-next-line no-spaced-func 8 | let subscriptions = new Map void>(); 9 | 10 | interface MessageData { 11 | action: string; 12 | data: any; 13 | } 14 | 15 | self.onmessage = (event: MessageEvent) => { 16 | const {action, data} = event.data; 17 | 18 | switch (action) { 19 | case "create_relay_pool": 20 | relayPool = new RelayPool(data.relays, data.options); 21 | 22 | // Set event listeners 23 | relayPool.onerror((relayUrl, err) => { 24 | postMessage({type: "error", err, relayUrl}); 25 | }); 26 | 27 | relayPool.onnotice((relayUrl, notice) => { 28 | postMessage({type: "notice", relayUrl, notice}); 29 | }); 30 | break; 31 | 32 | case "subscribe": 33 | const subscriptionId = data.subscriptionId; 34 | const unsub = relayPool.subscribe( 35 | data.filters, 36 | data.relays, 37 | (event: Event, isAfterEose: boolean, relayURL: string | undefined) => { 38 | postMessage({ 39 | type: "event", 40 | subscriptionId, 41 | event, 42 | isAfterEose, 43 | relayURL, 44 | }); 45 | }, 46 | data.maxDelayms, 47 | data.onEose 48 | ? (relayURL: string, minCreatedAt: number) => { 49 | postMessage({ 50 | type: "eose", 51 | subscriptionId, 52 | relayURL, 53 | minCreatedAt, 54 | }); 55 | } 56 | : undefined, 57 | data.options as SubscriptionOptions 58 | ); 59 | subscriptions.set(subscriptionId, unsub); 60 | postMessage({type: "subscribed", subscriptionId}); 61 | break; 62 | 63 | case "unsubscribe": 64 | const {subscriptionId: idToUnsubscribe} = data; 65 | const unsubscribe = subscriptions.get(idToUnsubscribe); 66 | if (unsubscribe) { 67 | unsubscribe(); 68 | subscriptions.delete(idToUnsubscribe); 69 | } 70 | break; 71 | 72 | case "publish": 73 | relayPool.publish(data.event, data.relays); 74 | break; 75 | 76 | case "set_write_relays_for_pub_key": 77 | relayPool.setWriteRelaysForPubKey( 78 | data.pubkey, 79 | data.writeRelays, 80 | data.created_at 81 | ); 82 | break; 83 | 84 | case "subscribe_referenced_events": 85 | const subRefId = data.subscriptionId; 86 | const unsubRef = relayPool.subscribeReferencedEvents( 87 | data.event, 88 | (event: Event, isAfterEose: boolean, relayURL: string | undefined) => { 89 | postMessage({ 90 | type: "event", 91 | subscriptionId: subRefId, 92 | event, 93 | isAfterEose, 94 | relayURL, 95 | }); 96 | }, 97 | data.maxDelayms, 98 | data.onEose 99 | ? (relayURL: string, minCreatedAt: number) => { 100 | postMessage({ 101 | type: "eose", 102 | subscriptionId: subRefId, 103 | relayURL, 104 | minCreatedAt, 105 | }); 106 | } 107 | : undefined, 108 | data.options as SubscriptionOptions 109 | ); 110 | subscriptions.set(subRefId, unsubRef); 111 | postMessage({type: "subscribed", subscriptionId: subRefId}); 112 | break; 113 | 114 | case "fetch_and_cache_metadata": 115 | relayPool.fetchAndCacheMetadata(data.pubkey).then((metadata: Event) => { 116 | postMessage({type: "metadata", pubkey: data.pubkey, metadata}); 117 | }); 118 | break; 119 | 120 | case "fetch_and_cache_contact_list": 121 | relayPool 122 | .fetchAndCacheContactList(data.pubkey) 123 | .then((contactList: Event) => { 124 | postMessage({type: "contactList", pubkey: data.pubkey, contactList}); 125 | }); 126 | break; 127 | 128 | case "subscribe_referenced_events_and_prefetch_metadata": 129 | const subPrefetchId = data.subscriptionId; 130 | const unsubPrefetch = 131 | relayPool.subscribeReferencedEventsAndPrefetchMetadata( 132 | data.event, 133 | ( 134 | event: Event, 135 | isAfterEose: boolean, 136 | relayURL: string | undefined 137 | ) => { 138 | postMessage({ 139 | type: "event", 140 | subscriptionId: subPrefetchId, 141 | event, 142 | isAfterEose, 143 | relayURL, 144 | }); 145 | }, 146 | data.maxDelayms, 147 | data.onEose 148 | ? (relayURL: string, minCreatedAt: number) => { 149 | postMessage({ 150 | type: "eose", 151 | subscriptionId: subPrefetchId, 152 | relayURL, 153 | minCreatedAt, 154 | }); 155 | } 156 | : undefined, 157 | data.options as SubscriptionOptions 158 | ); 159 | subscriptions.set(subPrefetchId, unsubPrefetch); 160 | postMessage({type: "subscribed", subscriptionId: subPrefetchId}); 161 | break; 162 | 163 | case "set_cached_metadata": 164 | relayPool.setCachedMetadata(data.pubkey, data.metadata); 165 | break; 166 | 167 | case "close": 168 | subscriptions.clear(); 169 | relayPool.close(); 170 | break; 171 | 172 | default: 173 | console.error("Unknown action:", action); 174 | } 175 | }; 176 | 177 | export {}; // This is necessary to make this module a "module" in TypeScript 178 | -------------------------------------------------------------------------------- /relay.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { 4 | generatePrivateKey, 5 | getEventHash, 6 | getPublicKey, 7 | type Event, 8 | getSignature, 9 | } from "nostr-tools"; 10 | 11 | import type {Relay} from "./relay"; 12 | 13 | import {relayInit} from "./relay"; 14 | import {InMemoryRelayServer} from "./in-memory-relay-server"; 15 | import WebSocket from "ws"; 16 | 17 | let relay: Relay; 18 | let _relayServer: InMemoryRelayServer; 19 | 20 | beforeAll(() => { 21 | _relayServer = new InMemoryRelayServer(8089); 22 | }); 23 | beforeEach(async () => { 24 | relay = relayInit("ws://localhost:8089/", undefined, true); 25 | relay.connect(); 26 | _relayServer.clear(); 27 | }); 28 | 29 | afterEach(async () => { 30 | await relay.close(); 31 | _relayServer.clear(); 32 | }); 33 | 34 | afterAll(async () => { 35 | await _relayServer.close(); 36 | }); 37 | 38 | test("connectivity", () => { 39 | return expect( 40 | new Promise((resolve) => { 41 | relay.on("connect", () => { 42 | resolve(true); 43 | }); 44 | relay.on("error", () => { 45 | resolve(false); 46 | }); 47 | }) 48 | ).resolves.toBe(true); 49 | }); 50 | 51 | async function publishAndGetEvent( 52 | options: {content?: string} = {} 53 | ): Promise { 54 | const sk = generatePrivateKey(); 55 | const pk = getPublicKey(sk); 56 | const unsignedEvent = { 57 | kind: 27572, 58 | pubkey: pk, 59 | created_at: Math.floor(Date.now() / 1000), 60 | tags: [], 61 | content: options.content || "nostr-tools test suite", 62 | }; 63 | const eventId = getEventHash(unsignedEvent); 64 | // const eventId = getEventHash(unsignedEvent); 65 | const event: Event = { 66 | sig: getSignature(unsignedEvent, sk), 67 | id: eventId, 68 | ...unsignedEvent, 69 | }; 70 | // console.log("publishing event", event); 71 | relay.publish(event); 72 | return new Promise((resolve) => 73 | relay 74 | // @ts-ignore 75 | .sub([{ids: [event.id]}]) 76 | .on("event", (event: Event) => { 77 | resolve(event); 78 | }) 79 | ); 80 | } 81 | 82 | test("querying", async () => { 83 | const event: Event & {id: string} = await publishAndGetEvent(); 84 | var resolve1: (success: boolean) => void; 85 | var resolve2: (success: boolean) => void; 86 | 87 | const promiseAll = Promise.all([ 88 | new Promise((resolve) => { 89 | resolve(true); 90 | resolve1 = resolve; 91 | }), 92 | new Promise((resolve) => { 93 | resolve2 = resolve; 94 | resolve(true); 95 | }), 96 | ]); 97 | 98 | const sub = relay.sub([ 99 | { 100 | kinds: [event.kind], 101 | }, 102 | ]); 103 | sub.on("event", (event: Event) => { 104 | expect(event).toHaveProperty("id", event.id); 105 | resolve1(true); 106 | }); 107 | sub.on("eose", () => { 108 | resolve2(true); 109 | }); 110 | 111 | return expect(promiseAll).resolves.toEqual([true, true]); 112 | }); 113 | 114 | test("listening (twice) and publishing", async () => { 115 | const sk = generatePrivateKey(); 116 | const pk = getPublicKey(sk); 117 | var resolve1: (success: boolean) => void; 118 | var resolve2: (success: boolean) => void; 119 | 120 | const sub = relay.sub([ 121 | { 122 | kinds: [27572], 123 | authors: [pk], 124 | }, 125 | ]); 126 | 127 | sub.on("event", (event: Event) => { 128 | expect(event).toHaveProperty("pubkey", pk); 129 | expect(event).toHaveProperty("kind", 27572); 130 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 131 | resolve1(true); 132 | }); 133 | sub.on("event", (event: Event) => { 134 | expect(event).toHaveProperty("pubkey", pk); 135 | expect(event).toHaveProperty("kind", 27572); 136 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 137 | resolve2(true); 138 | }); 139 | 140 | const event = { 141 | kind: 27572, 142 | pubkey: pk, 143 | created_at: Math.floor(Date.now() / 1000), 144 | tags: [], 145 | content: "nostr-tools test suite", 146 | }; 147 | // @ts-ignore 148 | event.id = getEventHash(event); 149 | // @ts-ignore 150 | event.sig = getSignature(event, sk); 151 | // @ts-ignore 152 | relay.publish(event); 153 | return expect( 154 | Promise.all([ 155 | new Promise((resolve) => { 156 | resolve1 = resolve; 157 | }), 158 | new Promise((resolve) => { 159 | resolve2 = resolve; 160 | }), 161 | ]) 162 | ).resolves.toEqual([true, true]); 163 | }); 164 | 165 | test("two subscriptions", async () => { 166 | const sk = generatePrivateKey(); 167 | const pk = getPublicKey(sk); 168 | 169 | const event = { 170 | kind: 27572, 171 | pubkey: pk, 172 | created_at: Math.floor(Date.now() / 1000), 173 | tags: [], 174 | content: "nostr-tools test suite", 175 | }; 176 | // @ts-ignore 177 | event.id = getEventHash(event); 178 | // @ts-ignore 179 | event.sig = getSignature(event, sk); 180 | 181 | await expect( 182 | new Promise((resolve) => { 183 | const sub = relay.sub([ 184 | { 185 | kinds: [27572], 186 | authors: [pk], 187 | }, 188 | ]); 189 | 190 | sub.on("event", (event: Event) => { 191 | expect(event).toHaveProperty("pubkey", pk); 192 | expect(event).toHaveProperty("kind", 27572); 193 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 194 | resolve(true); 195 | }); 196 | // @ts-ignore 197 | relay.publish(event); 198 | }) 199 | ).resolves.toEqual(true); 200 | 201 | await expect( 202 | new Promise((resolve) => { 203 | const sub = relay.sub([ 204 | { 205 | kinds: [27572], 206 | authors: [pk], 207 | }, 208 | ]); 209 | 210 | sub.on("event", (event: Event) => { 211 | expect(event).toHaveProperty("pubkey", pk); 212 | expect(event).toHaveProperty("kind", 27572); 213 | expect(event).toHaveProperty("content", "nostr-tools test suite"); 214 | resolve(true); 215 | }); 216 | // @ts-ignore 217 | relay.publish(event); 218 | }) 219 | ).resolves.toEqual(true); 220 | }); 221 | 222 | test("autoreconnect", async () => { 223 | expect(relay.status).toBe(WebSocket.CONNECTING); 224 | await publishAndGetEvent(); 225 | expect(relay.status).toBe(WebSocket.OPEN); 226 | _relayServer.disconnectAll(); 227 | await new Promise((resolve) => setTimeout(resolve, 0)); 228 | expect(relay.status).toBeGreaterThanOrEqual(WebSocket.CLOSING); 229 | await publishAndGetEvent(); 230 | }); 231 | 232 | // jest -t 'relay memory' --testTimeout 1000000 --logHeapUsage 233 | // PASS ./relay.test.ts (93.914 s, 480 MB heap size) 234 | test.skip("relay memory usage", async () => { 235 | // @ts-ignore 236 | relay.relay.logging = false; 237 | 238 | await publishAndGetEvent({content: "x".repeat(20 * 1024 * 1024)}); 239 | 240 | for (let i = 0; i < 300; i++) { 241 | await new Promise((resolve) => { 242 | const sub = relay.sub([{}]); 243 | sub.on("event", (event: Event) => { 244 | sub.unsub(); 245 | resolve(true); 246 | }); 247 | }); 248 | } 249 | }); 250 | -------------------------------------------------------------------------------- /relay.ts: -------------------------------------------------------------------------------- 1 | // allows sub/unsub and publishing before connection is established. 2 | // Much more refactoring is needed 3 | // Don't rely on Relay interface, it will change (I'll probably delete a lot of code from here, there's no need for 4 | // multiple listeners) 5 | 6 | import {type Event, verifySignature, validateEvent} from "nostr-tools"; 7 | import {type Filter, matchFilters} from "nostr-tools"; 8 | import WebSocket from "isomorphic-ws"; 9 | import {getHex64, getSubName} from "./fakejson"; 10 | 11 | type RelayEvent = "connect" | "disconnect" | "error" | "notice" | "auth"; 12 | 13 | export type Relay = { 14 | url: string; 15 | status: number; 16 | connect: () => Promise; 17 | close: () => Promise; 18 | sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub; 19 | publish: (event: Event) => Pub; 20 | auth: (event: Event) => Pub; 21 | on: (type: RelayEvent, cb: any) => void; 22 | off: (type: RelayEvent, cb: any) => void; 23 | }; 24 | export type Pub = { 25 | on: (type: "ok" | "seen" | "failed", cb: any) => void; 26 | off: (type: "ok" | "seen" | "failed", cb: any) => void; 27 | }; 28 | export type Sub = { 29 | sub: (filters: Filter[], opts: SubscriptionOptions) => Sub; 30 | unsub: () => void; 31 | on: (type: "event" | "eose", cb: any) => void; 32 | off: (type: "event" | "eose", cb: any) => void; 33 | }; 34 | 35 | type SubscriptionOptions = { 36 | skipVerification?: boolean; 37 | id?: string; 38 | eventIds?: Set; 39 | }; 40 | export function relayInit( 41 | url: string, 42 | alreadyHaveEvent?: (id: string) => (Event & {id: string}) | undefined, 43 | autoReconnect?: boolean 44 | ): Relay { 45 | return new RelayC(url, alreadyHaveEvent, autoReconnect).relayInit(); 46 | } 47 | class RelayC { 48 | url: string; 49 | alreadyHaveEvent?: (id: string) => (Event & {id: string}) | undefined; 50 | logging: boolean = false; 51 | constructor( 52 | url: string, 53 | alreadyHaveEvent?: (id: string) => (Event & {id: string}) | undefined, 54 | autoReconnect?: boolean 55 | ) { 56 | this.url = url; 57 | this.alreadyHaveEvent = alreadyHaveEvent; 58 | this.autoReconnect = autoReconnect; 59 | } 60 | autoReconnect?: boolean; 61 | ws: WebSocket | undefined; 62 | sendOnConnect: string[] = []; 63 | openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}; 64 | closedByClient: boolean = false; 65 | listeners: { 66 | connect: Array<() => void>; 67 | disconnect: Array<() => void>; 68 | error: Array<() => void>; 69 | notice: Array<(msg: string) => void>; 70 | auth: Array<(challenge: string) => void>; 71 | } = { 72 | connect: [], 73 | disconnect: [], 74 | error: [], 75 | notice: [], 76 | auth: [], 77 | }; 78 | subListeners: { 79 | [subid: string]: 80 | | { 81 | event: Array<(event: Event) => void>; 82 | eose: Array<() => void>; 83 | } 84 | | undefined; 85 | } = {}; 86 | pubListeners: { 87 | [eventid: string]: { 88 | ok: Array<() => void>; 89 | seen: Array<() => void>; 90 | failed: Array<(reason: string) => void>; 91 | }; 92 | } = {}; 93 | incomingMessageQueue: string[] = []; 94 | handleNextInterval: any; 95 | 96 | #handleNext() { 97 | if (this.incomingMessageQueue.length === 0) { 98 | clearInterval(this.handleNextInterval); 99 | this.handleNextInterval = null; 100 | return; 101 | } 102 | this.#handleMessage({data: this.incomingMessageQueue.shift()}); 103 | } 104 | 105 | async trySend(params: [string, ...any]) { 106 | const msg = JSON.stringify(params); 107 | 108 | if (this.connected) { 109 | this.ws?.send(msg); 110 | } else { 111 | this.sendOnConnect.push(msg); 112 | } 113 | } 114 | resolveClose: (() => void) | undefined = undefined; 115 | 116 | async #onclose() { 117 | if (this.closedByClient) { 118 | this.listeners.disconnect.forEach((cb) => cb()); 119 | this.resolveClose && this.resolveClose(); 120 | } else { 121 | if (this.autoReconnect) { 122 | this.#reconnect(); 123 | } 124 | } 125 | } 126 | reconnectTimeout: number = 0; 127 | #reconnect() { 128 | setTimeout(() => { 129 | this.reconnectTimeout = Math.max(2000, this.reconnectTimeout * 3); 130 | console.log( 131 | this.url, 132 | "reconnecting after " + this.reconnectTimeout / 1000 + "s" 133 | ); 134 | this.connect().catch(() => this.#reconnect()); 135 | }, this.reconnectTimeout); 136 | } 137 | 138 | async #onmessage(e: any) { 139 | this.incomingMessageQueue.push(e.data); 140 | if (!this.handleNextInterval) { 141 | this.handleNextInterval = setInterval(() => this.#handleNext(), 0); 142 | } 143 | } 144 | 145 | async #handleMessage(e: any) { 146 | let data; 147 | let json: string = e.data.toString(); 148 | if (!json) { 149 | return; 150 | } 151 | let eventId = getHex64(json, "id"); 152 | let event = this.alreadyHaveEvent?.(eventId); 153 | if (event) { 154 | const listener = this.subListeners[getSubName(json)]; 155 | 156 | if (!listener) { 157 | return; 158 | } 159 | 160 | return listener.event.forEach((cb) => cb(event!)); 161 | } 162 | try { 163 | data = JSON.parse(json); 164 | } catch (err) { 165 | data = e.data; 166 | } 167 | 168 | if (data.length >= 1) { 169 | switch (data[0]) { 170 | case "EVENT": 171 | if (this.logging) { 172 | console.log(data); 173 | } 174 | if (data.length !== 3) return; // ignore empty or malformed EVENT 175 | 176 | const id = data[1]; 177 | const event = data[2]; 178 | if (!this.openSubs[id]) { 179 | return; 180 | } 181 | if (this.openSubs[id].eventIds?.has(eventId)) { 182 | return; 183 | } 184 | this.openSubs[id].eventIds?.add(eventId); 185 | 186 | if ( 187 | validateEvent(event) && 188 | this.openSubs[id] && 189 | (this.openSubs[id].skipVerification || verifySignature(event)) && 190 | matchFilters(this.openSubs[id].filters, event) 191 | ) { 192 | this.openSubs[id]; 193 | (this.subListeners[id]?.event || []).forEach((cb) => cb(event)); 194 | } 195 | return; 196 | case "EOSE": { 197 | if (data.length !== 2) return; // ignore empty or malformed EOSE 198 | const id = data[1]; 199 | if (this.logging) { 200 | console.log("EOSE", this.url, id); 201 | } 202 | (this.subListeners[id]?.eose || []).forEach((cb) => cb()); 203 | return; 204 | } 205 | case "OK": { 206 | if (data.length < 3) return; // ignore empty or malformed OK 207 | const id: string = data[1]; 208 | const ok: boolean = data[2]; 209 | const reason: string = data[3] || ""; 210 | if (ok) this.pubListeners[id]?.ok.forEach((cb) => cb()); 211 | else this.pubListeners[id]?.failed.forEach((cb) => cb(reason)); 212 | return; 213 | } 214 | case "NOTICE": 215 | if (data.length !== 2) return; // ignore empty or malformed NOTICE 216 | const notice = data[1]; 217 | this.listeners.notice.forEach((cb) => cb(notice)); 218 | return; 219 | case "AUTH": 220 | if (data.length !== 2) return; 221 | const challenge = data[1]; 222 | this.listeners.auth.forEach((cb) => cb(challenge)); 223 | return; 224 | } 225 | } 226 | } 227 | #onopen(opened: () => void) { 228 | if (this.resolveClose) { 229 | this.resolveClose(); 230 | return; 231 | } 232 | // console.log("#onopen setting reconnectTimeout to 0"); 233 | // this.reconnectTimeout = 0; 234 | // TODO: Send ephereal messages after subscription, permament before 235 | for (const subid in this.openSubs) { 236 | if (this.logging) { 237 | console.log("REQ", this.url, subid, ...this.openSubs[subid].filters); 238 | } 239 | this.trySend(["REQ", subid, ...this.openSubs[subid].filters]); 240 | } 241 | for (const msg of this.sendOnConnect) { 242 | if (this.logging) { 243 | console.log("(Relay msg)", this.url, msg); 244 | } 245 | this.ws?.send(msg); 246 | } 247 | this.sendOnConnect = []; 248 | 249 | this.listeners.connect.forEach((cb) => cb()); 250 | opened(); 251 | } 252 | 253 | async connectRelay(): Promise { 254 | return new Promise((resolve, reject) => { 255 | try { 256 | const ws = new WebSocket(this.url); 257 | this.ws = ws; 258 | } catch (err) { 259 | reject(err); 260 | return; 261 | } 262 | 263 | this.ws.onopen = this.#onopen.bind(this, resolve); 264 | this.ws.onerror = (e) => { 265 | this.listeners.error.forEach((cb) => cb()); 266 | reject(e); 267 | }; 268 | this.ws.onclose = this.#onclose.bind(this); 269 | this.ws.onmessage = this.#onmessage.bind(this); 270 | }); 271 | } 272 | 273 | async connect(): Promise { 274 | if (this.ws?.readyState && this.ws.readyState < 2) return; // ws already open or connecting 275 | if (this.ws?.readyState === 2) { 276 | this.ws.close(); 277 | } 278 | this.ws = undefined; 279 | await this.connectRelay(); 280 | } 281 | 282 | relayInit(): Relay { 283 | const this2 = this; 284 | return { 285 | url: this2.url, 286 | sub: this2.sub.bind(this2), 287 | on: this2.on.bind(this2), 288 | off: this2.off.bind(this2), 289 | auth: this2.auth.bind(this2), 290 | publish: this2.publish.bind(this2), 291 | connect: this2.connect.bind(this2), 292 | close(): Promise { 293 | return this2.close(); 294 | }, 295 | get status() { 296 | return this2.status; 297 | }, 298 | // @ts-ignore 299 | relay: this2, 300 | }; 301 | } 302 | get status() { 303 | return this.ws?.readyState ?? 3; 304 | } 305 | get connected() { 306 | return this.ws?.readyState === 1; 307 | } 308 | close(): Promise { 309 | this.closedByClient = true; 310 | this.ws?.close(); 311 | return new Promise((resolve) => { 312 | this.resolveClose = resolve; 313 | }); 314 | } 315 | on(type: RelayEvent, cb: any) { 316 | this.listeners[type].push(cb); 317 | if (type === "connect" && this.ws?.readyState === 1) { 318 | cb(); 319 | } 320 | } 321 | 322 | off(type: RelayEvent, cb: any) { 323 | const index = this.listeners[type].indexOf(cb); 324 | if (index !== -1) this.listeners[type].splice(index, 1); 325 | } 326 | 327 | publish(event: Event): Pub { 328 | return this._publish(event, "EVENT"); 329 | } 330 | 331 | auth(event: Event): Pub { 332 | return this._publish(event, "AUTH"); 333 | } 334 | 335 | private _publish(event: Event, type: string) { 336 | const this2 = this; 337 | if (!event.id) throw new Error(`event ${event} has no id`); 338 | const id = event.id; 339 | 340 | let sent = false; 341 | let mustMonitor = false; 342 | 343 | this2 344 | .trySend([type, event]) 345 | .then(() => { 346 | sent = true; 347 | if (mustMonitor) { 348 | startMonitoring(); 349 | mustMonitor = false; 350 | } 351 | }) 352 | .catch(() => {}); 353 | 354 | const startMonitoring = () => { 355 | const monitor = this.sub([{ids: [id]}], { 356 | id: `monitor-${id.slice(0, 5)}`, 357 | }); 358 | const willUnsub = setTimeout(() => { 359 | (this2.pubListeners[id]?.failed || []).forEach((cb) => 360 | cb("event not seen after 5 seconds") 361 | ); 362 | monitor.unsub(); 363 | }, 5000); 364 | monitor.on("event", () => { 365 | clearTimeout(willUnsub); 366 | (this2.pubListeners[id]?.seen || []).forEach((cb) => cb()); 367 | }); 368 | }; 369 | 370 | return { 371 | on: (type: "ok" | "seen" | "failed", cb: any) => { 372 | this2.pubListeners[id] = this2.pubListeners[id] || { 373 | ok: [], 374 | seen: [], 375 | failed: [], 376 | }; 377 | this2.pubListeners[id][type].push(cb); 378 | 379 | if (type === "seen") { 380 | if (sent) startMonitoring(); 381 | else mustMonitor = true; 382 | } 383 | }, 384 | off: (type: "ok" | "seen" | "failed", cb: any) => { 385 | const listeners = this2.pubListeners[id]; 386 | if (!listeners) return; 387 | const idx = listeners[type].indexOf(cb); 388 | if (idx >= 0) listeners[type].splice(idx, 1); 389 | }, 390 | }; 391 | } 392 | 393 | sub(filters: Filter[], opts: SubscriptionOptions = {}): Sub { 394 | const this2 = this; 395 | const subid = opts.id || Math.random().toString().slice(2); 396 | const skipVerification = opts.skipVerification || false; 397 | 398 | this2.openSubs[subid] = { 399 | id: subid, 400 | filters, 401 | skipVerification, 402 | }; 403 | if (this2.connected) { 404 | if (this.logging) { 405 | console.log("REQ2", this.url, subid, ...filters); 406 | } 407 | this2.trySend(["REQ", subid, ...filters]); 408 | } 409 | 410 | return { 411 | sub: (newFilters, newOpts = {}) => 412 | this.sub(newFilters || filters, { 413 | skipVerification: newOpts.skipVerification || skipVerification, 414 | id: subid, 415 | }), 416 | unsub: () => { 417 | delete this2.openSubs[subid]; 418 | delete this2.subListeners[subid]; 419 | if (this2.connected) { 420 | if (this2.logging) { 421 | console.log("CLOSE", this.url, subid); 422 | } 423 | this2.trySend(["CLOSE", subid]); 424 | } 425 | }, 426 | on: (type: "event" | "eose", cb: any): void => { 427 | this2.subListeners[subid] = this2.subListeners[subid] || { 428 | event: [], 429 | eose: [], 430 | }; 431 | this2.subListeners[subid]![type].push(cb); 432 | }, 433 | off: (type: "event" | "eose", cb: any): void => { 434 | const listeners = this2.subListeners[subid]; 435 | 436 | if (!listeners) return; 437 | 438 | const idx = listeners[type].indexOf(cb); 439 | if (idx >= 0) listeners[type].splice(idx, 1); 440 | }, 441 | }; 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /subscription-filter-state-cache.ts: -------------------------------------------------------------------------------- 1 | // A simple subscription filter state cache can first just record filters that were called and the time of calling. 2 | // The simplest ,,continue on next start'' would just set an ,,until'' time for time of calling. 3 | // Time of calling must be rounded down to 5 minutes for example for easier batching. 4 | // After that a restore functionality can just call and update the time of calling while extending the event cache. 5 | // TODO: handling pagination, though most of it has to be done in RelayPool class. 6 | 7 | import {Event, Filter, matchFilter} from "nostr-tools"; 8 | import stableStringify from "safe-stable-stringify"; 9 | 10 | // FilterInfo contains start and end. multiple intervals for the same filter are boring. 11 | // Defaults should be [-Infinity, Infinity] 12 | type FilterInfo = Map; 13 | // For ,,continue'' support for non-batched events onEose server last timestamps must be recorded as well. 14 | export class SubscriptionFilterStateCache { 15 | filters: Map = new Map(); 16 | filterInfo: Map = new Map(); 17 | filtersByEventId: Map> = new Map(); 18 | addFilter(filter: Filter) { 19 | let filterString = stableStringify(filter); 20 | if (!this.filterInfo.has(filterString)) { 21 | this.filterInfo.set(filterString, new Map()); 22 | this.filters.set(filterString, filter); 23 | } 24 | } 25 | // updateFilter should be called on onEose with last event time. 26 | updateFilter(filter: Filter, start: number, end: number, relay: string) { 27 | if (filter.until && filter.until < start) { 28 | start = filter.until; 29 | } 30 | if (filter.until) { 31 | filter = {...filter, until: undefined}; 32 | } 33 | let filterString = stableStringify(filter); 34 | if (!this.filterInfo.has(filterString)) { 35 | this.filterInfo.set(filterString, new Map()); 36 | this.filters.set(filterString, filter); 37 | } 38 | let filterInfo = this.filterInfo.get(filterString)!; 39 | let time = filterInfo.get(relay); 40 | if (!time) { 41 | time = [start, end]; 42 | } else { 43 | if (start > time[0] && end <= time[0]) { 44 | time[0] = start; 45 | } else if (start > time[0]) { 46 | time = [start, end]; 47 | } else { 48 | // Continue 49 | if (start >= time[1] - 1) { 50 | time[1] = end; 51 | } 52 | // In other cases we don't update time 53 | } 54 | } 55 | filterInfo.set(relay, time); 56 | } 57 | // this is probably bad design, looking at filter call and onEose should be probably enough 58 | // this should still probably get the filters 59 | updateFilters(event: Event, relay: string) { 60 | // Get matching filters 61 | let filterStrings = this.filtersByEventId.get(event.id); 62 | if (!filterStrings) { 63 | filterStrings = new Set(); 64 | for (let filterString of this.filterInfo.keys()) { 65 | if (matchFilter(this.filters.get(filterString)!, event)) { 66 | filterStrings.add(filterString); 67 | } 68 | } 69 | this.filtersByEventId.set(event.id, filterStrings); 70 | } 71 | // update matching filters 72 | for (let filterString of filterStrings) { 73 | if (!this.filterInfo.has(filterString)) { 74 | this.filterInfo.set(filterString, new Map()); 75 | } 76 | let filterInfo = this.filterInfo.get(filterString)!; 77 | let time = filterInfo.get(relay) || [-Infinity, Infinity]; 78 | if (event.created_at < time[1]) { 79 | time[1] = event.created_at; 80 | } 81 | if (event.created_at > time[0]) { 82 | time[0] = event.created_at; 83 | } 84 | 85 | filterInfo.set(relay, time); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "lib": ["dom", "dom.iterable", "esnext", "webworker", "scripthost"], 6 | "declaration": true, 7 | "strict": true, 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "emitDeclarationOnly": true, 12 | "isolatedModules": true, 13 | "outDir": "dist", 14 | "rootDir": ".", 15 | } 16 | } 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /write-relays.ts: -------------------------------------------------------------------------------- 1 | export class WriteRelaysPerPubkey { 2 | data: Map; 3 | promises: Map>; 4 | servers: string[]; 5 | constructor(servers?: string[]) { 6 | this.data = new Map(); 7 | this.promises = new Map(); 8 | this.servers = servers || ["https://us.rbr.bio", "https://eu.rbr.bio"]; 9 | } 10 | 11 | async get(pubkey: string): Promise { 12 | let value = this.data.get(pubkey); 13 | if (value) { 14 | return Promise.resolve(value); 15 | } 16 | const promise = this.promises.get(pubkey); 17 | if (promise) { 18 | return promise; 19 | } 20 | const rs = []; 21 | for (let server of this.servers) { 22 | rs.push(fetchWriteRelays(server, pubkey)); 23 | } 24 | const r: Promise = firstGoodPromise(rs); 25 | r.then((x: string[]) => { 26 | this.data.set(pubkey, x); 27 | this.promises.delete(pubkey); 28 | }); 29 | this.promises.set(pubkey, r); 30 | return r; 31 | } 32 | } 33 | 34 | function fetchWriteRelays(server: string, pubkey: string): Promise { 35 | const url = `${server}/${pubkey}/writerelays.json`; 36 | return fetchJSON(url); 37 | } 38 | 39 | async function fetchJSON(url: string) { 40 | return fetch(url) 41 | .then((response) => response.json()) 42 | .catch((e) => { 43 | throw new Error("error fetching " + url + " " + e); 44 | }); 45 | } 46 | 47 | function firstGoodPromise(promises: Promise[]): Promise { 48 | return new Promise((resolve, reject) => { 49 | let rejects: any[] = []; 50 | promises.forEach((p) => { 51 | p.then(resolve).catch((rej) => { 52 | rejects.push(rej); 53 | if (rejects.length === promises.length) { 54 | reject(rejects); 55 | } 56 | }); 57 | }); 58 | }); 59 | } 60 | --------------------------------------------------------------------------------