From 58eefb42a58b37a3fdf4dab6022d4997af740003 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 26 Jun 2024 10:38:11 -0700 Subject: [PATCH] Add DVM package --- README.md | 1 + package-lock.json | 31 ++++++++-- packages/dvm/.eslintignore | 2 + packages/dvm/README.md | 89 ++++++++++++++++++++++++++ packages/dvm/package.json | 39 ++++++++++++ packages/dvm/src/handler.ts | 120 ++++++++++++++++++++++++++++++++++++ packages/dvm/src/index.ts | 2 + packages/dvm/src/request.ts | 47 ++++++++++++++ packages/dvm/tsc-multi.json | 7 +++ packages/dvm/tsconfig.json | 11 ++++ packages/lib/package.json | 2 +- packages/net/package.json | 2 +- packages/util/package.json | 2 +- 13 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 packages/dvm/.eslintignore create mode 100644 packages/dvm/README.md create mode 100644 packages/dvm/package.json create mode 100644 packages/dvm/src/handler.ts create mode 100644 packages/dvm/src/index.ts create mode 100644 packages/dvm/src/request.ts create mode 100644 packages/dvm/tsc-multi.json create mode 100644 packages/dvm/tsconfig.json diff --git a/README.md b/README.md index c316733..5db5a30 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,4 @@ This is a monorepo which is split into several different packages: - [@welshman/net](./packages/net) - framework for interacting with relays. - [@welshman/content](./packages/content) - utilities for parsing and rendering notes. - [@welshman/feeds](./packages/feeds) - an interpreter for custom nostr feeds. +- [@welshman/dvm](./packages/dvm) - utilities for creating and making request against dvms. diff --git a/package-lock.json b/package-lock.json index 5b8421f..e310f6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -541,6 +541,10 @@ "resolved": "packages/content", "link": true }, + "node_modules/@welshman/dvm": { + "resolved": "packages/dvm", + "link": true + }, "node_modules/@welshman/feeds": { "resolved": "packages/feeds", "link": true @@ -2996,8 +3000,9 @@ } }, "node_modules/ws": { - "version": "8.16.0", - "license": "MIT", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, @@ -3085,6 +3090,22 @@ "typescript": "~5.1.6" } }, + "packages/dvm": { + "name": "@welshman/dvm", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@welshman/lib": "0.0.11", + "@welshman/net": "0.0.14", + "@welshman/util": "0.0.16", + "nostr-tools": "^2.7.0" + }, + "devDependencies": { + "gts": "^5.0.1", + "tsc-multi": "^1.1.0", + "typescript": "~5.1.6" + } + }, "packages/feeds": { "name": "@welshman/feeds", "version": "0.0.12", @@ -3100,7 +3121,7 @@ }, "packages/lib": { "name": "@welshman/lib", - "version": "0.0.10", + "version": "0.0.11", "license": "MIT", "dependencies": { "@scure/base": "^1.1.6", @@ -3127,7 +3148,7 @@ "version": "0.0.14", "license": "MIT", "dependencies": { - "@welshman/lib": "0.0.10", + "@welshman/lib": "0.0.11", "@welshman/util": "0.0.16", "isomorphic-ws": "^5.0.0", "ws": "^8.16.0" @@ -3143,7 +3164,7 @@ "version": "0.0.16", "license": "MIT", "dependencies": { - "@welshman/lib": "0.0.10", + "@welshman/lib": "0.0.11", "nostr-tools": "^2.3.2" }, "devDependencies": { diff --git a/packages/dvm/.eslintignore b/packages/dvm/.eslintignore new file mode 100644 index 0000000..43e824a --- /dev/null +++ b/packages/dvm/.eslintignore @@ -0,0 +1,2 @@ +build +normalize-url diff --git a/packages/dvm/README.md b/packages/dvm/README.md new file mode 100644 index 0000000..e876370 --- /dev/null +++ b/packages/dvm/README.md @@ -0,0 +1,89 @@ +# @welshman/dvm [![version](https://badgen.net/npm/v/@welshman/dvm)](https://npmjs.com/package/@welshman/dvm) + +Utilities for building nostr DVMs. + +# Request example + +```javascript +import type {Publish, Subscription} from '@welshman/net' +import {makeDvmRequest, DVMEvent} from '@welshman/dvm' + +const req = makeDvmRequest({ + // Create and sign a dvm request event, including any desired tags + event: createAndSign({kind: 5300}), + // Publish and subscribe to these relays + relays: ['wss://relay.damus.io', 'wss://dvms.f7z.io'], + // Timeout defaults to 30 seconds + timeout: 30_000, + // Auto close on first result (defaults to true) + autoClose: true, + // Listen for and emit `progress` events + reportProgress: true, +}) + +// Listen for progress, result, etc +req.emitter.on(DVMEvent.Progress, (url, event) => console.log(event)) +req.emitter.on(DVMEvent.Result, (url, event) => console.log(event)) +``` + +# Handler example + +```javascript +const {bytesToHex} = require('@noble/hashes/utils') +const {generateSecretKey} = require('nostr-tools') +const {createEvent} = require('@welshman/util') +const {subscribe} = require('@welshman/net') +const {DVM} = require('@welshman/dvm') + +// Your DVM's private key. Store this somewhere safe +// const hexPrivateKey = bytesToHex(generateSecretKey()) +const hexPrivateKey = '9cd387a3aa0c1abc2ef517c8402f29c069b4174e02a426491aec7566501bee67' + +// Tags that we'll return as content discovery suggestions +const tags = [] + +// Populate the tags with music by Ainsley Costello +const sub = subscribe({ + timeout: 30_000, + relays: ["wss://relay.wavlake.com"], + filters: [{ + kinds: [31337], + '#p': ['8806372af51515bf4aef807291b96487ea1826c966a5596bca86697b5d8b23bc'], + }], +}) + +// Push event ids to our suggestions +sub.emitter.on('event', (url, e) => tags.push(["e", e.id, url])) + +const dvm = new DVM({ + // The private key used to sign events + sk: hexPrivateKey, + // Relays that the DVM will listen on + relays: ['wss://relay.damus.io', 'wss://dvms.f7z.io'], + // Only listen to requests tagging our dvm + requireMention: true, + // Expire results after 1 hour (the default) + expireAfter: 60 * 60, + // Handlers for various kinds + handlers: { + 5300: dvm => ({ + handleEvent: async function* (event) { + // DVM responses are stringified into the content + const content = JSON.stringify(tags) + + // Yield our response. Kind 7000 can be used for partial results too + yield createEvent(event.kind + 1000, {content}) + }, + }), + } +}) + +// Enable logging +dvm.logEvents = true + +// When you're ready +dvm.start() + +// When you're done +dvm.stop() +``` diff --git a/packages/dvm/package.json b/packages/dvm/package.json new file mode 100644 index 0000000..da3d2e0 --- /dev/null +++ b/packages/dvm/package.json @@ -0,0 +1,39 @@ +{ + "name": "@welshman/dvm", + "version": "0.0.1", + "author": "hodlbod", + "license": "MIT", + "description": "A collection of utilities for building nostr DVMs.", + "publishConfig": { + "access": "public" + }, + "type": "module", + "files": [ + "build" + ], + "types": "./build/src/index.d.ts", + "exports": { + ".": { + "types": "./build/src/index.d.ts", + "import": "./build/src/index.mjs", + "require": "./build/src/index.cjs" + } + }, + "scripts": { + "pub": "npm run lint && npm run build && npm publish", + "build": "gts clean && tsc-multi", + "lint": "gts lint", + "fix": "gts fix" + }, + "devDependencies": { + "gts": "^5.0.1", + "tsc-multi": "^1.1.0", + "typescript": "~5.1.6" + }, + "dependencies": { + "@welshman/lib": "0.0.11", + "@welshman/net": "0.0.14", + "@welshman/util": "0.0.16", + "nostr-tools": "^2.7.0" + } +} diff --git a/packages/dvm/src/handler.ts b/packages/dvm/src/handler.ts new file mode 100644 index 0000000..d5c2a76 --- /dev/null +++ b/packages/dvm/src/handler.ts @@ -0,0 +1,120 @@ +import {hexToBytes} from '@noble/hashes/utils' +import {getPublicKey, finalizeEvent} from 'nostr-tools' +import {now} from '@welshman/lib' +import type {TrustedEvent, EventTemplate, Filter} from '@welshman/util' +import {subscribe, publish} from '@welshman/net' + +export type DVMHandler = { + stop?: () => void + handleEvent: (e: TrustedEvent) => AsyncGenerator +} + +export type CreateDVMHandler = (dvm: DVM) => DVMHandler + +export type DVMOpts = { + sk: string + relays: string[] + handlers: Record + expireAfter?: number + requireMention?: boolean +} + +export class DVM { + active = false + logEvents = false + seen = new Set() + handlers = new Map() + + constructor(readonly opts: DVMOpts) { + for (const [kind, createHandler] of Object.entries(this.opts.handlers)) { + this.handlers.set(parseInt(kind), createHandler(this)) + } + } + + async start() { + this.active = true + + const {sk, relays, requireMention = false} = this.opts + + while (this.active) { + await new Promise(resolve => { + const since = now() + const kinds = Array.from(this.handlers.keys()) + const filter: Filter = {kinds, since} + + if (requireMention) { + filter['#p'] = [getPublicKey(hexToBytes(sk))] + } + + const filters = [filter] + const sub = subscribe({relays, filters}) + + sub.emitter.on('event', (url: string, e: TrustedEvent) => this.onEvent(e)) + sub.emitter.on('complete', () => resolve()) + }) + } + } + + stop() { + for (const handler of this.handlers.values()) { + handler.stop?.() + } + + this.active = false + } + + async onEvent(request: TrustedEvent) { + console.log(request) + const {expireAfter = 60 * 60} = this.opts + + if (this.seen.has(request.id)) { + return + } + + const handler = this.handlers.get(request.kind) + + if (!handler) { + return + } + + this.seen.add(request.id) + + if (this.logEvents) { + console.info('Handling request', request) + } + + for await (const event of handler.handleEvent(request)) { + if (event.kind !== 7000) { + event.tags.push(['request', JSON.stringify(request)]) + + const inputTag = request.tags.find(t => t[0] === 'i') + + if (inputTag) { + event.tags.push(inputTag) + } + } + + event.tags.push(['p', request.pubkey]) + event.tags.push(['e', request.id]) + + if (expireAfter) { + event.tags.push(['expiration', String(now() + expireAfter)]) + } + + if (this.logEvents) { + console.info('Publishing event', event) + } + + this.publish(event) + } + } + + async publish(template: EventTemplate) { + const {sk, relays} = this.opts + const event = finalizeEvent(template, hexToBytes(sk)) + + await new Promise(resolve => { + publish({event, relays}).emitter.on('success', () => resolve()) + }) + } +} diff --git a/packages/dvm/src/index.ts b/packages/dvm/src/index.ts new file mode 100644 index 0000000..8f18980 --- /dev/null +++ b/packages/dvm/src/index.ts @@ -0,0 +1,2 @@ +export * from './handler' +export * from './request' diff --git a/packages/dvm/src/request.ts b/packages/dvm/src/request.ts new file mode 100644 index 0000000..0ea148e --- /dev/null +++ b/packages/dvm/src/request.ts @@ -0,0 +1,47 @@ +import {Emitter, now} from '@welshman/lib' +import type {TrustedEvent, SignedEvent} from '@welshman/util' +import {subscribe, publish} from '@welshman/net' +import type {Subscription, Publish} from '@welshman/net' + +export enum DVMEvent { + Progress = "progress", + Result = "result", +} + +export type DVMRequestOptions = { + event: SignedEvent + relays: string[] + timeout?: number + autoClose?: boolean + reportProgress?: boolean +} + +export type DVMRequest = DVMRequestOptions & { + emitter: Emitter, + sub: Subscription + pub: Publish +} + +export const makeDvmRequest = (request: DVMRequest) => { + const emitter = new Emitter() + const {event, relays, timeout = 30_000, autoClose = true, reportProgress = true} = request + const kind = event.kind + 1000 + const kinds = reportProgress ? [kind, 7000] : [kind] + const filters = [{kinds, since: now() - 60, "#e": [event.id]}] + const sub = subscribe({relays, timeout, filters}) + const pub = publish({event, relays, timeout}) + + sub.emitter.on('event', (url: string, event: TrustedEvent) => { + if (event.kind === 7000) { + emitter.emit(DVMEvent.Progress, url, event) + } else { + emitter.emit(DVMEvent.Result, url, event) + + if (autoClose) { + sub.close() + } + } + }) + + return {request, emitter, sub, pub} +} diff --git a/packages/dvm/tsc-multi.json b/packages/dvm/tsc-multi.json new file mode 100644 index 0000000..6c37019 --- /dev/null +++ b/packages/dvm/tsc-multi.json @@ -0,0 +1,7 @@ +{ + "targets": [ + {"extname": ".cjs", "module": "commonjs"}, + {"extname": ".mjs", "module": "esnext", "moduleResolution": "node"} + ], + "projects": ["tsconfig.json"] +} diff --git a/packages/dvm/tsconfig.json b/packages/dvm/tsconfig.json new file mode 100644 index 0000000..15d351a --- /dev/null +++ b/packages/dvm/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "build", + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["esnext", "dom", "dom.iterable"] + }, + "include": ["**/*.ts"] +} diff --git a/packages/lib/package.json b/packages/lib/package.json index e2bf307..12f13d6 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -1,6 +1,6 @@ { "name": "@welshman/lib", - "version": "0.0.10", + "version": "0.0.11", "author": "hodlbod", "license": "MIT", "description": "A collection of utilities.", diff --git a/packages/net/package.json b/packages/net/package.json index 54f2a2b..0ee0a1b 100644 --- a/packages/net/package.json +++ b/packages/net/package.json @@ -31,7 +31,7 @@ "typescript": "~5.1.6" }, "dependencies": { - "@welshman/lib": "0.0.10", + "@welshman/lib": "0.0.11", "@welshman/util": "0.0.16", "isomorphic-ws": "^5.0.0", "ws": "^8.16.0" diff --git a/packages/util/package.json b/packages/util/package.json index 0aed349..39d2b51 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -31,7 +31,7 @@ "typescript": "~5.1.6" }, "dependencies": { - "@welshman/lib": "0.0.10", + "@welshman/lib": "0.0.11", "nostr-tools": "^2.3.2" } }