├── .env ├── .gitignore ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── prisma ├── migrations │ ├── 20230127175926_init │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── setupTests.ts ├── src ├── @types │ └── nostr-tools-shim.d.ts ├── index.ts ├── lib │ ├── Account.ts │ ├── AccountManager.ts │ ├── Config.ts │ ├── DbClient.ts │ ├── Indexer.ts │ ├── Logger.ts │ ├── NostrEvents.ts │ ├── Relay.ts │ ├── RelayManager.ts │ └── Subscription.ts └── utils │ ├── crypto.ts │ └── index.ts ├── test ├── Account.test.ts ├── AccountManager.test.ts ├── NostrEvents.test.ts ├── Relay.test.ts ├── RelayManager.test.ts ├── indexer.test.ts ├── mocks │ ├── DbClientMock.ts │ ├── index.ts │ └── prisma │ │ ├── client.ts │ │ └── singleton.ts └── utils.test.ts ├── tsconfig.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | DATABASE_URL="file:./dev.db" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | prisma/*.db 5 | prisma/*.db-journal 6 | dist/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lightning K0ala 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 | > WARNING: This project is still under heavy development. Currently undergoing rearchitecting. There are many features still missing and no security audits have been made. Use at your own risk. 2 | 3 | # 🔍 nostr-indexer 4 | 5 | An indexer for nostr events that can be installed as an npm package for your backend projects. It can be used as a backbone to build: 6 | 7 | - Remote backend (in REST, GraphQL, tRPC, ...) for a lightweight mobile / web client. 8 | - Local backend for a desktop client. 9 | - Personal nostr data and social graph vault. 10 | - Event notification system. 11 | - Bots. 12 | 13 | ## 🤷‍♂️ Why? 14 | 15 | Almost all nostr clients currently are communicating with relays directly. This can be problematic: 16 | 17 | - Data you care about is not immediately available, when you open most of the existing apps, you have to wait for data to trickle in. 18 | - Background syncing is not trivial, specially for web / PWAs. Battery life, storage and bandwidth are things to consider on mobile devices. 19 | - There is no single source of truth for your data and social graph so your experience on different devices / clients can be inconsistent. 20 | - Notifications are harder to implement without relying on third parties. 21 | - Developer experience can be simplified if we can fetch data using common patterns like REST, GraphQL, tRPC... 22 | - Replicating your posted data to a new relay might be an issue if your client doesn't hold a full cache / provide that feature. 23 | - Without a full client cache, your data can be permanently lost as there is no guarantee that relays will hold it / remain online. 24 | 25 | ## 🔌 How? 26 | `nostr-indexer` is like your own personal mini-relay. 27 | You start by telling it your account public key and the relays you post to. The indexer then fetches all your personal data and your social graph, making decisions about which relays to fetch from and which data is relevant to you. It indexes this data and provides a convenient set of functions to interact with it. 28 | 29 | Additionally, the database can be queried directly by using the exposed prisma db client. 30 | 31 | See the [database schema](prisma/schema.prisma) for more information about what columns each table has. 32 | 33 | # 🚀 Getting Started 34 | 35 | ### 1. Installation 36 | 37 | ```console 38 | yarn add nostr-indexer 39 | ``` 40 | 41 | ### 2. Setup Database 42 | 43 | ```console 44 | DATABASE_URL=file: npx prisma migrate reset --schema ./node_modules/nostr-indexer/dist/prisma/schema.prisma 45 | ``` 46 | 47 | Substitute `` with the full absolute path to your db file, eg. `/my-project/nostr.db` 48 | 49 | ### 3. Usage 50 | 51 | Some vanilla javascript code that you can try putting into a `index.js` and running with `DB_PATH= node index.js` 52 | 53 | ```js 54 | (async () => { 55 | const nostr = require('nostr-indexer') 56 | 57 | /** @type {import('nostr-indexer').Indexer} */ 58 | // Create the indexer, passing in the path to the database 59 | const indexer = nostr.createIndexer({ 60 | dbPath: process.env.DB_PATH, 61 | // debug: true 62 | }) 63 | 64 | // Add relays 65 | indexer.addRelay("wss://nostr.fmt.wiz.biz") 66 | indexer.addRelay("wss://jiggytom.ddns.net") 67 | 68 | // Start 69 | indexer.start() 70 | 71 | /** @type {import('nostr-indexer').Account} */ 72 | // Add an account to index 73 | const account = await indexer.addAccount("63c3f814e38f0b5bd64515a063791a0fdfd5b276a31bae4856a16219d8aa0d1f") 74 | 75 | setInterval(async () => { 76 | // Indexer database can be queried directly from the database using an internal prisma orm client 77 | // If any metadata changes get published to one of the relays, they will be reflected in our database. 78 | console.log("Metadata:", await indexer.db.client.metadata.findUnique({ where: { user_id: account.user_id } })) 79 | }, 1000) 80 | })(); 81 | ``` 82 | 83 | ## Projects 84 | 85 | Example projects using nostr-indexer: 86 | 87 | - [nostr-indexer-graphql](https://github.com/LightningK0ala/nostr-indexer-graphql): A GraphQL server exposing an API for nostr-indexer data. 88 | - [nostr-indexer-cli](https://github.com/LightningK0ala/nostr-indexer-cli): An interactive cli for nostr-indexer. 89 | 90 | ## Contributions 91 | 92 | Contributions are welcome! If you build anything with this library please make a pull request to include a link to it. 93 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // NOTE: tsdx doesn't seem to support loading ts config jest.config.ts 2 | /** @type {import('jest').Config} */ 3 | const config = { 4 | verbose: true, 5 | setupFilesAfterEnv: ['/setupTests.ts'], 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.3", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build; cp -r prisma dist/.", 16 | "test": "tsdx test", 17 | "test:watch": "tsdx test --watch", 18 | "lint": "tsdx lint", 19 | "prepare": "yarn build", 20 | "size": "size-limit", 21 | "analyze": "size-limit --why", 22 | "prisma:generate": "prisma generate", 23 | "prisma:migrate:dev": "prisma migrate dev" 24 | }, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "tsdx lint" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 80, 32 | "semi": true, 33 | "singleQuote": true, 34 | "trailingComma": "es5" 35 | }, 36 | "name": "nostr-indexer", 37 | "author": "Lightning K0ala", 38 | "module": "dist/nostr-indexer.esm.js", 39 | "size-limit": [ 40 | { 41 | "path": "dist/nostr-indexer.cjs.production.min.js", 42 | "limit": "10 KB" 43 | }, 44 | { 45 | "path": "dist/nostr-indexer.esm.js", 46 | "limit": "10 KB" 47 | } 48 | ], 49 | "devDependencies": { 50 | "@size-limit/preset-small-lib": "8.1.2", 51 | "@types/jest": "^29.4.0", 52 | "husky": "8.0.3", 53 | "jest-mock-extended": "3.0.1", 54 | "prisma": "4.9.0", 55 | "size-limit": "8.1.2", 56 | "tsdx": "0.14.1", 57 | "tslib": "2.4.1", 58 | "typescript": "4.9.4" 59 | }, 60 | "dependencies": { 61 | "@prisma/client": "4.9.0", 62 | "chalk": "^4.1.0", 63 | "just-pick": "4.2.0", 64 | "nostr-tools": "1.2.0", 65 | "ws": "^8.12.0" 66 | }, 67 | "prisma": { 68 | "seed": "npx ts-node prisma/seed.ts" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /prisma/migrations/20230127175926_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Account" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "pubkey" TEXT NOT NULL, 5 | "private_key" TEXT, 6 | "user_id" INTEGER NOT NULL, 7 | "added_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | CONSTRAINT "Account_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "User" ( 13 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 14 | "pubkey" TEXT NOT NULL, 15 | "added_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 16 | ); 17 | 18 | -- CreateTable 19 | CREATE TABLE "Metadata" ( 20 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 21 | "user_id" INTEGER NOT NULL, 22 | "event_id" INTEGER NOT NULL, 23 | "lud06" TEXT, 24 | "website" TEXT, 25 | "nip05" TEXT, 26 | "picture" TEXT, 27 | "display_name" TEXT, 28 | "banner" TEXT, 29 | "about" TEXT, 30 | "name" TEXT, 31 | CONSTRAINT "Metadata_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 32 | CONSTRAINT "Metadata_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "Event" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 33 | ); 34 | 35 | -- CreateTable 36 | CREATE TABLE "Relay" ( 37 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 38 | "url" TEXT NOT NULL, 39 | "added_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 40 | ); 41 | 42 | -- CreateTable 43 | CREATE TABLE "Event" ( 44 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 45 | "event_id" TEXT NOT NULL, 46 | "event_signature" TEXT NOT NULL, 47 | "event_kind" INTEGER NOT NULL, 48 | "event_pubkey" TEXT NOT NULL, 49 | "event_content" TEXT, 50 | "event_tags" TEXT, 51 | "event_createdAt" DATETIME NOT NULL, 52 | "added_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 53 | ); 54 | 55 | -- CreateIndex 56 | CREATE UNIQUE INDEX "Account_pubkey_key" ON "Account"("pubkey"); 57 | 58 | -- CreateIndex 59 | CREATE UNIQUE INDEX "Account_private_key_key" ON "Account"("private_key"); 60 | 61 | -- CreateIndex 62 | CREATE UNIQUE INDEX "Account_user_id_key" ON "Account"("user_id"); 63 | 64 | -- CreateIndex 65 | CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey"); 66 | 67 | -- CreateIndex 68 | CREATE UNIQUE INDEX "Metadata_user_id_key" ON "Metadata"("user_id"); 69 | 70 | -- CreateIndex 71 | CREATE UNIQUE INDEX "Metadata_event_id_key" ON "Metadata"("event_id"); 72 | 73 | -- CreateIndex 74 | CREATE UNIQUE INDEX "Relay_url_key" ON "Relay"("url"); 75 | 76 | -- CreateIndex 77 | CREATE UNIQUE INDEX "Event_event_id_key" ON "Event"("event_id"); 78 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Account { 14 | id Int @id @default(autoincrement()) 15 | pubkey String @unique 16 | private_key String? @unique 17 | user User @relation(fields: [user_id], references: [id]) 18 | user_id Int @unique 19 | added_at DateTime @default(now()) 20 | } 21 | 22 | model User { 23 | id Int @id @default(autoincrement()) 24 | pubkey String @unique 25 | account Account? 26 | metadata Metadata? 27 | added_at DateTime @default(now()) 28 | } 29 | 30 | model Metadata { 31 | id Int @id @default(autoincrement()) 32 | user User @relation(fields: [user_id], references: [id]) 33 | user_id Int @unique 34 | event Event @relation(fields: [event_id], references: [id]) 35 | event_id Int @unique 36 | lud06 String? 37 | website String? 38 | nip05 String? 39 | picture String? 40 | display_name String? 41 | banner String? 42 | about String? 43 | name String? 44 | } 45 | 46 | model Relay { 47 | id Int @id @default(autoincrement()) 48 | url String @unique 49 | added_at DateTime @default(now()) 50 | } 51 | 52 | model Event { 53 | id Int @id @default(autoincrement()) 54 | metadata Metadata? 55 | event_id String @unique 56 | event_signature String 57 | event_kind Int 58 | event_pubkey String 59 | event_content String? 60 | event_tags String? 61 | // Refactor this to event_created_at? 62 | event_createdAt DateTime 63 | added_at DateTime @default(now()) 64 | } 65 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '../node_modules/.prisma/client'; 2 | 3 | const prisma = new PrismaClient({ 4 | datasources: { 5 | db: { url: `file:${process.env.DATABASE_URL}` }, 6 | }, 7 | }); 8 | 9 | async function main() { 10 | await prisma.relay.create({ data: { url: 'wss://nostr.zebedee.cloud' } }); 11 | await prisma.relay.create({ data: { url: 'wss://jiggytom.ddns.net' } }); 12 | await prisma.relay.create({ data: { url: 'wss://nostr.fmt.wiz.biz' } }); 13 | await prisma.relay.create({ data: { url: 'wss://nostr.onsats.org' } }); 14 | await prisma.relay.create({ data: { url: 'wss://relay.damus.io' } }); 15 | await prisma.relay.create({ data: { url: 'wss://nostr-pub.wellorder.net' } }); 16 | await prisma.relay.create({ data: { url: 'wss://nost.lol' } }); 17 | // LK key 18 | let pubkey; 19 | pubkey = '63c3f814e38f0b5bd64515a063791a0fdfd5b276a31bae4856a16219d8aa0d1f'; 20 | await prisma.account.create({ 21 | data: { user: { create: { pubkey } }, pubkey }, 22 | }); 23 | // Jack's pubkey 24 | // pubkey = '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2'; 25 | // await prisma.account.create({ 26 | // data: { user: { create: { pubkey } }, pubkey }, 27 | // }); 28 | } 29 | 30 | main() 31 | .then(async () => { 32 | await prisma.$disconnect(); 33 | console.log('✅ Done seeding database'); 34 | }) 35 | .catch(async e => { 36 | console.error(e.message); 37 | await prisma.$disconnect(); 38 | process.exit(1); 39 | }); 40 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | // Fixes missing TextEncoder and TextDecoder in Jest 3 | // required in nostr-tools nip19 encode / decode functions 4 | const util = require('util'); 5 | global.TextEncoder = util.TextEncoder; 6 | global.TextDecoder = util.TextDecoder; 7 | 8 | -------------------------------------------------------------------------------- /src/@types/nostr-tools-shim.d.ts: -------------------------------------------------------------------------------- 1 | // WARN: Using nostr-tools Filter and other types breaks the build 2 | // when dependency is used elsewhere so type defined manually 3 | 4 | export type RelayEvent = 'connect' | 'disconnect' | 'error' | 'notice'; 5 | 6 | export enum Kind { 7 | Metadata = 0, 8 | Text = 1, 9 | RecommendRelay = 2, 10 | Contacts = 3, 11 | EncryptedDirectMessage = 4, 12 | EventDeletion = 5, 13 | Reaction = 7, 14 | ChannelCreation = 40, 15 | ChannelMetadata = 41, 16 | ChannelMessage = 42, 17 | ChannelHideMessage = 43, 18 | ChannelMuteUser = 44 19 | } 20 | 21 | export type Event = { 22 | id?: string 23 | sig?: string 24 | kind: Kind 25 | tags: string[][] 26 | pubkey: string 27 | content: string 28 | created_at: number 29 | } 30 | 31 | export type NostrRelay = { 32 | url: string; 33 | status: number; 34 | connect: () => Promise; 35 | close: () => Promise; 36 | sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub; 37 | publish: (event: Event) => Pub; 38 | on: (type: RelayEvent, cb: any) => void; 39 | off: (type: RelayEvent, cb: any) => void; 40 | }; 41 | 42 | export type Filter = { 43 | ids?: string[]; 44 | kinds?: number[]; 45 | authors?: string[]; 46 | since?: number; 47 | until?: number; 48 | limit?: number; 49 | [key: `#${string}`]: string[]; 50 | }; 51 | 52 | export type Pub = { 53 | on: (type: 'ok' | 'seen' | 'failed', cb: any) => void; 54 | off: (type: 'ok' | 'seen' | 'failed', cb: any) => void; 55 | }; 56 | 57 | // WARN: Likewise using Sub will also break 58 | export type Sub = { 59 | sub: (filters: Filter[], opts: SubscriptionOptions) => Sub; 60 | unsub: () => void; 61 | on: (type: 'event' | 'eose', cb: any) => void; 62 | off: (type: 'event' | 'eose', cb: any) => void; 63 | }; 64 | 65 | export type SubscriptionOptions = { 66 | skipVerification?: boolean; 67 | id?: string; 68 | }; 69 | 70 | export interface RelayInit { 71 | ( 72 | url: string, 73 | alreadyHaveEvent: (id: string) => boolean = () => false 74 | ): NostrRelay; 75 | } 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { AccountManager } from './lib/AccountManager'; 2 | import { Config } from './lib/Config'; 3 | import { Indexer } from './lib/Indexer'; 4 | import { RelayManager } from './lib/RelayManager'; 5 | import { Logger } from './lib/Logger'; 6 | import { relayInit } from 'nostr-tools'; 7 | import { DbClient } from './lib/DbClient'; 8 | 9 | // Shim Websocket for NodeJS 10 | Object.assign(global, { WebSocket: require('ws') }); 11 | 12 | export const createIndexer = (cfg: Config) => { 13 | const config = new Config(cfg); 14 | const logger = new Logger({ config }); 15 | const db = new DbClient({ dbPath: cfg.dbPath, logger }); 16 | const relayManager = new RelayManager({ db, logger, relayInit }); 17 | const accountManager = new AccountManager({ 18 | db, 19 | logger, 20 | relayManager, 21 | }); 22 | 23 | return new Indexer({ 24 | db, 25 | config, 26 | relayManager, 27 | accountManager, 28 | logger, 29 | }); 30 | }; 31 | 32 | export * from './lib/Indexer'; 33 | export * from './lib/RelayManager'; 34 | export * from './lib/Relay'; 35 | export * from './lib/AccountManager'; 36 | export * from './lib/Account'; 37 | export * from './lib/Subscription'; 38 | export * from './lib/NostrEvents'; 39 | -------------------------------------------------------------------------------- /src/lib/Account.ts: -------------------------------------------------------------------------------- 1 | import { nip19 } from 'nostr-tools'; 2 | import { Event } from '../@types/nostr-tools-shim'; 3 | import { DbClient } from './DbClient'; 4 | import { Logger } from './Logger'; 5 | import { Kind0Event } from './NostrEvents'; 6 | import { RelayManager } from './RelayManager'; 7 | 8 | export class Account { 9 | private _id: number; 10 | private _userId: number; 11 | private _logger: Logger; 12 | private _db: DbClient; 13 | private _relayManager: RelayManager; 14 | // TODO: remove undescore for public properties 15 | _pubkey: string; 16 | _privateKey?: string; 17 | 18 | constructor({ 19 | id, 20 | userId, 21 | logger, 22 | db, 23 | relayManager, 24 | pubkey, 25 | privateKey, 26 | }: { 27 | id: number; 28 | userId: number; 29 | logger: Logger; 30 | db: DbClient; 31 | relayManager: RelayManager; 32 | pubkey: string; 33 | privateKey?: string; 34 | }) { 35 | this._id = id; 36 | this._userId = userId; 37 | this._logger = logger; 38 | this._db = db; 39 | this._relayManager = relayManager; 40 | this._pubkey = pubkey; 41 | if (privateKey) { 42 | this._privateKey = privateKey; 43 | } 44 | } 45 | 46 | get id() { 47 | return this._id; 48 | } 49 | 50 | get pubkey() { 51 | return this._pubkey; 52 | } 53 | 54 | get privateKey() { 55 | return this._privateKey; 56 | } 57 | 58 | static validatePubkey(_pubkey: string) { 59 | // TODO 60 | return true; 61 | } 62 | 63 | static convertPubkeyToHex(pubkey: string) { 64 | let hexPubkey = pubkey; 65 | if (pubkey.startsWith('npub')) { 66 | const { data } = nip19.decode(pubkey); 67 | hexPubkey = data as string; 68 | } 69 | return hexPubkey; 70 | } 71 | 72 | // NIP-01 73 | // https://github.com/nostr-protocol/nips/blob/master/01.md 74 | // Kinds: 0 = metadata, 1 = text note, 2 = recommend_server 75 | indexMetadata() { 76 | this._logger.log('Indexing metadata for account', 'pubkey:', this._pubkey); 77 | // Latest one is the only one that matters 78 | const filters = [ 79 | { 80 | kinds: [0], 81 | authors: [this._pubkey], 82 | limit: 1, 83 | }, 84 | ]; 85 | this._relayManager.addSubscription({ 86 | filters, 87 | closeOnEose: false, // Keep subscription open 88 | onEvent: async (event: Event) => { 89 | // If event id or content not set skip processing it 90 | if (!event.id || !event.content) { 91 | this._logger.log( 92 | 'Huh... event has no id or content, skipping', 93 | 'filters:', 94 | JSON.stringify(filters), 95 | 'event.id:', 96 | event.id, 97 | 'event.content:', 98 | event.content 99 | ); 100 | return; 101 | } 102 | 103 | const nostrEvent = new Kind0Event(event); 104 | this._logger.log('Processing event', 'event_id:', `${event?.id}`); 105 | try { 106 | await this._db.createMetadata({ 107 | userId: this._userId, 108 | event: nostrEvent.parsedNostrEvent, 109 | metadata: nostrEvent.parsedContent, 110 | }); 111 | // TODO: Emit event that metadata was indexed? 112 | } catch (e) { 113 | return; 114 | } 115 | }, 116 | }); 117 | } 118 | 119 | indexAccount() { 120 | this._logger.log('Indexing account: ' + this._pubkey); 121 | this.indexMetadata(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/lib/AccountManager.ts: -------------------------------------------------------------------------------- 1 | import { Account as DbAccount } from '@prisma/client'; 2 | import { Logger } from './Logger'; 3 | import { Account } from './Account'; 4 | import { RelayManager } from './RelayManager'; 5 | import { DbClient } from './DbClient'; 6 | 7 | export class AccountManager { 8 | private _db: DbClient; 9 | private _logger: Logger; 10 | private _relayManager: RelayManager; 11 | private _accounts = new Map(); 12 | 13 | constructor({ 14 | db, 15 | logger, 16 | relayManager, 17 | }: { 18 | db: DbClient; 19 | logger: Logger; 20 | relayManager: RelayManager; 21 | }) { 22 | this._db = db; 23 | this._logger = logger; 24 | this._relayManager = relayManager; 25 | } 26 | 27 | get accounts() { 28 | return this._accounts; 29 | } 30 | 31 | indexAllAccounts() { 32 | this._accounts.forEach(account => account.indexAccount()); 33 | } 34 | 35 | async setup() { 36 | this._logger.log('AccountManager setup'); 37 | const accts = await this._db.client.account.findMany(); 38 | accts.forEach(({ id, user_id: userId, pubkey, private_key }: DbAccount) => { 39 | const account = new Account({ 40 | id, 41 | userId, 42 | db: this._db, 43 | logger: this._logger, 44 | pubkey: pubkey, 45 | privateKey: private_key ? private_key : undefined, 46 | relayManager: this._relayManager, 47 | }); 48 | if (this._accounts.has(pubkey)) return; 49 | account.indexAccount(); 50 | this._accounts.set(pubkey, account); 51 | }); 52 | } 53 | 54 | // Add an account, if an account already exists in db we load it 55 | async addAccount({ 56 | pubkey, 57 | privateKey, 58 | }: { 59 | pubkey: string; 60 | privateKey?: string; 61 | }) { 62 | if (privateKey) throw new Error('Not currently supported'); 63 | // Ensure pubkey is in hex format 64 | const hexPubkey = Account.convertPubkeyToHex(pubkey); 65 | let dbAccount: DbAccount | null; 66 | dbAccount = await this._db.client.account.findUnique({ where: { pubkey } }); 67 | if (!dbAccount) { 68 | dbAccount = await this._db.createAccount({ 69 | pubkey: hexPubkey, 70 | }); 71 | } 72 | if (!dbAccount) throw new Error('Failed to create account'); 73 | const account = new Account({ 74 | id: dbAccount.id, 75 | userId: dbAccount.user_id, 76 | db: this._db, 77 | logger: this._logger, 78 | pubkey: hexPubkey, 79 | relayManager: this._relayManager, 80 | }); 81 | if (this._accounts.has(hexPubkey)) return dbAccount; 82 | account.indexAccount(); 83 | this._accounts.set(hexPubkey, account); 84 | return dbAccount; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/Config.ts: -------------------------------------------------------------------------------- 1 | export type ConfigArgs = { 2 | dbPath: string; 3 | debug?: boolean; 4 | }; 5 | export class Config { 6 | public dbPath: string; 7 | public debug: boolean; 8 | 9 | constructor({ dbPath, debug = false }: ConfigArgs) { 10 | this.dbPath = dbPath; 11 | this.debug = debug; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/DbClient.ts: -------------------------------------------------------------------------------- 1 | // This class is a wrapper around the Prisma client. It is used to abstract away 2 | // the Prisma client and provide a more convenient interface for the rest of the 3 | // application. It also keeps the code tidy by not having to import the Prisma 4 | // client in every file that needs to access the database. If other database 5 | // clients are added in the future, this class can be used to abstract away the 6 | // differences between them. 7 | import { PrismaClient, Relay } from '@prisma/client'; 8 | import { PrismaClientKnownRequestError } from '@prisma/client/runtime'; 9 | import { Logger } from './Logger'; 10 | 11 | export type DbRelay = Relay; 12 | 13 | export class DbClient { 14 | private _logger: Logger; 15 | protected _db: PrismaClient; 16 | 17 | constructor({ logger, dbPath }: { logger: Logger; dbPath: string }) { 18 | this._logger = logger; 19 | this._db = new PrismaClient({ 20 | datasources: { db: { url: `file:${dbPath}` } }, 21 | }); 22 | } 23 | 24 | // This allows us to access the prisma client directly if needed. 25 | get client() { 26 | return this._db; 27 | } 28 | 29 | async createAccount({ pubkey }: { pubkey: string }) { 30 | return this._db.account.create({ 31 | data: { pubkey, user: { create: { pubkey } } }, 32 | }); 33 | } 34 | 35 | async createRelay({ url }: { url: string }) { 36 | return this._db.relay.create({ data: { url } }); 37 | } 38 | 39 | async createMetadata({ 40 | userId, 41 | event, 42 | metadata, 43 | }: { 44 | userId: number; 45 | event: any; 46 | metadata: any; 47 | }) { 48 | try { 49 | const result = await this._db.$transaction(async tx => { 50 | // 1. Check if user has metadata, if not create new metadata and event. Return. 51 | const existingMetadata = await tx.metadata.findUnique({ 52 | where: { user_id: userId }, 53 | include: { event: true }, 54 | }); 55 | if (!existingMetadata) { 56 | const { id: eventId } = await tx.event.create({ 57 | data: event, 58 | }); 59 | return tx.metadata.create({ 60 | data: { 61 | user_id: userId, 62 | event_id: eventId, 63 | ...metadata, 64 | }, 65 | }); 66 | } 67 | 68 | // 2. If event exists return. 69 | if (existingMetadata.event.event_id == event.id) 70 | return existingMetadata; 71 | // 3. If event is older than current return. 72 | if (existingMetadata.event.event_createdAt.getTime() > event.created_at) 73 | return existingMetadata; 74 | 75 | // 3. Otherwise, create a new event, delete the old and update the metadata record. 76 | const { id: eventId } = await tx.event.create({ 77 | data: event, 78 | }); 79 | const updatedMetadata = await tx.metadata.update({ 80 | where: { 81 | id: existingMetadata.id, 82 | }, 83 | data: { 84 | event_id: eventId, 85 | ...metadata, 86 | }, 87 | }); 88 | await tx.event.delete({ where: { id: existingMetadata.event_id } }); 89 | return updatedMetadata; 90 | }); 91 | 92 | return result; 93 | } catch (e) { 94 | // @ts-ignore 95 | if (e instanceof PrismaClientKnownRequestError) { 96 | // Unique constraint error, ignore 97 | if (e.code === 'P2002') return; 98 | this._logger.log( 99 | // @ts-ignore 100 | `Unexpected db client error creating metadata ${e.code} ${e.message}` 101 | ); 102 | } else { 103 | this._logger.log( 104 | // @ts-ignore 105 | `Unexpected error creating metadata ${e.code} ${e.message}` 106 | ); 107 | } 108 | throw Error('Failed to createMetadata'); 109 | } 110 | } 111 | 112 | getEvent(event_id: string) { 113 | return this._db.event.findFirst({ where: { event_id } }); 114 | } 115 | 116 | getEventCount() { 117 | return this._db.event.count(); 118 | } 119 | 120 | disconnect() { 121 | return this._db.$disconnect(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/lib/Indexer.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { RelayManager } from './RelayManager'; 3 | import { Config } from './Config'; 4 | import { Logger } from './Logger'; 5 | import { AccountManager } from './AccountManager'; 6 | import { DbClient } from './DbClient'; 7 | 8 | export class Indexer { 9 | private _db: DbClient; 10 | private _config: Config; 11 | private _relayManager: RelayManager; 12 | private _accountManager: AccountManager; 13 | private _startedAt?: Date; 14 | private _logger: Logger; 15 | public count: number = 0; 16 | 17 | constructor({ 18 | db, 19 | config, 20 | relayManager, 21 | accountManager, 22 | logger, 23 | }: { 24 | db: DbClient; 25 | config: Config; 26 | relayManager: RelayManager; 27 | accountManager: AccountManager; 28 | logger: Logger; 29 | }) { 30 | this._db = db; 31 | this._config = config; 32 | this._relayManager = relayManager; 33 | this._accountManager = accountManager; 34 | this._logger = logger; 35 | } 36 | 37 | // Getters 38 | get db() { 39 | return this._db; 40 | } 41 | 42 | get started() { 43 | return !!this._startedAt; 44 | } 45 | 46 | get startedAt() { 47 | return this._startedAt; 48 | } 49 | 50 | get relayManager() { 51 | return this._relayManager; 52 | } 53 | 54 | get accountManager() { 55 | this._db.client.user.findUnique({ where: { id: 1 } }); 56 | return this._accountManager; 57 | } 58 | 59 | get subscriptions() { 60 | return this._relayManager.subscriptions; 61 | } 62 | 63 | async addRelay(url: string) { 64 | return this.relayManager.addRelay(url); 65 | } 66 | 67 | async addAccount(pubkey: string) { 68 | return this.accountManager.addAccount({ pubkey }); 69 | } 70 | 71 | async dbFileSize() { 72 | return (await fs.promises.stat(this._config.dbPath)).size; 73 | } 74 | 75 | async start() { 76 | if (this.startedAt) return false; 77 | this._startedAt = new Date(); 78 | this._logger.log('Indexer started'); 79 | await this._relayManager.setup(); 80 | await this._accountManager.setup(); 81 | return true; 82 | } 83 | 84 | async stop() { 85 | if (!this.startedAt) return false; 86 | this._startedAt = undefined; 87 | await this._relayManager.teardown(); 88 | // await this._db.$disconnect(); 89 | // TODO: Stop relay manager 90 | return true; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/lib/Logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { Config } from './Config'; 3 | export class Logger { 4 | private _config: Config; 5 | 6 | constructor({ config }: { config: Config }) { 7 | this._config = config; 8 | } 9 | log(...args: any[]) { 10 | // add the new log to the end of the array 11 | const formattedLog = chalk.yellow('INDEXER:') + ' ' + args.join(' '); 12 | if (this._config.debug) console.log(formattedLog); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/NostrEvents.ts: -------------------------------------------------------------------------------- 1 | import pick from 'just-pick'; 2 | import { Event } from '../@types/nostr-tools-shim'; 3 | 4 | export enum Kind { 5 | Metadata = 0, 6 | Text = 1, 7 | RecommendRelay = 2, 8 | Contacts = 3, 9 | EncryptedDirectMessage = 4, 10 | EventDeletion = 5, 11 | Reaction = 7, 12 | ChannelCreation = 40, 13 | ChannelMetadata = 41, 14 | ChannelMessage = 42, 15 | ChannelHideMessage = 43, 16 | ChannelMuteUser = 44, 17 | } 18 | 19 | export class NostrEvent { 20 | id?: string; 21 | signature?: string; 22 | kind: Kind; 23 | tags: string[][]; 24 | pubkey: string; 25 | content: string; 26 | created_at: Date; 27 | 28 | constructor(e: Event) { 29 | this.id = e.id; 30 | this.signature = e.sig; 31 | this.kind = e.kind; 32 | this.tags = e.tags; 33 | this.pubkey = e.pubkey; 34 | this.content = e.content; 35 | this.created_at = new Date(e.created_at); 36 | } 37 | 38 | get parsedNostrEvent() { 39 | if (!this.id || !this.signature) { 40 | throw new Error('Event is missing id or sig'); 41 | } 42 | return { 43 | event_id: this.id, 44 | event_createdAt: new Date(this.created_at), 45 | event_kind: this.kind, 46 | event_signature: this.signature, 47 | event_content: this.content, 48 | event_pubkey: this.pubkey, 49 | // event_tags: JSON.stringify(event.tags), 50 | }; 51 | } 52 | } 53 | 54 | export class Kind0Event extends NostrEvent { 55 | get parsedContent() { 56 | try { 57 | return pick(JSON.parse(this.content), [ 58 | 'lud06', 59 | 'website', 60 | 'nip05', 61 | 'picture', 62 | 'banner', 63 | 'display_name', 64 | 'about', 65 | 'name', 66 | ]); 67 | } catch (_e) { 68 | return {}; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/Relay.ts: -------------------------------------------------------------------------------- 1 | import { NostrRelay } from '../@types/nostr-tools-shim'; 2 | import { Logger } from './Logger'; 3 | import { Subscription } from './Subscription'; 4 | 5 | export class Relay { 6 | private _id: number; 7 | private _url: string; 8 | private _connected: boolean = false; 9 | private _relay: NostrRelay; 10 | private _logger: Logger; 11 | private _subscriptions = new Map(); 12 | 13 | constructor({ 14 | id, 15 | url, 16 | logger, 17 | relayInit, 18 | }: { 19 | id: number; 20 | url: string; 21 | logger: Logger; 22 | relayInit: (url: string) => NostrRelay; 23 | }) { 24 | this._id = id; 25 | this._url = url; 26 | this._logger = logger; 27 | this._relay = relayInit(url) as any; 28 | this._relay.on('connect', () => { 29 | this._logger.log(`Connected to relay ${this._url}`); 30 | this._connected = true; 31 | }); 32 | 33 | this._relay.on('error', (e: any) => { 34 | this._logger.log(`Failed to connect to relay ${this._url}`, e); 35 | this._connected = false; 36 | }); 37 | 38 | this._relay.on('disconnect', () => { 39 | // TODO 40 | // - Implement a smarter reconnect strategy, eg. with throttle / exponential backoff 41 | this._logger.log(`Disconnected from relay ${this._url}`); 42 | this._connected = false; 43 | }); 44 | } 45 | 46 | // Getters 47 | get id() { 48 | return this._id; 49 | } 50 | get url() { 51 | return this._url; 52 | } 53 | get connected() { 54 | return this._connected; 55 | } 56 | 57 | // Methods 58 | async connect() { 59 | return this._relay 60 | .connect() 61 | .then(() => { 62 | this._connected = true; 63 | }) 64 | .catch(() => {}); 65 | } 66 | 67 | async disconnect() { 68 | await this._relay.close(); 69 | } 70 | 71 | subscribe(subscription: Subscription) { 72 | const { onEvent, onEose } = subscription; 73 | const sub = this._relay.sub(subscription.filters); 74 | // keep a reference to the sub so we can close it later if necessary 75 | subscription.sub = sub; 76 | this._subscriptions.set(subscription.id, subscription); 77 | onEvent && sub.on('event', onEvent); 78 | onEose && sub.on('eose', () => onEose(sub)); 79 | return sub; 80 | } 81 | 82 | // TODO 83 | // - Function argument to specify the filters 84 | // - Function argument to specify whether to close on eose or keep open 85 | // subscribe({ filters, closeOnEose }: { filters: any; closeOnEose: boolean }) { 86 | // if (!this._connected) return this._logger.log('Not connected to relay'); 87 | // const filtersStr = JSON.stringify(filters); 88 | // const filtersHash = sha1Hash(filtersStr); 89 | // if (this._subscriptions.has(filtersHash)) 90 | // return this._logger.log('Subscription already exist'); 91 | // const sub = this._relay.sub(filters); 92 | // this._subscriptions.set(filtersHash, { 93 | // sub, 94 | // filtersStr, 95 | // createdAt: new Date(), 96 | // }); 97 | // this._logger.log('New subscription added'); 98 | 99 | // sub.on('event', async (event: NostrEvent) => { 100 | // this._logger.log(`Received event ${event.id}`); 101 | // if (!event.id) return; 102 | // this._logger.log(`Creating event ${event.id}`); 103 | // if (!event.sig) 104 | // return this._logger.log(`Event ${event.id} has no signature`); 105 | // this._db.event 106 | // .create({ 107 | // data: { 108 | // event_id: event.id, 109 | // event_tags: JSON.stringify(event.tags), 110 | // event_signature: event.sig, 111 | // event_kind: event.kind, 112 | // event_content: event.content, 113 | // event_pubkey: event.pubkey, 114 | // event_createdAt: new Date(event.created_at), 115 | // }, 116 | // }) 117 | // .then((e: Event) => this._logger.log(`Created event ${e.event_id}`)) 118 | // .catch((e: PrismaClientKnownRequestError) => { 119 | // if (e.code == 'P2002') { 120 | // return this._logger.log( 121 | // `Cannot create event ${event.id} already exists` 122 | // ); 123 | // } 124 | // this._logger.log(`Failed to create event ${event.id}`, e.message); 125 | // }); 126 | // }); 127 | 128 | // sub.on('eose', () => { 129 | // this._logger.log('eose'); 130 | // if (closeOnEose) { 131 | // sub.unsub(); 132 | // this._subscriptions.delete(filtersHash); 133 | // } 134 | // }); 135 | // } 136 | 137 | unsubscribe() {} 138 | } 139 | 140 | // run({ 141 | // // relay, 142 | // onEvent, 143 | // onEose, 144 | // }: { 145 | // relay: Relay; 146 | // onEvent: (event: Event) => void; 147 | // onEose: () => void; 148 | // }) { 149 | // // if (!this._relay.connected) 150 | // return this._logger.log('Relay is not connected'); 151 | // // this._logger.log(this._filters); 152 | // // this._sub = this._relay.subscribe(this._filters); 153 | // this._sub?.on('event', onEvent); 154 | // this._sub?.on('eose', onEose); 155 | // } 156 | 157 | // private handleEvent = (_event: NostrEve) => { 158 | // if (!event.id) return; 159 | // this._logger.log(`Event received on subscription id ${this._id}`); 160 | // if (!event.sig) 161 | // return this._logger.log(`Event ${event.id} has no signature`); 162 | // this._db.event 163 | // .create({ 164 | // data: { 165 | // event_id: event.id, 166 | // event_tags: JSON.stringify(event.tags), 167 | // event_signature: event.sig, 168 | // event_kind: event.kind, 169 | // event_content: event.content, 170 | // event_pubkey: event.pubkey, 171 | // event_createdAt: new Date(event.created_at), 172 | // }, 173 | // }) 174 | // .then((e: Event) => this._logger.log(`Created event ${e.event_id}`)) 175 | // .catch((e: PrismaClientKnownRequestError) => { 176 | // if (e.code == 'P2002') { 177 | // return this._logger.log( 178 | // `Cannot create event ${event.id} already exists` 179 | // ); 180 | // } 181 | // this._logger.log(`Failed to create event ${event.id}`, e.message); 182 | // }); 183 | // }; 184 | 185 | // private handleEose = () => { 186 | // this._logger.log('eose'); 187 | // if (this._closeOnEose) { 188 | // this._sub?.unsub(); 189 | // } 190 | // }; 191 | 192 | // if (!event.id) return; 193 | // this._logger.log(`Creating event ${event.id}`); 194 | // if (!event.sig) 195 | // return this._logger.log(`Event ${event.id} has no signature`); 196 | // this._db.event 197 | // .create({ 198 | // data: { 199 | // event_id: event.id, 200 | // event_tags: JSON.stringify(event.tags), 201 | // event_signature: event.sig, 202 | // event_kind: event.kind, 203 | // event_content: event.content, 204 | // event_pubkey: event.pubkey, 205 | // event_createdAt: new Date(event.created_at), 206 | // }, 207 | // }) 208 | // .then((e: Event) => this._logger.log(`Created event ${e.event_id}`)) 209 | // .catch((e: PrismaClientKnownRequestError) => { 210 | // if (e.code == 'P2002') { 211 | // return this._logger.log( 212 | // `Cannot create event ${event.id} already exists` 213 | // ); 214 | // } 215 | // this._logger.log(`Failed to create event ${event.id}`, e.message); 216 | // }); 217 | -------------------------------------------------------------------------------- /src/lib/RelayManager.ts: -------------------------------------------------------------------------------- 1 | import { Event, Filter, RelayInit, Sub } from '../@types/nostr-tools-shim'; 2 | import { Logger } from './Logger'; 3 | import { Relay } from './Relay'; 4 | import { Subscription } from './Subscription'; 5 | import { sha1Hash } from '../utils/crypto'; 6 | import { DbClient, DbRelay } from './DbClient'; 7 | 8 | export class RelayManager { 9 | private _db: DbClient; 10 | private _relays = new Map(); 11 | private _logger: Logger; 12 | // Key is the hash for the stringified subscription filters 13 | private _subscriptions = new Map(); 14 | private _relayInit: RelayInit; 15 | 16 | constructor({ 17 | db, 18 | logger, 19 | relayInit, 20 | }: { 21 | db: DbClient; 22 | logger: Logger; 23 | relayInit: RelayInit; 24 | }) { 25 | this._db = db; 26 | this._logger = logger; 27 | this._relayInit = relayInit; 28 | } 29 | 30 | get subscriptions() { 31 | return this._subscriptions; 32 | } 33 | 34 | get relays() { 35 | return this._relays; 36 | } 37 | 38 | get connectedRelays() { 39 | return Array.from(this._relays.values()).filter( 40 | (relay: Relay) => relay.connected 41 | ); 42 | } 43 | 44 | async setup() { 45 | this._logger.log('Loading relays from db'); 46 | const relays = await this._db.client.relay.findMany(); 47 | relays.forEach(({ id, url }: DbRelay) => { 48 | this.setupNewRelay({ id, url }); 49 | }); 50 | } 51 | 52 | private setupNewRelay({ id, url }: { id: number; url: string }) { 53 | if (this._relays.has(id)) return; 54 | const relay = new Relay({ 55 | id, 56 | url, 57 | logger: this._logger, 58 | relayInit: this._relayInit, 59 | }); 60 | relay.connect(); 61 | this._relays.set(id, relay); 62 | // Add all existing subscriptions to the relay 63 | this._subscriptions.forEach(subscription => relay.subscribe(subscription)); 64 | } 65 | 66 | async teardown() { 67 | // log('Disconnecting relays'); 68 | // TODO 69 | // disconnect each relay and clear the array 70 | this._relays.forEach(async relay => { 71 | await relay.disconnect(); 72 | }); 73 | this._relays.clear(); 74 | } 75 | 76 | async addRelay(url: string) { 77 | let dbRelay: DbRelay | null; 78 | dbRelay = await this._db.client.relay.findUnique({ where: { url } }); 79 | if (!dbRelay) { 80 | dbRelay = await this._db.createRelay({ url }); 81 | } 82 | if (!dbRelay) throw new Error('Failed to create relay'); 83 | this.setupNewRelay({ id: dbRelay.id, url: dbRelay.url }); 84 | return dbRelay; 85 | } 86 | 87 | addSubscription({ 88 | filters, 89 | closeOnEose, 90 | onEvent = () => {}, 91 | onEose = () => {}, 92 | }: { 93 | filters: Filter[]; 94 | closeOnEose: boolean; 95 | onEvent?: (event: Event) => void; 96 | onEose?: (sub: Sub) => void; 97 | }) { 98 | this._logger.log('Adding subscription to relay manager'); 99 | const subscription = new Subscription({ 100 | filters, 101 | closeOnEose, 102 | onEvent, 103 | onEose, 104 | }); 105 | this._subscriptions.set(subscription.id, subscription); 106 | // Register the subscription in every relay 107 | this._relays.forEach(relay => relay.subscribe(subscription)); 108 | return subscription.id; 109 | } 110 | 111 | removeSubscription(id: string) { 112 | this._subscriptions.delete(id); 113 | } 114 | 115 | hasSubscription(filters: Filter[]) { 116 | const id = sha1Hash(JSON.stringify(filters)); 117 | return this._subscriptions.has(id); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/lib/Subscription.ts: -------------------------------------------------------------------------------- 1 | import { Event, Filter, Sub } from '../@types/nostr-tools-shim'; 2 | import { sha1Hash } from '../utils/crypto'; 3 | 4 | export class Subscription { 5 | private _id: string; 6 | private _filters: Filter[]; 7 | private _sub?: Sub; 8 | private _onEvent?: (event: Event) => void; 9 | private _onEose?: (sub: Sub) => void; 10 | private _closeOnEose: boolean; 11 | private _createdAt: Date; 12 | 13 | constructor({ 14 | filters, 15 | closeOnEose, 16 | onEvent, 17 | onEose, 18 | }: { 19 | filters: Filter[]; 20 | closeOnEose: boolean; 21 | onEvent: (event: Event) => void; 22 | onEose: (sub: Sub) => void; 23 | }) { 24 | this._closeOnEose = closeOnEose; 25 | this._id = sha1Hash(JSON.stringify(filters)); 26 | this._filters = filters; 27 | this._onEvent = onEvent; 28 | this._onEose = onEose; 29 | this._createdAt = new Date(); 30 | } 31 | 32 | get id() { 33 | return this._id; 34 | } 35 | get filters() { 36 | return this._filters; 37 | } 38 | get closeOnEose() { 39 | return this._closeOnEose; 40 | } 41 | get createdAt() { 42 | return this._createdAt; 43 | } 44 | get onEose() { 45 | return this._onEose; 46 | } 47 | get onEvent() { 48 | return this._onEvent; 49 | } 50 | 51 | getSub() { 52 | return this._sub; 53 | } 54 | 55 | set sub(sub: Sub) { 56 | this._sub = sub; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | export function sha1Hash(data: crypto.BinaryLike) { 4 | return crypto 5 | .createHash('sha1') 6 | .update(data) 7 | .digest('base64'); 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const filterMap = ( 2 | map: Map, 3 | filterFn: (value: V) => any 4 | ) => { 5 | return Array.from(map.values()).filter(filterFn); 6 | }; 7 | 8 | export function pick(obj: T, ...keys: K[]): Pick { 9 | const ret: any = {}; 10 | keys.forEach(key => { 11 | ret[key] = obj[key]; 12 | }); 13 | return ret; 14 | } 15 | -------------------------------------------------------------------------------- /test/Account.test.ts: -------------------------------------------------------------------------------- 1 | import { Account } from '../src/lib/Account'; 2 | import { RelayManager } from '../src/lib/RelayManager'; 3 | import { relayInit, logger } from './mocks'; 4 | import { DbClientMock } from './mocks/DbClientMock'; 5 | 6 | let account: Account; 7 | let relayManager: RelayManager; 8 | let db = new DbClientMock({ logger }); 9 | 10 | describe('Account', () => { 11 | beforeEach(() => { 12 | relayManager = new RelayManager({ 13 | db, 14 | logger, 15 | relayInit, 16 | }); 17 | account = new Account({ 18 | id: 0, 19 | userId: 0, 20 | db, 21 | logger, 22 | relayManager, 23 | pubkey: 'foo', 24 | privateKey: 'bar', 25 | }); 26 | }); 27 | 28 | // this is just a silly test to prevent ts lint errors until we have more tests 29 | it('can get id', () => { 30 | expect(account.id).toBe(0); 31 | }); 32 | // expect(relayManager.subscriptions.size).toBe(0); 33 | // account.indexContactList(); 34 | // expect(relayManager.subscriptions.size).toBe(1); 35 | }); 36 | -------------------------------------------------------------------------------- /test/AccountManager.test.ts: -------------------------------------------------------------------------------- 1 | import { AccountManager } from '../src/lib/AccountManager'; 2 | import { RelayManager } from '../src/lib/RelayManager'; 3 | import { logger, relayInit } from './mocks'; 4 | import { prismaMock } from './mocks/prisma/singleton'; 5 | import { DbClientMock } from './mocks/DbClientMock'; 6 | 7 | let accountManager: AccountManager; 8 | let relayManager: RelayManager; 9 | let db = new DbClientMock({ logger }); 10 | 11 | describe('AccountManager', () => { 12 | beforeEach(() => { 13 | relayManager = new RelayManager({ 14 | db, 15 | logger, 16 | relayInit, 17 | }); 18 | accountManager = new AccountManager({ 19 | db, 20 | logger, 21 | relayManager, 22 | }); 23 | }); 24 | 25 | it('.addAccount', async () => { 26 | prismaMock.account.create.mockResolvedValue({ 27 | id: 0, 28 | user_id: 0, 29 | pubkey: 'foo', 30 | added_at: new Date(), 31 | private_key: null, 32 | }); 33 | await accountManager.addAccount({ pubkey: 'foo' }); 34 | expect(accountManager.accounts.size).toBe(1); 35 | }); 36 | 37 | it.todo('validate pubkey'); 38 | }); 39 | -------------------------------------------------------------------------------- /test/NostrEvents.test.ts: -------------------------------------------------------------------------------- 1 | import { Kind0Event } from '../src/lib/NostrEvents'; 2 | 3 | let event = { 4 | id: 'foo', 5 | sig: 'bar', 6 | kind: 0, 7 | pubkey: 'baz', 8 | content: '', 9 | tags: [['foo', 'bar']], 10 | created_at: Date.now(), 11 | }; 12 | 13 | describe('NostrEvent', () => { 14 | it('Kind 0', () => { 15 | let kind0 = new Kind0Event(event); 16 | expect(kind0.parsedContent).toEqual({}); // Empty content string parses to {} 17 | // NOTE: Parsing doesn't actually validate what fields are valid. 18 | kind0 = new Kind0Event({ 19 | ...event, 20 | content: JSON.stringify({ foo: 'bar' }), 21 | }); 22 | // Unknown fields are ignored 23 | expect(kind0.parsedContent).toEqual({}); 24 | kind0 = new Kind0Event({ 25 | ...event, 26 | content: JSON.stringify({ name: 'alice' }), 27 | }); 28 | // Unknown fields are ignored 29 | expect(kind0.parsedContent).toEqual({ name: 'alice' }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/Relay.test.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './mocks'; 2 | import { Relay } from '../src/lib/Relay'; 3 | import { 4 | Filter, 5 | SubscriptionOptions, 6 | RelayEvent, 7 | } from '../src/@types/nostr-tools-shim'; 8 | jest.mock('../src/lib/Logger'); 9 | 10 | const relayInit = jest.fn().mockImplementation((url: string) => { 11 | return { 12 | url, 13 | status: 0, 14 | connect: jest.fn().mockResolvedValue({}), 15 | close: () => {}, 16 | sub: (_filters: Filter[], _opts?: SubscriptionOptions) => {}, 17 | publish: (_event: Event) => {}, 18 | on: (_type: RelayEvent, _cb: any) => {}, 19 | off: (_type: RelayEvent, _cb: any) => {}, 20 | }; 21 | }); 22 | 23 | let relay: Relay; 24 | 25 | describe('Relay', () => { 26 | beforeEach(() => { 27 | relay = new Relay({ 28 | id: 1, 29 | url: 'testUrl', 30 | logger, 31 | relayInit, 32 | }); 33 | }); 34 | 35 | it('connected true when relay connects', async () => { 36 | expect(relay.connected).toBeFalsy(); 37 | await relay.connect(); 38 | expect(relay.connected).toBeTruthy(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/RelayManager.test.ts: -------------------------------------------------------------------------------- 1 | import { RelayManager } from '../src/lib/RelayManager'; 2 | import { logger, relayInit } from './mocks'; 3 | import { prismaMock } from './mocks/prisma/singleton'; 4 | import { DbClientMock } from './mocks/DbClientMock'; 5 | import { sha1Hash } from '../src/utils/crypto'; 6 | 7 | let relayManager: RelayManager; 8 | let db = new DbClientMock({ logger }); 9 | 10 | describe('RelayManager', () => { 11 | beforeEach(() => { 12 | relayManager = new RelayManager({ 13 | db, 14 | logger, 15 | relayInit, 16 | }); 17 | }); 18 | 19 | it('no relays when it is instantiated', () => { 20 | expect(relayManager.relays.size).toBe(0); 21 | }); 22 | 23 | it('.addRelay', async () => { 24 | prismaMock.relay.create.mockResolvedValue({ 25 | id: 0, 26 | url: 'foo', 27 | added_at: new Date(), 28 | }); 29 | await relayManager.addRelay('foo'); 30 | // relay is added 31 | expect(relayManager.relays.size).toBe(1); 32 | }); 33 | 34 | it('.addSubscription, .hasSubscription', () => { 35 | const filters = [{ kinds: [0] }]; 36 | expect(relayManager.hasSubscription(filters)).toBe(false); 37 | const id = relayManager.addSubscription({ 38 | filters, 39 | closeOnEose: false, 40 | }); 41 | // subscription is added 42 | expect(relayManager.subscriptions.size).toBe(1); 43 | // id is a hash of stringified filters 44 | expect(id).toBe(sha1Hash(JSON.stringify(filters))); 45 | expect(relayManager.hasSubscription(filters)).toBe(true); 46 | }); 47 | 48 | it.todo('subscriptions are run when relay connects'); 49 | it.todo('subscriptions are run when added'); 50 | }); 51 | -------------------------------------------------------------------------------- /test/indexer.test.ts: -------------------------------------------------------------------------------- 1 | // import { Indexer } from '../src'; 2 | 3 | // let indexer: Indexer; 4 | 5 | // NOTE: For prisma db mocking https://www.prisma.io/docs/guides/testing/unit-testing 6 | 7 | describe('Indexer', () => { 8 | // beforeAll(() => { 9 | // indexer = new Indexer() 10 | // }) 11 | it('TODO', () => { 12 | expect(true).toBeTruthy(); 13 | }); 14 | 15 | // it('will start and stop', () => { 16 | // expect(indexer.started).toBeFalsy() 17 | // expect(indexer.startedAt).toBeUndefined() 18 | // indexer.start() 19 | // expect(indexer.started).toBeTruthy() 20 | // expect(indexer.startedAt).not.toBeUndefined() 21 | // }); 22 | 23 | // it('can get db size', () => { 24 | // expect(indexer.dbSize).toBeTruthy() 25 | // }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/mocks/DbClientMock.ts: -------------------------------------------------------------------------------- 1 | import { DbClient } from '../../src/lib/DbClient'; 2 | import { Logger } from '../../src/lib/Logger'; 3 | import client from './prisma/client'; 4 | 5 | export class DbClientMock extends DbClient { 6 | constructor({ logger }: { logger: Logger }) { 7 | super({ dbPath: '', logger }); 8 | this._db = client; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/mocks/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Filter, 3 | RelayEvent, 4 | SubscriptionOptions, 5 | } from '../../src/@types/nostr-tools-shim'; 6 | import { Config } from '../../src/lib/Config'; 7 | import { Logger } from '../../src/lib/Logger'; 8 | 9 | export const relayInit = jest.fn().mockImplementation((url: string) => { 10 | return { 11 | url, 12 | status: 0, 13 | connect: jest.fn().mockResolvedValue({}), 14 | close: () => {}, 15 | sub: (_filters: Filter[], _opts?: SubscriptionOptions) => {}, 16 | publish: (_event: Event) => {}, 17 | on: (_type: RelayEvent, _cb: any) => {}, 18 | off: (_type: RelayEvent, _cb: any) => {}, 19 | }; 20 | }); 21 | 22 | const config = new Config({ dbPath: 'mock', debug: true }); 23 | 24 | export const logger = new Logger({ config }); 25 | -------------------------------------------------------------------------------- /test/mocks/prisma/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | const prisma = new PrismaClient(); 3 | export default prisma; 4 | -------------------------------------------------------------------------------- /test/mocks/prisma/singleton.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended'; 3 | import prisma from './client'; 4 | 5 | jest.mock('./client', () => ({ 6 | __esModule: true, 7 | default: mockDeep(), 8 | })); 9 | 10 | beforeEach(() => { 11 | mockReset(prismaMock); 12 | }); 13 | 14 | export const prismaMock = (prisma as unknown) as DeepMockProxy; 15 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { filterMap } from '../src/utils'; 2 | 3 | describe('filterMap', () => { 4 | it('returns filtered array from map', () => { 5 | const map = new Map(); 6 | map.set(1, { name: 'alice' }); 7 | map.set(2, { name: 'bob' }); 8 | let filtered: { name: string }[]; 9 | filtered = filterMap(map, a => a.name === 'alice'); 10 | expect(filtered).toEqual([{ name: 'alice' }]); 11 | filtered = filterMap(map, a => a.name === 'bob'); 12 | expect(filtered).toEqual([{ name: 'bob' }]); 13 | filtered = filterMap(map, a => a.name === 'charles'); 14 | expect(filtered).toEqual([]); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "target": "ES5", 6 | "module": "esnext", 7 | "lib": ["dom", "esnext"], 8 | "importHelpers": true, 9 | // output .d.ts declaration files for consumers 10 | "declaration": true, 11 | // output .js.map sourcemap files for consumers 12 | "sourceMap": true, 13 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 14 | "rootDir": "./src", 15 | // stricter type-checking for stronger correctness. Recommended by TS 16 | "strict": true, 17 | // linter checks for common issues 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | // use Node's module resolution algorithm, instead of the legacy TS one 24 | "moduleResolution": "node", 25 | // transpile JSX to React.createElement 26 | "jsx": "react", 27 | // interop between ESM and CJS modules. Recommended by TS 28 | "esModuleInterop": true, 29 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 30 | "skipLibCheck": true, 31 | // error out if import and file system have a casing mismatch. Recommended by TS 32 | "forceConsistentCasingInFileNames": true, 33 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 34 | "noEmit": true, 35 | } 36 | } 37 | --------------------------------------------------------------------------------