├── .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 |
--------------------------------------------------------------------------------