Add relay package

This commit is contained in:
Jon Staab
2025-03-31 10:16:28 -07:00
parent 5245993d4e
commit cfd2e3aac7
21 changed files with 234 additions and 106 deletions
+1 -2
View File
@@ -1,10 +1,9 @@
import {throttle} from "@welshman/lib"
import {Repository, Relay} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Tracker} from "@welshman/net"
import {custom} from "@welshman/store"
export const repository = new Repository<TrustedEvent>()
export const repository = Repository.getSingleton()
export const relay = new Relay(repository)
+6 -13
View File
@@ -35,7 +35,9 @@ export const pull = async ({relays, filters}: AppSyncOpts) => {
relays.map(async relay => {
await (hasNegentropy(relay)
? basePull({filters, events, relays: [relay]})
: pullWithoutNegentropy({filters, relays: [relay]}))
: new Promise(resolve => {
new SingleRequest({filters, relay, closeOnEose: true}).on(RequestEvent.Close, resolve)
})
}),
)
}
@@ -47,19 +49,10 @@ export const push = async ({relays, filters}: AppSyncOpts) => {
relays.map(async relay => {
await (hasNegentropy(relay)
? basePush({filters, events, relays: [relay]})
: pushWithoutNegentropy({events, relays: [relay]}))
: new Promise(resolve => {
new SinglePublish({events, relay}).on(PublishEvent.Complete, resolve)
}))
}),
)
}
export const sync = async ({relays, filters}: AppSyncOpts) => {
const events = query(filters).filter(isSignedEvent)
await Promise.all(
relays.map(async relay => {
await (hasNegentropy(relay)
? baseSync({filters, events, relays: [relay]})
: syncWithoutNegentropy({filters, events, relays: [relay]}))
}),
)
}
+2 -2
View File
@@ -18,7 +18,7 @@ import {
isUnwrappedEvent,
isSignedEvent,
} from "@welshman/util"
import {publish, PublishStatus} from "@welshman/net"
import {MultiPublish, PublishStatus} from "@welshman/net"
import {repository, tracker} from "./core.js"
import {pubkey, getSession, getSigner} from "./session.js"
@@ -225,7 +225,7 @@ thunkWorker.addGlobalHandler((thunk: Thunk) => {
}
// Send it off
const pub = publish({event: signedEvent, relays: thunk.request.relays})
const pub = new MultiPublish({event: signedEvent, relays: thunk.request.relays})
// Copy the signature over since we had deferred it
const savedEvent = repository.getEvent(signedEvent.id) as SignedEvent
+3 -6
View File
@@ -1,6 +1,6 @@
import {writable, derived} from "svelte/store"
import {type Zapper} from "@welshman/util"
import {type SubscribeRequestWithHandlers} from "@welshman/net"
import {Zapper} from "@welshman/util"
import {MultiRequestOptions} from "@welshman/net"
import {
ctx,
identity,
@@ -80,10 +80,7 @@ export const {
}),
})
export const deriveZapperForPubkey = (
pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {},
) =>
export const deriveZapperForPubkey = (pubkey: string, request: Partial<MultiRequestOptions> = {}) =>
derived([zappersByLnurl, deriveProfile(pubkey, request)], ([$zappersByLnurl, $profile]) => {
if (!$profile?.lnurl) {
return undefined
+1
View File
@@ -28,6 +28,7 @@
"dependencies": {
"@welshman/lib": "^0.1.0",
"@welshman/util": "^0.1.0",
"@welshman/relay": "^0.1.0",
"isomorphic-ws": "^5.0.0",
"nostr-tools": "^2.11.0",
"typed-emitter": "^2.1.0"
+7 -4
View File
@@ -1,6 +1,7 @@
import EventEmitter from "events"
import {call, on} from "@welshman/lib"
import {Relay, LOCAL_RELAY_URL, isRelayUrl} from "@welshman/util"
import {isRelayUrl} from "@welshman/util"
import {LocalRelay, LOCAL_RELAY_URL, Repository} from "@welshman/relay"
import {RelayMessage, ClientMessage} from "./message.js"
import {Socket, SocketEvent} from "./socket.js"
import {TypedEmitter, Unsubscriber} from "./util.js"
@@ -52,7 +53,7 @@ export class SocketAdapter extends AbstractAdapter {
}
export class LocalAdapter extends AbstractAdapter {
constructor(readonly relay: Relay) {
constructor(readonly relay: LocalRelay) {
super()
this._unsubscribers.push(
@@ -91,7 +92,7 @@ export class EmptyAdapter extends AbstractAdapter {
export type AdapterContext = {
pool?: Pool
relay?: Relay
relay?: LocalRelay
getAdapter?: (url: string, context: AdapterContext) => AbstractAdapter
}
@@ -105,7 +106,9 @@ export const getAdapter = (url: string, context: AdapterContext = {}) => {
}
if (url === LOCAL_RELAY_URL) {
return context.relay ? new LocalAdapter(context.relay) : new EmptyAdapter()
const relay = context.relay || new LocalRelay(Repository.getSingleton())
return new LocalAdapter(relay)
}
if (isRelayUrl(url)) {
+4
View File
@@ -0,0 +1,4 @@
build
normalize-url
Negentropy.ts
__tests__
+61
View File
@@ -0,0 +1,61 @@
# @welshman/net [![version](https://badgen.net/npm/v/@welshman/net)](https://npmjs.com/package/@welshman/net)
Utilities having to do with connection management and nostr messages.
```typescript
import {ctx, setContext} from '@welshman/lib'
import {type TrustedEvent, createEvent, NOTE} from '@welshman/util'
import {subscribe, publish, getDefaultNetContext} from '@welshman/net'
// Sets up customizable event valdation, handlers, etc
setContext(getDefaultNetContext())
// Send a subscription
const sub = subscribe({
relays: ['wss://relay.example.com/'],
filters: [{kinds: [1], limit: 1}],
closeOnEose: true,
timeout: 10000,
})
sub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => {
console.log(url, event)
sub.close()
})
// Publish an event
const pub = publish({
relays: ['wss://relay.example.com/'],
event: createEvent(NOTE, {content: 'hi'}),
})
pub.emitter.on('*', (status: PublishStatus, url: string) => {
console.log(status, url)
})
// The Tracker class can tell you which relays an event was read from or published to
console.log(ctx.net.tracker.getRelays(event.id))
```
The main reason this module exists is to support different backends via Executor and different `target` classes. For example, to add a local relay that automatically gets used:
```typescript
import {setContext} from '@welshman/lib'
import {LOCAL_RELAY_URL, Relay, Repository} from '@welshman/util'
import {getDefaultNetContext, Multi, Local, Relays, Executor} from '@welshman/net'
const repository = new Repository()
const relay = new Relay(repository)
setContext(getDefaultNetContext({
getExecutor: (relays: string[]) => {
return new Executor(
new Multi([
new Local(relay),
new Relays(remoteUrls.map(url => ctx.net.pool.get(url))),
])
)
},
}))
```
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@welshman/relay",
"version": "0.1.0",
"author": "hodlbod",
"license": "MIT",
"description": "An in-memory nostr relay implementation.",
"publishConfig": {
"access": "public"
},
"type": "module",
"files": [
"build"
],
"types": "./build/src/index.d.ts",
"exports": {
".": {
"types": "./build/src/index.d.ts",
"import": "./build/src/index.js",
"require": "./build/src/index.js"
}
},
"scripts": {
"pub": "npm run lint && npm run build && npm publish",
"build": "gts clean && tsc",
"lint": "gts lint",
"fix": "gts fix"
},
"dependencies": {
"@welshman/lib": "^0.1.0",
"@welshman/util": "^0.1.0"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./repository.js"
export * from "./relay.js"
+56
View File
@@ -0,0 +1,56 @@
import {Emitter, sleep} from "@welshman/lib"
import {Filter, TrustedEvent, HashedEvent, matchFilters} from "@welshman/util"
import {Repository} from "./repository.js"
export class LocalRelay<E extends HashedEvent = TrustedEvent> extends Emitter {
subs = new Map<string, Filter[]>()
constructor(readonly repository: Repository<E>) {
super()
}
send(type: string, ...message: any[]) {
switch (type) {
case "EVENT":
return this.handleEVENT(message as [E])
case "CLOSE":
return this.handleCLOSE(message as [string])
case "REQ":
return this.handleREQ(message as [string, ...Filter[]])
}
}
handleEVENT([event]: [E]) {
this.repository.publish(event)
// Callers generally expect async relays
void sleep(1).then(() => {
this.emit("OK", event.id, true, "")
if (!this.repository.isDeleted(event)) {
for (const [subId, filters] of this.subs.entries()) {
if (matchFilters(filters, event)) {
this.emit("EVENT", subId, event)
}
}
}
})
}
handleCLOSE([subId]: [string]) {
this.subs.delete(subId)
}
handleREQ([subId, ...filters]: [string, ...Filter[]]) {
this.subs.set(subId, filters)
// Callers generally expect async relays
void sleep(1).then(() => {
for (const event of this.repository.query(filters)) {
this.emit("EVENT", subId, event)
}
this.emit("EOSE", subId)
})
}
}
@@ -1,15 +1,34 @@
import {flatten, pluck, Emitter, sortBy, inc, chunk, uniq, omit, now, range} from "@welshman/lib"
import {DELETE} from "./Kinds.js"
import {EPOCH, matchFilter} from "./Filters.js"
import {isReplaceable, isUnwrappedEvent} from "./Events.js"
import {getAddress} from "./Address.js"
import type {Filter} from "./Filters.js"
import type {TrustedEvent, HashedEvent} from "./Events.js"
import {
DAY,
Emitter,
flatten,
pluck,
sortBy,
inc,
chunk,
uniq,
omit,
now,
range,
} from "@welshman/lib"
import {
DELETE,
EPOCH,
matchFilter,
isReplaceable,
isUnwrappedEvent,
getAddress,
Filter,
TrustedEvent,
HashedEvent,
} from "@welshman/util"
export const DAY = 86400
export const LOCAL_RELAY_URL = "local://welshman.relay/"
const getDay = (ts: number) => Math.floor(ts / DAY)
export let repositorySingleton: Repository<TrustedEvent>
export class Repository<E extends HashedEvent = TrustedEvent> extends Emitter {
eventsById = new Map<string, E>()
eventsByWrap = new Map<string, E>()
@@ -20,6 +39,14 @@ export class Repository<E extends HashedEvent = TrustedEvent> extends Emitter {
eventsByKind = new Map<number, E[]>()
deletes = new Map<string, number>()
static getSingleton() {
if (!repositorySingleton) {
repositorySingleton = new Repository()
}
return repositorySingleton
}
constructor() {
super()
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../node_modules/gts/tsconfig-google.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "build",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["esnext", "dom"]
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"entryPoints": ["src/index.ts"]
}
+1
View File
@@ -28,6 +28,7 @@
"dependencies": {
"@welshman/lib": "^0.1.0",
"@welshman/util": "^0.1.0",
"@welshman/relay": "^0.1.0",
"svelte": "^4.2.18"
}
}
+3 -4
View File
@@ -10,10 +10,9 @@ import {
partition,
first,
} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {Repository} from "@welshman/util"
import {matchFilters, getIdAndAddress, getIdFilters} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util"
import {Maybe} from "@welshman/lib"
import {Repository} from "@welshman/relay"
import {matchFilters, getIdAndAddress, getIdFilters, Filter, TrustedEvent} from "@welshman/util"
// Sync with localstorage
+1 -64
View File
@@ -1,15 +1,7 @@
import {last, Emitter, normalizeUrl, sleep, stripProtocol} from "@welshman/lib"
import {matchFilters} from "./Filters.js"
import type {Repository} from "./Repository.js"
import type {Filter} from "./Filters.js"
import type {HashedEvent, TrustedEvent} from "./Events.js"
import {last, normalizeUrl, stripProtocol} from "@welshman/lib"
// Constants and types
export const LOCAL_RELAY_URL = "local://welshman.relay/"
export const BOGUS_RELAY_URL = "bogus://welshman.relay/"
export type RelayProfile = {
url: string
icon?: string
@@ -83,58 +75,3 @@ export const displayRelayUrl = (url: string) => last(url.split("://")).replace(/
export const displayRelayProfile = (profile?: RelayProfile, fallback = "") =>
profile?.name || fallback
// In-memory relay implementation backed by Repository
export class Relay<E extends HashedEvent = TrustedEvent> extends Emitter {
subs = new Map<string, Filter[]>()
constructor(readonly repository: Repository<E>) {
super()
}
send(type: string, ...message: any[]) {
switch (type) {
case "EVENT":
return this.handleEVENT(message as [E])
case "CLOSE":
return this.handleCLOSE(message as [string])
case "REQ":
return this.handleREQ(message as [string, ...Filter[]])
}
}
handleEVENT([event]: [E]) {
this.repository.publish(event)
// Callers generally expect async relays
void sleep(1).then(() => {
this.emit("OK", event.id, true, "")
if (!this.repository.isDeleted(event)) {
for (const [subId, filters] of this.subs.entries()) {
if (matchFilters(filters, event)) {
this.emit("EVENT", subId, event)
}
}
}
})
}
handleCLOSE([subId]: [string]) {
this.subs.delete(subId)
}
handleREQ([subId, ...filters]: [string, ...Filter[]]) {
this.subs.set(subId, filters)
// Callers generally expect async relays
void sleep(1).then(() => {
for (const event of this.repository.query(filters)) {
this.emit("EVENT", subId, event)
}
this.emit("EOSE", subId)
})
}
}
-1
View File
@@ -8,6 +8,5 @@ export * from "./Links.js"
export * from "./List.js"
export * from "./Profile.js"
export * from "./Relay.js"
export * from "./Repository.js"
export * from "./Tags.js"
export * from "./Zaps.js"