diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2b0ca3f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch", + "skipFiles": ["/**", "data/**"], + "args": ["--loader", "@swc-node/register/esm", "src/index.ts"], + "outFiles": ["${workspaceFolder}/**/*.js"], + "env": { + "NODE_ENV": "development", + "DEBUG": "bakery,bakery:*,applesauce,applesauce:*", + "DEBUG_HIDE_DATE": "true", + "DEBUG_COLORS": "true" + } + } + ] +} diff --git a/package.json b/package.json index 806ddce..4610797 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "@noble/hashes": "^1.7.1", "@satellite-earth/core": "^0.5.0", "applesauce-core": "next", - "applesauce-factory": "0.0.0-next-20250206174509", - "applesauce-loaders": "^0.10.0", + "applesauce-factory": "next", + "applesauce-loaders": "next", "applesauce-signers": "next", "better-sqlite3": "^11.8.1", "blossom-client-sdk": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f4cd50..2fa9e77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,16 +19,16 @@ importers: version: 0.5.0(typescript@5.7.3) applesauce-core: specifier: next - version: 0.0.0-next-20250206174509(typescript@5.7.3) + version: 0.0.0-next-20250207175032(typescript@5.7.3) applesauce-factory: - specifier: 0.0.0-next-20250206174509 - version: 0.0.0-next-20250206174509(typescript@5.7.3) + specifier: next + version: 0.0.0-next-20250207175032(typescript@5.7.3) applesauce-loaders: - specifier: ^0.10.0 - version: 0.10.0(typescript@5.7.3) + specifier: next + version: 0.0.0-next-20250207175032(typescript@5.7.3) applesauce-signers: specifier: next - version: 0.0.0-next-20250206174509(typescript@5.7.3) + version: 0.0.0-next-20250207175032(typescript@5.7.3) better-sqlite3: specifier: ^11.8.1 version: 11.8.1 @@ -560,23 +560,20 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - applesauce-content@0.0.0-next-20250206174509: - resolution: {integrity: sha512-+LXzDZdANYwZGKnpc0CFoEgF0KgKuOcYTEUrf0X79ezKejCEGwvn1UvNce2c8MIWfwhaHBTJq8kQO2gKaODQ9A==} + applesauce-content@0.0.0-next-20250207175032: + resolution: {integrity: sha512-n6fA+OGWipHXEQI8rt42/GRAYUTHvUScpXiBei9KvCs2rhIIA1wJ4dlHEyOTL12slcVMi3q6PfCxdv0uWNuHxw==} - applesauce-core@0.0.0-next-20250206174509: - resolution: {integrity: sha512-+5G8y4WwjJVvL2dfn56w0mYhm4GjL0hoMk4ZJBM7FUBcpuq40UoBsslheIFaLYuzzZHvd28EtJycbyhBKM86pQ==} + applesauce-core@0.0.0-next-20250207175032: + resolution: {integrity: sha512-jTM62hmA7BRjO3EGZePMTrl77pA1uXaWOWMbdwyDKFcil9RZUGMKV2cL73SXtuxFQJRrFmgrTWSLzOEWEfVdVg==} - applesauce-core@0.10.0: - resolution: {integrity: sha512-QMhUh4FIARcqY5soCB4Z8DIu+py0rYb28IgWT4gP9DLBGpDrY8lStXk7W1/46TLjEH97y0hbiXFK7kMCZ31oOQ==} + applesauce-factory@0.0.0-next-20250207175032: + resolution: {integrity: sha512-NMGrHpD/BxFNvm0AzK2fYzXGBjzqvoA/o6ZWZUvo8Y52qvETjz1qS7asMndMXBWszDHwo8e0buDMHjlAA5/prA==} - applesauce-factory@0.0.0-next-20250206174509: - resolution: {integrity: sha512-E5OTJLYSh5vejQmuHZOqxLajJwPiQOFnlkhKQSBMeA2RyxClOuuDIm8Qj3PXlC1T9kAqIqL/CsieUcJ1jhUWdQ==} + applesauce-loaders@0.0.0-next-20250207175032: + resolution: {integrity: sha512-2gQF3O4jS7hFjFkI2lI0gMJVn1IB4iWUSR9FjWYICQfQXZ0ewpOhhi2KzX6oXdlMeZx/y2bh8aNUO6unvagGFA==} - applesauce-loaders@0.10.0: - resolution: {integrity: sha512-cEPV8nSJKfmvVVRaSDc/3TRaDE8s0CSFJGzEvSYBTjokZBvQz2QZrlQXbwTvYD90XrWFH1LJ8dLDDnEUehaOow==} - - applesauce-signers@0.0.0-next-20250206174509: - resolution: {integrity: sha512-SfV1nG9YhF9aKS1c8uUg2ciNmetBbUgmV58l8/nnulHatGDDNtX+d34XGKxXXGRHUHk7bYtFKeeYSBNw4Wm94A==} + applesauce-signers@0.0.0-next-20250207175032: + resolution: {integrity: sha512-rJQvFpeIk9s0eHdNjl/DvMzD8pXTtx+Hr0Rcadsq0w2t+vAA0Z0B7G4xtG2Y+eCnIdSeJ/TdkebEcRLDV5eZHw==} argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1606,8 +1603,8 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} - possible-typed-array-names@1.0.0: - resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} prebuild-install@7.1.3: @@ -2605,13 +2602,13 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - applesauce-content@0.0.0-next-20250206174509(typescript@5.7.3): + applesauce-content@0.0.0-next-20250207175032(typescript@5.7.3): dependencies: '@cashu/cashu-ts': 2.0.0-rc1 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 - applesauce-core: 0.0.0-next-20250206174509(typescript@5.7.3) + applesauce-core: 0.0.0-next-20250207175032(typescript@5.7.3) mdast-util-find-and-replace: 3.0.2 nostr-tools: 2.10.4(typescript@5.7.3) remark: 15.0.1 @@ -2622,7 +2619,7 @@ snapshots: - supports-color - typescript - applesauce-core@0.0.0-next-20250206174509(typescript@5.7.3): + applesauce-core@0.0.0-next-20250207175032(typescript@5.7.3): dependencies: '@scure/base': 1.2.4 debug: 4.4.0(supports-color@5.5.0) @@ -2636,33 +2633,19 @@ snapshots: - supports-color - typescript - applesauce-core@0.10.0(typescript@5.7.3): + applesauce-factory@0.0.0-next-20250207175032(typescript@5.7.3): dependencies: - '@scure/base': 1.2.4 - debug: 4.4.0(supports-color@5.5.0) - fast-deep-equal: 3.1.3 - hash-sum: 2.0.0 - light-bolt11-decoder: 3.2.0 - nanoid: 5.0.9 - nostr-tools: 2.10.4(typescript@5.7.3) - rxjs: 7.8.1 - transitivePeerDependencies: - - supports-color - - typescript - - applesauce-factory@0.0.0-next-20250206174509(typescript@5.7.3): - dependencies: - applesauce-content: 0.0.0-next-20250206174509(typescript@5.7.3) - applesauce-core: 0.0.0-next-20250206174509(typescript@5.7.3) + applesauce-content: 0.0.0-next-20250207175032(typescript@5.7.3) + applesauce-core: 0.0.0-next-20250207175032(typescript@5.7.3) nanoid: 5.0.9 nostr-tools: 2.10.4(typescript@5.7.3) transitivePeerDependencies: - supports-color - typescript - applesauce-loaders@0.10.0(typescript@5.7.3): + applesauce-loaders@0.0.0-next-20250207175032(typescript@5.7.3): dependencies: - applesauce-core: 0.10.0(typescript@5.7.3) + applesauce-core: 0.0.0-next-20250207175032(typescript@5.7.3) nanoid: 5.0.9 nostr-tools: 2.10.4(typescript@5.7.3) rx-nostr: 3.5.0 @@ -2671,12 +2654,12 @@ snapshots: - supports-color - typescript - applesauce-signers@0.0.0-next-20250206174509(typescript@5.7.3): + applesauce-signers@0.0.0-next-20250207175032(typescript@5.7.3): dependencies: '@noble/hashes': 1.7.1 '@noble/secp256k1': 1.7.1 '@scure/base': 1.2.4 - applesauce-core: 0.0.0-next-20250206174509(typescript@5.7.3) + applesauce-core: 0.0.0-next-20250207175032(typescript@5.7.3) debug: 4.4.0(supports-color@5.5.0) nanoid: 5.0.9 nostr-tools: 2.10.4(typescript@5.7.3) @@ -2707,7 +2690,7 @@ snapshots: available-typed-arrays@1.0.7: dependencies: - possible-typed-array-names: 1.0.0 + possible-typed-array-names: 1.1.0 b4a@1.6.7: {} @@ -3918,7 +3901,7 @@ snapshots: pirates@4.0.6: {} - possible-typed-array-names@1.0.0: {} + possible-typed-array-names@1.1.0: {} prebuild-install@7.1.3: dependencies: diff --git a/src/app/database.ts b/src/app/database.ts index ba2a6d7..1fc1965 100644 --- a/src/app/database.ts +++ b/src/app/database.ts @@ -1,11 +1,8 @@ import EventEmitter from "events"; import Database, { type Database as SQLDatabase } from "better-sqlite3"; -import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - export type LocalDatabaseConfig = { directory: string; name: string; diff --git a/src/app/index.ts b/src/app/index.ts index 88694de..fe3ba78 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -1,13 +1,12 @@ -import path from "path"; import { WebSocketServer } from "ws"; import { createServer, Server } from "http"; +import { SimpleSigner } from "applesauce-signers/signers/simple-signer"; import { IEventStore, NostrRelay, SQLiteEventStore } from "@satellite-earth/core"; import { getDMRecipient } from "@satellite-earth/core/helpers/nostr"; import { kinds } from "nostr-tools"; import { AbstractRelay } from "nostr-tools/abstract-relay"; import express, { Express } from "express"; import { EventEmitter } from "events"; -import { SimpleSigner } from "applesauce-signers/signers/simple-signer"; import cors from "cors"; import { logger } from "../logger.js"; @@ -44,6 +43,12 @@ import SecretsManager from "../modules/secrets-manager.js"; import outboundNetwork, { OutboundNetworkManager } from "../modules/network/outbound/index.js"; import Switchboard from "../modules/switchboard/switchboard.js"; import Gossip from "../modules/gossip.js"; +import database from "../services/database.js"; +import secrets from "../services/secrets.js"; +import config from "../services/config.js"; +import logStore from "../services/log-store.js"; +import stateManager from "../services/state.js"; +import eventCache from "../services/event-cache.js"; type EventMap = { listening: []; @@ -81,14 +86,12 @@ export default class App extends EventEmitter { switchboard: Switchboard; gossip: Gossip; - constructor(dataPath: string) { + constructor() { super(); - this.config = new ConfigManager(path.join(dataPath, "node.json")); - this.config.read(); + this.config = config; - this.secrets = new SecretsManager(path.join(dataPath, "secrets.json")); - this.secrets.read(); + this.secrets = secrets; this.signer = new SimpleSigner(this.secrets.get("nostrKey")); @@ -131,15 +134,13 @@ export default class App extends EventEmitter { headers.push("Access-Control-Allow-Origin: *"); }); - // Init embedded sqlite database - this.database = new Database({ directory: dataPath }); + // Init sqlite database + this.database = database; // create log managers - this.logStore = new LogStore(this.database.db); - this.logStore.setup(); + this.logStore = logStore; - this.state = new ApplicationStateManager(this.database.db); - this.state.setup(); + this.state = stateManager; // Recognize local relay by matching auth string this.pool = new CautiousPool((relay: AbstractRelay, challenge: string) => { @@ -150,17 +151,16 @@ export default class App extends EventEmitter { }); // Initialize the event store - this.eventStore = new SQLiteEventStore(this.database.db); - this.eventStore.setup(); + this.eventStore = eventCache; // setup decryption cache this.decryptionCache = new DecryptionCache(this.database.db); this.decryptionCache.setup(); // Setup managers user contacts and profiles - this.addressBook = new AddressBook(this); - this.profileBook = new ProfileBook(this); - this.contactBook = new ContactBook(this); + this.addressBook = new AddressBook(); + this.profileBook = new ProfileBook(); + this.contactBook = new ContactBook(); // Setup the notifications manager this.notifications = new NotificationsManager(this /*this.eventStore, this.state*/); @@ -291,8 +291,8 @@ export default class App extends EventEmitter { // send direct message const results = await this.directMessageManager.forwardMessage(ctx.event); - if (!results || !results.some((p) => p.status === "fulfilled")) throw new Error("Failed to forward message"); - return `Forwarded message to ${results.filter((p) => p.status === "fulfilled").length}/${results.length} relays`; + if (!results || !results.some((p) => p.ok)) throw new Error("Failed to forward message"); + return `Forwarded message to ${results.filter((p) => p.ok).length}/${results.length} relays`; } else return next(); }); @@ -320,8 +320,8 @@ export default class App extends EventEmitter { const profile = this.profileBook.getProfile(pubkey); if (!profile) { this.profileBook.loadProfile(pubkey, this.addressBook.getOutboxes(pubkey)); - this.addressBook.loadOutboxes(pubkey).then((outboxes) => { - this.profileBook.loadProfile(pubkey, outboxes ?? undefined); + this.addressBook.loadMailboxes(pubkey).then((mailboxes) => { + this.profileBook.loadProfile(pubkey, mailboxes?.outboxes); }); } }; diff --git a/src/core.ts b/src/core.ts deleted file mode 100644 index 5004f1b..0000000 --- a/src/core.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createRxNostr, noopVerifier } from "rx-nostr"; -import { verifyEvent } from "nostr-tools/wasm"; -import { EventStore, QueryStore } from "applesauce-core"; -import { logger } from "./logger.js"; - -const log = logger.extend("rx-nostr"); - -export const rxNostr = createRxNostr({ - verifier: async (event) => { - try { - return verifyEvent(event); - } catch (error) { - return false; - } - }, -}); - -rxNostr.createConnectionStateObservable().subscribe((packet) => { - log(`${packet.state} ${packet.from}`); -}); - -export const eventStore = new EventStore(); -export const queryStore = new QueryStore(eventStore); diff --git a/src/env.ts b/src/env.ts index 784cf67..a5df14f 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,4 +1,5 @@ -import "dotenv/config.js"; +import "dotenv/config"; +import { mkdirp } from "mkdirp"; import { OUTBOUND_PROXY_TYPES } from "./const.js"; import { safeRelayUrls } from "applesauce-core/helpers"; @@ -7,6 +8,8 @@ import { normalizeToHexPubkey } from "./helpers/nip19.js"; export const OWNER_PUBKEY = process.env.OWNER_PUBKEY ? normalizeToHexPubkey(process.env.OWNER_PUBKEY) : undefined; export const PUBLIC_ADDRESS = process.env.PUBLIC_ADDRESS; export const DATA_PATH = process.env.DATA_PATH || "./data"; +await mkdirp(DATA_PATH); + export const PORT = parseInt(process.env.PORT ?? "") || 3000; // I2P config @@ -30,4 +33,6 @@ export const BOOTSTRAP_RELAYS = process.env.BOOTSTRAP_RELAYS export const COMMON_CONTACT_RELAYS = process.env.COMMON_CONTACT_RELAYS ? safeRelayUrls(process.env.COMMON_CONTACT_RELAYS.split(",")) - : safeRelayUrls(["wss://purplepag.es", "wss://user.kindpag.es", "wss://relay.nos.social"]); + : safeRelayUrls(["wss://purplepag.es", "wss://user.kindpag.es"]); + +export const IS_DEV = process.env.NODE_ENV === "development"; diff --git a/src/helpers/array.ts b/src/helpers/array.ts index 5cb2383..6e9127a 100644 --- a/src/helpers/array.ts +++ b/src/helpers/array.ts @@ -1,4 +1,4 @@ -export function arrayFallback(arr: T[], fallback: T[]): T[] { - if (arr.length === 0) return fallback; +export function arrayFallback(arr: T[] | undefined, fallback: T[]): T[] { + if (arr === undefined || arr.length === 0) return fallback; else return arr; } diff --git a/src/helpers/rxjs.ts b/src/helpers/rxjs.ts new file mode 100644 index 0000000..75ac0fa --- /dev/null +++ b/src/helpers/rxjs.ts @@ -0,0 +1,16 @@ +import { bufferTime, MonoTypeOperatorFunction, Subject, tap } from "rxjs"; + +export function bufferAudit(buffer = 10_000, audit: (messages: T[]) => void): MonoTypeOperatorFunction { + return (source) => { + const logBuffer = new Subject(); + + logBuffer + .pipe( + bufferTime(buffer), + tap((values) => audit(values)), + ) + .subscribe(); + + return source.pipe(tap((value) => logBuffer.next(value))); + }; +} diff --git a/src/index.ts b/src/index.ts index 4bbe744..d4eb28f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,13 +4,12 @@ import process from "node:process"; import path from "node:path"; import express, { Request } from "express"; -import { mkdirp } from "mkdirp"; import dayjs from "dayjs"; import duration from "dayjs/plugin/duration.js"; import localizedFormat from "dayjs/plugin/localizedFormat.js"; import App from "./app/index.js"; -import { DATA_PATH, PUBLIC_ADDRESS } from "./env.js"; +import { PUBLIC_ADDRESS } from "./env.js"; import { addListener, logger } from "./logger.js"; import { pathExists } from "./helpers/fs.js"; @@ -19,8 +18,7 @@ dayjs.extend(duration); dayjs.extend(localizedFormat); // create app -await mkdirp(DATA_PATH); -const app = new App(DATA_PATH); +const app = new App(); // connect logger to app LogStore addListener(({ namespace }, ...args) => { diff --git a/src/logger.ts b/src/logger.ts index c836cf7..f097b1b 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -20,6 +20,4 @@ debug.log = function (this: Debugger, ...args: any[]) { console.log.apply(this, args); }; -const logger = debug("bakery"); - -export { logger }; +export const logger = debug("bakery"); diff --git a/src/modules/address-book.ts b/src/modules/address-book.ts index ac3945a..caecf31 100644 --- a/src/modules/address-book.ts +++ b/src/modules/address-book.ts @@ -1,66 +1,44 @@ -import { NostrEvent, kinds } from "nostr-tools"; -import _throttle from "lodash.throttle"; +import { tap } from "rxjs"; +import { kinds } from "nostr-tools"; +import { MailboxesQuery } from "applesauce-core/queries"; +import { getObservableValue } from "applesauce-core/observable"; +import { getInboxes, getOutboxes } from "applesauce-core/helpers"; -import { getInboxes, getOutboxes } from "@satellite-earth/core/helpers/nostr/mailboxes.js"; import { logger } from "../logger.js"; -import App from "../app/index.js"; -import PubkeyBatchLoader from "./pubkey-batch-loader.js"; +import { COMMON_CONTACT_RELAYS } from "../env.js"; +import { replaceableLoader } from "../services/loaders.js"; +import { eventStore, queryStore } from "../services/stores.js"; +import { simpleTimeout } from "../operators/simple-timeout.js"; +import { arrayFallback } from "../helpers/array.js"; + +const DEFAULT_REQUEST_TIMEOUT = 10_000; /** Loads 10002 events for pubkeys */ export default class AddressBook { log = logger.extend("AddressBook"); - app: App; - loader: PubkeyBatchLoader; - - get extraRelays() { - return this.loader.extraRelays; - } - set extraRelays(v: string[]) { - this.loader.extraRelays = v; - } - - constructor(app: App) { - this.app = app; - - this.loader = new PubkeyBatchLoader(kinds.RelayList, this.app.pool, (pubkey) => { - return this.app.eventStore.getEventsForFilters([{ kinds: [kinds.RelayList], authors: [pubkey] }])?.[0]; - }); - - this.loader.on("event", (event) => this.app.eventStore.addEvent(event)); - this.loader.on("batch", (found, failed) => { - this.log(`Found ${found}, failed ${failed}, pending ${this.loader.queue}`); - }); - } getMailboxes(pubkey: string) { - return this.loader.getEvent(pubkey); + return eventStore.getReplaceable(kinds.RelayList, pubkey); } - getOutboxes(pubkey: string) { const mailboxes = this.getMailboxes(pubkey); return mailboxes && getOutboxes(mailboxes); } - getInboxes(pubkey: string) { const mailboxes = this.getMailboxes(pubkey); return mailboxes && getInboxes(mailboxes); } - handleEvent(event: NostrEvent) { - this.loader.handleEvent(event); - } + async loadMailboxes(pubkey: string, relays?: string[], force?: boolean) { + relays = arrayFallback(relays, COMMON_CONTACT_RELAYS); + this.log(`Requesting mailboxes from ${relays.length} relays for ${pubkey}`); + replaceableLoader.next({ kind: kinds.RelayList, pubkey, relays, force }); - async loadMailboxes(pubkey: string, relays?: string[]) { - return this.loader.getOrLoadEvent(pubkey, relays); - } - - async loadOutboxes(pubkey: string, relays?: string[]) { - const mailboxes = await this.loadMailboxes(pubkey, relays); - return mailboxes && getOutboxes(mailboxes); - } - - async loadInboxes(pubkey: string, relays?: string[]) { - const mailboxes = await this.loadMailboxes(pubkey, relays); - return mailboxes && getInboxes(mailboxes); + return getObservableValue( + queryStore.createQuery(MailboxesQuery, pubkey).pipe( + simpleTimeout(DEFAULT_REQUEST_TIMEOUT, `Failed to load mailboxes for ${pubkey}`), + tap((m) => m && this.log(`Found mailboxes for ${pubkey}`, m)), + ), + ); } } diff --git a/src/modules/contact-book.ts b/src/modules/contact-book.ts index 3a5ef62..3616fac 100644 --- a/src/modules/contact-book.ts +++ b/src/modules/contact-book.ts @@ -1,33 +1,24 @@ -import { NostrEvent, kinds } from "nostr-tools"; -import _throttle from "lodash.throttle"; +import { tap } from "rxjs"; +import { kinds } from "nostr-tools"; +import { ReplaceableQuery, UserContactsQuery } from "applesauce-core/queries"; +import { getObservableValue } from "applesauce-core/observable"; -import { COMMON_CONTACT_RELAYS } from "../env.js"; import { logger } from "../logger.js"; -import App from "../app/index.js"; -import PubkeyBatchLoader from "./pubkey-batch-loader.js"; +import { COMMON_CONTACT_RELAYS } from "../env.js"; +import { replaceableLoader } from "../services/loaders.js"; +import { eventStore, queryStore } from "../services/stores.js"; +import { simpleTimeout } from "../operators/simple-timeout.js"; +import { arrayFallback } from "../helpers/array.js"; + +const DEFAULT_REQUEST_TIMEOUT = 10_000; /** Loads 3 contact lists for pubkeys */ export default class ContactBook { log = logger.extend("ContactsBook"); - app: App; - loader: PubkeyBatchLoader; - extraRelays = COMMON_CONTACT_RELAYS; - - constructor(app: App) { - this.app = app; - - this.loader = new PubkeyBatchLoader(kinds.Contacts, this.app.pool, (pubkey) => { - return this.app.eventStore.getEventsForFilters([{ kinds: [kinds.Contacts], authors: [pubkey] }])?.[0]; - }); - - this.loader.on("event", (event) => this.app.eventStore.addEvent(event)); - this.loader.on("batch", (found, failed) => { - this.log(`Found ${found}, failed ${failed}, pending ${this.loader.queue}`); - }); - } + /** @deprecated use loadContacts instead */ getContacts(pubkey: string) { - return this.loader.getEvent(pubkey); + return eventStore.getReplaceable(kinds.Contacts, pubkey); } getFollowedPubkeys(pubkey: string): string[] { @@ -44,11 +35,29 @@ export default class ContactBook { return []; } - handleEvent(event: NostrEvent) { - this.loader.handleEvent(event); + async loadContacts(pubkey: string, relays?: string[], force?: boolean) { + relays = arrayFallback(relays, COMMON_CONTACT_RELAYS); + this.log(`Requesting contacts from ${relays.length} relays for ${pubkey}`); + replaceableLoader.next({ kind: kinds.Contacts, pubkey, relays, force }); + + return getObservableValue( + queryStore + .createQuery(UserContactsQuery, pubkey) + .pipe(simpleTimeout(DEFAULT_REQUEST_TIMEOUT, `Failed to load contacts for ${pubkey}`)), + ); } - async loadContacts(pubkey: string, relays: string[] = []) { - return this.loader.getOrLoadEvent(pubkey, relays); + /** @deprecated */ + async loadContactsEvent(pubkey: string, relays?: string[]) { + relays = arrayFallback(relays, COMMON_CONTACT_RELAYS); + this.log(`Requesting contacts from ${relays.length} relays for ${pubkey}`); + replaceableLoader.next({ kind: kinds.Contacts, pubkey, relays }); + + return getObservableValue( + queryStore.createQuery(ReplaceableQuery, kinds.Contacts, pubkey).pipe( + simpleTimeout(DEFAULT_REQUEST_TIMEOUT, `Failed to load contacts for ${pubkey}`), + tap((c) => c && this.log(`Found contacts for ${pubkey}`, c)), + ), + ); } } diff --git a/src/modules/direct-message-manager.ts b/src/modules/direct-message-manager.ts index 4b9d099..f9d5f91 100644 --- a/src/modules/direct-message-manager.ts +++ b/src/modules/direct-message-manager.ts @@ -1,13 +1,17 @@ +import { filter, lastValueFrom, mergeMap, Subscription, tap, toArray } from "rxjs"; import { NostrEvent, kinds } from "nostr-tools"; -import { SubCloser } from "nostr-tools/abstract-pool"; -import { Subscription } from "nostr-tools/abstract-relay"; -import { getInboxes } from "applesauce-core/helpers"; +import { createRxForwardReq } from "rx-nostr"; +import { getRelaysFromContactList } from "@satellite-earth/core/helpers/nostr/contacts.js"; +import { MailboxesQuery } from "applesauce-core/queries"; import { EventEmitter } from "events"; -import { getRelaysFromContactList } from "@satellite-earth/core/helpers/nostr/contacts.js"; import { logger } from "../logger.js"; import type App from "../app/index.js"; import { arrayFallback } from "../helpers/array.js"; +import { rxNostr } from "../services/rx-nostr.js"; +import { eventStore, queryStore } from "../services/stores.js"; +import { COMMON_CONTACT_RELAYS } from "../env.js"; +import { bufferAudit } from "../helpers/rxjs.js"; type EventMap = { open: [string, string]; @@ -47,11 +51,11 @@ export default class DirectMessageManager extends EventEmitter { if (!addressedTo) return; // get users inboxes - let relays = await this.app.addressBook.loadInboxes(addressedTo); + let relays = (await this.app.addressBook.loadMailboxes(addressedTo))?.inboxes; if (!relays || relays.length === 0) { // try to send the DM to the users legacy app relays - const contacts = await this.app.contactBook.loadContacts(addressedTo); + const contacts = await this.app.contactBook.loadContactsEvent(addressedTo); if (contacts) { const appRelays = getRelaysFromContactList(contacts); @@ -65,7 +69,7 @@ export default class DirectMessageManager extends EventEmitter { } this.log(`Forwarding message to ${relays.length} relays`); - const results = await Promise.allSettled(this.app.pool.publish(relays, event)); + const results = await lastValueFrom(rxNostr.send(event, { on: { relays } }).pipe(toArray())); return results; } @@ -75,95 +79,93 @@ export default class DirectMessageManager extends EventEmitter { else return b + ":" + a; } - watching = new Map>(); + watching = new Map(); async watchInbox(pubkey: string) { if (this.watching.has(pubkey)) return; + this.app.addressBook.loadMailboxes(pubkey, COMMON_CONTACT_RELAYS, true); + this.log(`Watching ${pubkey} inboxes for mail`); - const mailboxes = await this.app.addressBook.loadMailboxes(pubkey); - if (!mailboxes) { - this.log(`Failed to get ${pubkey} mailboxes`); - return; - } + const subscription = queryStore + .createQuery(MailboxesQuery, pubkey) + .pipe( + // ignore undefined + filter((m) => !!m), + // start a new subscription for each update + mergeMap((mailboxes) => { + const relays = arrayFallback(mailboxes.inboxes, this.explicitRelays); + this.log(`Subscribing to ${relays.length} relays for ${pubkey}`); - const relays = arrayFallback(getInboxes(mailboxes), this.explicitRelays); - const subscriptions = new Map(); + const req = createRxForwardReq(); + const sub = rxNostr.use(req, { on: { relays } }).pipe( + filter((packet) => this.app.eventStore.addEvent(packet.event)), + // also pass to event store + tap((packet) => eventStore.add(packet.event, packet.from)), + // log how many events where found every 10s + bufferAudit(10_000, (events) => { + if (events.length > 0) this.log(`Found ${events.length} new events for ${pubkey}`); + }), + ); - for (const url of relays) { - const subscribe = async () => { - const relay = await this.app.pool.ensureRelay(url); - const sub = relay.subscribe([{ kinds: [kinds.EncryptedDirectMessage], "#p": [pubkey] }], { - onevent: (event) => { - this.app.eventStore.addEvent(event); - }, - onclose: () => { - // reconnect if we are still watching this pubkey - if (this.watching.has(pubkey)) { - this.log(`Reconnecting to ${relay.url} for ${pubkey} inbox DMs`); - setTimeout(() => subscribe(), 30_000); - } - }, - }); + req.emit({ kinds: [kinds.EncryptedDirectMessage], "#p": [pubkey] }); - subscriptions.set(relay.url, sub); - }; + return sub; + }), + ) + .subscribe(); - subscribe(); - } - this.watching.set(pubkey, subscriptions); + this.watching.set(pubkey, subscription); } stopWatchInbox(pubkey: string) { - const subs = this.watching.get(pubkey); - if (subs) { + const sub = this.watching.get(pubkey); + if (sub) { + sub.unsubscribe(); this.watching.delete(pubkey); - for (const [_, sub] of subs) { - sub.close(); - } } } - subscriptions = new Map(); + openConversations = new Map(); async openConversation(a: string, b: string) { const key = this.getConversationKey(a, b); - if (this.subscriptions.has(key)) return; + if (this.openConversations.has(key)) return; const aMailboxes = await this.app.addressBook.loadMailboxes(a); const bMailboxes = await this.app.addressBook.loadMailboxes(b); // If inboxes for either user cannot be determined, either because nip65 - // was not found, or nip65 had no listed read relays, fall back to explicit - const aInboxes = aMailboxes ? arrayFallback(getInboxes(aMailboxes), this.explicitRelays) : this.explicitRelays; - const bInboxes = bMailboxes ? arrayFallback(getInboxes(bMailboxes), this.explicitRelays) : this.explicitRelays; + // was not found, or nip65 had no listed read relays, fallback to explicit relays + const aInboxes = aMailboxes ? arrayFallback(aMailboxes.inboxes, this.explicitRelays) : this.explicitRelays; + const bInboxes = bMailboxes ? arrayFallback(bMailboxes.inboxes, this.explicitRelays) : this.explicitRelays; const relays = new Set([...aInboxes, ...bInboxes]); - let events = 0; - const sub = this.app.pool.subscribeMany( - Array.from(relays), - [{ kinds: [kinds.EncryptedDirectMessage], authors: [a, b], "#p": [a, b] }], - { - onevent: (event) => { - events += +this.app.eventStore.addEvent(event); - }, - oneose: () => { - if (events) this.log(`Found ${events} new messages`); - }, - }, - ); + const req = createRxForwardReq(); + const sub = rxNostr + .use(req) + .pipe(filter((packet) => this.app.eventStore.addEvent(packet.event))) + .subscribe(); + + req.emit([{ kinds: [kinds.EncryptedDirectMessage], authors: [a, b], "#p": [a, b] }]); this.log(`Opened conversation ${key} on ${relays.size} relays`); - this.subscriptions.set(key, sub); + this.openConversations.set(key, sub); this.emit("open", a, b); } + closeConversation(a: string, b: string) { const key = this.getConversationKey(a, b); - const sub = this.subscriptions.get(key); + const sub = this.openConversations.get(key); if (sub) { - sub.close(); - this.subscriptions.delete(key); + sub.unsubscribe(); + this.openConversations.delete(key); this.emit("close", a, b); } } + + [Symbol.dispose]() { + for (const [_, sub] of this.watching) sub.unsubscribe(); + for (const [_, sub] of this.openConversations) sub.unsubscribe(); + } } diff --git a/src/modules/notifications/notifications-manager.ts b/src/modules/notifications/notifications-manager.ts index 0b11d10..24840e8 100644 --- a/src/modules/notifications/notifications-manager.ts +++ b/src/modules/notifications/notifications-manager.ts @@ -1,13 +1,15 @@ import { NotificationChannel, WebPushNotification } from "@satellite-earth/core/types/control-api/notifications.js"; -import { getDMRecipient, getDMSender, getUserDisplayName, parseKind0Event } from "@satellite-earth/core/helpers/nostr"; +import { getDMRecipient, getDMSender, getUserDisplayName } from "@satellite-earth/core/helpers/nostr"; import { NostrEvent, kinds } from "nostr-tools"; import { npubEncode } from "nostr-tools/nip19"; +import { getDisplayName, unixNow } from "applesauce-core/helpers"; import EventEmitter from "events"; import webPush from "web-push"; -import dayjs from "dayjs"; import { logger } from "../../logger.js"; import App from "../../app/index.js"; +import stateManager from "../../services/state.js"; +import config from "../../services/config.js"; export type NotificationsManagerState = { channels: NotificationChannel[]; @@ -22,7 +24,7 @@ type EventMap = { export default class NotificationsManager extends EventEmitter { log = logger.extend("Notifications"); app: App; - lastRead: number = dayjs().unix(); + lastRead: number = unixNow(); webPushKeys: webPush.VapidKeys = webPush.generateVAPIDKeys(); @@ -39,7 +41,7 @@ export default class NotificationsManager extends EventEmitter { async setup() { this.state = ( - await this.app.state.getMutableState("notification-manager", { channels: [] }) + await stateManager.getMutableState("notification-manager", { channels: [] }) ).proxy; } @@ -71,7 +73,7 @@ export default class NotificationsManager extends EventEmitter { /** Whether a notification should be sent */ shouldNotify(event: NostrEvent) { if (event.kind !== kinds.EncryptedDirectMessage) return; - if (getDMRecipient(event) !== this.app.config.data.owner) return; + if (getDMRecipient(event) !== config.data.owner) return; if (event.created_at > this.lastRead) return true; } @@ -82,9 +84,8 @@ export default class NotificationsManager extends EventEmitter { switch (event.kind) { case kinds.EncryptedDirectMessage: const sender = getDMSender(event); - const senderProfileEvent = await this.app.profileBook.loadProfile(sender); - const senderProfile = senderProfileEvent ? parseKind0Event(senderProfileEvent) : undefined; - const senderName = getUserDisplayName(senderProfile, sender); + const senderProfile = await this.app.profileBook.loadProfile(sender); + const senderName = senderProfile ? (getDisplayName(senderProfile) ?? "Unknown") : "Unknown"; return { kind: event.kind, diff --git a/src/modules/profile-book.ts b/src/modules/profile-book.ts index 4131f98..fbf9175 100644 --- a/src/modules/profile-book.ts +++ b/src/modules/profile-book.ts @@ -1,40 +1,35 @@ -import { NostrEvent, kinds } from "nostr-tools"; -import _throttle from "lodash.throttle"; +import { tap } from "rxjs"; +import { kinds } from "nostr-tools"; +import { getObservableValue } from "applesauce-core/observable"; +import { ProfileQuery } from "applesauce-core/queries"; import { COMMON_CONTACT_RELAYS } from "../env.js"; import { logger } from "../logger.js"; -import App from "../app/index.js"; -import PubkeyBatchLoader from "./pubkey-batch-loader.js"; +import { replaceableLoader } from "../services/loaders.js"; +import { eventStore, queryStore } from "../services/stores.js"; +import { simpleTimeout } from "../operators/simple-timeout.js"; +import { arrayFallback } from "../helpers/array.js"; + +const DEFAULT_REQUEST_TIMEOUT = 10_000; /** loads kind 0 metadata for pubkeys */ export default class ProfileBook { log = logger.extend("ProfileBook"); - app: App; - loader: PubkeyBatchLoader; - extraRelays = COMMON_CONTACT_RELAYS; - - constructor(app: App) { - this.app = app; - - this.loader = new PubkeyBatchLoader(kinds.Metadata, this.app.pool, (pubkey) => { - return this.app.eventStore.getEventsForFilters([{ kinds: [kinds.Metadata], authors: [pubkey] }])?.[0]; - }); - - this.loader.on("event", (event) => this.app.eventStore.addEvent(event)); - this.loader.on("batch", (found, failed) => { - this.log(`Found ${found}, failed ${failed}, pending ${this.loader.queue}`); - }); - } getProfile(pubkey: string) { - return this.loader.getEvent(pubkey); + return eventStore.getReplaceable(kinds.Metadata, pubkey); } - handleEvent(event: NostrEvent) { - this.loader.handleEvent(event); - } + async loadProfile(pubkey: string, relays?: string[], force?: boolean) { + relays = arrayFallback(relays, COMMON_CONTACT_RELAYS); + this.log(`Requesting profile from ${relays.length} relays for ${pubkey}`); + replaceableLoader.next({ kind: kinds.Metadata, pubkey, relays, force }); - async loadProfile(pubkey: string, relays: string[] = []) { - return this.loader.getOrLoadEvent(pubkey, relays); + return getObservableValue( + queryStore.createQuery(ProfileQuery, pubkey).pipe( + simpleTimeout(DEFAULT_REQUEST_TIMEOUT, `Failed to load profile for ${pubkey}`), + tap((p) => p && this.log(`Found profile for ${pubkey}`, p)), + ), + ); } } diff --git a/src/modules/receiver/index.ts b/src/modules/receiver/index.ts index 1296330..00056f1 100644 --- a/src/modules/receiver/index.ts +++ b/src/modules/receiver/index.ts @@ -1,36 +1,13 @@ import EventEmitter from "events"; -import { NostrEvent, SimplePool, Filter } from "nostr-tools"; +import { NostrEvent, SimplePool } from "nostr-tools"; import SuperMap from "@satellite-earth/core/helpers/super-map.js"; -import { AbstractRelay, Subscription, SubscriptionParams } from "nostr-tools/abstract-relay"; -import { getPubkeysFromList } from "@satellite-earth/core/helpers/nostr/lists.js"; -import { getInboxes, getOutboxes } from "@satellite-earth/core/helpers/nostr/mailboxes.js"; -import { getRelaysFromContactList } from "@satellite-earth/core/helpers/nostr/contacts.js"; +import { Subscription } from "nostr-tools/abstract-relay"; +import { getRelaysFromContactsEvent } from "applesauce-core/helpers"; -import { BOOTSTRAP_RELAYS } from "../../env.js"; +import { BOOTSTRAP_RELAYS, COMMON_CONTACT_RELAYS } from "../../env.js"; import { logger } from "../../logger.js"; import App from "../../app/index.js"; - -/** creates a new subscription and waits for it to get an event or close */ -function asyncSubscription(relay: AbstractRelay, filters: Filter[], opts: SubscriptionParams) { - let resolved = false; - - return new Promise((res, rej) => { - const sub = relay.subscribe(filters, { - onevent: (event) => { - if (!resolved) res(sub); - opts.onevent?.(event); - }, - oneose: () => { - if (!resolved) res(sub); - opts.oneose?.(); - }, - onclose: (reason) => { - if (!resolved) rej(new Error(reason)); - opts.onclose?.(reason); - }, - }); - }); -} +import { arrayFallback } from "../../helpers/array.js"; type EventMap = { started: [Receiver]; @@ -83,24 +60,26 @@ export default class Receiver extends EventEmitter { const owner = this.app.config.data.owner; if (!owner) throw new Error("Missing owner"); - const ownerMailboxes = await this.app.addressBook.loadMailboxes(owner); - const ownerInboxes = getInboxes(ownerMailboxes); - const ownerOutboxes = getOutboxes(ownerMailboxes); + const commonMailboxesRelays = [...BOOTSTRAP_RELAYS, ...COMMON_CONTACT_RELAYS]; + + const ownerMailboxes = await this.app.addressBook.loadMailboxes(owner, commonMailboxesRelays, true); this.log("Searching for owner kind:3 contacts"); - const contacts = await this.app.contactBook.loadContacts(owner); + const contacts = await this.app.contactBook.loadContacts( + owner, + arrayFallback(ownerMailboxes?.outboxes, BOOTSTRAP_RELAYS), + true, + ); if (!contacts) throw new Error("Cant find contacts"); this.pubkeyRelays.clear(); this.relayPubkeys.clear(); // add the owners details - this.pubkeyRelays.set(owner, new Set(ownerOutboxes)); - for (const url of ownerOutboxes) this.relayPubkeys.get(url).add(owner); + this.pubkeyRelays.set(owner, new Set(ownerMailboxes?.outboxes)); + if (ownerMailboxes?.outboxes) for (const url of ownerMailboxes?.outboxes) this.relayPubkeys.get(url).add(owner); - const people = getPubkeysFromList(contacts); - - this.log(`Found ${people.length} contacts`); + this.log(`Found ${contacts.length} contacts`); let usersWithMailboxes = 0; let usersWithContactRelays = 0; @@ -108,20 +87,28 @@ export default class Receiver extends EventEmitter { // fetch all addresses in parallel await Promise.all( - people.map(async (person) => { - const mailboxes = await this.app.addressBook.loadMailboxes(person.pubkey, ownerInboxes ?? BOOTSTRAP_RELAYS); + contacts.map(async (person) => { + const mailboxes = await this.app.addressBook.loadMailboxes( + person.pubkey, + arrayFallback(ownerMailboxes?.inboxes, commonMailboxesRelays), + ); - let relays = getOutboxes(mailboxes); + let relays = mailboxes?.outboxes ?? []; // if the user does not have any mailboxes try to get the relays stored in the contact list if (relays.length === 0) { this.log(`Failed to find mailboxes for ${person.pubkey}`); - const contacts = await this.app.contactBook.loadContacts(person.pubkey, ownerInboxes ?? BOOTSTRAP_RELAYS); + const contacts = await this.app.contactBook.loadContactsEvent( + person.pubkey, + arrayFallback(ownerMailboxes?.inboxes, BOOTSTRAP_RELAYS), + ); if (contacts && contacts.content.startsWith("{")) { - const parsed = getRelaysFromContactList(contacts); + const parsed = getRelaysFromContactsEvent(contacts); if (parsed) { - relays = parsed.filter((r) => r.write).map((r) => r.url); + relays = Array.from(parsed.entries()) + .filter(([r, mode]) => mode === "all" || mode === "outbox") + .map(([r]) => r); usersWithContactRelays++; } else { relays = BOOTSTRAP_RELAYS; diff --git a/src/modules/scrapper/index.ts b/src/modules/scrapper/index.ts index 752982d..a388ecc 100644 --- a/src/modules/scrapper/index.ts +++ b/src/modules/scrapper/index.ts @@ -5,7 +5,6 @@ import { Deferred, createDefer } from "applesauce-core/promise"; import App from "../../app/index.js"; import { logger } from "../../logger.js"; -import { getPubkeysFromList } from "@satellite-earth/core/helpers/nostr/lists.js"; import PubkeyScrapper from "./pubkey-scrapper.js"; const MAX_TASKS = 10; @@ -49,7 +48,7 @@ export default class Scrapper extends EventEmitter { if (!contacts) throw new Error("Missing contact list"); - return { contacts: getPubkeysFromList(contacts), mailboxes }; + return { contacts, mailboxes }; } private async scrapeOwner() { @@ -104,7 +103,7 @@ export default class Scrapper extends EventEmitter { // check running again since this is resuming if (!this.running) return; - const promise = this.scrapeForPubkey(person.pubkey, person.relay); + const promise = this.scrapeForPubkey(person.pubkey, person.relays?.[0]); // add it to the tasks array this.tasks.add(promise); diff --git a/src/modules/scrapper/pubkey-scrapper.ts b/src/modules/scrapper/pubkey-scrapper.ts index f08ddd5..70a0c1c 100644 --- a/src/modules/scrapper/pubkey-scrapper.ts +++ b/src/modules/scrapper/pubkey-scrapper.ts @@ -3,7 +3,6 @@ import { NostrEvent } from "nostr-tools"; import { EventEmitter } from "events"; import { Debugger } from "debug"; -import { getOutboxes } from "@satellite-earth/core/helpers/nostr/mailboxes.js"; import PubkeyRelayScrapper, { PubkeyRelayScrapperState } from "./pubkey-relay-scrapper.js"; import { logger } from "../../logger.js"; @@ -39,9 +38,7 @@ export default class PubkeyScrapper extends EventEmitter { async loadNext() { const { mailboxes } = await this.ensureData(); - const outboxes = getOutboxes(mailboxes); - - const relays = [...outboxes, ...this.additionalRelays]; + const relays = [...(mailboxes?.outboxes ?? []), ...this.additionalRelays]; const scrappers: PubkeyRelayScrapper[] = []; for (const url of relays) { if (this.failed.has(url)) continue; diff --git a/src/operators/simple-timeout.ts b/src/operators/simple-timeout.ts new file mode 100644 index 0000000..b604a1b --- /dev/null +++ b/src/operators/simple-timeout.ts @@ -0,0 +1,5 @@ +import { MonoTypeOperatorFunction, throwError, timeout } from "rxjs"; + +export function simpleTimeout(first: number, message: string): MonoTypeOperatorFunction { + return timeout({ first, with: () => throwError(() => new Error(message)) }); +} diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..d6bb001 --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,10 @@ +import path from "node:path"; + +import ConfigManager from "../modules/config-manager.js"; +import { DATA_PATH } from "../env.js"; + +const config = new ConfigManager(path.join(DATA_PATH, "node.json")); + +config.read(); + +export default config; diff --git a/src/services/database.ts b/src/services/database.ts new file mode 100644 index 0000000..fc064fc --- /dev/null +++ b/src/services/database.ts @@ -0,0 +1,7 @@ +import LocalDatabase from "../app/database.js"; +import { DATA_PATH } from "../env.js"; + +// setup database +const database = new LocalDatabase({ directory: DATA_PATH }); + +export default database; diff --git a/src/services/event-cache.ts b/src/services/event-cache.ts new file mode 100644 index 0000000..458222b --- /dev/null +++ b/src/services/event-cache.ts @@ -0,0 +1,7 @@ +import { SQLiteEventStore } from "@satellite-earth/core"; +import database from "./database.js"; + +const eventCache = new SQLiteEventStore(database.db); +await eventCache.setup(); + +export default eventCache; diff --git a/src/services/loaders.ts b/src/services/loaders.ts new file mode 100644 index 0000000..3dff79c --- /dev/null +++ b/src/services/loaders.ts @@ -0,0 +1,23 @@ +import { from, tap } from "rxjs"; +import { Filter } from "nostr-tools"; +import { isFromCache, markFromCache } from "applesauce-core/helpers"; +import { ReplaceableLoader } from "applesauce-loaders/loaders"; + +import { rxNostr } from "./rx-nostr.js"; +import eventCache from "./event-cache.js"; +import { eventStore } from "./stores.js"; + +function cacheRequest(filters: Filter[]) { + const events = eventCache.getEventsForFilters(filters); + return from(events).pipe(tap(markFromCache)); +} + +export const replaceableLoader = new ReplaceableLoader(rxNostr, { cacheRequest }); + +replaceableLoader.subscribe((packet) => { + // add it to event store + const event = eventStore.add(packet.event, packet.from); + + // save it to the cache if its new + if (!isFromCache(event)) eventCache.addEvent(event); +}); diff --git a/src/services/log-store.ts b/src/services/log-store.ts new file mode 100644 index 0000000..5b73ea6 --- /dev/null +++ b/src/services/log-store.ts @@ -0,0 +1,7 @@ +import LogStore from "../modules/log-store/log-store.js"; +import database from "./database.js"; + +const logStore = new LogStore(database.db); +await logStore.setup(); + +export default logStore; diff --git a/src/services/rx-nostr.ts b/src/services/rx-nostr.ts new file mode 100644 index 0000000..f370ecc --- /dev/null +++ b/src/services/rx-nostr.ts @@ -0,0 +1,36 @@ +import { ConnectionState, createRxNostr } from "rx-nostr"; +import { unixNow } from "applesauce-core/helpers"; +import { verifyEvent } from "nostr-tools/wasm"; +import { BehaviorSubject } from "rxjs"; +import { nanoid } from "nanoid"; + +// import { logger } from "../logger.js"; +// const log = logger.extend("rx-nostr"); + +export const rxNostr = createRxNostr({ + verifier: async (event) => { + try { + return verifyEvent(event); + } catch (error) { + return false; + } + }, +}); + +// keep track of all relay connection states +export const connections$ = new BehaviorSubject>({}); +rxNostr.createConnectionStateObservable().subscribe((packet) => { + const url = new URL(packet.from).toString(); + connections$.next({ ...connections$.value, [url]: packet.state }); +}); + +// capture all notices sent from relays +export const notices$ = new BehaviorSubject<{ id: string; from: string; message: string; timestamp: number }[]>([]); +rxNostr.createAllMessageObservable().subscribe((packet) => { + if (packet.type === "NOTICE") { + const from = new URL(packet.from).toString(); + + const notice = { id: nanoid(), from, message: packet.notice, timestamp: unixNow() }; + notices$.next([...notices$.value, notice]); + } +}); diff --git a/src/services/secrets.ts b/src/services/secrets.ts new file mode 100644 index 0000000..01fbce6 --- /dev/null +++ b/src/services/secrets.ts @@ -0,0 +1,9 @@ +import path from "node:path"; + +import SecretsManager from "../modules/secrets-manager.js"; +import { DATA_PATH } from "../env.js"; + +const secrets = new SecretsManager(path.join(DATA_PATH, "secrets.json")); +secrets.read(); + +export default secrets; diff --git a/src/services/state.ts b/src/services/state.ts new file mode 100644 index 0000000..8898df9 --- /dev/null +++ b/src/services/state.ts @@ -0,0 +1,7 @@ +import ApplicationStateManager from "../modules/state/application-state-manager.js"; +import database from "./database.js"; + +const stateManager = new ApplicationStateManager(database.db); +await stateManager.setup(); + +export default stateManager; diff --git a/src/services/stores.ts b/src/services/stores.ts new file mode 100644 index 0000000..8c65b00 --- /dev/null +++ b/src/services/stores.ts @@ -0,0 +1,20 @@ +import { EventStore, QueryStore } from "applesauce-core"; +import { IS_DEV } from "../env.js"; +import { rxNostr } from "./rx-nostr.js"; + +export const eventStore = new EventStore(); +export const queryStore = new QueryStore(eventStore); + +// add all new events to event store +rxNostr.createAllEventObservable().subscribe((packet) => { + eventStore.add(packet.event, packet.from); +}); + +setTimeout(() => { + eventStore.database.prune(); +}, 10 * 60_000); + +if (IS_DEV) { + // @ts-ignore + global.eventStore = eventStore; +}