Move net2 to net, update dvm
This commit is contained in:
@@ -424,12 +424,13 @@ export const truncate = (
|
||||
currentSize += size
|
||||
|
||||
if (currentSize > minLength) {
|
||||
content = content.slice(0, Math.max(1, i)).concat({type: ParsedType.Ellipsis, value: "…", raw: ""})
|
||||
content = content
|
||||
.slice(0, Math.max(1, i))
|
||||
.concat({type: ParsedType.Ellipsis, value: "…", raw: ""})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import {hexToBytes} from "@noble/hashes/utils"
|
||||
import {getPublicKey, finalizeEvent} from "nostr-tools/pure"
|
||||
import {now} from "@welshman/lib"
|
||||
import type {TrustedEvent, StampedEvent, Filter} from "@welshman/util"
|
||||
import {subscribe, publish} from "@welshman/net"
|
||||
import {TrustedEvent, StampedEvent, Filter} from "@welshman/util"
|
||||
import {
|
||||
multireq,
|
||||
multicast,
|
||||
PublishEventType,
|
||||
RequestEventType,
|
||||
AdapterContext,
|
||||
} from "@welshman/net"
|
||||
|
||||
export type DVMHandler = {
|
||||
stop?: () => void
|
||||
@@ -14,6 +20,7 @@ export type CreateDVMHandler = (dvm: DVM) => DVMHandler
|
||||
export type DVMOpts = {
|
||||
sk: string
|
||||
relays: string[]
|
||||
context: AdapterContext
|
||||
handlers: Record<string, CreateDVMHandler>
|
||||
expireAfter?: number
|
||||
requireMention?: boolean
|
||||
@@ -34,7 +41,7 @@ export class DVM {
|
||||
async start() {
|
||||
this.active = true
|
||||
|
||||
const {sk, relays, requireMention = false} = this.opts
|
||||
const {sk, relays, context, requireMention = false} = this.opts
|
||||
|
||||
while (this.active) {
|
||||
await new Promise<void>(resolve => {
|
||||
@@ -46,11 +53,10 @@ export class DVM {
|
||||
filter["#p"] = [getPublicKey(hexToBytes(sk))]
|
||||
}
|
||||
|
||||
const filters = [filter]
|
||||
const sub = subscribe({relays, filters})
|
||||
const sub = multireq({relays, filter, context})
|
||||
|
||||
sub.on("event", (url: string, e: TrustedEvent) => this.onEvent(e))
|
||||
sub.on("complete", () => resolve())
|
||||
sub.on(RequestEventType.Event, (e: TrustedEvent, url: string) => this.onEvent(e))
|
||||
sub.on(RequestEventType.Close, () => resolve())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -109,11 +115,11 @@ export class DVM {
|
||||
}
|
||||
|
||||
async publish(template: StampedEvent) {
|
||||
const {sk, relays} = this.opts
|
||||
const {sk, relays, context} = this.opts
|
||||
const event = finalizeEvent(template, hexToBytes(sk))
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
publish({event, relays}).emitter.on("success", () => resolve())
|
||||
multicast({event, relays, context}).on(PublishEventType.Complete, resolve)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+24
-10
@@ -1,7 +1,13 @@
|
||||
import {Emitter, now} from "@welshman/lib"
|
||||
import type {TrustedEvent, SignedEvent, Filter} from "@welshman/util"
|
||||
import {subscribe, publish, SubscriptionEvent} from "@welshman/net"
|
||||
import type {Subscription, Publish} from "@welshman/net"
|
||||
import {TrustedEvent, SignedEvent, Filter} from "@welshman/util"
|
||||
import {
|
||||
multireq,
|
||||
multicast,
|
||||
Multireq,
|
||||
Multicast,
|
||||
RequestEventType,
|
||||
AdapterContext,
|
||||
} from "@welshman/net"
|
||||
|
||||
export enum DVMEvent {
|
||||
Progress = "progress",
|
||||
@@ -11,6 +17,7 @@ export enum DVMEvent {
|
||||
export type DVMRequestOptions = {
|
||||
event: SignedEvent
|
||||
relays: string[]
|
||||
context: AdapterContext
|
||||
timeout?: number
|
||||
autoClose?: boolean
|
||||
reportProgress?: boolean
|
||||
@@ -19,21 +26,28 @@ export type DVMRequestOptions = {
|
||||
export type DVMRequest = {
|
||||
request: DVMRequestOptions
|
||||
emitter: Emitter
|
||||
sub: Subscription
|
||||
pub: Publish
|
||||
sub: Multireq
|
||||
pub: Multicast
|
||||
}
|
||||
|
||||
export const makeDvmRequest = (request: DVMRequestOptions) => {
|
||||
const emitter = new Emitter()
|
||||
const {event, relays, timeout = 30_000, autoClose = true, reportProgress = true} = request
|
||||
const {
|
||||
event,
|
||||
relays,
|
||||
context,
|
||||
timeout = 30_000,
|
||||
autoClose = true,
|
||||
reportProgress = true,
|
||||
} = request
|
||||
const kind = event.kind + 1000
|
||||
const kinds = reportProgress ? [kind, 7000] : [kind]
|
||||
const filters: Filter[] = [{kinds, since: now() - 60, "#e": [event.id]}]
|
||||
const filter: Filter = {kinds, since: now() - 60, "#e": [event.id]}
|
||||
|
||||
const sub = subscribe({relays, timeout, filters})
|
||||
const pub = publish({event, relays, timeout})
|
||||
const sub = multireq({relays, filter, timeout, context})
|
||||
const pub = multicast({relays, event, timeout, context})
|
||||
|
||||
sub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => {
|
||||
sub.on(RequestEventType.Event, (event: TrustedEvent, url: string) => {
|
||||
if (event.kind === 7000) {
|
||||
emitter.emit(DVMEvent.Progress, url, event)
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
build
|
||||
normalize-url
|
||||
Negentropy.ts
|
||||
__tests__
|
||||
__tests__
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import {Connection, ConnectionStatus} from "../src/Connection"
|
||||
import {ConnectionEvent} from "../src/ConnectionEvent"
|
||||
import {vi, describe, it, expect, beforeEach, afterEach} from "vitest"
|
||||
|
||||
describe("Connection", () => {
|
||||
let connection: Connection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new Connection("wss://test.relay/")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.cleanup()
|
||||
})
|
||||
|
||||
it("should initialize with correct state", () => {
|
||||
expect(connection.status).toBe(ConnectionStatus.Open)
|
||||
expect(connection.url).toBe("wss://test.relay/")
|
||||
})
|
||||
|
||||
it("should emit events with connection instance", () => {
|
||||
const spy = vi.fn()
|
||||
connection.on(ConnectionEvent.Open, spy)
|
||||
connection.emit(ConnectionEvent.Open)
|
||||
expect(spy).toHaveBeenCalledWith(connection)
|
||||
})
|
||||
|
||||
it("should throw when sending message on closed connection", async () => {
|
||||
connection.close()
|
||||
await expect(connection.send(["EVENT", {}])).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("should cleanup properly", () => {
|
||||
const spy = vi.fn()
|
||||
connection.on("test", spy)
|
||||
connection.cleanup()
|
||||
connection.emit("test" as any)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,261 +0,0 @@
|
||||
import {ConnectionAuth, AuthStatus, AuthMode} from "../src/ConnectionAuth"
|
||||
import {Connection} from "../src/Connection"
|
||||
import {ConnectionEvent} from "../src/ConnectionEvent"
|
||||
import {ctx, sleep} from "@welshman/lib"
|
||||
import {vi, describe, it, expect, beforeEach, afterEach} from "vitest"
|
||||
import {SocketStatus} from "../src/Socket"
|
||||
|
||||
describe("ConnectionAuth", () => {
|
||||
let connection: Connection
|
||||
let auth: ConnectionAuth
|
||||
let mockSignEvent: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
connection = new Connection("wss://test.relay/")
|
||||
// Mock socket operations
|
||||
connection.socket.open = vi.fn().mockResolvedValue(undefined)
|
||||
connection.socket.status = SocketStatus.Open
|
||||
connection.send = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
auth = connection.auth
|
||||
mockSignEvent = vi.fn()
|
||||
ctx.net = {...ctx.net, signEvent: mockSignEvent, authMode: AuthMode.Explicit}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("initialization", () => {
|
||||
it("should initialize with None status", () => {
|
||||
expect(auth.status).toBe(AuthStatus.None)
|
||||
expect(auth.challenge).toBeUndefined()
|
||||
expect(auth.request).toBeUndefined()
|
||||
expect(auth.message).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("message handling", () => {
|
||||
it("should handle AUTH message and set challenge", () => {
|
||||
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
|
||||
expect(auth.challenge).toBe("challenge123")
|
||||
expect(auth.status).toBe(AuthStatus.Requested)
|
||||
})
|
||||
|
||||
it("should ignore AUTH message if challenge matches current challenge", () => {
|
||||
auth.challenge = "challenge123"
|
||||
auth.status = AuthStatus.PendingResponse
|
||||
|
||||
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
|
||||
expect(auth.status).toBe(AuthStatus.PendingResponse)
|
||||
})
|
||||
|
||||
it("should handle successful OK message", () => {
|
||||
auth.challenge = "challenge123"
|
||||
auth.request = "request123"
|
||||
auth.status = AuthStatus.PendingResponse
|
||||
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "request123", true, "success"])
|
||||
expect(auth.status).toBe(AuthStatus.Ok)
|
||||
expect(auth.message).toBe("success")
|
||||
})
|
||||
|
||||
it("should handle failed OK message", () => {
|
||||
auth.challenge = "challenge123"
|
||||
auth.request = "request123"
|
||||
auth.status = AuthStatus.PendingResponse
|
||||
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "request123", false, "forbidden"])
|
||||
expect(auth.status).toBe(AuthStatus.Forbidden)
|
||||
expect(auth.message).toBe("forbidden")
|
||||
})
|
||||
|
||||
it("should ignore OK message for different request", () => {
|
||||
auth.challenge = "challenge123"
|
||||
auth.request = "request123"
|
||||
auth.status = AuthStatus.PendingResponse
|
||||
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "different123", true, "success"])
|
||||
expect(auth.status).toBe(AuthStatus.PendingResponse)
|
||||
expect(auth.message).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("connection close handling", () => {
|
||||
it("should reset state on connection close", () => {
|
||||
auth.challenge = "challenge123"
|
||||
auth.request = "request123"
|
||||
auth.message = "message"
|
||||
auth.status = AuthStatus.Ok
|
||||
|
||||
connection.emit(ConnectionEvent.Close)
|
||||
|
||||
expect(auth.challenge).toBeUndefined()
|
||||
expect(auth.request).toBeUndefined()
|
||||
expect(auth.message).toBeUndefined()
|
||||
expect(auth.status).toBe(AuthStatus.None)
|
||||
})
|
||||
})
|
||||
|
||||
describe("respond()", () => {
|
||||
it("should throw if no challenge exists", async () => {
|
||||
await expect(auth.respond()).rejects.toThrow("Attempted to authenticate with no challenge")
|
||||
})
|
||||
|
||||
it("should throw if status is not Requested", async () => {
|
||||
auth.challenge = "challenge123"
|
||||
auth.status = AuthStatus.Ok
|
||||
|
||||
await expect(auth.respond()).rejects.toThrow(
|
||||
"Attempted to authenticate when auth is already ok",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle successful signature", async () => {
|
||||
auth.challenge = "challenge123"
|
||||
auth.status = AuthStatus.Requested
|
||||
const signedEvent = {id: "event123" /* other event fields */}
|
||||
mockSignEvent.mockResolvedValue(signedEvent)
|
||||
|
||||
await auth.respond()
|
||||
|
||||
expect(auth.request).toBe("event123")
|
||||
expect(auth.status).toBe(AuthStatus.PendingResponse)
|
||||
expect(connection.send).toHaveBeenCalledWith(["AUTH", signedEvent])
|
||||
})
|
||||
|
||||
it("should handle denied signature", async () => {
|
||||
auth.challenge = "challenge123"
|
||||
auth.status = AuthStatus.Requested
|
||||
mockSignEvent.mockResolvedValue(undefined)
|
||||
|
||||
await auth.respond()
|
||||
|
||||
expect(auth.status).toBe(AuthStatus.DeniedSignature)
|
||||
expect(connection.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("automatic authentication", () => {
|
||||
it("should auto-respond in implicit mode", () => {
|
||||
ctx.net.authMode = AuthMode.Implicit
|
||||
const respondSpy = vi.spyOn(auth, "respond")
|
||||
|
||||
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
|
||||
expect(respondSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not auto-respond in explicit mode", () => {
|
||||
ctx.net.authMode = AuthMode.Explicit
|
||||
const respondSpy = vi.spyOn(auth, "respond")
|
||||
|
||||
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
|
||||
expect(respondSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("waitFor methods", () => {
|
||||
it("should wait for challenge", async () => {
|
||||
const waitPromise = auth.waitForChallenge()
|
||||
|
||||
setTimeout(() => {
|
||||
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
|
||||
}, 100)
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await waitPromise
|
||||
expect(auth.challenge).toBe("challenge123")
|
||||
})
|
||||
|
||||
it("should timeout waiting for challenge", async () => {
|
||||
const waitPromise = auth.waitForChallenge(50)
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await waitPromise
|
||||
expect(auth.challenge).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should wait for resolution", async () => {
|
||||
auth.challenge = "challenge123"
|
||||
auth.request = "request123"
|
||||
auth.status = AuthStatus.PendingResponse
|
||||
|
||||
const waitPromise = auth.waitForResolution()
|
||||
|
||||
setTimeout(() => {
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "request123", true, "success"])
|
||||
}, 100)
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await waitPromise
|
||||
expect(auth.status).toBe(AuthStatus.Ok)
|
||||
})
|
||||
|
||||
it("should timeout waiting for resolution", async () => {
|
||||
auth.status = AuthStatus.PendingResponse
|
||||
|
||||
const waitPromise = auth.waitForResolution(50)
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await waitPromise
|
||||
expect(auth.status).toBe(AuthStatus.PendingResponse)
|
||||
})
|
||||
})
|
||||
|
||||
describe("attempt()", () => {
|
||||
it("should complete full authentication flow", async () => {
|
||||
const signedEvent = {id: "event123" /* other event fields */}
|
||||
mockSignEvent.mockResolvedValue(signedEvent)
|
||||
|
||||
const attemptPromise = auth.attempt()
|
||||
|
||||
// Simulate socket opening and challenge received
|
||||
|
||||
setTimeout(() => {
|
||||
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
|
||||
}, 100)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
// Simulate successful authentication
|
||||
setTimeout(() => {
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "event123", true, "success"])
|
||||
}, 200)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
await attemptPromise
|
||||
|
||||
expect(auth.status).toBe(AuthStatus.Ok)
|
||||
})
|
||||
|
||||
it("should handle authentication failure", async () => {
|
||||
mockSignEvent.mockResolvedValue(undefined)
|
||||
|
||||
const attemptPromise = auth.attempt()
|
||||
|
||||
setTimeout(() => {
|
||||
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
|
||||
}, 100)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
await attemptPromise
|
||||
|
||||
expect(auth.status).toBe(AuthStatus.DeniedSignature)
|
||||
})
|
||||
|
||||
it("should timeout if no challenge received", async () => {
|
||||
const attemptPromise = auth.attempt(100)
|
||||
|
||||
// 2 loops (2 * 100ms) in the waitForChallenge before timeout
|
||||
// 1 loop in waitForResolution as it reach the condition immediately
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
await attemptPromise
|
||||
|
||||
expect(auth.status).toBe(AuthStatus.None)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import {ConnectionSender} from "../src/ConnectionSender"
|
||||
import {Connection} from "../src/Connection"
|
||||
import {Message, SocketStatus} from "../src/Socket"
|
||||
import {AuthStatus} from "../src/ConnectionAuth"
|
||||
import {AUTH_JOIN} from "@welshman/util"
|
||||
import {vi, describe, it, expect, beforeEach, afterEach} from "vitest"
|
||||
|
||||
describe("ConnectionSender", () => {
|
||||
let connection: Connection
|
||||
let sender: ConnectionSender
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
connection = new Connection("wss://test.relay/")
|
||||
connection.socket.send = vi.fn()
|
||||
connection.socket.open = vi.fn().mockResolvedValue(undefined)
|
||||
connection.socket.status = SocketStatus.Open
|
||||
connection.send = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
sender = connection.sender
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("message deferral", () => {
|
||||
it("should not defer CLOSE messages", async () => {
|
||||
// First send a REQ message to set up the pending request
|
||||
const reqId = "subscription-id"
|
||||
connection.sender.push([
|
||||
"REQ",
|
||||
reqId,
|
||||
{
|
||||
/* filters */
|
||||
},
|
||||
] as Message)
|
||||
const message: Message = ["CLOSE", reqId]
|
||||
// there is a setTimeout in the worker, so we need to advance timers
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
connection.sender.push(message)
|
||||
// there is a setTimeout in the worker, so we need to advance timers
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(connection.socket.send).toHaveBeenCalledWith(message)
|
||||
})
|
||||
|
||||
it("should defer messages when socket is not open", () => {
|
||||
connection.socket.status = SocketStatus.Closed
|
||||
const message: Message = [
|
||||
"EVENT",
|
||||
{
|
||||
/* event data */
|
||||
},
|
||||
]
|
||||
sender.push(message)
|
||||
expect(connection.socket.send).not.toHaveBeenCalled()
|
||||
expect(sender.worker.buffer).toContain(message)
|
||||
})
|
||||
|
||||
it("should not defer AUTH messages", () => {
|
||||
const message: Message = [
|
||||
"AUTH",
|
||||
{
|
||||
/* auth data */
|
||||
},
|
||||
]
|
||||
sender.push(message)
|
||||
// there is a setTimeout in the worker, so we need to advance timers
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(connection.socket.send).toHaveBeenCalledWith(message)
|
||||
})
|
||||
|
||||
it("should not defer AUTH_JOIN event messages", () => {
|
||||
const message: Message = ["EVENT", {kind: AUTH_JOIN}]
|
||||
sender.push(message)
|
||||
// there is a setTimeout in the worker, so we need to advance timers
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(connection.socket.send).toHaveBeenCalledWith(message)
|
||||
})
|
||||
|
||||
it("should defer messages when auth is pending", () => {
|
||||
connection.socket.status = SocketStatus.Open
|
||||
connection.auth.status = AuthStatus.PendingResponse
|
||||
const message: Message = [
|
||||
"EVENT",
|
||||
{
|
||||
/* event data */
|
||||
},
|
||||
]
|
||||
sender.push(message)
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(connection.socket.send).not.toHaveBeenCalled()
|
||||
expect(sender.worker.buffer).toContain(message)
|
||||
})
|
||||
|
||||
it("should defer REQ messages when too many pending requests", () => {
|
||||
connection.socket.status = SocketStatus.Open
|
||||
connection.auth.status = AuthStatus.Ok
|
||||
// Set up 50 pending requests
|
||||
for (let i = 0; i < 50; i++) {
|
||||
connection.state.pendingRequests.set(`req${i}`, {
|
||||
filters: [],
|
||||
sent: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
const message: Message = [
|
||||
"REQ",
|
||||
"newReq",
|
||||
{
|
||||
/* filter */
|
||||
},
|
||||
]
|
||||
sender.push(message)
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(connection.socket.send).not.toHaveBeenCalled()
|
||||
expect(sender.worker.buffer).toContain(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe("message handling", () => {
|
||||
it("should send messages when conditions are met", () => {
|
||||
connection.socket.status = SocketStatus.Open
|
||||
connection.auth.status = AuthStatus.Ok
|
||||
const message: Message = [
|
||||
"EVENT",
|
||||
{
|
||||
/* event data */
|
||||
},
|
||||
]
|
||||
sender.push(message)
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(connection.socket.send).toHaveBeenCalledWith(message)
|
||||
})
|
||||
|
||||
it("should handle CLOSE messages for non-existent requests", () => {
|
||||
const message: Message = ["CLOSE", "non-existent-req"]
|
||||
sender.push(message)
|
||||
expect(connection.socket.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should remove pending REQ when handling CLOSE", () => {
|
||||
const reqId = "req123"
|
||||
const reqMessage: Message = [
|
||||
"REQ",
|
||||
reqId,
|
||||
{
|
||||
/* filter */
|
||||
},
|
||||
]
|
||||
sender.worker.buffer.push(reqMessage)
|
||||
|
||||
const closeMessage: Message = ["CLOSE", reqId]
|
||||
sender.push(closeMessage)
|
||||
|
||||
expect(sender.worker.buffer).not.toContain(reqMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("worker behavior", () => {
|
||||
it("should process deferred messages when conditions become favorable", async () => {
|
||||
connection.socket.status = SocketStatus.Closed
|
||||
const message: Message = [
|
||||
"EVENT",
|
||||
{
|
||||
/* event data */
|
||||
},
|
||||
]
|
||||
sender.push(message)
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(connection.socket.send).not.toHaveBeenCalled()
|
||||
|
||||
// Simulate socket opening and auth completing
|
||||
connection.socket.status = SocketStatus.Open
|
||||
connection.auth.status = AuthStatus.Ok
|
||||
|
||||
// Trigger worker processing
|
||||
sender.worker.resume()
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(connection.socket.send).toHaveBeenCalledWith(message)
|
||||
})
|
||||
|
||||
it("should maintain message order", async () => {
|
||||
connection.socket.status = SocketStatus.Open
|
||||
connection.auth.status = AuthStatus.Ok
|
||||
|
||||
const messages: Message[] = [
|
||||
["EVENT", {id: "1"}],
|
||||
["EVENT", {id: "2"}],
|
||||
["EVENT", {id: "3"}],
|
||||
]
|
||||
|
||||
messages.forEach(msg => sender.push(msg))
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
const sendCalls = connection.socket.send.mock.calls
|
||||
expect(sendCalls.map(call => call[0])).toEqual(messages)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,201 +0,0 @@
|
||||
import {ConnectionState} from "../src/ConnectionState"
|
||||
import {Connection} from "../src/Connection"
|
||||
import {SocketStatus} from "../src/Socket"
|
||||
import {ConnectionEvent} from "../src/ConnectionEvent"
|
||||
import {AUTH_JOIN, SignedEvent} from "@welshman/util"
|
||||
import {vi, describe, it, expect, beforeEach} from "vitest"
|
||||
|
||||
describe("ConnectionState", () => {
|
||||
let connection: Connection
|
||||
let state: ConnectionState
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
connection = new Connection("wss://test.relay/")
|
||||
connection.socket.status = SocketStatus.Open
|
||||
connection.socket.send = vi.fn().mockResolvedValue(undefined)
|
||||
connection.socket.open = vi.fn().mockResolvedValue(undefined)
|
||||
connection.send = vi.fn().mockResolvedValue(undefined)
|
||||
state = connection.state
|
||||
})
|
||||
|
||||
describe("request tracking", () => {
|
||||
it("should track new REQ messages", async () => {
|
||||
const reqId = "req123"
|
||||
const filters = [{kinds: [1]}]
|
||||
|
||||
connection.sender.worker.push(["REQ", reqId, ...filters])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(state.pendingRequests.has(reqId)).toBe(true)
|
||||
expect(state.pendingRequests.get(reqId)).toEqual({
|
||||
filters,
|
||||
sent: Date.now(),
|
||||
eose: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("should remove requests on CLOSE", async () => {
|
||||
const reqId = "req123"
|
||||
state.pendingRequests.set(reqId, {
|
||||
filters: [],
|
||||
sent: Date.now(),
|
||||
})
|
||||
|
||||
connection.socket.worker.push(["CLOSED", reqId])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(state.pendingRequests.has(reqId)).toBe(false)
|
||||
})
|
||||
|
||||
it("should mark requests as EOSE", async () => {
|
||||
const reqId = "req123"
|
||||
state.pendingRequests.set(reqId, {
|
||||
filters: [],
|
||||
sent: Date.now(),
|
||||
})
|
||||
|
||||
connection.socket.worker.push(["EOSE", reqId])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(state.pendingRequests.get(reqId)?.eose).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("publish tracking", () => {
|
||||
it("should track EVENT messages", async () => {
|
||||
const event = {id: "event123", kind: 1}
|
||||
|
||||
connection.sender.worker.push(["EVENT", event])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(state.pendingPublishes.has(event.id)).toBeTruthy()
|
||||
expect(state.pendingPublishes.get(event.id)).toEqual({
|
||||
sent: Date.now(),
|
||||
event,
|
||||
})
|
||||
})
|
||||
|
||||
it("should remove publishes on successful OK", async () => {
|
||||
const eventId = "event123"
|
||||
state.pendingPublishes.set(eventId, {
|
||||
sent: Date.now(),
|
||||
event: {id: eventId, kind: 1} as SignedEvent,
|
||||
})
|
||||
|
||||
connection.socket.worker.push(["OK", eventId, true])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(state.pendingPublishes.has(eventId)).toBe(false)
|
||||
})
|
||||
|
||||
it("should re-enqueue events on auth challenge", async () => {
|
||||
const event = {id: "event123", kind: 1} as SignedEvent
|
||||
state.pendingPublishes.set(event.id, {
|
||||
sent: Date.now(),
|
||||
event,
|
||||
})
|
||||
|
||||
connection.socket.worker.push(["OK", event.id, false, "auth-required:challenge123"])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
// Event should still be in pending publishes
|
||||
expect(state.pendingPublishes.has(event.id)).toBe(true)
|
||||
// And should have been re-sent
|
||||
expect(connection.send).toHaveBeenCalledWith(["EVENT", event])
|
||||
})
|
||||
|
||||
it("should not re-enqueue AUTH_JOIN events on auth challenge", async () => {
|
||||
const event = {id: "event123", kind: AUTH_JOIN} as SignedEvent
|
||||
state.pendingPublishes.set(event.id, {
|
||||
sent: Date.now(),
|
||||
event,
|
||||
})
|
||||
|
||||
connection.socket.worker.push(["OK", event.id, false, "auth-required:challenge123"])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
// Event should be removed from pending publishes
|
||||
expect(state.pendingPublishes.has(event.id)).toBe(false)
|
||||
// And should not have been re-sent
|
||||
expect(connection.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("notice handling", () => {
|
||||
it("should emit notices", async () => {
|
||||
const noticeSpy = vi.fn()
|
||||
connection.on(ConnectionEvent.Notice, noticeSpy)
|
||||
|
||||
connection.socket.worker.push(["NOTICE", "test notice"])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(noticeSpy).toHaveBeenCalledWith(connection, "test notice")
|
||||
})
|
||||
|
||||
it("should emit auth-required notice from CLOSED", async () => {
|
||||
const noticeSpy = vi.fn()
|
||||
connection.on(ConnectionEvent.Notice, noticeSpy)
|
||||
|
||||
connection.socket.worker.push(["CLOSED", "req123", "auth-required:challenge123"])
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(noticeSpy).toHaveBeenCalledWith(connection, "auth-required:challenge123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("reconnection behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should re-enqueue pending requests on reconnection", async () => {
|
||||
const reqId = "req123"
|
||||
const filters = [{kinds: [1]}]
|
||||
state.pendingRequests.set(reqId, {
|
||||
filters,
|
||||
sent: Date.now(),
|
||||
})
|
||||
|
||||
// Simulate connection close and wait for reconnection delay
|
||||
connection.emit(ConnectionEvent.Close, connection)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(connection.send).toHaveBeenCalledWith(["REQ", reqId, ...filters])
|
||||
})
|
||||
|
||||
it("should re-enqueue pending publishes on reconnection", async () => {
|
||||
const event = {id: "event123", kind: 1} as SignedEvent
|
||||
state.pendingPublishes.set(event.id, {
|
||||
sent: Date.now(),
|
||||
event,
|
||||
})
|
||||
|
||||
// Simulate connection close and wait for reconnection delay
|
||||
connection.emit(ConnectionEvent.Close, connection)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(connection.send).toHaveBeenCalledWith(["EVENT", event])
|
||||
})
|
||||
|
||||
it("should trigger reconnection when there are pending items", async () => {
|
||||
const reqId = "req123"
|
||||
state.pendingRequests.set(reqId, {
|
||||
filters: [],
|
||||
sent: Date.now(),
|
||||
})
|
||||
|
||||
connection.emit(ConnectionEvent.Close, connection)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(connection.socket.open).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not trigger reconnection when there are no pending items", async () => {
|
||||
connection.emit(ConnectionEvent.Close, connection)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(connection.socket.open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,220 +0,0 @@
|
||||
import {ctx} from "@welshman/lib"
|
||||
import {AuthMode} from "@welshman/net"
|
||||
import {SignedEvent} from "@welshman/util"
|
||||
import {beforeEach, describe, expect, it, vi} from "vitest"
|
||||
import {Connection} from "../src/Connection"
|
||||
import {ConnectionEvent} from "../src/ConnectionEvent"
|
||||
import {ConnectionStats} from "../src/ConnectionStats"
|
||||
|
||||
describe("ConnectionStats", () => {
|
||||
let connection: Connection
|
||||
let stats: ConnectionStats
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
connection = new Connection("wss://test.relay/")
|
||||
stats = connection.stats
|
||||
ctx.net = {...ctx.net, authMode: AuthMode.Explicit}
|
||||
})
|
||||
|
||||
describe("connection events tracking", () => {
|
||||
it("should track socket open events", () => {
|
||||
const now = Date.now()
|
||||
connection.emit(ConnectionEvent.Open, connection)
|
||||
|
||||
expect(stats.openCount).toBe(1)
|
||||
expect(stats.lastOpen).toBeGreaterThanOrEqual(now)
|
||||
})
|
||||
|
||||
it("should track socket close events", () => {
|
||||
const now = Date.now()
|
||||
connection.emit(ConnectionEvent.Close, connection)
|
||||
|
||||
expect(stats.closeCount).toBe(1)
|
||||
expect(stats.lastClose).toBeGreaterThanOrEqual(now)
|
||||
})
|
||||
|
||||
it("should track socket error events", () => {
|
||||
const now = Date.now()
|
||||
connection.emit(ConnectionEvent.Error, connection)
|
||||
|
||||
expect(stats.errorCount).toBe(1)
|
||||
expect(stats.lastError).toBeGreaterThanOrEqual(now)
|
||||
})
|
||||
|
||||
it("should accumulate multiple events", () => {
|
||||
connection.emit(ConnectionEvent.Open, connection)
|
||||
connection.emit(ConnectionEvent.Close, connection)
|
||||
connection.emit(ConnectionEvent.Open, connection)
|
||||
connection.emit(ConnectionEvent.Error, connection)
|
||||
|
||||
expect(stats.openCount).toBe(2)
|
||||
expect(stats.closeCount).toBe(1)
|
||||
expect(stats.errorCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("message tracking", () => {
|
||||
describe("outgoing messages", () => {
|
||||
it("should track REQ messages", () => {
|
||||
const now = Date.now()
|
||||
connection.emit(ConnectionEvent.Send, ["REQ", "id1"])
|
||||
|
||||
expect(stats.requestCount).toBe(1)
|
||||
expect(stats.lastRequest).toBeGreaterThanOrEqual(now)
|
||||
})
|
||||
|
||||
it("should track EVENT messages", () => {
|
||||
const now = Date.now()
|
||||
connection.emit(ConnectionEvent.Send, ["EVENT", {id: "123"}])
|
||||
|
||||
expect(stats.publishCount).toBe(1)
|
||||
expect(stats.lastPublish).toBeGreaterThanOrEqual(now)
|
||||
})
|
||||
})
|
||||
|
||||
describe("incoming messages", () => {
|
||||
it("should track received EVENT messages", () => {
|
||||
const now = Date.now()
|
||||
connection.emit(ConnectionEvent.Receive, ["EVENT", {id: "123"}])
|
||||
|
||||
expect(stats.eventCount).toBe(1)
|
||||
expect(stats.lastEvent).toBeGreaterThanOrEqual(now)
|
||||
})
|
||||
|
||||
it("should track AUTH messages", () => {
|
||||
const now = Date.now()
|
||||
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge"])
|
||||
|
||||
expect(stats.lastAuth).toBeGreaterThanOrEqual(now)
|
||||
})
|
||||
|
||||
it("should track NOTICE messages", () => {
|
||||
connection.emit(ConnectionEvent.Receive, ["NOTICE", "test"])
|
||||
expect(stats.noticeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("publish tracking", () => {
|
||||
beforeEach(() => {
|
||||
// Setup a pending publish
|
||||
connection.state.pendingPublishes.set("123", {
|
||||
sent: Date.now() - 1000, // 1 second ago
|
||||
event: {id: "123"} as SignedEvent,
|
||||
})
|
||||
})
|
||||
|
||||
it("should track successful publishes", () => {
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "123", true])
|
||||
|
||||
expect(stats.publishSuccessCount).toBe(1)
|
||||
expect(stats.publishFailureCount).toBe(0)
|
||||
expect(stats.publishTimer).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should track failed publishes", () => {
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "123", false])
|
||||
|
||||
expect(stats.publishSuccessCount).toBe(0)
|
||||
expect(stats.publishFailureCount).toBe(1)
|
||||
expect(stats.publishTimer).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should accumulate publish timing", () => {
|
||||
const firstTimer = stats.publishTimer
|
||||
// First publish took 1000ms
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "123", true])
|
||||
|
||||
// Second publish took 2000ms
|
||||
connection.state.pendingPublishes.set("456", {
|
||||
sent: Date.now() - 2000,
|
||||
event: {id: "456"} as SignedEvent,
|
||||
})
|
||||
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "456", true])
|
||||
|
||||
expect(stats.publishTimer).toBe(firstTimer + 1000 + 2000)
|
||||
expect(stats.publishSuccessCount).toBe(2)
|
||||
})
|
||||
|
||||
it("should not increment publish timer for unknown publishes", () => {
|
||||
connection.emit(ConnectionEvent.Receive, ["OK", "unknown", true])
|
||||
|
||||
expect(stats.publishSuccessCount).toBe(1)
|
||||
expect(stats.publishFailureCount).toBe(0)
|
||||
expect(stats.publishTimer).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("EOSE tracking", () => {
|
||||
beforeEach(() => {
|
||||
// Setup a pending request
|
||||
connection.state.pendingRequests.set("req1", {
|
||||
sent: Date.now() - 1000,
|
||||
filters: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("should track first EOSE for a request", () => {
|
||||
connection.emit(ConnectionEvent.Receive, ["EOSE", "req1"])
|
||||
|
||||
expect(stats.eoseCount).toBe(1)
|
||||
expect(stats.eoseTimer).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should ignore subsequent EOSE for same request", () => {
|
||||
// Mark request as already EOSE'd
|
||||
connection.state.pendingRequests.set("req1", {
|
||||
sent: Date.now() - 1000,
|
||||
filters: [],
|
||||
eose: true,
|
||||
})
|
||||
|
||||
connection.emit(ConnectionEvent.Receive, ["EOSE", "req1"])
|
||||
|
||||
expect(stats.eoseCount).toBe(0)
|
||||
expect(stats.eoseTimer).toBe(0)
|
||||
})
|
||||
|
||||
it("should accumulate EOSE timing", () => {
|
||||
// First EOSE took 1000ms
|
||||
connection.emit(ConnectionEvent.Receive, ["EOSE", "req1"])
|
||||
const firstTimer = stats.eoseTimer
|
||||
|
||||
// Setup second request that takes 2000ms
|
||||
connection.state.pendingRequests.set("req2", {
|
||||
sent: Date.now() - 2000,
|
||||
filters: [],
|
||||
})
|
||||
connection.emit(ConnectionEvent.Receive, ["EOSE", "req2"])
|
||||
|
||||
expect(stats.eoseTimer).toBe(firstTimer + 2000)
|
||||
expect(stats.eoseCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("speed calculations", () => {
|
||||
it("should calculate request speed", () => {
|
||||
stats.eoseCount = 2
|
||||
stats.eoseTimer = 3000 // 3 seconds total for 2 requests
|
||||
|
||||
expect(stats.getRequestSpeed()).toBe(1500) // 1.5 seconds average
|
||||
})
|
||||
|
||||
it("should return 0 request speed when no EOSE received", () => {
|
||||
expect(stats.getRequestSpeed()).toBe(0)
|
||||
})
|
||||
|
||||
it("should calculate publish speed", () => {
|
||||
stats.publishSuccessCount = 2
|
||||
stats.publishTimer = 4000 // 4 seconds total for 2 publishes
|
||||
|
||||
expect(stats.getPublishSpeed()).toBe(2000) // 2 seconds average
|
||||
})
|
||||
|
||||
it("should return 0 publish speed when no successful publishes", () => {
|
||||
expect(stats.getPublishSpeed()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,192 +0,0 @@
|
||||
import type {Filter, TrustedEvent} from "@welshman/util"
|
||||
import {hasValidSignature, isSignedEvent, LOCAL_RELAY_URL, matchFilters} from "@welshman/util"
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
|
||||
import {AuthMode} from "../src/ConnectionAuth"
|
||||
import {
|
||||
defaultOptimizeSubscriptions,
|
||||
eventValidationScores,
|
||||
getDefaultNetContext,
|
||||
isEventValid,
|
||||
} from "../src/Context"
|
||||
|
||||
// Mock utilities that are imported
|
||||
vi.mock(import("@welshman/util"), async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
isSignedEvent: vi.fn(),
|
||||
hasValidSignature: vi.fn(),
|
||||
matchFilters: vi.fn(),
|
||||
LOCAL_RELAY_URL: "local",
|
||||
}))
|
||||
|
||||
describe("Context", () => {
|
||||
describe("getDefaultNetContext", () => {
|
||||
it("should return default context with expected properties", () => {
|
||||
const context = getDefaultNetContext()
|
||||
|
||||
expect(context).toEqual(
|
||||
expect.objectContaining({
|
||||
authMode: AuthMode.Implicit,
|
||||
onEvent: expect.any(Function),
|
||||
signEvent: expect.any(Function),
|
||||
isDeleted: expect.any(Function),
|
||||
isValid: expect.any(Function),
|
||||
getExecutor: expect.any(Function),
|
||||
matchFilters: expect.any(Function),
|
||||
optimizeSubscriptions: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should merge overrides with defaults", () => {
|
||||
const customOnEvent = vi.fn()
|
||||
const context = getDefaultNetContext({onEvent: customOnEvent})
|
||||
|
||||
expect(context.onEvent).toBe(customOnEvent)
|
||||
expect(context.authMode).toBe(AuthMode.Implicit) // default value preserved
|
||||
})
|
||||
})
|
||||
|
||||
describe("defaultOptimizeSubscriptions", () => {
|
||||
it("should group subscriptions by relay", () => {
|
||||
const subs = [
|
||||
{
|
||||
request: {
|
||||
relays: ["relay1", "relay2"],
|
||||
filters: [{kinds: [1]}],
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [2]}],
|
||||
},
|
||||
},
|
||||
] as any
|
||||
|
||||
const result = defaultOptimizeSubscriptions(subs)
|
||||
// should unionize filters for requests with the same relay
|
||||
expect(result).toEqual([
|
||||
{
|
||||
relays: ["relay1"],
|
||||
filters: expect.arrayContaining([{kinds: [1, 2]}]),
|
||||
},
|
||||
{
|
||||
relays: ["relay2"],
|
||||
filters: [{kinds: [1]}],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should deduplicate relays", () => {
|
||||
const subs = [
|
||||
{
|
||||
request: {
|
||||
relays: ["relay1", "relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
},
|
||||
},
|
||||
] as any
|
||||
|
||||
const result = defaultOptimizeSubscriptions(subs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].relays).toEqual(["relay1"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isEventValid", () => {
|
||||
const mockEvent = {id: "123"} as TrustedEvent
|
||||
beforeEach(() => {
|
||||
eventValidationScores.clear()
|
||||
// vi.mocked(isSignedEvent)
|
||||
// vi.mocked(hasValidSignature)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should always return true for LOCAL_RELAY_URL", () => {
|
||||
expect(isEventValid(LOCAL_RELAY_URL, mockEvent)).toBe(true)
|
||||
})
|
||||
|
||||
it("should validate signature for non-local events", () => {
|
||||
vi.mocked(isSignedEvent).mockReturnValue(true)
|
||||
vi.mocked(hasValidSignature).mockReturnValue(true)
|
||||
|
||||
const result = isEventValid("relay1", mockEvent)
|
||||
|
||||
expect(isSignedEvent).toHaveBeenCalledWith(mockEvent)
|
||||
expect(hasValidSignature).toHaveBeenCalledWith(mockEvent)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should update validation score on successful validation", () => {
|
||||
vi.mocked(isSignedEvent).mockReturnValue(true)
|
||||
vi.mocked(hasValidSignature).mockReturnValue(true)
|
||||
|
||||
isEventValid("relay1", mockEvent)
|
||||
|
||||
expect(eventValidationScores.get("relay1")).toBe(1)
|
||||
})
|
||||
|
||||
it("should reset validation score on failed validation", () => {
|
||||
// Set initial score
|
||||
eventValidationScores.set("relay1", 10)
|
||||
|
||||
vi.mocked(isSignedEvent).mockReturnValue(false)
|
||||
vi.mocked(hasValidSignature).mockReturnValue(true)
|
||||
|
||||
isEventValid("relay1", mockEvent)
|
||||
|
||||
expect(eventValidationScores.get("relay1")).toBe(0)
|
||||
})
|
||||
|
||||
it("should skip validation when score is high enough", () => {
|
||||
eventValidationScores.set("relay1", 1000)
|
||||
|
||||
const result = isEventValid("relay1", mockEvent)
|
||||
|
||||
expect(isSignedEvent).not.toHaveBeenCalled()
|
||||
expect(hasValidSignature).not.toHaveBeenCalled()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should maintain minimum validation rate", () => {
|
||||
eventValidationScores.set("relay1", 800)
|
||||
vi.spyOn(Math, "random").mockReturnValue(1000) // ensure randomInt returns
|
||||
vi.mocked(isSignedEvent).mockReturnValue(true)
|
||||
vi.mocked(hasValidSignature).mockReturnValue(true)
|
||||
|
||||
isEventValid("relay1", mockEvent)
|
||||
|
||||
expect(eventValidationScores.get("relay1")).toBe(801)
|
||||
})
|
||||
})
|
||||
|
||||
describe("default functions behavior", () => {
|
||||
const context = getDefaultNetContext()
|
||||
|
||||
it("default onEvent should not throw", () => {
|
||||
expect(() => context.onEvent("relay1", {} as TrustedEvent)).not.toThrow()
|
||||
})
|
||||
|
||||
it("default signEvent should return undefined", async () => {
|
||||
const result = await context.signEvent({} as any)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("default isDeleted should return false", () => {
|
||||
expect(context.isDeleted("relay1", {} as TrustedEvent)).toBe(false)
|
||||
})
|
||||
|
||||
it("default matchFilters should use util matchFilters", () => {
|
||||
const filters: Filter[] = []
|
||||
const event = {} as TrustedEvent
|
||||
|
||||
context.matchFilters("relay1", filters, event)
|
||||
|
||||
expect(vi.mocked(matchFilters)).toHaveBeenCalledWith(filters, event)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,256 +0,0 @@
|
||||
import {ctx} from "@welshman/lib"
|
||||
import type {Filter, SignedEvent, TrustedEvent} from "@welshman/util"
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
|
||||
import {Executor} from "../src/Executor"
|
||||
import {Negentropy} from "../src/Negentropy"
|
||||
|
||||
// Mock Negentropy
|
||||
vi.mock("../src/Negentropy.js", () => ({
|
||||
Negentropy: vi.fn().mockImplementation(() => ({
|
||||
reconcile: vi.fn().mockResolvedValue(["newMsg", ["id1"], ["id2"]]),
|
||||
initiate: vi.fn().mockResolvedValue("initialMsg"),
|
||||
})),
|
||||
NegentropyStorageVector: vi.fn().mockImplementation(() => ({
|
||||
insert: vi.fn(),
|
||||
seal: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe("Executor", () => {
|
||||
let mockTarget: any
|
||||
// let mockNegentropy: any
|
||||
let executor: Executor
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// Setup mock target
|
||||
mockTarget = {
|
||||
connections: [],
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
}
|
||||
|
||||
// Setup mock context
|
||||
ctx.net = {
|
||||
...ctx.net,
|
||||
onEvent: vi.fn(),
|
||||
}
|
||||
|
||||
executor = new Executor(mockTarget)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("subscribe", () => {
|
||||
const filters: Filter[] = [{kinds: [1]}]
|
||||
|
||||
it("should setup subscription correctly", () => {
|
||||
const onEvent = vi.fn()
|
||||
const onEose = vi.fn()
|
||||
|
||||
executor.subscribe(filters, {onEvent, onEose})
|
||||
|
||||
expect(mockTarget.on).toHaveBeenCalledWith("EVENT", expect.any(Function))
|
||||
expect(mockTarget.on).toHaveBeenCalledWith("EOSE", expect.any(Function))
|
||||
expect(mockTarget.send).toHaveBeenCalledWith("REQ", expect.any(String), ...filters)
|
||||
})
|
||||
|
||||
it("should handle events for matching subscription ID", () => {
|
||||
const onEvent = vi.fn()
|
||||
executor.subscribe(filters, {onEvent})
|
||||
|
||||
// Get the event listener that was registered
|
||||
const eventListener = mockTarget.on.mock.calls.find(call => call[0] === "EVENT")[1]
|
||||
const event = {id: "123"} as TrustedEvent
|
||||
|
||||
// Simulate event with matching subId (extract it from the REQ call)
|
||||
const subId = mockTarget.send.mock.calls[0][1]
|
||||
eventListener("relay1", subId, event)
|
||||
|
||||
expect(ctx.net.onEvent).toHaveBeenCalledWith("relay1", event)
|
||||
expect(onEvent).toHaveBeenCalledWith("relay1", event)
|
||||
})
|
||||
|
||||
it("should handle EOSE for matching subscription ID", () => {
|
||||
const onEose = vi.fn()
|
||||
executor.subscribe(filters, {onEose})
|
||||
|
||||
const eoseListener = mockTarget.on.mock.calls.find(call => call[0] === "EOSE")[1]
|
||||
const subId = mockTarget.send.mock.calls[0][1]
|
||||
|
||||
eoseListener("relay1", subId)
|
||||
|
||||
expect(onEose).toHaveBeenCalledWith("relay1")
|
||||
})
|
||||
|
||||
it("should cleanup on unsubscribe", () => {
|
||||
const sub = executor.subscribe(filters)
|
||||
const subId = mockTarget.send.mock.calls[0][1]
|
||||
|
||||
sub.unsubscribe()
|
||||
|
||||
expect(mockTarget.send).toHaveBeenLastCalledWith("CLOSE", subId)
|
||||
expect(mockTarget.off).toHaveBeenCalledTimes(2) // EVENT and EOSE listeners
|
||||
})
|
||||
|
||||
it("should not send CLOSE multiple times", () => {
|
||||
const sub = executor.subscribe(filters)
|
||||
sub.unsubscribe()
|
||||
const sendCallCount = mockTarget.send.mock.calls.length
|
||||
|
||||
sub.unsubscribe()
|
||||
|
||||
expect(mockTarget.send.mock.calls.length).toBe(sendCallCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe("publish", () => {
|
||||
const event: SignedEvent = {
|
||||
id: "event123",
|
||||
kind: 1,
|
||||
content: "",
|
||||
tags: [],
|
||||
created_at: 0,
|
||||
pubkey: "",
|
||||
sig: "",
|
||||
}
|
||||
|
||||
it("should setup publish correctly", () => {
|
||||
const onOk = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
executor.publish(event, {onOk, onError})
|
||||
|
||||
expect(mockTarget.on).toHaveBeenCalledWith("OK", expect.any(Function))
|
||||
expect(mockTarget.on).toHaveBeenCalledWith("ERROR", expect.any(Function))
|
||||
expect(mockTarget.send).toHaveBeenCalledWith("EVENT", event)
|
||||
})
|
||||
|
||||
it("should handle successful publish", () => {
|
||||
const onOk = vi.fn()
|
||||
executor.publish(event, {onOk})
|
||||
|
||||
const okListener = mockTarget.on.mock.calls.find(call => call[0] === "OK")[1]
|
||||
okListener("relay1", event.id, true, "success")
|
||||
|
||||
expect(ctx.net.onEvent).toHaveBeenCalledWith("relay1", event)
|
||||
expect(onOk).toHaveBeenCalledWith("relay1", event.id, true, "success")
|
||||
})
|
||||
|
||||
it("should handle failed publish", () => {
|
||||
const onOk = vi.fn()
|
||||
executor.publish(event, {onOk})
|
||||
|
||||
const okListener = mockTarget.on.mock.calls.find(call => call[0] === "OK")[1]
|
||||
okListener("relay1", event.id, false, "failed")
|
||||
|
||||
expect(ctx.net.onEvent).not.toHaveBeenCalled()
|
||||
expect(onOk).toHaveBeenCalledWith("relay1", event.id, false, "failed")
|
||||
})
|
||||
|
||||
it("should handle publish errors", () => {
|
||||
const onError = vi.fn()
|
||||
executor.publish(event, {onError})
|
||||
|
||||
const errorListener = mockTarget.on.mock.calls.find(call => call[0] === "ERROR")[1]
|
||||
errorListener("relay1", event.id, "error message")
|
||||
|
||||
expect(onError).toHaveBeenCalledWith("relay1", event.id, "error message")
|
||||
})
|
||||
|
||||
it("should cleanup on unsubscribe", () => {
|
||||
const pub = executor.publish(event)
|
||||
pub.unsubscribe()
|
||||
|
||||
expect(mockTarget.off).toHaveBeenCalledTimes(2) // OK and ERROR listeners
|
||||
})
|
||||
})
|
||||
|
||||
describe("diff", () => {
|
||||
const filter: Filter = {kinds: [1]}
|
||||
const events: TrustedEvent[] = [
|
||||
{id: "event1", created_at: 1000} as TrustedEvent,
|
||||
{id: "event2", created_at: 2000} as TrustedEvent,
|
||||
]
|
||||
|
||||
it("should setup diff correctly", async () => {
|
||||
const onMessage = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
executor.diff(filter, events, {onMessage, onError, onClose})
|
||||
|
||||
expect(mockTarget.on).toHaveBeenCalledWith("NEG-MSG", expect.any(Function))
|
||||
expect(mockTarget.on).toHaveBeenCalledWith("NEG-ERR", expect.any(Function))
|
||||
// Wait for initiate promise
|
||||
await vi.runAllTimersAsync()
|
||||
expect(mockTarget.send).toHaveBeenCalledWith(
|
||||
"NEG-OPEN",
|
||||
expect.any(String),
|
||||
filter,
|
||||
"initialMsg",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle diff messages", async () => {
|
||||
const onMessage = vi.fn()
|
||||
executor.diff(filter, events, {onMessage})
|
||||
|
||||
const msgListener = mockTarget.on.mock.calls.find(call => call[0] === "NEG-MSG")[1]
|
||||
// wait for initiate promise
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
|
||||
await msgListener("relay1", mockTarget.send.mock.calls[0][1], "msg")
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith("relay1", {
|
||||
have: ["id1"],
|
||||
need: ["id2"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle diff errors", async () => {
|
||||
const onError = vi.fn()
|
||||
executor.diff(filter, events, {onError})
|
||||
|
||||
const errListener = mockTarget.on.mock.calls.find(call => call[0] === "NEG-ERR")[1]
|
||||
// wait for initiate promise
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
|
||||
errListener("relay1", mockTarget.send.mock.calls[0][1], "error")
|
||||
|
||||
expect(onError).toHaveBeenCalledWith("relay1", "error")
|
||||
})
|
||||
|
||||
it("should close diff when reconciliation completes", async () => {
|
||||
const onClose = vi.fn()
|
||||
executor.diff(filter, events, {onClose})
|
||||
|
||||
const msgListener = mockTarget.on.mock.calls.find(call => call[0] === "NEG-MSG")[1]
|
||||
// wait for initiate promise
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
|
||||
// Get the mock instance's reconcile function from the last Negentropy constructor call
|
||||
const mockReconcile = vi.mocked(Negentropy).mock.results[0].value.reconcile
|
||||
mockReconcile.mockResolvedValueOnce([null, [], []])
|
||||
const reqId = mockTarget.send.mock.calls[0][1]
|
||||
|
||||
await msgListener("relay1", reqId, "msg")
|
||||
|
||||
expect(mockTarget.send).toHaveBeenCalledWith("NEG-CLOSE", reqId)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should cleanup on unsubscribe", () => {
|
||||
const diff = executor.diff(filter, events)
|
||||
diff.unsubscribe()
|
||||
|
||||
expect(mockTarget.send).toHaveBeenCalledWith("NEG-CLOSE", expect.any(String))
|
||||
expect(mockTarget.off).toHaveBeenCalledTimes(2) // NEG-MSG and NEG-ERR listeners
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,125 +1,130 @@
|
||||
import {Pool} from "../src/Pool"
|
||||
import {Connection} from "../src/Connection"
|
||||
import {vi, describe, it, expect, beforeEach} from "vitest"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { Socket } from "../src/socket"
|
||||
import { Pool, makeSocket } from "../src/pool"
|
||||
import { normalizeRelayUrl } from "@welshman/util"
|
||||
|
||||
// Mock Connection class
|
||||
vi.mock("../src/Connection", () => ({
|
||||
Connection: vi.fn().mockImplementation(url => ({
|
||||
url,
|
||||
cleanup: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
vi.mock('isomorphic-ws', () => {
|
||||
const WebSocket = vi.fn(function () {
|
||||
setTimeout(() => this.onopen())
|
||||
})
|
||||
|
||||
WebSocket.prototype.send = vi.fn()
|
||||
|
||||
WebSocket.prototype.close = vi.fn(function () {
|
||||
this.onclose()
|
||||
})
|
||||
|
||||
return { default: WebSocket }
|
||||
})
|
||||
|
||||
describe("Pool", () => {
|
||||
let pool: Pool
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
pool = new Pool()
|
||||
})
|
||||
|
||||
describe("initialization", () => {
|
||||
it("should initialize with empty data map", () => {
|
||||
expect(pool.data.size).toBe(0)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("has", () => {
|
||||
it("should return false for non-existent connection", () => {
|
||||
it("should return false for non-existent socket", () => {
|
||||
expect(pool.has("wss://test.relay")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true for existing connection", () => {
|
||||
pool.get("wss://test.relay")
|
||||
it("should return true for existing socket, normalizing the url", () => {
|
||||
pool.get("wss://test.relay/")
|
||||
expect(pool.has("wss://test.relay")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("get", () => {
|
||||
it("should create new connection if none exists", () => {
|
||||
const connection = pool.get("wss://test.relay")
|
||||
it("should create new socket if none exists, normalizing the relay url", () => {
|
||||
const socket = pool.get("wss://test.relay")
|
||||
|
||||
expect(Connection).toHaveBeenCalledWith("wss://test.relay")
|
||||
expect(pool.data.get("wss://test.relay")).toBe(connection)
|
||||
expect(socket.url).toEqual("wss://test.relay/")
|
||||
})
|
||||
|
||||
it("should emit init event for new connections", () => {
|
||||
const initSpy = vi.fn()
|
||||
pool.on("init", initSpy)
|
||||
it("should return existing socket if it exists", () => {
|
||||
const firstSocket = pool.get("wss://test.relay")
|
||||
const secondSocket = pool.get("wss://test.relay")
|
||||
|
||||
const connection = pool.get("wss://test.relay")
|
||||
|
||||
expect(initSpy).toHaveBeenCalledWith(connection)
|
||||
expect(firstSocket).toBe(secondSocket)
|
||||
})
|
||||
})
|
||||
|
||||
it("should return existing connection if it exists", () => {
|
||||
const firstConnection = pool.get("wss://test.relay")
|
||||
const secondConnection = pool.get("wss://test.relay")
|
||||
describe("subscribe", () => {
|
||||
it("should notify subscribers of new sockets", () => {
|
||||
const sub1 = vi.fn()
|
||||
const sub2 = vi.fn()
|
||||
|
||||
expect(Connection).toHaveBeenCalledTimes(1)
|
||||
expect(firstConnection).toBe(secondConnection)
|
||||
})
|
||||
|
||||
it("should not emit init event for existing connections", () => {
|
||||
const initSpy = vi.fn()
|
||||
pool.subscribe(sub1)
|
||||
pool.subscribe(sub2)
|
||||
pool.get("wss://test.relay")
|
||||
|
||||
pool.on("init", initSpy)
|
||||
expect(sub1).toHaveBeenCalledTimes(1)
|
||||
expect(sub2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should not notify subscribers for existing sockets", () => {
|
||||
pool.get("wss://test.relay")
|
||||
|
||||
expect(initSpy).not.toHaveBeenCalled()
|
||||
const sub = vi.fn()
|
||||
pool.subscribe(sub)
|
||||
pool.get("wss://test.relay")
|
||||
|
||||
expect(sub).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should add subscription", () => {
|
||||
const sub = vi.fn()
|
||||
pool.subscribe(sub)
|
||||
expect(pool._subs).toContain(sub)
|
||||
})
|
||||
|
||||
it("should return unsubscribe function", () => {
|
||||
const sub = vi.fn()
|
||||
const unsubscribe = pool.subscribe(sub)
|
||||
|
||||
unsubscribe()
|
||||
|
||||
expect(pool._subs).not.toContain(sub)
|
||||
})
|
||||
})
|
||||
|
||||
describe("remove", () => {
|
||||
it("should remove existing connection", () => {
|
||||
const connection = pool.get("wss://test.relay")
|
||||
pool.remove("wss://test.relay")
|
||||
it("should remove and cleanup existing socket", () => {
|
||||
const mockSocket = { url: "wss://test.relay", cleanup: vi.fn() }
|
||||
|
||||
expect(pool.has("wss://test.relay")).toBe(false)
|
||||
expect(connection.cleanup).toHaveBeenCalled()
|
||||
pool._data.set(mockSocket.url, mockSocket)
|
||||
pool.remove(mockSocket.url)
|
||||
|
||||
expect(mockSocket.cleanup).toHaveBeenCalled()
|
||||
expect(pool._data.has(mockSocket.url)).toBe(false)
|
||||
})
|
||||
|
||||
it("should do nothing for non-existent connection", () => {
|
||||
it("should do nothing for non-existent socket", () => {
|
||||
pool.remove("wss://test.relay")
|
||||
expect(pool.has("wss://test.relay")).toBe(false)
|
||||
})
|
||||
|
||||
it("should cleanup connection before removal", () => {
|
||||
const connection = pool.get("wss://test.relay")
|
||||
pool.remove("wss://test.relay")
|
||||
|
||||
const spy = vi.spyOn(pool.data, "delete")
|
||||
|
||||
expect(connection.cleanup).toHaveBeenCalled()
|
||||
expect(pool._data.has("wss://test.relay")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear", () => {
|
||||
it("should remove all connections", () => {
|
||||
const urls = ["wss://test1.relay", "wss://test2.relay", "wss://test3.relay"]
|
||||
it("should remove all sockets", () => {
|
||||
const urls = ["wss://test1.relay", "wss://test2.relay"]
|
||||
const mockSockets = urls.map(url => ({ url, cleanup: vi.fn() }))
|
||||
|
||||
// Create multiple connections
|
||||
urls.forEach(url => pool.get(url))
|
||||
expect(pool.data.size).toBe(3)
|
||||
for (const mockSocket of mockSockets) {
|
||||
pool._data.set(mockSocket.url, mockSocket)
|
||||
}
|
||||
|
||||
pool.clear()
|
||||
expect(pool.data.size).toBe(0)
|
||||
})
|
||||
|
||||
it("should cleanup all connections", () => {
|
||||
const urls = ["wss://test1.relay", "wss://test2.relay", "wss://test3.relay"]
|
||||
|
||||
const connections = urls.map(url => pool.get(url))
|
||||
pool.clear()
|
||||
|
||||
connections.forEach(connection => {
|
||||
expect(connection.cleanup).toHaveBeenCalled()
|
||||
expect(pool._data.size).toBe(0)
|
||||
mockSockets.forEach(socket => {
|
||||
expect(socket.cleanup).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("should do nothing on empty pool", () => {
|
||||
expect(() => pool.clear()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,184 +1,228 @@
|
||||
import {ctx} from "@welshman/lib"
|
||||
import type {SignedEvent} from "@welshman/util"
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
|
||||
import {makePublish, publish, PublishStatus} from "../src/Publish"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { EventEmitter } from "events"
|
||||
import { Unicast, Multicast, PublishEventType, PublishStatus, unicast, multicast } from "../src/publish"
|
||||
import { AbstractAdapter, AdapterEventType } from "../src/adapter"
|
||||
import { ClientMessageType, RelayMessage } from "../src/message"
|
||||
import { SignedEvent, makeEvent } from "@welshman/util"
|
||||
import { Nip01Signer } from '@welshman/signer'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@welshman/lib", async importOriginal => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
randomId: () => "test-id",
|
||||
now: () => 1000,
|
||||
defer: () => ({
|
||||
resolve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
promise: Promise.resolve(),
|
||||
}),
|
||||
class MockAdapter extends AbstractAdapter {
|
||||
constructor(readonly url: string, readonly send) {
|
||||
super()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@welshman/util", () => ({
|
||||
asSignedEvent: vi.fn(event => event),
|
||||
}))
|
||||
get sockets() {
|
||||
return []
|
||||
}
|
||||
|
||||
describe("Publish", () => {
|
||||
let mockExecutor: any
|
||||
let mockExecutorSub: any
|
||||
get urls() {
|
||||
return [this.url]
|
||||
}
|
||||
|
||||
receive = (message: RelayMessage) => {
|
||||
this.emit(AdapterEventType.Receive, message, this.url)
|
||||
}
|
||||
}
|
||||
|
||||
describe("Unicast", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
mockExecutorSub = {
|
||||
unsubscribe: vi.fn(),
|
||||
}
|
||||
|
||||
mockExecutor = {
|
||||
publish: vi.fn().mockReturnValue(mockExecutorSub),
|
||||
target: {
|
||||
cleanup: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.net = {
|
||||
...ctx.net,
|
||||
getExecutor: vi.fn().mockReturnValue(mockExecutor),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("makePublish", () => {
|
||||
it("should create publish object with correct properties", () => {
|
||||
const request = {
|
||||
event: {id: "event123"} as SignedEvent,
|
||||
relays: ["relay1"],
|
||||
}
|
||||
it("success works", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
const adapter = new MockAdapter('1', sendSpy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
const pub = makePublish(request)
|
||||
|
||||
expect(pub).toEqual({
|
||||
id: "test-id",
|
||||
created_at: 1000,
|
||||
request,
|
||||
emitter: expect.any(Object),
|
||||
result: expect.any(Object),
|
||||
status: expect.any(Map),
|
||||
})
|
||||
const pub = unicast({
|
||||
relay: '1',
|
||||
context: {getAdapter: () => adapter},
|
||||
event,
|
||||
})
|
||||
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event])
|
||||
|
||||
adapter.receive(["OK", event.id, true, "hi"])
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(successSpy).toHaveBeenCalledWith(event.id, "hi")
|
||||
expect(failureSpy).not.toHaveBeenCalled()
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe("publish", () => {
|
||||
const event = {id: "event123"} as SignedEvent
|
||||
const relays = ["relay1", "relay2"]
|
||||
it("failure works", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
const adapter = new MockAdapter('1', sendSpy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
it("should initialize publish with pending status", async () => {
|
||||
const pub = publish({event, relays})
|
||||
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
|
||||
relays.forEach(relay => {
|
||||
expect(pub.status.get(relay)).toBe(PublishStatus.Pending)
|
||||
})
|
||||
const pub = unicast({
|
||||
relay: '1',
|
||||
context: {getAdapter: () => adapter},
|
||||
event,
|
||||
})
|
||||
|
||||
it("should delegate to executor with correct parameters", () => {
|
||||
publish({event, relays})
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
|
||||
expect(ctx.net.getExecutor).toHaveBeenCalledWith(relays)
|
||||
expect(mockExecutor.publish).toHaveBeenCalledWith(
|
||||
event,
|
||||
expect.objectContaining({
|
||||
verb: "EVENT",
|
||||
onOk: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event])
|
||||
|
||||
adapter.receive(["OK", event.id, false, "hi"])
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(successSpy).not.toHaveBeenCalled()
|
||||
expect(failureSpy).toHaveBeenCalledWith(event.id, "hi")
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("timeout works", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
const adapter = new MockAdapter('1', sendSpy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
const pub = unicast({
|
||||
relay: '1',
|
||||
context: {getAdapter: () => adapter},
|
||||
event,
|
||||
})
|
||||
|
||||
it("should handle successful publish", async () => {
|
||||
const pub = publish({event, relays})
|
||||
await vi.runAllTimersAsync()
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
const timeoutSpy = vi.fn()
|
||||
|
||||
const onOk = mockExecutor.publish.mock.calls[0][1].onOk
|
||||
onOk("relay1", event.id, true, "success")
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
pub.on(PublishEventType.Timeout, timeoutSpy)
|
||||
|
||||
expect(pub.status.get("relay1")).toBe(PublishStatus.Success)
|
||||
await vi.runAllTimers(200)
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event])
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(successSpy).not.toHaveBeenCalled()
|
||||
expect(failureSpy).not.toHaveBeenCalled(event.id, "hi")
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
expect(timeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("abort works", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
const adapter = new MockAdapter('1', sendSpy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
const pub = unicast({
|
||||
relay: '1',
|
||||
context: {getAdapter: () => adapter},
|
||||
event,
|
||||
})
|
||||
|
||||
it("should handle failed publish", async () => {
|
||||
const pub = publish({event, relays})
|
||||
await vi.runAllTimersAsync()
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
const abortSpy = vi.fn()
|
||||
|
||||
const onOk = mockExecutor.publish.mock.calls[0][1].onOk
|
||||
onOk("relay1", event.id, false, "failed")
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
pub.on(PublishEventType.Timeout, abortSpy)
|
||||
|
||||
expect(pub.status.get("relay1")).toBe(PublishStatus.Failure)
|
||||
})
|
||||
await vi.runAllTimers(200)
|
||||
|
||||
it("should handle publish errors", async () => {
|
||||
const pub = publish({event, relays})
|
||||
await vi.runAllTimersAsync()
|
||||
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event])
|
||||
|
||||
const onError = mockExecutor.publish.mock.calls[0][1].onError
|
||||
onError("relay1")
|
||||
pub.abort()
|
||||
|
||||
expect(pub.status.get("relay1")).toBe(PublishStatus.Failure)
|
||||
})
|
||||
await vi.runAllTimers()
|
||||
|
||||
it("should handle timeout", async () => {
|
||||
const pub = publish({event, relays, timeout: 5000})
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
relays.forEach(relay => {
|
||||
expect(pub.status.get(relay)).toBe(PublishStatus.Timeout)
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle abort signal", async () => {
|
||||
const controller = new AbortController()
|
||||
const pub = publish({event, relays, signal: controller.signal})
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
|
||||
controller.abort()
|
||||
|
||||
relays.forEach(relay => {
|
||||
expect(pub.status.get(relay)).toBe(PublishStatus.Aborted)
|
||||
})
|
||||
})
|
||||
|
||||
it("should cleanup when all relays complete", async () => {
|
||||
const pub = publish({event, relays})
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
const onOk = mockExecutor.publish.mock.calls[0][1].onOk
|
||||
|
||||
// Complete all relays
|
||||
relays.forEach(relay => {
|
||||
onOk(relay, event.id, true, "success")
|
||||
})
|
||||
|
||||
expect(mockExecutorSub.unsubscribe).toHaveBeenCalled()
|
||||
expect(mockExecutor.target.cleanup).toHaveBeenCalled()
|
||||
expect(pub.result.resolve).toHaveBeenCalledWith(pub.status)
|
||||
})
|
||||
|
||||
it("should use custom verb if provided", () => {
|
||||
const pub = publish({event, relays, verb: "AUTH"})
|
||||
|
||||
expect(mockExecutor.publish.mock.calls[0][1].verb).toBe("AUTH")
|
||||
})
|
||||
|
||||
it("should use default timeout if not specified", async () => {
|
||||
const pub = publish({event, relays})
|
||||
|
||||
// Advance to default timeout
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
relays.forEach(relay => {
|
||||
expect(pub.status.get(relay)).toBe(PublishStatus.Timeout)
|
||||
})
|
||||
})
|
||||
expect(successSpy).not.toHaveBeenCalled()
|
||||
expect(failureSpy).not.toHaveBeenCalled(event.id, "hi")
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
expect(abortSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multicast", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should all basically work", async () => {
|
||||
const send1Spy = vi.fn()
|
||||
const adapter1 = new MockAdapter('1', send1Spy)
|
||||
const send2Spy = vi.fn()
|
||||
const adapter2 = new MockAdapter('2', send2Spy)
|
||||
const send3Spy = vi.fn()
|
||||
const adapter3 = new MockAdapter('3', send3Spy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
const pub = multicast({
|
||||
event,
|
||||
relays: ['1', '2', '3'],
|
||||
context: {
|
||||
getAdapter: (url: string) => {
|
||||
switch(url) {
|
||||
case '1': return adapter1
|
||||
case '2': return adapter2
|
||||
case '3': return adapter3
|
||||
default: throw new Error(`Unknown relay: ${url}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
const timeoutSpy = vi.fn()
|
||||
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
pub.on(PublishEventType.Timeout, timeoutSpy)
|
||||
|
||||
adapter1.receive(["OK", event.id, true, "hi"])
|
||||
adapter2.receive(["OK", event.id, false, "hi"])
|
||||
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(successSpy).toHaveBeenCalledWith(event.id, "hi", "1")
|
||||
expect(failureSpy).toHaveBeenCalledWith(event.id, "hi", "2")
|
||||
expect(completeSpy).toHaveBeenCalledTimes(1)
|
||||
expect(timeoutSpy).toHaveBeenCalledWith("3")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,244 +1,195 @@
|
||||
import {sleep} from "@welshman/lib"
|
||||
import WebSocket from "isomorphic-ws"
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
|
||||
import {ConnectionEvent} from "../src/ConnectionEvent"
|
||||
import {Message, Socket, SocketStatus} from "../src/Socket"
|
||||
import { sleep } from "@welshman/lib"
|
||||
import WebSocket from 'isomorphic-ws'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { Socket, SocketStatus, SocketEventType } from "../src/socket"
|
||||
import { ClientMessage, RelayMessage } from "../src/message"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("isomorphic-ws")
|
||||
// vi.mock("@welshman/lib", async importOriginal => {
|
||||
// return {
|
||||
// ...(await importOriginal()),
|
||||
// // sleep: vi.fn().mockResolvedValue(undefined),
|
||||
// }
|
||||
// })
|
||||
vi.mock('isomorphic-ws', () => {
|
||||
const WebSocket = vi.fn(function () {
|
||||
setTimeout(() => this.onopen())
|
||||
})
|
||||
|
||||
WebSocket.prototype.send = vi.fn()
|
||||
|
||||
WebSocket.prototype.close = vi.fn(function () {
|
||||
this.onclose()
|
||||
})
|
||||
|
||||
return { default: WebSocket }
|
||||
})
|
||||
|
||||
describe("Socket", () => {
|
||||
let socket: Socket
|
||||
let mockConnection: any
|
||||
let mockWs: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// Reset mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup mock connection
|
||||
mockConnection = {
|
||||
url: "wss://test.relay",
|
||||
emit: vi.fn(),
|
||||
}
|
||||
|
||||
// Setup mock WebSocket
|
||||
mockWs = {
|
||||
close: vi.fn(),
|
||||
send: vi.fn(),
|
||||
onopen: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
onmessage: null,
|
||||
}
|
||||
vi.mocked(WebSocket).mockImplementation(() => mockWs)
|
||||
|
||||
socket = new Socket(mockConnection)
|
||||
socket = new Socket("wss://test.relay")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
socket.cleanup()
|
||||
})
|
||||
|
||||
describe("initialization", () => {
|
||||
it("should initialize with New status", () => {
|
||||
expect(socket.status).toBe(SocketStatus.New)
|
||||
})
|
||||
|
||||
it("should setup worker handler", () => {
|
||||
const message = ["EVENT", {id: "123"}] as Message
|
||||
socket.worker.push(message)
|
||||
// workers batch messages every 50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Receive, message)
|
||||
})
|
||||
it("should initialize with correct url", () => {
|
||||
expect(socket.url).toBe("wss://test.relay")
|
||||
})
|
||||
|
||||
describe("open", () => {
|
||||
it("should initialize WebSocket connection", async () => {
|
||||
socket.open()
|
||||
// wait for 2 timeout on wait
|
||||
await vi.advanceTimersByTimeAsync(10_000 * 2)
|
||||
expect(WebSocket).toHaveBeenCalledWith("wss://test.relay")
|
||||
expect(socket.status).toBe(SocketStatus.Opening)
|
||||
})
|
||||
it("should create websocket and emit opening status", () => {
|
||||
const statusSpy = vi.fn()
|
||||
socket.on(SocketEventType.Status, statusSpy)
|
||||
|
||||
// @check this test
|
||||
it("should handle successful connection", async () => {
|
||||
socket.open()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
mockWs.onopen()
|
||||
|
||||
expect(socket.status).toBe(SocketStatus.Open)
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Open)
|
||||
})
|
||||
|
||||
it("should handle connection error (parallel)", async () => {
|
||||
await Promise.all([
|
||||
socket.open(),
|
||||
vi.advanceTimersByTimeAsync(1000),
|
||||
new Promise((resolve, reject) => setTimeout(() => resolve(mockWs.onerror()), 1000)),
|
||||
])
|
||||
|
||||
expect(socket.status).toBe(SocketStatus.Error)
|
||||
expect(socket.lastError).toBe(Date.now())
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Error)
|
||||
})
|
||||
|
||||
it("should retry after error timeout", async () => {
|
||||
// Simulate initial error
|
||||
socket.status = SocketStatus.Error
|
||||
socket.lastError = Date.now() - 16000 // More than 15 seconds ago
|
||||
|
||||
// @check awaiting socket open remains hanging as no socket callback is called
|
||||
// to change the socket status
|
||||
// await socket.open()
|
||||
socket.open()
|
||||
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
expect(socket._ws).toBeDefined()
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Opening, "wss://test.relay")
|
||||
|
||||
expect(WebSocket).toHaveBeenCalled()
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Reset)
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Open, "wss://test.relay")
|
||||
})
|
||||
|
||||
it("should not retry before error timeout", async () => {
|
||||
// Simulate recent error
|
||||
socket.status = SocketStatus.Error
|
||||
socket.lastError = Date.now() - 5000 // Less than 15 seconds ago
|
||||
it("should throw error if socket already exists", () => {
|
||||
socket.open()
|
||||
expect(() => socket.open()).toThrow("Attempted to open a websocket that has not been closed")
|
||||
})
|
||||
|
||||
await socket.open()
|
||||
it("should emit invalid status on invalid URL", () => {
|
||||
const statusSpy = vi.fn()
|
||||
socket.on(SocketEventType.Status, statusSpy)
|
||||
|
||||
expect(WebSocket).not.toHaveBeenCalled()
|
||||
vi.mocked(WebSocket).mockImplementationOnce(() => {
|
||||
throw new Error()
|
||||
})
|
||||
|
||||
socket.open()
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Invalid, "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("close", () => {
|
||||
it("should close WebSocket connection", async () => {
|
||||
socket.ws = mockWs
|
||||
socket.close()
|
||||
it("should close websocket and emit closed status", () => {
|
||||
const statusSpy = vi.fn()
|
||||
socket.on(SocketEventType.Status, statusSpy)
|
||||
|
||||
expect(mockWs.close).toHaveBeenCalled()
|
||||
expect(socket.ws).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should pause worker", async () => {
|
||||
const pauseSpy = vi.spyOn(socket.worker, "pause")
|
||||
socket.close()
|
||||
|
||||
expect(pauseSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle normal close", async () => {
|
||||
socket.open()
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
mockWs.onclose()
|
||||
|
||||
expect(socket.status).toBe(SocketStatus.Closed)
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Close)
|
||||
const ws = socket._ws
|
||||
|
||||
socket.close()
|
||||
|
||||
expect(ws.close).toHaveBeenCalled()
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Closed, "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("send", () => {
|
||||
it("should send message through WebSocket", async () => {
|
||||
const message = ["EVENT", {id: "123"}] as Message
|
||||
it("should queue messages and emit enqueue event", () => {
|
||||
const enqueueSpy = vi.fn()
|
||||
socket.on(SocketEventType.Enqueue, enqueueSpy)
|
||||
|
||||
// Setup open connection
|
||||
socket.open()
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
mockWs.onopen()
|
||||
const message: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
|
||||
socket.send(message)
|
||||
|
||||
await socket.send(message)
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(message))
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Send, message)
|
||||
expect(enqueueSpy).toHaveBeenCalledWith(message, "wss://test.relay")
|
||||
})
|
||||
|
||||
it("should throw if no WebSocket available", () => {
|
||||
const message = ["EVENT", {id: "123"}] as Message
|
||||
socket.ws = undefined
|
||||
// unreachable code
|
||||
// expect(socket.send(message)).rejects.toThrow()
|
||||
it("should send messages when socket is open", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
socket.on(SocketEventType.Send, sendSpy)
|
||||
|
||||
socket.open()
|
||||
socket._ws.onopen()
|
||||
|
||||
const message: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
|
||||
socket.send(message)
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(socket._ws.send).toHaveBeenCalledWith(JSON.stringify(message))
|
||||
expect(sendSpy).toHaveBeenCalledWith(message, "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("message handling", () => {
|
||||
it("should handle valid messages", async () => {
|
||||
const validMessage = ["EVENT", {id: "123"}]
|
||||
describe("receive", () => {
|
||||
it("should handle valid relay messages", async () => {
|
||||
const receiveSpy = vi.fn()
|
||||
socket.on(SocketEventType.Receive, receiveSpy)
|
||||
|
||||
socket.open()
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
const message: RelayMessage = ["EVENT", "123", { id: "123", kind: 1 }]
|
||||
socket._ws.onmessage({ data: JSON.stringify(message) })
|
||||
|
||||
mockWs.onmessage({data: JSON.stringify(validMessage)})
|
||||
// Allow task queue to process
|
||||
await vi.runAllTimers()
|
||||
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Receive, validMessage)
|
||||
expect(receiveSpy).toHaveBeenCalledWith(message, "wss://test.relay")
|
||||
})
|
||||
|
||||
it("should handle non-array messages", async () => {
|
||||
const invalidMessage = {type: "EVENT"}
|
||||
it("should emit error on invalid JSON", () => {
|
||||
const errorSpy = vi.fn()
|
||||
socket.on(SocketEventType.Error, errorSpy)
|
||||
|
||||
socket.open()
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
mockWs.onmessage({data: JSON.stringify(invalidMessage)})
|
||||
socket._ws.onmessage({ data: "invalid json" })
|
||||
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(
|
||||
ConnectionEvent.InvalidMessage,
|
||||
JSON.stringify(invalidMessage),
|
||||
)
|
||||
expect(errorSpy).toHaveBeenCalledWith("Invalid message received", "wss://test.relay")
|
||||
})
|
||||
|
||||
it("should handle invalid JSON", async () => {
|
||||
const invalidJson = "invalid json"
|
||||
it("should emit error on non-array message", () => {
|
||||
const errorSpy = vi.fn()
|
||||
socket.on(SocketEventType.Error, errorSpy)
|
||||
|
||||
socket.open()
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
mockWs.onmessage({data: invalidJson})
|
||||
socket._ws.onmessage({ data: JSON.stringify({ not: "an array" }) })
|
||||
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.InvalidMessage, invalidJson)
|
||||
expect(errorSpy).toHaveBeenCalledWith("Invalid message received", "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("wait", () => {
|
||||
it("should wait for provisional states to resolve", async () => {
|
||||
socket.status = SocketStatus.Opening
|
||||
const waitPromise = socket.wait()
|
||||
describe("cleanup", () => {
|
||||
it("should close socket and clear queues", () => {
|
||||
socket.open()
|
||||
|
||||
// Change status after delay
|
||||
setTimeout(() => {
|
||||
socket.status = SocketStatus.Open
|
||||
}, 200)
|
||||
const ws = socket._ws
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await waitPromise
|
||||
socket.cleanup()
|
||||
|
||||
expect(socket.status).toBe(SocketStatus.Open)
|
||||
expect(ws.close).toHaveBeenCalled()
|
||||
expect(socket.listenerCount(SocketEventType.Send)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle invalid URLs", async () => {
|
||||
vi.mocked(WebSocket).mockImplementationOnce(() => {
|
||||
throw new Error("Invalid URL")
|
||||
})
|
||||
it("should emit error status on websocket error", () => {
|
||||
const statusSpy = vi.fn()
|
||||
socket.on(SocketEventType.Status, statusSpy)
|
||||
|
||||
const now = Date.now()
|
||||
vi.setSystemTime(now)
|
||||
socket.open()
|
||||
socket._ws.onerror()
|
||||
|
||||
await socket.open()
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Error, "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
expect(socket.status).toBe(SocketStatus.Invalid)
|
||||
expect(socket.lastError).toBe(now)
|
||||
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.InvalidUrl)
|
||||
describe("attemptToOpen", () => {
|
||||
it("should open socket if not already open", () => {
|
||||
const openSpy = vi.spyOn(socket, "open")
|
||||
|
||||
socket.attemptToOpen()
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not open socket if already open", () => {
|
||||
const openSpy = vi.spyOn(socket, "open")
|
||||
|
||||
socket.open()
|
||||
socket.attemptToOpen()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import {diff, pull, push, sync, pullWithoutNegentropy, pushWithoutNegentropy} from "../src/Sync"
|
||||
import {ctx, now} from "@welshman/lib"
|
||||
import type {SignedEvent, TrustedEvent, Filter} from "@welshman/util"
|
||||
import {vi, describe, it, expect, beforeEach} from "vitest"
|
||||
import {subscribe} from "../src/Subscribe"
|
||||
import {publish} from "../src/Publish"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../src/Subscribe", () => ({
|
||||
subscribe: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../src/Publish", () => ({
|
||||
publish: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@welshman/lib", async importOriginal => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
now: vi.fn().mockReturnValue(1000),
|
||||
}
|
||||
})
|
||||
|
||||
describe("Sync", () => {
|
||||
let mockExecutor: any
|
||||
let mockDiffSub: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockDiffSub = {unsubscribe: vi.fn()}
|
||||
mockExecutor = {
|
||||
diff: vi.fn().mockImplementation((filter, events, {onMessage, onClose}) => {
|
||||
// Simulate diff message
|
||||
onMessage("relay1", {have: ["id1"], need: ["id2"]})
|
||||
onClose()
|
||||
return mockDiffSub
|
||||
}),
|
||||
target: {
|
||||
cleanup: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.net = {
|
||||
...ctx.net,
|
||||
getExecutor: vi.fn().mockReturnValue(mockExecutor),
|
||||
}
|
||||
|
||||
// Mock subscribe to simulate event reception
|
||||
vi.mocked(subscribe).mockImplementation(({onEvent, onClose, onComplete}) => {
|
||||
if (onEvent) {
|
||||
onEvent({id: "id2", created_at: 900} as TrustedEvent)
|
||||
}
|
||||
onClose?.("relay1")
|
||||
onComplete?.()
|
||||
return {close: vi.fn()}
|
||||
})
|
||||
|
||||
// Mock publish to return resolved result
|
||||
vi.mocked(publish).mockImplementation(() => ({
|
||||
result: Promise.resolve(new Map()),
|
||||
id: "pub1",
|
||||
created_at: 1000,
|
||||
emitter: {} as any,
|
||||
request: {} as any,
|
||||
status: new Map(),
|
||||
}))
|
||||
})
|
||||
|
||||
describe("diff", () => {
|
||||
it("should aggregate diff results by relay", async () => {
|
||||
const result = await diff({
|
||||
relays: ["relay1", "relay2"],
|
||||
filters: [{kinds: [1]}],
|
||||
events: [{id: "id1"} as TrustedEvent],
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
relay: "relay1",
|
||||
have: ["id1"],
|
||||
need: ["id2"],
|
||||
},
|
||||
{
|
||||
relay: "relay2",
|
||||
have: ["id1"],
|
||||
need: ["id2"],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle multiple filters", async () => {
|
||||
const result = await diff({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}, {kinds: [2]}],
|
||||
events: [{id: "id1"} as TrustedEvent],
|
||||
})
|
||||
|
||||
expect(mockExecutor.diff).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should handle diff errors", async () => {
|
||||
mockExecutor.diff.mockImplementation((filter, events, {onError}) => {
|
||||
onError("relay1", "error message")
|
||||
return mockDiffSub
|
||||
})
|
||||
|
||||
await expect(
|
||||
diff({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
events: [],
|
||||
}),
|
||||
).rejects.toEqual("error message")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pull", () => {
|
||||
it("should pull needed events", async () => {
|
||||
const onEvent = vi.fn()
|
||||
const result = await pull({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
events: [],
|
||||
onEvent,
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("id2")
|
||||
expect(onEvent).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should limit duplicate pulls", async () => {
|
||||
// Mock diff to return same need from multiple relays
|
||||
mockExecutor.diff.mockImplementation((filter, events, {onMessage, onClose}) => {
|
||||
onMessage("relay1", {have: [], need: ["id2"]})
|
||||
onClose()
|
||||
return mockDiffSub
|
||||
})
|
||||
|
||||
await pull({
|
||||
relays: ["relay1", "relay2", "relay3"],
|
||||
filters: [{kinds: [1]}],
|
||||
events: [],
|
||||
})
|
||||
|
||||
// Should only subscribe maximum twice for the same ID
|
||||
expect(subscribe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should chunk large ID lists", async () => {
|
||||
const manyIds = Array.from({length: 2000}, (_, i) => `id${i}`)
|
||||
mockExecutor.diff.mockImplementation((filter, events, {onMessage, onClose}) => {
|
||||
onMessage("relay1", {have: [], need: manyIds})
|
||||
onClose()
|
||||
return mockDiffSub
|
||||
})
|
||||
|
||||
await pull({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
events: [],
|
||||
})
|
||||
|
||||
// Should split into chunks of 1024
|
||||
expect(subscribe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("push", () => {
|
||||
it("should push events to relays that have them", async () => {
|
||||
await push({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
events: [{id: "id1"} as SignedEvent],
|
||||
})
|
||||
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
event: expect.any(Object),
|
||||
relays: ["relay1"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should skip events with no matching relays", async () => {
|
||||
mockExecutor.diff.mockImplementation((filter, events, {onMessage, onClose}) => {
|
||||
onMessage("relay1", {have: [], need: []})
|
||||
onClose()
|
||||
return mockDiffSub
|
||||
})
|
||||
|
||||
await push({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
events: [{id: "id1"} as SignedEvent],
|
||||
})
|
||||
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("sync", () => {
|
||||
it("should perform pull and push operations", async () => {
|
||||
await sync({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
events: [{id: "id1"} as SignedEvent],
|
||||
})
|
||||
|
||||
expect(subscribe).toHaveBeenCalled()
|
||||
expect(publish).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pullWithoutNegentropy", () => {
|
||||
it("should pull events until no more results", async () => {
|
||||
let callCount = 0
|
||||
vi.mocked(subscribe).mockImplementation(({onEvent, onComplete}) => {
|
||||
if (callCount++ < 2) {
|
||||
onEvent?.({id: `id${callCount}`, created_at: 900} as TrustedEvent)
|
||||
}
|
||||
onComplete?.()
|
||||
return {close: vi.fn()}
|
||||
})
|
||||
|
||||
const result = await pullWithoutNegentropy({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(subscribe).toHaveBeenCalledTimes(3) // 2 with results + 1 final check
|
||||
})
|
||||
|
||||
it("should update until timestamp based on events", async () => {
|
||||
let callCount = 0
|
||||
vi.mocked(subscribe).mockImplementation(({onEvent, onComplete}) => {
|
||||
if (!callCount) {
|
||||
onEvent?.({id: "id1", created_at: 500} as TrustedEvent)
|
||||
callCount++
|
||||
}
|
||||
onComplete?.()
|
||||
return {close: vi.fn()}
|
||||
})
|
||||
|
||||
await pullWithoutNegentropy({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
})
|
||||
|
||||
// Second subscription should use updated until
|
||||
expect(subscribe).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filters: expect.arrayContaining([expect.objectContaining({until: 499})]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pushWithoutNegentropy", () => {
|
||||
it("should push all events to all relays", async () => {
|
||||
await pushWithoutNegentropy({
|
||||
relays: ["relay1", "relay2"],
|
||||
events: [{id: "id1"} as SignedEvent, {id: "id2"} as SignedEvent],
|
||||
})
|
||||
|
||||
expect(publish).toHaveBeenCalledTimes(2)
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
event: expect.any(Object),
|
||||
relays: ["relay1", "relay2"],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,258 +0,0 @@
|
||||
import {ctx} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {vi, describe, it, expect, beforeEach} from "vitest"
|
||||
import {Subscription, SubscriptionEvent} from "../../src/Subscribe"
|
||||
import {ConnectionEvent} from "../../src/ConnectionEvent"
|
||||
|
||||
describe("Subscription", () => {
|
||||
let mockExecutor: any
|
||||
let mockConnection: any
|
||||
let mockExecutorSub: any
|
||||
|
||||
const relayUrl = "wss://test.relay/"
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockExecutorSub = {unsubscribe: vi.fn()}
|
||||
mockConnection = {
|
||||
url: relayUrl,
|
||||
auth: {attempt: vi.fn().mockResolvedValue(undefined)},
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
}
|
||||
mockExecutor = {
|
||||
subscribe: vi.fn().mockReturnValue(mockExecutorSub),
|
||||
target: {
|
||||
connections: [mockConnection],
|
||||
cleanup: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.net = {
|
||||
...ctx.net,
|
||||
getExecutor: vi.fn().mockReturnValue(mockExecutor),
|
||||
isDeleted: vi.fn().mockReturnValue(false),
|
||||
matchFilters: vi.fn().mockReturnValue(true),
|
||||
isValid: vi.fn().mockReturnValue(true),
|
||||
}
|
||||
})
|
||||
|
||||
describe("event handling", () => {
|
||||
it("should handle duplicate events", () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [],
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.Duplicate, spy)
|
||||
|
||||
// Simulate duplicate event
|
||||
const event = {id: "event123"} as TrustedEvent
|
||||
sub.tracker.track(event.id, relayUrl)
|
||||
sub.onEvent(relayUrl, event)
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(relayUrl, event)
|
||||
})
|
||||
|
||||
it("should handle deleted events", () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [],
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.DeletedEvent, spy)
|
||||
|
||||
// @ts-ignore
|
||||
ctx.net.isDeleted.mockReturnValue(true)
|
||||
const event = {id: "event123"} as TrustedEvent
|
||||
sub.onEvent(relayUrl, event)
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(relayUrl, event)
|
||||
})
|
||||
|
||||
it("should handle failed filters", () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [],
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.FailedFilter, spy)
|
||||
// @ts-ignore
|
||||
ctx.net.matchFilters.mockReturnValue(false)
|
||||
const event = {id: "event123"} as TrustedEvent
|
||||
sub.onEvent(relayUrl, event)
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(relayUrl, event)
|
||||
})
|
||||
|
||||
it("should handle invalid events", () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [],
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.Invalid, spy)
|
||||
// @ts-ignore
|
||||
ctx.net.isValid.mockReturnValue(false)
|
||||
const event = {id: "event123"} as TrustedEvent
|
||||
sub.onEvent(relayUrl, event)
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(relayUrl, event)
|
||||
})
|
||||
|
||||
it("should handle valid events", () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [],
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.Event, spy)
|
||||
|
||||
const event = {id: "event123"} as TrustedEvent
|
||||
sub.onEvent(relayUrl, event)
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(relayUrl, event)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execution", () => {
|
||||
it("should setup auth timeout", async () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [{kinds: [1]}],
|
||||
authTimeout: 1000,
|
||||
})
|
||||
|
||||
await sub.execute()
|
||||
|
||||
expect(mockConnection.auth.attempt).toHaveBeenCalledWith(1000)
|
||||
})
|
||||
|
||||
it("should chunk filters", async () => {
|
||||
const filters = Array(10).fill({kinds: [1]})
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters,
|
||||
})
|
||||
|
||||
await sub.execute()
|
||||
|
||||
expect(mockExecutor.subscribe).toHaveBeenCalledTimes(2) // 8 filters + 2 filters
|
||||
})
|
||||
|
||||
it("should handle empty filters", async () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [],
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.Complete, spy)
|
||||
|
||||
await sub.execute()
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
expect(mockExecutor.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should setup connection close handlers", async () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [{kinds: [1]}],
|
||||
})
|
||||
|
||||
await sub.execute()
|
||||
|
||||
expect(mockConnection.on).toHaveBeenCalledWith(ConnectionEvent.Close, sub.onClose)
|
||||
})
|
||||
})
|
||||
|
||||
describe("completion", () => {
|
||||
it("should complete on timeout", async () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [{kinds: [1]}],
|
||||
timeout: 1000,
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.Complete, spy)
|
||||
|
||||
await sub.execute()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should complete on abort signal", async () => {
|
||||
const controller = new AbortController()
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [{kinds: [1]}],
|
||||
signal: controller.signal,
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.Complete, spy)
|
||||
|
||||
await sub.execute()
|
||||
controller.abort()
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should complete when all relays close", () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [{kinds: [1]}],
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.Complete, spy)
|
||||
|
||||
sub.onClose(mockConnection)
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should complete on EOSE when closeOnEose is true", () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [{kinds: [1]}],
|
||||
closeOnEose: true,
|
||||
})
|
||||
const spy = vi.fn()
|
||||
sub.on(SubscriptionEvent.Complete, spy)
|
||||
|
||||
sub.onEose(relayUrl)
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("should cleanup on completion", async () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [{kinds: [1]}],
|
||||
})
|
||||
|
||||
await sub.execute()
|
||||
sub.onComplete()
|
||||
|
||||
expect(mockExecutorSub.unsubscribe).toHaveBeenCalled()
|
||||
expect(mockExecutor.target.cleanup).toHaveBeenCalled()
|
||||
expect(mockConnection.off).toHaveBeenCalledWith(ConnectionEvent.Close, sub.onClose)
|
||||
})
|
||||
|
||||
it("should only cleanup once", async () => {
|
||||
const sub = new Subscription({
|
||||
relays: [relayUrl],
|
||||
filters: [{kinds: [1]}],
|
||||
})
|
||||
|
||||
await sub.execute()
|
||||
sub.onComplete()
|
||||
sub.onComplete()
|
||||
|
||||
expect(mockExecutorSub.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(mockExecutor.target.cleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
import {ctx} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {vi, describe, it, expect, beforeEach} from "vitest"
|
||||
import {
|
||||
calculateSubscriptionGroup,
|
||||
mergeSubscriptions,
|
||||
Subscription,
|
||||
SubscriptionEvent,
|
||||
} from "../../src/Subscribe"
|
||||
|
||||
describe("Subscription optimization", () => {
|
||||
let mockExecutor: any
|
||||
beforeEach(() => {
|
||||
// Setup mock executor
|
||||
mockExecutor = {
|
||||
subscribe: vi.fn().mockReturnValue({unsubscribe: vi.fn()}),
|
||||
target: {
|
||||
connections: [],
|
||||
cleanup: vi.fn(),
|
||||
},
|
||||
}
|
||||
ctx.net = {
|
||||
...ctx.net,
|
||||
optimizeSubscriptions: vi.fn(subs =>
|
||||
subs.map(sub => ({
|
||||
relays: sub.request.relays,
|
||||
filters: sub.request.filters,
|
||||
})),
|
||||
),
|
||||
getExecutor: vi.fn().mockReturnValue(mockExecutor),
|
||||
isDeleted: vi.fn().mockReturnValue(false),
|
||||
matchFilters: vi.fn().mockReturnValue(true),
|
||||
isValid: vi.fn().mockReturnValue(true),
|
||||
}
|
||||
})
|
||||
|
||||
describe("calculateSubscriptionGroup", () => {
|
||||
it("should group by timeout", () => {
|
||||
const sub = new Subscription({
|
||||
relays: ["relay1"],
|
||||
filters: [],
|
||||
timeout: 1000,
|
||||
})
|
||||
|
||||
expect(calculateSubscriptionGroup(sub)).toBe("timeout:1000")
|
||||
})
|
||||
|
||||
it("should group by auth timeout", () => {
|
||||
const sub = new Subscription({
|
||||
relays: ["relay1"],
|
||||
filters: [],
|
||||
authTimeout: 500,
|
||||
})
|
||||
|
||||
expect(calculateSubscriptionGroup(sub)).toBe("authTimeout:500")
|
||||
})
|
||||
|
||||
it("should group by closeOnEose", () => {
|
||||
const sub = new Subscription({
|
||||
relays: ["relay1"],
|
||||
filters: [],
|
||||
closeOnEose: true,
|
||||
})
|
||||
|
||||
expect(calculateSubscriptionGroup(sub)).toBe("closeOnEose")
|
||||
})
|
||||
|
||||
it("should combine multiple properties", () => {
|
||||
const sub = new Subscription({
|
||||
relays: ["relay1"],
|
||||
filters: [],
|
||||
timeout: 1000,
|
||||
authTimeout: 500,
|
||||
closeOnEose: true,
|
||||
})
|
||||
|
||||
expect(calculateSubscriptionGroup(sub)).toBe("timeout:1000|authTimeout:500|closeOnEose")
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeSubscriptions", () => {
|
||||
it("should merge relays and filters", () => {
|
||||
const subs = [
|
||||
new Subscription({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
}),
|
||||
new Subscription({
|
||||
relays: ["relay2"],
|
||||
filters: [{kinds: [2]}],
|
||||
}),
|
||||
]
|
||||
|
||||
const merged = mergeSubscriptions(subs)
|
||||
|
||||
expect(merged.request.relays).toEqual(["relay1", "relay2"])
|
||||
expect(merged.request.filters).toEqual([{kinds: [1, 2]}])
|
||||
})
|
||||
|
||||
it("should propagate events from original subscriptions to merged subscription", () => {
|
||||
const mergedSpy = vi.fn()
|
||||
const subs = [
|
||||
new Subscription({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
}),
|
||||
new Subscription({
|
||||
relays: ["relay2"],
|
||||
filters: [{kinds: [1]}],
|
||||
}),
|
||||
]
|
||||
|
||||
const merged = mergeSubscriptions(subs)
|
||||
merged.on(SubscriptionEvent.Event, mergedSpy)
|
||||
|
||||
const event = {id: "event123", kind: 1} as TrustedEvent
|
||||
|
||||
// Simulate event from original subscription
|
||||
subs[0].emit(SubscriptionEvent.Event, "relay1", event)
|
||||
|
||||
expect(mergedSpy).toHaveBeenCalledWith("relay1", event)
|
||||
})
|
||||
|
||||
it("should avoid duplicate events in merged subscription", () => {
|
||||
const mergedSpy = vi.fn()
|
||||
const subs = [
|
||||
new Subscription({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
}),
|
||||
new Subscription({
|
||||
relays: ["relay2"],
|
||||
filters: [{kinds: [1]}],
|
||||
}),
|
||||
]
|
||||
|
||||
const merged = mergeSubscriptions(subs)
|
||||
merged.on(SubscriptionEvent.Event, mergedSpy)
|
||||
|
||||
const event = {id: "event123", kind: 1} as TrustedEvent
|
||||
|
||||
// Simulate same event from both subscriptions
|
||||
subs[0].emit(SubscriptionEvent.Event, "relay1", event)
|
||||
subs[1].emit(SubscriptionEvent.Event, "relay2", event)
|
||||
|
||||
expect(mergedSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mergedSpy).toHaveBeenCalledWith("relay1", event)
|
||||
})
|
||||
|
||||
it("should complete when all subscriptions complete", () => {
|
||||
const spy = vi.fn()
|
||||
const subs = [
|
||||
new Subscription({
|
||||
relays: ["relay1"],
|
||||
filters: [{kinds: [1]}],
|
||||
}),
|
||||
new Subscription({
|
||||
relays: ["relay2"],
|
||||
filters: [{kinds: [1]}],
|
||||
}),
|
||||
]
|
||||
|
||||
const merged = mergeSubscriptions(subs)
|
||||
merged.on(SubscriptionEvent.Complete, spy)
|
||||
|
||||
subs[0].emit(SubscriptionEvent.Complete)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
|
||||
subs[1].emit(SubscriptionEvent.Complete)
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,193 +0,0 @@
|
||||
import {LOCAL_RELAY_URL} from "@welshman/util"
|
||||
import {beforeEach, describe, expect, it, vi} from "vitest"
|
||||
import {ConnectionEvent, Echo, Local, Multi, Relay, Relays} from "../src/index"
|
||||
|
||||
describe("Target implementations", () => {
|
||||
describe("Echo", () => {
|
||||
it("should emit received messages", () => {
|
||||
const echo = new Echo()
|
||||
const spy = vi.fn()
|
||||
echo.on("event", spy)
|
||||
|
||||
echo.send("event", "data")
|
||||
expect(spy).toHaveBeenCalledWith("data")
|
||||
})
|
||||
|
||||
it("should cleanup properly", () => {
|
||||
const echo = new Echo()
|
||||
const spy = vi.fn()
|
||||
echo.on("event", spy)
|
||||
echo.cleanup()
|
||||
|
||||
echo.send("event", "data")
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Local", () => {
|
||||
let mockRelay: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockRelay = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
send: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
it("should route messages through relay", async () => {
|
||||
const local = new Local(mockRelay)
|
||||
await local.send("event", "data")
|
||||
expect(mockRelay.send).toHaveBeenCalledWith("event", "data")
|
||||
})
|
||||
|
||||
it("should emit received messages with LOCAL_RELAY_URL", () => {
|
||||
const local = new Local(mockRelay)
|
||||
const spy = vi.fn()
|
||||
local.on("event", spy)
|
||||
|
||||
mockRelay.on.mock.calls[0][1]("event", "data")
|
||||
expect(spy).toHaveBeenCalledWith(LOCAL_RELAY_URL, "data")
|
||||
})
|
||||
|
||||
it("should remove relay listener on cleanup", () => {
|
||||
const local = new Local(mockRelay)
|
||||
const onMessage = mockRelay.on.mock.calls[0][1]
|
||||
|
||||
local.cleanup()
|
||||
expect(mockRelay.off).toHaveBeenCalledWith("*", onMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multi", () => {
|
||||
let target1: any
|
||||
let target2: any
|
||||
|
||||
beforeEach(() => {
|
||||
target1 = {send: vi.fn(), on: vi.fn(), cleanup: vi.fn(), connections: []}
|
||||
target2 = {send: vi.fn(), on: vi.fn(), cleanup: vi.fn(), connections: []}
|
||||
})
|
||||
|
||||
it("should forward messages to all targets", async () => {
|
||||
const multi = new Multi([target1, target2])
|
||||
await multi.send("event", "data")
|
||||
|
||||
expect(target1.send).toHaveBeenCalledWith("event", "data")
|
||||
expect(target2.send).toHaveBeenCalledWith("event", "data")
|
||||
})
|
||||
|
||||
it("should propagate events from targets", () => {
|
||||
const multi = new Multi([target1, target2])
|
||||
const spy = vi.fn()
|
||||
multi.on("event", spy)
|
||||
|
||||
target1.on.mock.calls[0][1]("event", "data")
|
||||
expect(spy).toHaveBeenCalledWith("data")
|
||||
})
|
||||
|
||||
it("should cleanup all targets", () => {
|
||||
const multi = new Multi([target1, target2])
|
||||
multi.cleanup()
|
||||
|
||||
expect(target1.cleanup).toHaveBeenCalled()
|
||||
expect(target2.cleanup).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Relay", () => {
|
||||
let mockConnection: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockConnection = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
send: vi.fn(),
|
||||
url: "test-url",
|
||||
}
|
||||
})
|
||||
|
||||
it("should forward messages to connection", async () => {
|
||||
const relay = new Relay(mockConnection)
|
||||
await relay.send("event", "data")
|
||||
expect(mockConnection.send).toHaveBeenCalledWith(["event", "data"])
|
||||
})
|
||||
|
||||
it("should emit received messages with connection url", () => {
|
||||
const relay = new Relay(mockConnection)
|
||||
const spy = vi.fn()
|
||||
relay.on("event", spy)
|
||||
|
||||
mockConnection.on.mock.calls[0][1](mockConnection, ["event", "data"])
|
||||
expect(spy).toHaveBeenCalledWith("test-url", "data")
|
||||
})
|
||||
|
||||
it("should remove connection listener on cleanup", () => {
|
||||
const relay = new Relay(mockConnection)
|
||||
const onMessage = mockConnection.on.mock.calls[0][1]
|
||||
|
||||
relay.cleanup()
|
||||
expect(mockConnection.off).toHaveBeenCalledWith(ConnectionEvent.Receive, onMessage)
|
||||
})
|
||||
|
||||
it("should stop propagating events after cleanup", () => {
|
||||
const relay = new Relay(mockConnection)
|
||||
const spy = vi.fn()
|
||||
relay.on("event", spy)
|
||||
|
||||
relay.cleanup()
|
||||
|
||||
mockConnection.on.mock.calls[0][1](mockConnection, ["event", "data"])
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Relays", () => {
|
||||
let connections: any[]
|
||||
|
||||
beforeEach(() => {
|
||||
connections = [
|
||||
{on: vi.fn(), off: vi.fn(), send: vi.fn(), url: "url1"},
|
||||
{on: vi.fn(), off: vi.fn(), send: vi.fn(), url: "url2"},
|
||||
]
|
||||
})
|
||||
|
||||
it("should forward messages to all connections", async () => {
|
||||
const relays = new Relays(connections)
|
||||
await relays.send("event", "data")
|
||||
|
||||
connections.forEach(conn => {
|
||||
expect(conn.send).toHaveBeenCalledWith(["event", "data"])
|
||||
})
|
||||
})
|
||||
|
||||
it("should emit received messages with connection url", () => {
|
||||
const relays = new Relays(connections)
|
||||
const spy = vi.fn()
|
||||
relays.on("event", spy)
|
||||
|
||||
connections[0].on.mock.calls[0][1](connections[0], ["event", "data"])
|
||||
expect(spy).toHaveBeenCalledWith("url1", "data")
|
||||
})
|
||||
|
||||
it("should remove all connection listeners on cleanup", () => {
|
||||
const relays = new Relays(connections)
|
||||
const onMessage = connections[0].on.mock.calls[0][1] // Same handler for all connections
|
||||
|
||||
relays.cleanup()
|
||||
|
||||
connections.forEach(conn => {
|
||||
expect(conn.off).toHaveBeenCalledWith("receive:message", onMessage)
|
||||
})
|
||||
})
|
||||
|
||||
it("should stop propagating events after cleanup", () => {
|
||||
const relays = new Relays(connections)
|
||||
const spy = vi.fn()
|
||||
relays.on("event", spy)
|
||||
|
||||
relays.cleanup()
|
||||
connections[0].on.mock.calls[0][1](connections[0], ["event", "data"])
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@welshman/net",
|
||||
"version": "0.0.49",
|
||||
"version": "0.0.48",
|
||||
"author": "hodlbod",
|
||||
"license": "MIT",
|
||||
"description": "Utilities for connecting with nostr relays.",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@welshman/lib": "^0.1.0",
|
||||
"@welshman/util": "^0.1.0",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"ws": "^8.16.0"
|
||||
"nostr-tools": "^2.11.0",
|
||||
"typed-emitter": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import {Emitter} from "@welshman/lib"
|
||||
import {normalizeRelayUrl} from "@welshman/util"
|
||||
import {Socket} from "./Socket.js"
|
||||
import type {Message} from "./Socket.js"
|
||||
import {ConnectionEvent} from "./ConnectionEvent.js"
|
||||
import {ConnectionState} from "./ConnectionState.js"
|
||||
import {ConnectionStats} from "./ConnectionStats.js"
|
||||
import {ConnectionAuth} from "./ConnectionAuth.js"
|
||||
import {ConnectionSender} from "./ConnectionSender.js"
|
||||
|
||||
export enum ConnectionStatus {
|
||||
Open = "open",
|
||||
Closed = "Closed",
|
||||
}
|
||||
|
||||
const {Open, Closed} = ConnectionStatus
|
||||
|
||||
export class Connection extends Emitter {
|
||||
url: string
|
||||
socket: Socket
|
||||
sender: ConnectionSender
|
||||
state: ConnectionState
|
||||
stats: ConnectionStats
|
||||
auth: ConnectionAuth
|
||||
status = Open
|
||||
|
||||
constructor(url: string) {
|
||||
super()
|
||||
|
||||
if (url !== normalizeRelayUrl(url)) {
|
||||
console.warn(`Attempted to open connection to non-normalized url ${url}`)
|
||||
}
|
||||
|
||||
this.url = url
|
||||
this.socket = new Socket(this)
|
||||
this.sender = new ConnectionSender(this)
|
||||
this.state = new ConnectionState(this)
|
||||
this.stats = new ConnectionStats(this)
|
||||
this.auth = new ConnectionAuth(this)
|
||||
this.setMaxListeners(100)
|
||||
}
|
||||
|
||||
emit = (type: ConnectionEvent, ...args: any[]) => super.emit(type, this, ...args)
|
||||
|
||||
send = async (message: Message) => {
|
||||
if (this.status !== Open) {
|
||||
throw new Error(`Attempted to send message on ${this.status} connection`)
|
||||
}
|
||||
|
||||
this.socket.open()
|
||||
this.sender.push(message)
|
||||
}
|
||||
|
||||
open = () => {
|
||||
this.status = Open
|
||||
this.socket.open()
|
||||
this.sender.worker.resume()
|
||||
}
|
||||
|
||||
close = () => {
|
||||
this.status = Closed
|
||||
this.socket.close()
|
||||
this.sender.worker.pause()
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.close()
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import {ctx, sleep} from "@welshman/lib"
|
||||
import {CLIENT_AUTH, createEvent} from "@welshman/util"
|
||||
import {ConnectionEvent} from "./ConnectionEvent.js"
|
||||
import type {Connection} from "./Connection.js"
|
||||
import type {Message} from "./Socket.js"
|
||||
|
||||
export enum AuthMode {
|
||||
Implicit = "implicit",
|
||||
Explicit = "explicit",
|
||||
}
|
||||
|
||||
export enum AuthStatus {
|
||||
None = "none",
|
||||
Requested = "requested",
|
||||
PendingSignature = "pending_signature",
|
||||
DeniedSignature = "denied_signature",
|
||||
PendingResponse = "pending_response",
|
||||
Forbidden = "forbidden",
|
||||
Ok = "ok",
|
||||
}
|
||||
|
||||
const {None, Requested, PendingSignature, DeniedSignature, PendingResponse, Forbidden, Ok} =
|
||||
AuthStatus
|
||||
|
||||
export class ConnectionAuth {
|
||||
challenge: string | undefined
|
||||
request: string | undefined
|
||||
message: string | undefined
|
||||
status = None
|
||||
|
||||
constructor(readonly cxn: Connection) {
|
||||
this.cxn.on(ConnectionEvent.Close, this.#onClose)
|
||||
this.cxn.on(ConnectionEvent.Receive, this.#onReceive)
|
||||
}
|
||||
|
||||
#onReceive = (cxn: Connection, [verb, ...extra]: Message) => {
|
||||
if (verb === "OK") {
|
||||
const [id, ok, message] = extra
|
||||
|
||||
if (id === this.request) {
|
||||
this.message = message
|
||||
this.status = ok ? Ok : Forbidden
|
||||
}
|
||||
}
|
||||
|
||||
if (verb === "AUTH" && extra[0] !== this.challenge) {
|
||||
this.challenge = extra[0]
|
||||
this.request = undefined
|
||||
this.message = undefined
|
||||
this.status = Requested
|
||||
|
||||
if (ctx.net.authMode === AuthMode.Implicit) {
|
||||
this.respond()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#onClose = (cxn: Connection) => {
|
||||
this.challenge = undefined
|
||||
this.request = undefined
|
||||
this.message = undefined
|
||||
this.status = None
|
||||
}
|
||||
|
||||
waitFor = async (condition: () => boolean, timeout = 300) => {
|
||||
const start = Date.now()
|
||||
|
||||
while (Date.now() - timeout <= start) {
|
||||
if (condition()) {
|
||||
break
|
||||
}
|
||||
await sleep(Math.min(100, Math.ceil(timeout / 3)))
|
||||
}
|
||||
}
|
||||
|
||||
waitForChallenge = async (timeout = 300) => this.waitFor(() => Boolean(this.challenge), timeout)
|
||||
|
||||
waitForResolution = async (timeout = 300) =>
|
||||
this.waitFor(() => [None, DeniedSignature, Forbidden, Ok].includes(this.status), timeout)
|
||||
|
||||
respond = async () => {
|
||||
if (!this.challenge) {
|
||||
throw new Error("Attempted to authenticate with no challenge")
|
||||
}
|
||||
|
||||
if (this.status !== Requested) {
|
||||
throw new Error(`Attempted to authenticate when auth is already ${this.status}`)
|
||||
}
|
||||
|
||||
this.status = PendingSignature
|
||||
|
||||
const template = createEvent(CLIENT_AUTH, {
|
||||
tags: [
|
||||
["relay", this.cxn.url],
|
||||
["challenge", this.challenge],
|
||||
],
|
||||
})
|
||||
|
||||
const [event] = await Promise.all([ctx.net.signEvent(template), this.cxn.socket.open()])
|
||||
|
||||
if (event) {
|
||||
this.request = event.id
|
||||
this.cxn.send(["AUTH", event])
|
||||
this.status = PendingResponse
|
||||
} else {
|
||||
this.status = DeniedSignature
|
||||
}
|
||||
}
|
||||
|
||||
attempt = async (timeout = 300) => {
|
||||
await this.cxn.socket.open()
|
||||
await this.waitForChallenge(Math.ceil(timeout / 2))
|
||||
|
||||
if (this.status === Requested) {
|
||||
await this.respond()
|
||||
}
|
||||
|
||||
await this.waitForResolution(Math.ceil(timeout / 2))
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export enum ConnectionEvent {
|
||||
InvalidUrl = "invalid:url",
|
||||
InvalidMessage = "invalid:message:receive",
|
||||
Open = "socket:open",
|
||||
Reset = "socket:reset",
|
||||
Close = "socket:close",
|
||||
Error = "socket:error",
|
||||
Receive = "receive:message",
|
||||
Notice = "receive:notice",
|
||||
Send = "send:message",
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import {Worker, complement, spec} from "@welshman/lib"
|
||||
import {AUTH_JOIN} from "@welshman/util"
|
||||
import {SocketStatus} from "./Socket.js"
|
||||
import type {Message} from "./Socket.js"
|
||||
import type {Connection} from "./Connection.js"
|
||||
import {AuthStatus} from "./ConnectionAuth.js"
|
||||
|
||||
export class ConnectionSender {
|
||||
worker: Worker<Message>
|
||||
|
||||
constructor(readonly cxn: Connection) {
|
||||
this.worker = new Worker({
|
||||
shouldDefer: (message: Message) => {
|
||||
const verb = message[0]
|
||||
|
||||
// Always send CLOSE to clean up pending requests
|
||||
if (verb === "CLOSE") return false
|
||||
|
||||
// If we're not connected, nothing we can do
|
||||
if (cxn.socket.status !== SocketStatus.Open) return true
|
||||
|
||||
// Always allow sending AUTH
|
||||
if (verb === "AUTH") return false
|
||||
|
||||
// Always allow sending join requests
|
||||
if (verb === "EVENT" && message[1].kind === AUTH_JOIN) return false
|
||||
|
||||
// Wait for auth
|
||||
if (![AuthStatus.None, AuthStatus.Ok].includes(cxn.auth.status)) return true
|
||||
|
||||
// Limit concurrent requests
|
||||
if (verb === "REQ") return cxn.state.pendingRequests.size >= 50
|
||||
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
this.worker.addGlobalHandler((message: Message) => {
|
||||
const verb = message[0]
|
||||
|
||||
// If we're closing something that never got sent, skip it
|
||||
if (verb === "CLOSE" && !cxn.state.pendingRequests.has(message[1])) {
|
||||
return
|
||||
}
|
||||
cxn.socket.send(message)
|
||||
})
|
||||
}
|
||||
|
||||
push = (message: Message) => {
|
||||
// If we ended up handling a CLOSE before we sent the REQ, don't send the REQ
|
||||
if (message[0] === "CLOSE") {
|
||||
this.worker.buffer = this.worker.buffer.filter(complement(spec(["REQ", message[1]])))
|
||||
}
|
||||
|
||||
this.worker.push(message)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import {sleep} from "@welshman/lib"
|
||||
import {AUTH_JOIN} from "@welshman/util"
|
||||
import type {SignedEvent, Filter} from "@welshman/util"
|
||||
import type {Message} from "./Socket.js"
|
||||
import type {Connection} from "./Connection.js"
|
||||
import {ConnectionEvent} from "./ConnectionEvent.js"
|
||||
|
||||
export type PublishState = {
|
||||
sent: number
|
||||
event: SignedEvent
|
||||
}
|
||||
|
||||
export type RequestState = {
|
||||
sent: number
|
||||
filters: Filter[]
|
||||
eose?: boolean
|
||||
}
|
||||
|
||||
export class ConnectionState {
|
||||
pendingPublishes = new Map<string, PublishState>()
|
||||
pendingRequests = new Map<string, RequestState>()
|
||||
|
||||
constructor(readonly cxn: Connection) {
|
||||
cxn.sender.worker.addGlobalHandler(([verb, ...extra]: Message) => {
|
||||
if (verb === "REQ") {
|
||||
const [reqId, ...filters] = extra
|
||||
|
||||
this.pendingRequests.set(reqId, {filters, sent: Date.now()})
|
||||
}
|
||||
|
||||
if (verb === "CLOSE") {
|
||||
const [reqId] = extra
|
||||
|
||||
this.pendingRequests.delete(reqId)
|
||||
}
|
||||
|
||||
if (verb === "EVENT") {
|
||||
const [event] = extra
|
||||
|
||||
this.pendingPublishes.set(event.id, {sent: Date.now(), event})
|
||||
}
|
||||
})
|
||||
|
||||
cxn.socket.worker.addGlobalHandler(([verb, ...extra]: Message) => {
|
||||
if (verb === "OK") {
|
||||
const [eventId, _ok, notice] = extra
|
||||
const pub = this.pendingPublishes.get(eventId)
|
||||
|
||||
if (!pub) return
|
||||
|
||||
// Re-enqueue pending events when auth challenge is received
|
||||
if (notice?.startsWith("auth-required:") && pub.event.kind !== AUTH_JOIN) {
|
||||
this.cxn.send(["EVENT", pub.event])
|
||||
} else {
|
||||
this.pendingPublishes.delete(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
if (verb === "EOSE") {
|
||||
const [reqId] = extra
|
||||
const req = this.pendingRequests.get(reqId)
|
||||
|
||||
if (req) {
|
||||
req.eose = true
|
||||
}
|
||||
}
|
||||
|
||||
if (verb === "CLOSED") {
|
||||
const [reqId] = extra
|
||||
|
||||
// Re-enqueue pending reqs when auth challenge is received
|
||||
if (extra[1]?.startsWith("auth-required:")) {
|
||||
const req = this.pendingRequests.get(reqId)
|
||||
|
||||
if (req) {
|
||||
this.cxn.send(["REQ", reqId, ...req.filters])
|
||||
}
|
||||
|
||||
if (extra[1]) {
|
||||
this.cxn.emit(ConnectionEvent.Notice, extra[1])
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingRequests.delete(reqId)
|
||||
}
|
||||
|
||||
if (verb === "NOTICE") {
|
||||
const [notice] = extra
|
||||
|
||||
this.cxn.emit(ConnectionEvent.Notice, notice)
|
||||
}
|
||||
})
|
||||
|
||||
// Whenever we reconnect, re-enqueue pending stuff. Delay this so that if a connection
|
||||
// is flapping we're not sending too much noise.
|
||||
cxn.on(ConnectionEvent.Close, async (cxn: Connection) => {
|
||||
await sleep(10_000)
|
||||
|
||||
if (this.pendingRequests.size > 0 || this.pendingPublishes.size > 0) {
|
||||
this.cxn.open()
|
||||
}
|
||||
|
||||
for (const [reqId, req] of this.pendingRequests.entries()) {
|
||||
this.cxn.send(["REQ", reqId, ...req.filters])
|
||||
}
|
||||
|
||||
for (const [_, pub] of this.pendingPublishes.entries()) {
|
||||
this.cxn.send(["EVENT", pub.event])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import type {Message} from "./Socket.js"
|
||||
import type {Connection} from "./Connection.js"
|
||||
import {ConnectionEvent} from "./ConnectionEvent.js"
|
||||
|
||||
export class ConnectionStats {
|
||||
openCount = 0
|
||||
closeCount = 0
|
||||
errorCount = 0
|
||||
publishCount = 0
|
||||
requestCount = 0
|
||||
eventCount = 0
|
||||
lastOpen = 0
|
||||
lastClose = 0
|
||||
lastError = 0
|
||||
lastPublish = 0
|
||||
lastRequest = 0
|
||||
lastEvent = 0
|
||||
lastAuth = 0
|
||||
publishTimer = 0
|
||||
publishSuccessCount = 0
|
||||
publishFailureCount = 0
|
||||
eoseCount = 0
|
||||
eoseTimer = 0
|
||||
noticeCount = 0
|
||||
|
||||
constructor(readonly cxn: Connection) {
|
||||
cxn.on(ConnectionEvent.Open, (cxn: Connection) => {
|
||||
this.openCount++
|
||||
this.lastOpen = Date.now()
|
||||
})
|
||||
|
||||
cxn.on(ConnectionEvent.Close, (cxn: Connection) => {
|
||||
this.closeCount++
|
||||
this.lastClose = Date.now()
|
||||
})
|
||||
|
||||
cxn.on(ConnectionEvent.Error, (cxn: Connection) => {
|
||||
this.errorCount++
|
||||
this.lastError = Date.now()
|
||||
})
|
||||
|
||||
cxn.on(ConnectionEvent.Send, (cxn: Connection, [verb]: Message) => {
|
||||
if (verb === "REQ") {
|
||||
this.requestCount++
|
||||
this.lastRequest = Date.now()
|
||||
}
|
||||
|
||||
if (verb === "EVENT") {
|
||||
this.publishCount++
|
||||
this.lastPublish = Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
cxn.on(ConnectionEvent.Receive, (cxn: Connection, [verb, ...extra]: Message) => {
|
||||
if (verb === "OK") {
|
||||
const pub = this.cxn.state.pendingPublishes.get(extra[0])
|
||||
|
||||
if (pub) {
|
||||
this.publishTimer += Date.now() - pub.sent
|
||||
}
|
||||
|
||||
if (extra[1]) {
|
||||
this.publishSuccessCount++
|
||||
} else {
|
||||
this.publishFailureCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (verb === "AUTH") {
|
||||
this.lastAuth = Date.now()
|
||||
}
|
||||
|
||||
if (verb === "EVENT") {
|
||||
this.eventCount++
|
||||
this.lastEvent = Date.now()
|
||||
}
|
||||
|
||||
if (verb === "EOSE") {
|
||||
const request = this.cxn.state.pendingRequests.get(extra[0])
|
||||
|
||||
// Only count the first eose
|
||||
if (request && !request.eose) {
|
||||
this.eoseCount++
|
||||
this.eoseTimer += Date.now() - request.sent
|
||||
}
|
||||
}
|
||||
|
||||
if (verb === "NOTICE") {
|
||||
this.noticeCount++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getRequestSpeed = () => (this.eoseCount ? this.eoseTimer / this.eoseCount : 0)
|
||||
|
||||
getPublishSpeed = () =>
|
||||
this.publishSuccessCount ? this.publishTimer / this.publishSuccessCount : 0
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import {ctx, randomInt, uniq, noop, always} from "@welshman/lib"
|
||||
import {
|
||||
LOCAL_RELAY_URL,
|
||||
matchFilters,
|
||||
unionFilters,
|
||||
isSignedEvent,
|
||||
hasValidSignature,
|
||||
} from "@welshman/util"
|
||||
import type {StampedEvent, SignedEvent, Filter, TrustedEvent} from "@welshman/util"
|
||||
import {Pool} from "./Pool.js"
|
||||
import {Executor} from "./Executor.js"
|
||||
import {AuthMode} from "./ConnectionAuth.js"
|
||||
import {Relays} from "./target/Relays.js"
|
||||
import type {Subscription, RelaysAndFilters} from "./Subscribe.js"
|
||||
|
||||
export type NetContext = {
|
||||
pool: Pool
|
||||
authMode: AuthMode
|
||||
onEvent: (url: string, event: TrustedEvent) => void
|
||||
signEvent: (event: StampedEvent) => Promise<SignedEvent | undefined>
|
||||
getExecutor: (relays: string[]) => Executor
|
||||
isDeleted: (url: string, event: TrustedEvent) => boolean
|
||||
isValid: (url: string, event: TrustedEvent) => boolean
|
||||
matchFilters: (url: string, filters: Filter[], event: TrustedEvent) => boolean
|
||||
optimizeSubscriptions: (subs: Subscription[]) => RelaysAndFilters[]
|
||||
}
|
||||
|
||||
export const defaultOptimizeSubscriptions = (subs: Subscription[]) =>
|
||||
uniq(subs.flatMap(sub => sub.request.relays || [])).map(relay => {
|
||||
const relaySubs = subs.filter(sub => sub.request.relays.includes(relay))
|
||||
const filters = unionFilters(relaySubs.flatMap(sub => sub.request.filters))
|
||||
|
||||
return {relays: [relay], filters}
|
||||
})
|
||||
|
||||
export const eventValidationScores = new Map<string, number>()
|
||||
|
||||
export const isEventValid = (url: string, event: TrustedEvent) => {
|
||||
if (url === LOCAL_RELAY_URL) return true
|
||||
|
||||
const validCount = eventValidationScores.get(url) || 0
|
||||
|
||||
// The more events we've actually validated from this relay, the more we can trust it.
|
||||
if (validCount > randomInt(100, 1000)) return true
|
||||
|
||||
const isValid = isSignedEvent(event) && hasValidSignature(event)
|
||||
|
||||
// If the event was valid, increase the relay's score. If not, reset it
|
||||
// Never validate less than 10% to make sure we're never totally checking out
|
||||
if (!isValid || validCount < 900) {
|
||||
eventValidationScores.set(url, isValid ? validCount + 1 : 0)
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
export const getDefaultNetContext = (overrides: Partial<NetContext> = {}) => ({
|
||||
pool: new Pool(),
|
||||
authMode: AuthMode.Implicit,
|
||||
onEvent: noop,
|
||||
signEvent: noop,
|
||||
isDeleted: always(false),
|
||||
isValid: isEventValid,
|
||||
getExecutor: (relays: string[]) =>
|
||||
new Executor(new Relays(relays.map((relay: string) => ctx.net.pool.get(relay)))),
|
||||
matchFilters: (url: string, filters: Filter[], event: TrustedEvent) =>
|
||||
matchFilters(filters, event),
|
||||
optimizeSubscriptions: defaultOptimizeSubscriptions,
|
||||
...overrides,
|
||||
})
|
||||
@@ -1,154 +0,0 @@
|
||||
import {ctx, noop} from "@welshman/lib"
|
||||
import type {Emitter} from "@welshman/lib"
|
||||
import type {SignedEvent, TrustedEvent, Filter} from "@welshman/util"
|
||||
import type {Message} from "./Socket.js"
|
||||
import type {Connection} from "./Connection.js"
|
||||
import {Negentropy, NegentropyStorageVector} from "./Negentropy.js"
|
||||
|
||||
export type Target = Emitter & {
|
||||
connections: Connection[]
|
||||
send: (...args: Message) => Promise<void>
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
export type NegentropyMessage = {
|
||||
have: string[]
|
||||
need: string[]
|
||||
}
|
||||
|
||||
type EventCallback = (url: string, event: TrustedEvent) => void
|
||||
type EoseCallback = (url: string) => void
|
||||
type CloseCallback = () => void
|
||||
type OkCallback = (url: string, id: string, ...extra: any[]) => void
|
||||
type ErrorCallback = (url: string, id: string, ...extra: any[]) => void
|
||||
type DiffMessage = {have: string[]; need: string[]}
|
||||
type DiffMessageCallback = (url: string, {have, need}: DiffMessage) => void
|
||||
type SubscribeOpts = {onEvent?: EventCallback; onEose?: EoseCallback}
|
||||
type PublishOpts = {verb?: string; onOk?: OkCallback; onError?: ErrorCallback}
|
||||
type DiffOpts = {onError?: ErrorCallback; onMessage?: DiffMessageCallback; onClose?: CloseCallback}
|
||||
|
||||
const createSubId = (prefix: string) => `${prefix}-${Math.random().toString().slice(2, 10)}`
|
||||
|
||||
export class Executor {
|
||||
constructor(readonly target: Target) {}
|
||||
|
||||
subscribe(filters: Filter[], {onEvent, onEose}: SubscribeOpts = {}) {
|
||||
let closed = false
|
||||
|
||||
const id = createSubId("REQ")
|
||||
|
||||
const eventListener = (url: string, subid: string, e: TrustedEvent) => {
|
||||
if (subid === id) {
|
||||
ctx.net.onEvent(url, e)
|
||||
onEvent?.(url, e)
|
||||
}
|
||||
}
|
||||
|
||||
const eoseListener = (url: string, subid: string) => {
|
||||
if (subid === id) {
|
||||
onEose?.(url)
|
||||
}
|
||||
}
|
||||
|
||||
this.target.on("EVENT", eventListener)
|
||||
this.target.on("EOSE", eoseListener)
|
||||
this.target.send("REQ", id, ...filters)
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
if (closed) return
|
||||
|
||||
this.target.send("CLOSE", id).catch(noop)
|
||||
this.target.off("EVENT", eventListener)
|
||||
this.target.off("EOSE", eoseListener)
|
||||
|
||||
closed = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
publish(event: SignedEvent, {verb = "EVENT", onOk, onError}: PublishOpts = {}) {
|
||||
const okListener = (url: string, id: string, ok: boolean, message: string) => {
|
||||
if (id === event.id) {
|
||||
if (ok) {
|
||||
ctx.net.onEvent(url, event)
|
||||
}
|
||||
|
||||
onOk?.(url, id, ok, message)
|
||||
}
|
||||
}
|
||||
|
||||
const errorListener = (url: string, id: string, ...payload: any[]) => {
|
||||
if (id === event.id) {
|
||||
onError?.(url, id, ...payload)
|
||||
}
|
||||
}
|
||||
|
||||
this.target.on("OK", okListener)
|
||||
this.target.on("ERROR", errorListener)
|
||||
this.target.send(verb, event)
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
this.target.off("OK", okListener)
|
||||
this.target.off("ERROR", errorListener)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
diff(filter: Filter, events: TrustedEvent[], {onMessage, onError, onClose}: DiffOpts = {}) {
|
||||
let closed = false
|
||||
|
||||
const id = createSubId("NEG")
|
||||
const storage = new NegentropyStorageVector()
|
||||
const neg = new Negentropy(storage, 50_000)
|
||||
|
||||
for (const event of events) {
|
||||
storage.insert(event.created_at, event.id)
|
||||
}
|
||||
|
||||
storage.seal()
|
||||
|
||||
const msgListener = async (url: string, negid: string, msg: string) => {
|
||||
if (negid === id) {
|
||||
const [newMsg, have, need] = await neg.reconcile(msg)
|
||||
|
||||
onMessage?.(url, {have, need})
|
||||
|
||||
if (newMsg) {
|
||||
this.target.send("NEG-MSG", id, newMsg)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const errListener = (url: string, negid: string, msg: string) => {
|
||||
if (negid === id) {
|
||||
onError?.(url, msg)
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
|
||||
this.target.send("NEG-CLOSE", id).catch(noop)
|
||||
this.target.off("NEG-MSG", msgListener)
|
||||
this.target.off("NEG-ERR", errListener)
|
||||
|
||||
closed = true
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
this.target.on("NEG-MSG", msgListener)
|
||||
this.target.on("NEG-ERR", errListener)
|
||||
|
||||
neg.initiate().then((msg: string) => {
|
||||
this.target.send("NEG-OPEN", id, filter, msg)
|
||||
})
|
||||
|
||||
return {
|
||||
unsubscribe: close,
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
-23
@@ -1,46 +1,81 @@
|
||||
import {Emitter} from "@welshman/lib"
|
||||
import {Connection} from "./Connection.js"
|
||||
import {remove} from "@welshman/lib"
|
||||
import {normalizeRelayUrl} from "@welshman/util"
|
||||
import {Socket} from "./socket.js"
|
||||
import {defaultSocketPolicies} from "./policy.js"
|
||||
|
||||
export class Pool extends Emitter {
|
||||
data: Map<string, Connection>
|
||||
export const makeSocket = (url: string, policies = defaultSocketPolicies) => {
|
||||
const socket = new Socket(url)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.data = new Map()
|
||||
for (const applyPolicy of policies) {
|
||||
applyPolicy(socket)
|
||||
}
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
export type PoolSubscription = (socket: Socket) => void
|
||||
|
||||
export type PoolOptions = {
|
||||
makeSocket?: (url: string) => Socket
|
||||
}
|
||||
|
||||
export class Pool {
|
||||
_data = new Map<string, Socket>()
|
||||
_subs: PoolSubscription[] = []
|
||||
|
||||
constructor(readonly options: PoolOptions = {}) {}
|
||||
|
||||
has(url: string) {
|
||||
return this.data.has(url)
|
||||
return this._data.has(normalizeRelayUrl(url))
|
||||
}
|
||||
|
||||
get(url: string): Connection {
|
||||
const oldConnection = this.data.get(url)
|
||||
|
||||
if (oldConnection) {
|
||||
return oldConnection
|
||||
makeSocket(url: string) {
|
||||
if (this.options.makeSocket) {
|
||||
return this.options.makeSocket(url)
|
||||
}
|
||||
|
||||
const newConnection = new Connection(url)
|
||||
return makeSocket(url)
|
||||
}
|
||||
|
||||
this.data.set(url, newConnection)
|
||||
this.emit("init", newConnection)
|
||||
get(_url: string): Socket {
|
||||
const url = normalizeRelayUrl(_url)
|
||||
const oldSocket = this._data.get(url)
|
||||
|
||||
return newConnection
|
||||
if (oldSocket) {
|
||||
return oldSocket
|
||||
}
|
||||
|
||||
const socket = this.makeSocket(url)
|
||||
|
||||
this._data.set(url, socket)
|
||||
|
||||
for (const cb of this._subs) {
|
||||
cb(socket)
|
||||
}
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
subscribe(cb: PoolSubscription) {
|
||||
this._subs.push(cb)
|
||||
|
||||
return () => {
|
||||
this._subs = remove(cb, this._subs)
|
||||
}
|
||||
}
|
||||
|
||||
remove(url: string) {
|
||||
const connection = this.data.get(url)
|
||||
const socket = this._data.get(url)
|
||||
|
||||
if (connection) {
|
||||
connection.cleanup()
|
||||
if (socket) {
|
||||
socket.cleanup()
|
||||
|
||||
this.data.delete(url)
|
||||
this._data.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const url of this.data.keys()) {
|
||||
for (const url of this._data.keys()) {
|
||||
this.remove(url)
|
||||
}
|
||||
}
|
||||
|
||||
+167
-80
@@ -1,98 +1,185 @@
|
||||
import {ctx, Emitter, now, randomId, defer} from "@welshman/lib"
|
||||
import type {Deferred} from "@welshman/lib"
|
||||
import {asSignedEvent} from "@welshman/util"
|
||||
import type {SignedEvent} from "@welshman/util"
|
||||
import {EventEmitter} from "events"
|
||||
import {on, fromPairs, sleep, yieldThread} from "@welshman/lib"
|
||||
import {SignedEvent} from "@welshman/util"
|
||||
import {RelayMessage, ClientMessageType, isRelayOk} from "./message.js"
|
||||
import {AbstractAdapter, AdapterEventType, AdapterContext, getAdapter} from "./adapter.js"
|
||||
import {TypedEmitter} from "./util.js"
|
||||
|
||||
export enum PublishStatus {
|
||||
Pending = "pending",
|
||||
Success = "success",
|
||||
Failure = "failure",
|
||||
Timeout = "timeout",
|
||||
Aborted = "aborted",
|
||||
Pending = "publish:status:pending",
|
||||
Success = "publish:status:success",
|
||||
Failure = "publish:status:failure",
|
||||
Timeout = "publish:status:timeout",
|
||||
Aborted = "publish:status:aborted",
|
||||
}
|
||||
|
||||
export type PublishStatusMap = Map<string, PublishStatus>
|
||||
export enum PublishEventType {
|
||||
Success = "publish:event:success",
|
||||
Failure = "publish:event:failure",
|
||||
Timeout = "publish:event:timeout",
|
||||
Aborted = "publish:event:aborted",
|
||||
Complete = "publish:event:complete",
|
||||
}
|
||||
|
||||
export type PublishRequest = {
|
||||
// Unicast
|
||||
|
||||
export type UnicastEvents = {
|
||||
[PublishEventType.Success]: (id: string, detail: string) => void
|
||||
[PublishEventType.Failure]: (id: string, detail: string) => void
|
||||
[PublishEventType.Timeout]: () => void
|
||||
[PublishEventType.Aborted]: () => void
|
||||
[PublishEventType.Complete]: () => void
|
||||
}
|
||||
|
||||
export type UnicastOptions = {
|
||||
event: SignedEvent
|
||||
relays: string[]
|
||||
signal?: AbortSignal
|
||||
relay: string
|
||||
context: AdapterContext
|
||||
timeout?: number
|
||||
verb?: "EVENT" | "AUTH"
|
||||
}
|
||||
|
||||
export type Publish = {
|
||||
id: string
|
||||
created_at: number
|
||||
emitter: Emitter
|
||||
request: PublishRequest
|
||||
status: PublishStatusMap
|
||||
result: Deferred<PublishStatusMap>
|
||||
}
|
||||
export class Unicast extends (EventEmitter as new () => TypedEmitter<UnicastEvents>) {
|
||||
status = PublishStatus.Pending
|
||||
|
||||
export const makePublish = (request: PublishRequest) => {
|
||||
const id = randomId()
|
||||
const created_at = now()
|
||||
const emitter = new Emitter()
|
||||
const result: Publish["result"] = defer()
|
||||
const status: Publish["status"] = new Map()
|
||||
_unsubscriber: () => void
|
||||
_adapter: AbstractAdapter
|
||||
|
||||
return {id, created_at, request, emitter, result, status}
|
||||
}
|
||||
constructor(readonly options: UnicastOptions) {
|
||||
super()
|
||||
|
||||
export const publish = (request: PublishRequest) => {
|
||||
const pub = makePublish(request)
|
||||
const event = asSignedEvent(request.event)
|
||||
const executor = ctx.net.getExecutor(request.relays)
|
||||
// Set up our adapter
|
||||
this._adapter = getAdapter(this.options.relay, this.options.context)
|
||||
|
||||
const abort = (reason: PublishStatus) => {
|
||||
for (const [url, status] of pub.status.entries()) {
|
||||
if (status === PublishStatus.Pending) {
|
||||
pub.emitter.emit(reason, url)
|
||||
// Listen for Unicast result
|
||||
this._unsubscriber = on(
|
||||
this._adapter,
|
||||
AdapterEventType.Receive,
|
||||
(message: RelayMessage, url: string) => {
|
||||
if (isRelayOk(message)) {
|
||||
const [_, id, ok, detail] = message
|
||||
|
||||
if (id !== this.options.event.id) return
|
||||
|
||||
if (ok) {
|
||||
this.status = PublishStatus.Success
|
||||
this.emit(PublishEventType.Success, id, detail)
|
||||
} else {
|
||||
this.status = PublishStatus.Failure
|
||||
this.emit(PublishEventType.Failure, id, detail)
|
||||
}
|
||||
|
||||
this.cleanup()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Set timeout
|
||||
sleep(this.options.timeout || 10_000).then(() => {
|
||||
if (this.status === PublishStatus.Pending) {
|
||||
this.status = PublishStatus.Timeout
|
||||
this.emit(PublishEventType.Timeout)
|
||||
}
|
||||
|
||||
this.cleanup()
|
||||
})
|
||||
|
||||
// Start asynchronously so the caller can set up listeners
|
||||
yieldThread().then(() => {
|
||||
this._adapter.send([ClientMessageType.Event, this.options.event])
|
||||
})
|
||||
}
|
||||
|
||||
abort = () => {
|
||||
if (this.status === PublishStatus.Pending) {
|
||||
this.status = PublishStatus.Aborted
|
||||
this.emit(PublishEventType.Aborted)
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to updates and keep status up to date. Every time there's an update, check to
|
||||
// see if we're done. If we are, clean everything up
|
||||
pub.emitter.on("*", (status: PublishStatus, url: string) => {
|
||||
pub.status.set(url, status)
|
||||
|
||||
if (Array.from(pub.status.values()).every((s: PublishStatus) => s !== PublishStatus.Pending)) {
|
||||
clearTimeout(timeout)
|
||||
executorSub.unsubscribe()
|
||||
executor.target.cleanup()
|
||||
pub.result.resolve(pub.status)
|
||||
}
|
||||
})
|
||||
|
||||
// Start everything off as pending. Do it asynchronously to avoid breaking caller assumptions
|
||||
setTimeout(() => {
|
||||
for (const relay of request.relays) {
|
||||
pub.emitter.emit(PublishStatus.Pending, relay)
|
||||
}
|
||||
})
|
||||
|
||||
// Give up after a specified time
|
||||
const timeout = setTimeout(() => abort(PublishStatus.Timeout), request.timeout || 10_000)
|
||||
|
||||
// If we have a signal, use it
|
||||
request.signal?.addEventListener("abort", () => abort(PublishStatus.Aborted))
|
||||
|
||||
// Delegate to our executor
|
||||
const executorSub = executor.publish(event, {
|
||||
verb: request.verb || "EVENT",
|
||||
onOk: (url: string, eventId: string, ok: boolean, message: string) => {
|
||||
if (ok) {
|
||||
pub.emitter.emit(PublishStatus.Success, url, message)
|
||||
} else {
|
||||
pub.emitter.emit(PublishStatus.Failure, url, message)
|
||||
}
|
||||
},
|
||||
onError: (url: string) => {
|
||||
pub.emitter.emit(PublishStatus.Failure, url)
|
||||
},
|
||||
})
|
||||
|
||||
return pub
|
||||
cleanup = () => {
|
||||
this.emit(PublishEventType.Complete)
|
||||
this.removeAllListeners()
|
||||
this._adapter.cleanup()
|
||||
this._unsubscriber()
|
||||
}
|
||||
}
|
||||
|
||||
// Multicast
|
||||
|
||||
export type MulticastEvents = {
|
||||
[PublishEventType.Success]: (id: string, detail: string, url: string) => void
|
||||
[PublishEventType.Failure]: (id: string, detail: string, url: string) => void
|
||||
[PublishEventType.Timeout]: (url: string) => void
|
||||
[PublishEventType.Aborted]: (url: string) => void
|
||||
[PublishEventType.Complete]: () => void
|
||||
}
|
||||
|
||||
export type MulticastOptions = Omit<UnicastOptions, "relay"> & {
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
export class Multicast extends (EventEmitter as new () => TypedEmitter<MulticastEvents>) {
|
||||
status: Record<string, PublishStatus>
|
||||
|
||||
_children: Unicast[] = []
|
||||
_completed = new Set<string>()
|
||||
|
||||
constructor({relays, ...options}: MulticastOptions) {
|
||||
super()
|
||||
|
||||
this.status = fromPairs(relays.map(relay => [relay, PublishStatus.Pending]))
|
||||
|
||||
for (const relay of relays) {
|
||||
const unicast = new Unicast({relay, ...options})
|
||||
|
||||
unicast.on(PublishEventType.Success, (id: string, detail: string) => {
|
||||
this.status[relay] = unicast.status
|
||||
this.emit(PublishEventType.Success, id, detail, relay)
|
||||
})
|
||||
|
||||
unicast.on(PublishEventType.Failure, (id: string, detail: string) => {
|
||||
this.status[relay] = unicast.status
|
||||
this.emit(PublishEventType.Failure, id, detail, relay)
|
||||
})
|
||||
|
||||
unicast.on(PublishEventType.Timeout, () => {
|
||||
this.status[relay] = unicast.status
|
||||
this.emit(PublishEventType.Timeout, relay)
|
||||
})
|
||||
|
||||
unicast.on(PublishEventType.Aborted, () => {
|
||||
this.status[relay] = unicast.status
|
||||
this.emit(PublishEventType.Aborted, relay)
|
||||
})
|
||||
|
||||
unicast.on(PublishEventType.Complete, () => {
|
||||
this._completed.add(relay)
|
||||
this.status[relay] = unicast.status
|
||||
|
||||
if (this._completed.size === relays.length) {
|
||||
this.emit(PublishEventType.Complete)
|
||||
this.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
this._children.push(unicast)
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
for (const child of this._children) {
|
||||
child.abort()
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
|
||||
export const unicast = (options: UnicastOptions) => new Unicast(options)
|
||||
|
||||
export const multicast = (options: MulticastOptions) => new Multicast(options)
|
||||
|
||||
+96
-100
@@ -1,134 +1,130 @@
|
||||
import WebSocket from "isomorphic-ws"
|
||||
import {Worker, sleep} from "@welshman/lib"
|
||||
import {ConnectionEvent} from "./ConnectionEvent.js"
|
||||
import type {Connection} from "./Connection.js"
|
||||
|
||||
export type Message = [string, ...any[]]
|
||||
import EventEmitter from "events"
|
||||
import {TaskQueue} from "@welshman/lib"
|
||||
import {RelayMessage, ClientMessage} from "./message.js"
|
||||
import {TypedEmitter} from "./util.js"
|
||||
|
||||
export enum SocketStatus {
|
||||
New = "new",
|
||||
Open = "open",
|
||||
Opening = "opening",
|
||||
Closing = "closing",
|
||||
Closed = "closed",
|
||||
Error = "error",
|
||||
Invalid = "invalid",
|
||||
Open = "socket:status:open",
|
||||
Opening = "socket:status:opening",
|
||||
Closing = "socket:status:closing",
|
||||
Closed = "socket:status:closed",
|
||||
Error = "socket:status:error",
|
||||
Invalid = "socket:status:invalid",
|
||||
}
|
||||
|
||||
export class Socket {
|
||||
lastError = 0
|
||||
status = SocketStatus.New
|
||||
worker = new Worker<Message>()
|
||||
ws?: WebSocket
|
||||
export enum SocketEventType {
|
||||
Error = "socket:event:error",
|
||||
Status = "socket:event:status",
|
||||
Send = "socket:event:send",
|
||||
Enqueue = "socket:event:enqueue",
|
||||
Receive = "socket:event:receive",
|
||||
}
|
||||
|
||||
constructor(readonly cxn: Connection) {
|
||||
// Use a worker to throttle incoming data
|
||||
this.worker.addGlobalHandler((message: Message) => {
|
||||
this.cxn.emit(ConnectionEvent.Receive, message)
|
||||
export type SocketEvents = {
|
||||
[SocketEventType.Error]: (error: string, url: string) => void
|
||||
[SocketEventType.Status]: (status: SocketStatus, url: string) => void
|
||||
[SocketEventType.Send]: (message: ClientMessage, url: string) => void
|
||||
[SocketEventType.Enqueue]: (message: ClientMessage, url: string) => void
|
||||
[SocketEventType.Receive]: (message: RelayMessage, url: string) => void
|
||||
}
|
||||
|
||||
export class Socket extends (EventEmitter as new () => TypedEmitter<SocketEvents>) {
|
||||
status = SocketStatus.Closed
|
||||
|
||||
_ws?: WebSocket
|
||||
_sendQueue: TaskQueue<ClientMessage>
|
||||
_recvQueue: TaskQueue<RelayMessage>
|
||||
|
||||
constructor(readonly url: string) {
|
||||
super()
|
||||
|
||||
this._sendQueue = new TaskQueue<ClientMessage>({
|
||||
batchSize: 50,
|
||||
processItem: (message: ClientMessage) => {
|
||||
this._ws?.send(JSON.stringify(message))
|
||||
this.emit(SocketEventType.Send, message, this.url)
|
||||
},
|
||||
})
|
||||
|
||||
this._recvQueue = new TaskQueue<RelayMessage>({
|
||||
batchSize: 50,
|
||||
processItem: (message: RelayMessage) => {
|
||||
this.emit(SocketEventType.Receive, message, this.url)
|
||||
},
|
||||
})
|
||||
|
||||
this.on(SocketEventType.Status, (status: SocketStatus) => {
|
||||
this.status = status
|
||||
})
|
||||
}
|
||||
|
||||
wait = async (timeout = 300) => {
|
||||
const start = Date.now()
|
||||
while (
|
||||
Date.now() - timeout <= start &&
|
||||
[SocketStatus.Opening, SocketStatus.Closing].includes(this.status)
|
||||
) {
|
||||
await sleep(100)
|
||||
}
|
||||
}
|
||||
|
||||
open = async () => {
|
||||
// If we're in a provisional state, wait
|
||||
await this.wait()
|
||||
|
||||
// If the socket is closed, reset
|
||||
if (this.status === SocketStatus.Closed) {
|
||||
this.status = SocketStatus.New
|
||||
this.cxn.emit(ConnectionEvent.Reset)
|
||||
open = () => {
|
||||
if (this._ws) {
|
||||
throw new Error("Attempted to open a websocket that has not been closed")
|
||||
}
|
||||
|
||||
// If we're closed due to an error retry after a delay
|
||||
if (this.status === SocketStatus.Error && Date.now() - this.lastError > 15_000) {
|
||||
this.status = SocketStatus.New
|
||||
this.cxn.emit(ConnectionEvent.Reset)
|
||||
}
|
||||
|
||||
// If the socket is new, connect
|
||||
if (this.status === SocketStatus.New) {
|
||||
this.#init()
|
||||
}
|
||||
|
||||
// Wait until we're connected (or fail to connect)
|
||||
await this.wait()
|
||||
}
|
||||
|
||||
close = async () => {
|
||||
this.worker.pause()
|
||||
this.ws?.close()
|
||||
this.ws = undefined
|
||||
|
||||
// Allow the socket to start closing before waiting
|
||||
await sleep(100)
|
||||
|
||||
// Wait for the socket to fully close
|
||||
await this.wait()
|
||||
}
|
||||
|
||||
send = async (message: Message) => {
|
||||
await this.open()
|
||||
|
||||
if (!this.ws) {
|
||||
throw new Error(`No websocket available when sending to ${this.cxn.url}`)
|
||||
}
|
||||
|
||||
this.cxn.emit(ConnectionEvent.Send, message)
|
||||
this.ws.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
#init = () => {
|
||||
try {
|
||||
this.ws = new WebSocket(this.cxn.url)
|
||||
this.status = SocketStatus.Opening
|
||||
this._ws = new WebSocket(this.url)
|
||||
this.emit(SocketEventType.Status, SocketStatus.Opening, this.url)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.status = SocketStatus.Open
|
||||
this.cxn.emit(ConnectionEvent.Open)
|
||||
this._ws.onopen = () => {
|
||||
this.emit(SocketEventType.Status, SocketStatus.Open, this.url)
|
||||
this._sendQueue.start()
|
||||
}
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.status = SocketStatus.Error
|
||||
this.lastError = Date.now()
|
||||
this.cxn.emit(ConnectionEvent.Error)
|
||||
this._ws.onerror = () => {
|
||||
this.emit(SocketEventType.Status, SocketStatus.Error, this.url)
|
||||
this._sendQueue.stop()
|
||||
this._ws = undefined
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (this.status !== SocketStatus.Error) {
|
||||
this.status = SocketStatus.Closed
|
||||
}
|
||||
|
||||
this.cxn.emit(ConnectionEvent.Close)
|
||||
this._ws.onclose = () => {
|
||||
this.emit(SocketEventType.Status, SocketStatus.Closed, this.url)
|
||||
this._sendQueue.stop()
|
||||
this._ws = undefined
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event: any) => {
|
||||
this._ws.onmessage = (event: any) => {
|
||||
const data = event.data as string
|
||||
|
||||
try {
|
||||
const message = JSON.parse(data)
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
this.worker.push(message as Message)
|
||||
this._recvQueue.push(message as RelayMessage)
|
||||
} else {
|
||||
this.cxn.emit(ConnectionEvent.InvalidMessage, data)
|
||||
this.emit(SocketEventType.Error, "Invalid message received", this.url)
|
||||
}
|
||||
} catch (e) {
|
||||
this.cxn.emit(ConnectionEvent.InvalidMessage, data)
|
||||
this.emit(SocketEventType.Error, "Invalid message received", this.url)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.lastError = Date.now()
|
||||
this.status = SocketStatus.Invalid
|
||||
this.cxn.emit(ConnectionEvent.InvalidUrl)
|
||||
this.emit(SocketEventType.Status, SocketStatus.Invalid, this.url)
|
||||
}
|
||||
}
|
||||
|
||||
attemptToOpen = () => {
|
||||
if (!this._ws) {
|
||||
this.open()
|
||||
}
|
||||
}
|
||||
|
||||
close = () => {
|
||||
this._ws?.close()
|
||||
this._ws = undefined
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.close()
|
||||
this._recvQueue.clear()
|
||||
this._sendQueue.clear()
|
||||
this.removeAllListeners()
|
||||
}
|
||||
|
||||
send = (message: ClientMessage) => {
|
||||
this._sendQueue.push(message)
|
||||
this.emit(SocketEventType.Enqueue, message, this.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
import {ctx, Emitter, max, chunk, randomId, once, groupBy, uniq} from "@welshman/lib"
|
||||
import {
|
||||
LOCAL_RELAY_URL,
|
||||
matchFilters,
|
||||
normalizeRelayUrl,
|
||||
unionFilters,
|
||||
TrustedEvent,
|
||||
} from "@welshman/util"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {Tracker} from "./Tracker.js"
|
||||
import {Executor} from "./Executor.js"
|
||||
import {Connection} from "./Connection.js"
|
||||
import {ConnectionEvent} from "./ConnectionEvent.js"
|
||||
|
||||
// `subscribe` is a super function that handles batching subscriptions by merging
|
||||
// them based on parameters (filters and subscribe opts), then splits them by relay.
|
||||
// This results in fewer REQs being opened per connection, fewer duplicate events
|
||||
// being downloaded, and therefore less signature validation.
|
||||
//
|
||||
// Behavior can be further configured using ctx.net. This can be useful for
|
||||
// adding support for querying a local cache like a relay, tracking deleted events,
|
||||
// and bypassing validation for trusted relays.
|
||||
//
|
||||
// Urls that any given event was seen on are tracked using subscription request's `tracker`
|
||||
// property. These are merged across all subscription requests, so it is possible that an
|
||||
// event may be seen on more relays that were actually requested, in the case of overlapping
|
||||
// subscriptions.
|
||||
|
||||
export enum SubscriptionEvent {
|
||||
Eose = "eose",
|
||||
Send = "send",
|
||||
Close = "close",
|
||||
Event = "event",
|
||||
Complete = "complete",
|
||||
Duplicate = "duplicate",
|
||||
DeletedEvent = "deleted-event",
|
||||
FailedFilter = "failed-filter",
|
||||
Invalid = "invalid",
|
||||
}
|
||||
|
||||
export type RelaysAndFilters = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
}
|
||||
|
||||
export type SubscribeRequest = RelaysAndFilters & {
|
||||
delay?: number
|
||||
signal?: AbortSignal
|
||||
timeout?: number
|
||||
tracker?: Tracker
|
||||
closeOnEose?: boolean
|
||||
authTimeout?: number
|
||||
}
|
||||
|
||||
export class Subscription extends Emitter {
|
||||
id = randomId()
|
||||
controller = new AbortController()
|
||||
tracker = new Tracker()
|
||||
completed = new Set()
|
||||
executorSubs: {unsubscribe: () => void}[] = []
|
||||
executor: Executor
|
||||
|
||||
constructor(readonly request: SubscribeRequest) {
|
||||
super()
|
||||
|
||||
if (request.tracker) {
|
||||
this.tracker = request.tracker
|
||||
}
|
||||
|
||||
this.setMaxListeners(100)
|
||||
this.executor = ctx.net.getExecutor(request.relays)
|
||||
}
|
||||
|
||||
onEvent = (url: string, event: TrustedEvent) => {
|
||||
const {filters} = this.request
|
||||
|
||||
if (this.tracker.track(event.id, url)) {
|
||||
this.emit(SubscriptionEvent.Duplicate, url, event)
|
||||
} else if (ctx.net.isDeleted(url, event)) {
|
||||
this.emit(SubscriptionEvent.DeletedEvent, url, event)
|
||||
} else if (!ctx.net.matchFilters(url, filters, event)) {
|
||||
this.emit(SubscriptionEvent.FailedFilter, url, event)
|
||||
} else if (!ctx.net.isValid(url, event)) {
|
||||
this.emit(SubscriptionEvent.Invalid, url, event)
|
||||
} else {
|
||||
this.emit(SubscriptionEvent.Event, url, event)
|
||||
}
|
||||
}
|
||||
|
||||
onEose = (url: string) => {
|
||||
const {closeOnEose, relays} = this.request
|
||||
|
||||
this.emit(SubscriptionEvent.Eose, url)
|
||||
|
||||
this.completed.add(url)
|
||||
|
||||
if (closeOnEose && this.completed.size === uniq(relays).length) {
|
||||
this.onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
onClose = (connection: Connection) => {
|
||||
const {relays} = this.request
|
||||
|
||||
this.emit(SubscriptionEvent.Close, connection.url)
|
||||
|
||||
this.completed.add(connection.url)
|
||||
|
||||
if (this.completed.size === uniq(relays).length) {
|
||||
this.onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
onComplete = once(() => {
|
||||
this.emit(SubscriptionEvent.Complete)
|
||||
this.executorSubs.forEach(sub => sub.unsubscribe())
|
||||
this.removeAllListeners()
|
||||
this.executor.target.cleanup()
|
||||
this.executor.target.connections.forEach((c: Connection) => {
|
||||
c.off(ConnectionEvent.Close, this.onClose)
|
||||
})
|
||||
})
|
||||
|
||||
execute = async () => {
|
||||
const {filters, signal, timeout, authTimeout = 0} = this.request
|
||||
|
||||
// If we didn't get any filters, don't even send the request, just close it.
|
||||
// This can be valid when a caller fulfills a request themselves but still needs a subscription object.
|
||||
if (filters.length === 0) {
|
||||
this.emit(SubscriptionEvent.Send)
|
||||
this.onComplete()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Hook up our events
|
||||
|
||||
// Listen for abort via caller signal
|
||||
signal?.addEventListener("abort", this.onComplete)
|
||||
|
||||
// Listen for abort via our own internal signal
|
||||
this.controller.signal.addEventListener("abort", this.onComplete)
|
||||
|
||||
// If we have a timeout, complete the subscription automatically
|
||||
if (timeout) setTimeout(this.onComplete, timeout + authTimeout)
|
||||
|
||||
// If one of our connections gets closed make sure to kill our sub
|
||||
this.executor.target.connections.forEach((c: Connection) =>
|
||||
c.on(ConnectionEvent.Close, this.onClose),
|
||||
)
|
||||
|
||||
// Wait for auth if needed
|
||||
await Promise.all(
|
||||
this.executor.target.connections.map(async (connection: Connection) => {
|
||||
if (authTimeout) {
|
||||
await connection.auth.attempt(authTimeout)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// If we send too many filters in a request relays will refuse to respond. REQs are rate
|
||||
// limited client-side by Connection, so this will throttle concurrent requests.
|
||||
for (const filtersChunk of chunk(8, filters)) {
|
||||
this.executorSubs.push(
|
||||
this.executor.subscribe(filtersChunk, {
|
||||
onEvent: this.onEvent,
|
||||
onEose: this.onEose,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Notify that we've sent the subscription
|
||||
this.emit(SubscriptionEvent.Send)
|
||||
}
|
||||
|
||||
close = () => this.controller.abort()
|
||||
}
|
||||
|
||||
export const calculateSubscriptionGroup = (sub: Subscription) => {
|
||||
const parts: string[] = []
|
||||
|
||||
if (sub.request.timeout) parts.push(`timeout:${sub.request.timeout}`)
|
||||
if (sub.request.authTimeout) parts.push(`authTimeout:${sub.request.authTimeout}`)
|
||||
if (sub.request.closeOnEose) parts.push("closeOnEose")
|
||||
|
||||
return parts.join("|")
|
||||
}
|
||||
|
||||
export const mergeSubscriptions = (subs: Subscription[]) => {
|
||||
const mergedSub = new Subscription({
|
||||
relays: uniq(subs.flatMap(sub => sub.request.relays)),
|
||||
filters: unionFilters(subs.flatMap(sub => sub.request.filters)),
|
||||
timeout: max(subs.map(sub => sub.request.timeout || 0)),
|
||||
authTimeout: max(subs.map(sub => sub.request.authTimeout || 0)),
|
||||
closeOnEose: subs.every(sub => sub.request.closeOnEose),
|
||||
})
|
||||
|
||||
mergedSub.controller.signal.addEventListener("abort", () => {
|
||||
for (const sub of subs) {
|
||||
sub.close()
|
||||
}
|
||||
})
|
||||
|
||||
const completedSubs = new Set()
|
||||
|
||||
for (const sub of subs) {
|
||||
// Propagate events, but avoid duplicates
|
||||
sub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => {
|
||||
if (!mergedSub.tracker.track(event.id, url)) {
|
||||
mergedSub.emit(SubscriptionEvent.Event, url, event)
|
||||
}
|
||||
})
|
||||
|
||||
// Propagate subscription completion. Since we split subs by relay, we need to wait
|
||||
// until all relays are completed before we notify
|
||||
sub.on(SubscriptionEvent.Complete, () => {
|
||||
completedSubs.add(sub.id)
|
||||
|
||||
if (completedSubs.size === subs.length) {
|
||||
mergedSub.emit(SubscriptionEvent.Complete)
|
||||
}
|
||||
|
||||
sub.removeAllListeners()
|
||||
})
|
||||
|
||||
// Propagate everything else too
|
||||
const propagateEvent = (type: SubscriptionEvent) =>
|
||||
sub.on(type, (...args) => mergedSub.emit(type, ...args))
|
||||
|
||||
propagateEvent(SubscriptionEvent.Duplicate)
|
||||
propagateEvent(SubscriptionEvent.DeletedEvent)
|
||||
propagateEvent(SubscriptionEvent.FailedFilter)
|
||||
propagateEvent(SubscriptionEvent.Invalid)
|
||||
propagateEvent(SubscriptionEvent.Eose)
|
||||
propagateEvent(SubscriptionEvent.Send)
|
||||
propagateEvent(SubscriptionEvent.Close)
|
||||
}
|
||||
|
||||
return mergedSub
|
||||
}
|
||||
|
||||
export const optimizeSubscriptions = (subs: Subscription[]) => {
|
||||
return Array.from(groupBy(calculateSubscriptionGroup, subs).values()).flatMap(group => {
|
||||
const timeout = max(group.map(sub => sub.request.timeout || 0))
|
||||
const authTimeout = max(group.map(sub => sub.request.authTimeout || 0))
|
||||
const closeOnEose = group.every(sub => sub.request.closeOnEose)
|
||||
const completedSubs = new Set<string>()
|
||||
const abortedSubs = new Set<string>()
|
||||
const closedSubs = new Set<string>()
|
||||
const eosedSubs = new Set<string>()
|
||||
const sentSubs = new Set<string>()
|
||||
const mergedSubs: Subscription[] = []
|
||||
|
||||
for (const {relays, filters} of ctx.net.optimizeSubscriptions(group)) {
|
||||
for (const filter of filters) {
|
||||
const mergedSub = new Subscription({
|
||||
filters: [filter],
|
||||
relays,
|
||||
timeout,
|
||||
authTimeout,
|
||||
closeOnEose,
|
||||
})
|
||||
|
||||
for (const {id, controller, request} of group) {
|
||||
const onAbort = () => {
|
||||
abortedSubs.add(id)
|
||||
|
||||
if (abortedSubs.size === group.length) {
|
||||
mergedSub.close()
|
||||
}
|
||||
}
|
||||
|
||||
request.signal?.addEventListener("abort", onAbort)
|
||||
controller.signal.addEventListener("abort", onAbort)
|
||||
}
|
||||
|
||||
mergedSub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => {
|
||||
for (const sub of group) {
|
||||
if (matchFilters(sub.request.filters, event) && !sub.tracker.track(event.id, url)) {
|
||||
sub.emit(SubscriptionEvent.Event, url, event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Pass events back to caller
|
||||
const propagateEvent = (type: SubscriptionEvent) =>
|
||||
mergedSub.on(type, (url: string, event: TrustedEvent) => {
|
||||
for (const sub of group) {
|
||||
if (matchFilters(sub.request.filters, event)) {
|
||||
sub.emit(type, url, event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
propagateEvent(SubscriptionEvent.Duplicate)
|
||||
propagateEvent(SubscriptionEvent.DeletedEvent)
|
||||
propagateEvent(SubscriptionEvent.Invalid)
|
||||
|
||||
const propagateFinality = (type: SubscriptionEvent, subIds: Set<string>) =>
|
||||
mergedSub.on(type, (...args: any[]) => {
|
||||
subIds.add(mergedSub.id)
|
||||
|
||||
// Wait for all subscriptions to complete before reporting finality to the caller.
|
||||
// This is sub-optimal, but because we're outsourcing filter/relay optimization
|
||||
// we can't make any assumptions about which caller subscriptions have completed
|
||||
// at any given time.
|
||||
if (subIds.size === mergedSubs.length) {
|
||||
for (const sub of group) {
|
||||
sub.emit(type, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
if (type === SubscriptionEvent.Complete) {
|
||||
mergedSub.removeAllListeners()
|
||||
}
|
||||
})
|
||||
|
||||
propagateFinality(SubscriptionEvent.Send, sentSubs)
|
||||
propagateFinality(SubscriptionEvent.Eose, eosedSubs)
|
||||
propagateFinality(SubscriptionEvent.Close, closedSubs)
|
||||
propagateFinality(SubscriptionEvent.Complete, completedSubs)
|
||||
|
||||
mergedSubs.push(mergedSub)
|
||||
}
|
||||
}
|
||||
|
||||
return mergedSubs
|
||||
})
|
||||
}
|
||||
|
||||
export const executeSubscription = (sub: Subscription) =>
|
||||
optimizeSubscriptions([sub]).forEach(sub => sub.execute())
|
||||
|
||||
export const executeSubscriptions = (subs: Subscription[]) =>
|
||||
optimizeSubscriptions(subs).forEach(sub => sub.execute())
|
||||
|
||||
export const executeSubscriptionBatched = (() => {
|
||||
const subs: Subscription[] = []
|
||||
const timeouts: number[] = []
|
||||
|
||||
const executeAll = () => {
|
||||
executeSubscriptions(subs.splice(0))
|
||||
|
||||
for (const timeout of timeouts.splice(0)) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
return (sub: Subscription) => {
|
||||
subs.push(sub)
|
||||
timeouts.push(setTimeout(executeAll, Math.max(16, sub.request.delay!)) as unknown as number)
|
||||
}
|
||||
})()
|
||||
|
||||
export type SubscribeRequestWithHandlers = SubscribeRequest & {
|
||||
onEvent?: (event: TrustedEvent) => void
|
||||
onEose?: (url: string) => void
|
||||
onClose?: (url: string) => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export const subscribe = ({
|
||||
onEvent,
|
||||
onEose,
|
||||
onClose,
|
||||
onComplete,
|
||||
...request
|
||||
}: SubscribeRequestWithHandlers) => {
|
||||
const sub: Subscription = new Subscription({delay: 50, ...request})
|
||||
|
||||
for (const relay of request.relays) {
|
||||
if (relay !== LOCAL_RELAY_URL && relay !== normalizeRelayUrl(relay)) {
|
||||
console.warn(`Attempted to open subscription to non-normalized url ${relay}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (request.delay === 0) {
|
||||
executeSubscription(sub)
|
||||
} else {
|
||||
executeSubscriptionBatched(sub)
|
||||
}
|
||||
|
||||
// Signature for onEvent is different from emitter signature for historical reasons and convenience
|
||||
if (onEvent) sub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => onEvent(event))
|
||||
if (onEose) sub.on(SubscriptionEvent.Eose, onEose)
|
||||
if (onClose) sub.on(SubscriptionEvent.Close, onClose)
|
||||
if (onComplete) sub.on(SubscriptionEvent.Complete, onComplete)
|
||||
|
||||
return sub
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import {ctx, assoc, lt, groupBy, now, pushToMapKey, inc, flatten, chunk} from "@welshman/lib"
|
||||
import type {SignedEvent, TrustedEvent, Filter} from "@welshman/util"
|
||||
import {subscribe} from "./Subscribe.js"
|
||||
import {publish} from "./Publish.js"
|
||||
|
||||
export type DiffOpts = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
events: TrustedEvent[]
|
||||
}
|
||||
|
||||
export const diff = async ({relays, filters, events}: DiffOpts) => {
|
||||
const diffs = flatten(
|
||||
await Promise.all(
|
||||
relays.flatMap(async relay => {
|
||||
return await Promise.all(
|
||||
filters.map(async filter => {
|
||||
const executor = ctx.net.getExecutor([relay])
|
||||
const have = new Set<string>()
|
||||
const need = new Set<string>()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
executor.diff(filter, events, {
|
||||
onClose: resolve,
|
||||
onError: (url, message) => reject(message),
|
||||
onMessage: (url, message) => {
|
||||
for (const id of message.have) {
|
||||
have.add(id)
|
||||
}
|
||||
|
||||
for (const id of message.need) {
|
||||
need.add(id)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return {relay, have, need}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Array.from(groupBy(diff => diff.relay, diffs).entries()).map(([relay, diffs]) => {
|
||||
const have = new Set<string>()
|
||||
const need = new Set<string>()
|
||||
|
||||
for (const diff of diffs) {
|
||||
for (const id of diff.have) {
|
||||
have.add(id)
|
||||
}
|
||||
|
||||
for (const id of diff.need) {
|
||||
need.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return {relay, have: Array.from(have), need: Array.from(need)}
|
||||
})
|
||||
}
|
||||
|
||||
export type PullOpts = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
events: TrustedEvent[]
|
||||
onEvent?: (event: TrustedEvent) => void
|
||||
}
|
||||
|
||||
export const pull = async ({relays, filters, events, onEvent}: PullOpts) => {
|
||||
const countById = new Map<string, number>()
|
||||
const idsByRelay = new Map<string, string[]>()
|
||||
|
||||
for (const {relay, need} of await diff({relays, filters, events})) {
|
||||
for (const id of need) {
|
||||
const count = countById.get(id) || 0
|
||||
|
||||
// Reduce, but don't completely eliminate duplicates, just in case a relay
|
||||
// won't give us what we ask for.
|
||||
if (count < 2) {
|
||||
pushToMapKey(idsByRelay, relay, id)
|
||||
countById.set(id, inc(count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: TrustedEvent[] = []
|
||||
|
||||
await Promise.all(
|
||||
Array.from(idsByRelay.entries()).map(([relay, allIds]) => {
|
||||
return Promise.all(
|
||||
chunk(1024, allIds).map(ids => {
|
||||
return new Promise(resolve => {
|
||||
subscribe({
|
||||
relays: [relay],
|
||||
filters: [{ids}],
|
||||
closeOnEose: true,
|
||||
onClose: resolve,
|
||||
onEvent: event => {
|
||||
result.push(event)
|
||||
onEvent?.(event)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export type PushOpts = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
events: SignedEvent[]
|
||||
}
|
||||
|
||||
export const push = async ({relays, filters, events}: PushOpts) => {
|
||||
const relaysById = new Map<string, string[]>()
|
||||
|
||||
for (const {relay, have} of await diff({relays, filters, events})) {
|
||||
for (const id of have) {
|
||||
pushToMapKey(relaysById, id, relay)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
events.map(async event => {
|
||||
const relays = relaysById.get(event.id)
|
||||
|
||||
if (relays) {
|
||||
await publish({event, relays}).result
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export type SyncOpts = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
events: SignedEvent[]
|
||||
}
|
||||
|
||||
export const sync = async (opts: SyncOpts) => {
|
||||
await pull(opts)
|
||||
await push(opts)
|
||||
}
|
||||
|
||||
// Legacy alternatives for use with relays that don't support negentropy
|
||||
|
||||
export type PullWithoutNegentropyOpts = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
onEvent?: (event: TrustedEvent) => void
|
||||
}
|
||||
|
||||
export const pullWithoutNegentropy = async ({
|
||||
relays,
|
||||
filters,
|
||||
onEvent,
|
||||
}: PullWithoutNegentropyOpts) => {
|
||||
let done = false
|
||||
let until = now() + 30
|
||||
|
||||
const result: TrustedEvent[] = []
|
||||
|
||||
while (!done) {
|
||||
let anyResults = false
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
subscribe({
|
||||
relays,
|
||||
filters: filters.filter(f => lt(f.since, until)).map(assoc("until", until)),
|
||||
closeOnEose: true,
|
||||
onComplete: () => {
|
||||
done = !anyResults
|
||||
resolve()
|
||||
},
|
||||
onEvent: event => {
|
||||
anyResults = true
|
||||
until = Math.min(until, event.created_at - 1)
|
||||
result.push(event)
|
||||
onEvent?.(event)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export type PushWithoutNegentropyOpts = {
|
||||
relays: string[]
|
||||
events: SignedEvent[]
|
||||
}
|
||||
|
||||
export const pushWithoutNegentropy = ({relays, events}: PushWithoutNegentropyOpts) =>
|
||||
Promise.all(
|
||||
events.map(async event => {
|
||||
await publish({event, relays}).result
|
||||
}),
|
||||
)
|
||||
|
||||
export const syncWithoutNegentropy = async (opts: SyncOpts) => {
|
||||
await pullWithoutNegentropy(opts)
|
||||
await pushWithoutNegentropy(opts)
|
||||
}
|
||||
+11
-27
@@ -1,27 +1,11 @@
|
||||
export * from "./Connection.js"
|
||||
export * from "./ConnectionAuth.js"
|
||||
export * from "./ConnectionEvent.js"
|
||||
export * from "./ConnectionSender.js"
|
||||
export * from "./ConnectionState.js"
|
||||
export * from "./ConnectionStats.js"
|
||||
export * from "./Context.js"
|
||||
export * from "./Executor.js"
|
||||
export * from "./Pool.js"
|
||||
export * from "./Publish.js"
|
||||
export * from "./Socket.js"
|
||||
export * from "./Subscribe.js"
|
||||
export * from "./Sync.js"
|
||||
export * from "./Tracker.js"
|
||||
export * from "./target/Echo.js"
|
||||
export * from "./target/Multi.js"
|
||||
export * from "./target/Relay.js"
|
||||
export * from "./target/Relays.js"
|
||||
export * from "./target/Local.js"
|
||||
|
||||
import type {NetContext} from "./Context.js"
|
||||
|
||||
declare module "@welshman/lib" {
|
||||
interface Context {
|
||||
net: NetContext
|
||||
}
|
||||
}
|
||||
export * from "./adapter.js"
|
||||
export * from "./auth.js"
|
||||
export * from "./diff.js"
|
||||
export * from "./message.js"
|
||||
export * from "./negentropy.js"
|
||||
export * from "./policy.js"
|
||||
export * from "./pool.js"
|
||||
export * from "./publish.js"
|
||||
export * from "./socket.js"
|
||||
export * from "./request.js"
|
||||
export * from "./tracker.js"
|
||||
|
||||
@@ -205,9 +205,9 @@ export const socketPolicyReopenActive = (socket: Socket) => {
|
||||
|
||||
// If the socket closed and we have no error, reopen it but don't flap
|
||||
if (newStatus === SocketStatus.Closed && pending.size) {
|
||||
console.log('1')
|
||||
console.log("1")
|
||||
sleep(Math.max(0, 30_000 - (Date.now() - lastOpen))).then(() => {
|
||||
console.log('2')
|
||||
console.log("2")
|
||||
for (const message of pending.values()) {
|
||||
socket.send(message)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {EventEmitter} from "events"
|
||||
import {verifyEvent as nostrToolsVerifyEvent} from 'nostr-tools'
|
||||
import {verifyEvent as nostrToolsVerifyEvent} from "nostr-tools/pure"
|
||||
import {on, call, randomId, yieldThread} from "@welshman/lib"
|
||||
import {Filter, matchFilter, SignedEvent} from "@welshman/util"
|
||||
import {RelayMessage, ClientMessageType, isRelayEvent, isRelayEose} from "./message.js"
|
||||
@@ -1,16 +0,0 @@
|
||||
import {Emitter} from "@welshman/lib"
|
||||
import type {Message} from "../Socket.js"
|
||||
|
||||
export class Echo extends Emitter {
|
||||
get connections() {
|
||||
return []
|
||||
}
|
||||
|
||||
async send(...payload: Message) {
|
||||
this.emit(...payload)
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import {Emitter} from "@welshman/lib"
|
||||
import {Relay, LOCAL_RELAY_URL} from "@welshman/util"
|
||||
import type {Message} from "../Socket.js"
|
||||
|
||||
export class Local extends Emitter {
|
||||
constructor(readonly relay: Relay) {
|
||||
super()
|
||||
|
||||
relay.on("*", this.onMessage)
|
||||
}
|
||||
|
||||
get connections() {
|
||||
return []
|
||||
}
|
||||
|
||||
async send(...payload: Message) {
|
||||
await this.relay.send(...payload)
|
||||
}
|
||||
|
||||
onMessage = (...message: Message) => {
|
||||
const [verb, ...payload] = message
|
||||
|
||||
this.emit(verb, LOCAL_RELAY_URL, ...payload)
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.removeAllListeners()
|
||||
this.relay.off("*", this.onMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import {Emitter} from "@welshman/lib"
|
||||
import type {Message} from "../Socket.js"
|
||||
import type {Target} from "../Executor.js"
|
||||
|
||||
export class Multi extends Emitter {
|
||||
constructor(readonly targets: Target[]) {
|
||||
super()
|
||||
|
||||
targets.forEach(t => {
|
||||
t.on("*", (verb, ...args) => this.emit(verb, ...args))
|
||||
})
|
||||
}
|
||||
|
||||
get connections() {
|
||||
return this.targets.flatMap(t => t.connections)
|
||||
}
|
||||
|
||||
async send(...payload: Message) {
|
||||
await Promise.all(this.targets.map(t => t.send(...payload)))
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.removeAllListeners()
|
||||
this.targets.forEach(t => t.cleanup())
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import {Emitter} from "@welshman/lib"
|
||||
import {ConnectionEvent} from "../ConnectionEvent.js"
|
||||
import type {Message} from "../Socket.js"
|
||||
import type {Connection} from "../Connection.js"
|
||||
|
||||
export class Relay extends Emitter {
|
||||
constructor(readonly connection: Connection) {
|
||||
super()
|
||||
|
||||
this.connection.on(ConnectionEvent.Receive, this.onMessage)
|
||||
}
|
||||
|
||||
get connections() {
|
||||
return [this.connection]
|
||||
}
|
||||
|
||||
async send(...payload: Message) {
|
||||
await this.connection.send(payload)
|
||||
}
|
||||
|
||||
onMessage = (connection: Connection, [verb, ...payload]: Message) => {
|
||||
this.emit(verb, connection.url, ...payload)
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.removeAllListeners()
|
||||
this.connection.off(ConnectionEvent.Receive, this.onMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import {Emitter} from "@welshman/lib"
|
||||
import type {Message} from "../Socket.js"
|
||||
import type {Connection} from "../Connection.js"
|
||||
import {ConnectionEvent} from "../ConnectionEvent.js"
|
||||
|
||||
export class Relays extends Emitter {
|
||||
constructor(readonly connections: Connection[]) {
|
||||
super()
|
||||
|
||||
connections.forEach(connection => {
|
||||
connection.on(ConnectionEvent.Receive, this.onMessage)
|
||||
})
|
||||
}
|
||||
|
||||
async send(...payload: Message) {
|
||||
await Promise.all(this.connections.map(c => c.send(payload)))
|
||||
}
|
||||
|
||||
onMessage = (connection: Connection, [verb, ...payload]: Message) => {
|
||||
this.emit(verb, connection.url, ...payload)
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.removeAllListeners()
|
||||
this.connections.forEach(connection => {
|
||||
connection.off(ConnectionEvent.Receive, this.onMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
const assert = require('assert')
|
||||
const {setContext} = require('@welshman/lib')
|
||||
const {Executor, Echo, getDefaultNetContext} = require('@welshman/net')
|
||||
|
||||
const event = {
|
||||
"content": "👀",
|
||||
"created_at":1727389659,
|
||||
"id": "acaee505278bd8842ab6df906bf39bb143cf9905f36453c9bc13554cf5006e2d",
|
||||
"kind": 1,
|
||||
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
|
||||
"sig": "3aa512e2dbcd704bd287e6a35eaa8c4388606d553d385e482cc94d536eea25585731c36da6658c941c4668a473860a12d75ba588ca50470df09f8827e164e640",
|
||||
"tags": [
|
||||
["p","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
|
||||
["e","d423aa132e5dc741ddecbac5e67515b6fd900c2559058397ec7fd860b3d77ea6","wss://nostr.mom","root"]
|
||||
]
|
||||
}
|
||||
|
||||
setContext({net: getDefaultNetContext()})
|
||||
|
||||
describe('myFunction', () => {
|
||||
const target = new Echo()
|
||||
const executor = new Executor(target)
|
||||
|
||||
it('should return the correct result', done => {
|
||||
const messages = []
|
||||
const neg = executor.diff({kinds: [1]}, [event], {})
|
||||
|
||||
target.on('*', (...message) => messages.push(message))
|
||||
|
||||
setTimeout(() => {
|
||||
neg.unsubscribe()
|
||||
assert.equal(messages[0][0], 'NEG-OPEN')
|
||||
assert.equal(messages[1][0], 'NEG-CLOSE')
|
||||
done()
|
||||
}, 10)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
build
|
||||
normalize-url
|
||||
Negentropy.ts
|
||||
__tests__
|
||||
@@ -1,61 +0,0 @@
|
||||
# @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))),
|
||||
])
|
||||
)
|
||||
},
|
||||
}))
|
||||
```
|
||||
@@ -1,195 +0,0 @@
|
||||
import { sleep } from "@welshman/lib"
|
||||
import WebSocket from 'isomorphic-ws'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { Socket, SocketStatus, SocketEventType } from "../src/socket"
|
||||
import { ClientMessage, RelayMessage } from "../src/message"
|
||||
|
||||
vi.mock('isomorphic-ws', () => {
|
||||
const WebSocket = vi.fn(function () {
|
||||
setTimeout(() => this.onopen())
|
||||
})
|
||||
|
||||
WebSocket.prototype.send = vi.fn()
|
||||
|
||||
WebSocket.prototype.close = vi.fn(function () {
|
||||
this.onclose()
|
||||
})
|
||||
|
||||
return { default: WebSocket }
|
||||
})
|
||||
|
||||
describe("Socket", () => {
|
||||
let socket: Socket
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
socket = new Socket("wss://test.relay")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
socket.cleanup()
|
||||
})
|
||||
|
||||
it("should initialize with correct url", () => {
|
||||
expect(socket.url).toBe("wss://test.relay")
|
||||
})
|
||||
|
||||
describe("open", () => {
|
||||
it("should create websocket and emit opening status", () => {
|
||||
const statusSpy = vi.fn()
|
||||
socket.on(SocketEventType.Status, statusSpy)
|
||||
|
||||
socket.open()
|
||||
|
||||
expect(socket._ws).toBeDefined()
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Opening, "wss://test.relay")
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Open, "wss://test.relay")
|
||||
})
|
||||
|
||||
it("should throw error if socket already exists", () => {
|
||||
socket.open()
|
||||
expect(() => socket.open()).toThrow("Attempted to open a websocket that has not been closed")
|
||||
})
|
||||
|
||||
it("should emit invalid status on invalid URL", () => {
|
||||
const statusSpy = vi.fn()
|
||||
socket.on(SocketEventType.Status, statusSpy)
|
||||
|
||||
vi.mocked(WebSocket).mockImplementationOnce(() => {
|
||||
throw new Error()
|
||||
})
|
||||
|
||||
socket.open()
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Invalid, "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("close", () => {
|
||||
it("should close websocket and emit closed status", () => {
|
||||
const statusSpy = vi.fn()
|
||||
socket.on(SocketEventType.Status, statusSpy)
|
||||
|
||||
socket.open()
|
||||
|
||||
const ws = socket._ws
|
||||
|
||||
socket.close()
|
||||
|
||||
expect(ws.close).toHaveBeenCalled()
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Closed, "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("send", () => {
|
||||
it("should queue messages and emit enqueue event", () => {
|
||||
const enqueueSpy = vi.fn()
|
||||
socket.on(SocketEventType.Enqueue, enqueueSpy)
|
||||
|
||||
const message: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
|
||||
socket.send(message)
|
||||
|
||||
expect(enqueueSpy).toHaveBeenCalledWith(message, "wss://test.relay")
|
||||
})
|
||||
|
||||
it("should send messages when socket is open", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
socket.on(SocketEventType.Send, sendSpy)
|
||||
|
||||
socket.open()
|
||||
socket._ws.onopen()
|
||||
|
||||
const message: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
|
||||
socket.send(message)
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(socket._ws.send).toHaveBeenCalledWith(JSON.stringify(message))
|
||||
expect(sendSpy).toHaveBeenCalledWith(message, "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("receive", () => {
|
||||
it("should handle valid relay messages", async () => {
|
||||
const receiveSpy = vi.fn()
|
||||
socket.on(SocketEventType.Receive, receiveSpy)
|
||||
|
||||
socket.open()
|
||||
const message: RelayMessage = ["EVENT", "123", { id: "123", kind: 1 }]
|
||||
socket._ws.onmessage({ data: JSON.stringify(message) })
|
||||
|
||||
// Allow task queue to process
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(receiveSpy).toHaveBeenCalledWith(message, "wss://test.relay")
|
||||
})
|
||||
|
||||
it("should emit error on invalid JSON", () => {
|
||||
const errorSpy = vi.fn()
|
||||
socket.on(SocketEventType.Error, errorSpy)
|
||||
|
||||
socket.open()
|
||||
socket._ws.onmessage({ data: "invalid json" })
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("Invalid message received", "wss://test.relay")
|
||||
})
|
||||
|
||||
it("should emit error on non-array message", () => {
|
||||
const errorSpy = vi.fn()
|
||||
socket.on(SocketEventType.Error, errorSpy)
|
||||
|
||||
socket.open()
|
||||
socket._ws.onmessage({ data: JSON.stringify({ not: "an array" }) })
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("Invalid message received", "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("should close socket and clear queues", () => {
|
||||
socket.open()
|
||||
|
||||
const ws = socket._ws
|
||||
|
||||
socket.cleanup()
|
||||
|
||||
expect(ws.close).toHaveBeenCalled()
|
||||
expect(socket.listenerCount(SocketEventType.Send)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should emit error status on websocket error", () => {
|
||||
const statusSpy = vi.fn()
|
||||
socket.on(SocketEventType.Status, statusSpy)
|
||||
|
||||
socket.open()
|
||||
socket._ws.onerror()
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Error, "wss://test.relay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("attemptToOpen", () => {
|
||||
it("should open socket if not already open", () => {
|
||||
const openSpy = vi.spyOn(socket, "open")
|
||||
|
||||
socket.attemptToOpen()
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not open socket if already open", () => {
|
||||
const openSpy = vi.spyOn(socket, "open")
|
||||
|
||||
socket.open()
|
||||
socket.attemptToOpen()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { Socket } from "../src/socket"
|
||||
import { Pool, makeSocket } from "../src/pool"
|
||||
import { normalizeRelayUrl } from "@welshman/util"
|
||||
|
||||
vi.mock('isomorphic-ws', () => {
|
||||
const WebSocket = vi.fn(function () {
|
||||
setTimeout(() => this.onopen())
|
||||
})
|
||||
|
||||
WebSocket.prototype.send = vi.fn()
|
||||
|
||||
WebSocket.prototype.close = vi.fn(function () {
|
||||
this.onclose()
|
||||
})
|
||||
|
||||
return { default: WebSocket }
|
||||
})
|
||||
|
||||
describe("Pool", () => {
|
||||
let pool: Pool
|
||||
|
||||
beforeEach(() => {
|
||||
pool = new Pool()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("has", () => {
|
||||
it("should return false for non-existent socket", () => {
|
||||
expect(pool.has("wss://test.relay")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true for existing socket, normalizing the url", () => {
|
||||
pool.get("wss://test.relay/")
|
||||
expect(pool.has("wss://test.relay")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("get", () => {
|
||||
it("should create new socket if none exists, normalizing the relay url", () => {
|
||||
const socket = pool.get("wss://test.relay")
|
||||
|
||||
expect(socket.url).toEqual("wss://test.relay/")
|
||||
})
|
||||
|
||||
it("should return existing socket if it exists", () => {
|
||||
const firstSocket = pool.get("wss://test.relay")
|
||||
const secondSocket = pool.get("wss://test.relay")
|
||||
|
||||
expect(firstSocket).toBe(secondSocket)
|
||||
})
|
||||
})
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("should notify subscribers of new sockets", () => {
|
||||
const sub1 = vi.fn()
|
||||
const sub2 = vi.fn()
|
||||
|
||||
pool.subscribe(sub1)
|
||||
pool.subscribe(sub2)
|
||||
pool.get("wss://test.relay")
|
||||
|
||||
expect(sub1).toHaveBeenCalledTimes(1)
|
||||
expect(sub2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should not notify subscribers for existing sockets", () => {
|
||||
pool.get("wss://test.relay")
|
||||
|
||||
const sub = vi.fn()
|
||||
pool.subscribe(sub)
|
||||
pool.get("wss://test.relay")
|
||||
|
||||
expect(sub).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should add subscription", () => {
|
||||
const sub = vi.fn()
|
||||
pool.subscribe(sub)
|
||||
expect(pool._subs).toContain(sub)
|
||||
})
|
||||
|
||||
it("should return unsubscribe function", () => {
|
||||
const sub = vi.fn()
|
||||
const unsubscribe = pool.subscribe(sub)
|
||||
|
||||
unsubscribe()
|
||||
|
||||
expect(pool._subs).not.toContain(sub)
|
||||
})
|
||||
})
|
||||
|
||||
describe("remove", () => {
|
||||
it("should remove and cleanup existing socket", () => {
|
||||
const mockSocket = { url: "wss://test.relay", cleanup: vi.fn() }
|
||||
|
||||
pool._data.set(mockSocket.url, mockSocket)
|
||||
pool.remove(mockSocket.url)
|
||||
|
||||
expect(mockSocket.cleanup).toHaveBeenCalled()
|
||||
expect(pool._data.has(mockSocket.url)).toBe(false)
|
||||
})
|
||||
|
||||
it("should do nothing for non-existent socket", () => {
|
||||
pool.remove("wss://test.relay")
|
||||
expect(pool._data.has("wss://test.relay")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear", () => {
|
||||
it("should remove all sockets", () => {
|
||||
const urls = ["wss://test1.relay", "wss://test2.relay"]
|
||||
const mockSockets = urls.map(url => ({ url, cleanup: vi.fn() }))
|
||||
|
||||
for (const mockSocket of mockSockets) {
|
||||
pool._data.set(mockSocket.url, mockSocket)
|
||||
}
|
||||
|
||||
pool.clear()
|
||||
|
||||
expect(pool._data.size).toBe(0)
|
||||
mockSockets.forEach(socket => {
|
||||
expect(socket.cleanup).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,228 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { EventEmitter } from "events"
|
||||
import { Unicast, Multicast, PublishEventType, PublishStatus, unicast, multicast } from "../src/publish"
|
||||
import { AbstractAdapter, AdapterEventType } from "../src/adapter"
|
||||
import { ClientMessageType, RelayMessage } from "../src/message"
|
||||
import { SignedEvent, makeEvent } from "@welshman/util"
|
||||
import { Nip01Signer } from '@welshman/signer'
|
||||
|
||||
class MockAdapter extends AbstractAdapter {
|
||||
constructor(readonly url: string, readonly send) {
|
||||
super()
|
||||
}
|
||||
|
||||
get sockets() {
|
||||
return []
|
||||
}
|
||||
|
||||
get urls() {
|
||||
return [this.url]
|
||||
}
|
||||
|
||||
receive = (message: RelayMessage) => {
|
||||
this.emit(AdapterEventType.Receive, message, this.url)
|
||||
}
|
||||
}
|
||||
|
||||
describe("Unicast", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("success works", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
const adapter = new MockAdapter('1', sendSpy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
const pub = unicast({
|
||||
relay: '1',
|
||||
context: {getAdapter: () => adapter},
|
||||
event,
|
||||
})
|
||||
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event])
|
||||
|
||||
adapter.receive(["OK", event.id, true, "hi"])
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(successSpy).toHaveBeenCalledWith(event.id, "hi")
|
||||
expect(failureSpy).not.toHaveBeenCalled()
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("failure works", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
const adapter = new MockAdapter('1', sendSpy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
const pub = unicast({
|
||||
relay: '1',
|
||||
context: {getAdapter: () => adapter},
|
||||
event,
|
||||
})
|
||||
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event])
|
||||
|
||||
adapter.receive(["OK", event.id, false, "hi"])
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(successSpy).not.toHaveBeenCalled()
|
||||
expect(failureSpy).toHaveBeenCalledWith(event.id, "hi")
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("timeout works", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
const adapter = new MockAdapter('1', sendSpy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
const pub = unicast({
|
||||
relay: '1',
|
||||
context: {getAdapter: () => adapter},
|
||||
event,
|
||||
})
|
||||
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
const timeoutSpy = vi.fn()
|
||||
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
pub.on(PublishEventType.Timeout, timeoutSpy)
|
||||
|
||||
await vi.runAllTimers(200)
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event])
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(successSpy).not.toHaveBeenCalled()
|
||||
expect(failureSpy).not.toHaveBeenCalled(event.id, "hi")
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
expect(timeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("abort works", async () => {
|
||||
const sendSpy = vi.fn()
|
||||
const adapter = new MockAdapter('1', sendSpy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
const pub = unicast({
|
||||
relay: '1',
|
||||
context: {getAdapter: () => adapter},
|
||||
event,
|
||||
})
|
||||
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
const abortSpy = vi.fn()
|
||||
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
pub.on(PublishEventType.Timeout, abortSpy)
|
||||
|
||||
await vi.runAllTimers(200)
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event])
|
||||
|
||||
pub.abort()
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(successSpy).not.toHaveBeenCalled()
|
||||
expect(failureSpy).not.toHaveBeenCalled(event.id, "hi")
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
expect(abortSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multicast", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should all basically work", async () => {
|
||||
const send1Spy = vi.fn()
|
||||
const adapter1 = new MockAdapter('1', send1Spy)
|
||||
const send2Spy = vi.fn()
|
||||
const adapter2 = new MockAdapter('2', send2Spy)
|
||||
const send3Spy = vi.fn()
|
||||
const adapter3 = new MockAdapter('3', send3Spy)
|
||||
const signer = Nip01Signer.ephemeral()
|
||||
const event = await signer.sign(makeEvent(1))
|
||||
|
||||
const pub = multicast({
|
||||
event,
|
||||
relays: ['1', '2', '3'],
|
||||
context: {
|
||||
getAdapter: (url: string) => {
|
||||
switch(url) {
|
||||
case '1': return adapter1
|
||||
case '2': return adapter2
|
||||
case '3': return adapter3
|
||||
default: throw new Error(`Unknown relay: ${url}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const successSpy = vi.fn()
|
||||
const failureSpy = vi.fn()
|
||||
const completeSpy = vi.fn()
|
||||
const timeoutSpy = vi.fn()
|
||||
|
||||
pub.on(PublishEventType.Success, successSpy)
|
||||
pub.on(PublishEventType.Failure, failureSpy)
|
||||
pub.on(PublishEventType.Complete, completeSpy)
|
||||
pub.on(PublishEventType.Timeout, timeoutSpy)
|
||||
|
||||
adapter1.receive(["OK", event.id, true, "hi"])
|
||||
adapter2.receive(["OK", event.id, false, "hi"])
|
||||
|
||||
|
||||
await vi.runAllTimers()
|
||||
|
||||
expect(successSpy).toHaveBeenCalledWith(event.id, "hi", "1")
|
||||
expect(failureSpy).toHaveBeenCalledWith(event.id, "hi", "2")
|
||||
expect(completeSpy).toHaveBeenCalledTimes(1)
|
||||
expect(timeoutSpy).toHaveBeenCalledWith("3")
|
||||
})
|
||||
})
|
||||
@@ -1,189 +0,0 @@
|
||||
import {Tracker} from "../src/Tracker"
|
||||
import {vi, describe, it, expect, beforeEach} from "vitest"
|
||||
|
||||
describe("Tracker", () => {
|
||||
let tracker: Tracker
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new Tracker()
|
||||
})
|
||||
|
||||
describe("basic operations", () => {
|
||||
it("should initialize with empty maps", () => {
|
||||
expect(tracker.relaysById.size).toBe(0)
|
||||
expect(tracker.idsByRelay.size).toBe(0)
|
||||
})
|
||||
|
||||
it("should return empty set for non-existent relay", () => {
|
||||
expect(tracker.getIds("relay1")).toEqual(new Set())
|
||||
})
|
||||
|
||||
it("should return empty set for non-existent event", () => {
|
||||
expect(tracker.getRelays("event1")).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe("addRelay", () => {
|
||||
it("should add new relay-event pair", () => {
|
||||
tracker.addRelay("event1", "relay1")
|
||||
|
||||
expect(tracker.hasRelay("event1", "relay1")).toBe(true)
|
||||
expect(tracker.getRelays("event1")).toEqual(new Set(["relay1"]))
|
||||
// expect(tracker.getIds("relay1")).toEqual(new Set(["event1"]))
|
||||
})
|
||||
|
||||
it("should not duplicate existing pairs", () => {
|
||||
const updateSpy = vi.fn()
|
||||
tracker.on("update", updateSpy)
|
||||
|
||||
tracker.addRelay("event1", "relay1")
|
||||
tracker.addRelay("event1", "relay1")
|
||||
|
||||
// expect(updateSpy).toHaveBeenCalledTimes(1)
|
||||
expect(tracker.getRelays("event1").size).toBe(1)
|
||||
})
|
||||
|
||||
it("should emit update event", () => {
|
||||
const updateSpy = vi.fn()
|
||||
tracker.on("update", updateSpy)
|
||||
|
||||
tracker.addRelay("event1", "relay1")
|
||||
|
||||
expect(updateSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeRelay", () => {
|
||||
beforeEach(() => {
|
||||
tracker.addRelay("event1", "relay1")
|
||||
})
|
||||
|
||||
it("should remove existing relay-event pair", () => {
|
||||
tracker.removeRelay("event1", "relay1")
|
||||
|
||||
expect(tracker.hasRelay("event1", "relay1")).toBe(false)
|
||||
expect(tracker.getRelays("event1").size).toBe(0)
|
||||
expect(tracker.getIds("relay1").size).toBe(0)
|
||||
})
|
||||
|
||||
it("should emit update event on successful removal", () => {
|
||||
const updateSpy = vi.fn()
|
||||
tracker.on("update", updateSpy)
|
||||
|
||||
tracker.removeRelay("event1", "relay1")
|
||||
|
||||
expect(updateSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not emit update event if nothing was removed", () => {
|
||||
const updateSpy = vi.fn()
|
||||
tracker.on("update", updateSpy)
|
||||
|
||||
tracker.removeRelay("nonexistent", "relay1")
|
||||
|
||||
expect(updateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("track", () => {
|
||||
it("should return false for first occurrence", () => {
|
||||
const seen = tracker.track("event1", "relay1")
|
||||
expect(seen).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true for subsequent occurrences", () => {
|
||||
tracker.track("event1", "relay1")
|
||||
const seen = tracker.track("event1", "relay2")
|
||||
expect(seen).toBe(true)
|
||||
})
|
||||
|
||||
it("should add relay-event pair", () => {
|
||||
tracker.track("event1", "relay1")
|
||||
expect(tracker.hasRelay("event1", "relay1")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("copy", () => {
|
||||
it("should copy relays from one event to another", () => {
|
||||
tracker.addRelay("event1", "relay1")
|
||||
tracker.addRelay("event1", "relay2")
|
||||
|
||||
tracker.copy("event1", "event2")
|
||||
|
||||
expect(tracker.getRelays("event2")).toEqual(tracker.getRelays("event1"))
|
||||
})
|
||||
|
||||
it("should handle copying from non-existent event", () => {
|
||||
tracker.copy("nonexistent", "event2")
|
||||
expect(tracker.getRelays("event2").size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("load", () => {
|
||||
it("should load data from relaysById map", () => {
|
||||
const data = new Map([
|
||||
["event1", new Set(["relay1", "relay2"])],
|
||||
["event2", new Set(["relay2", "relay3"])],
|
||||
])
|
||||
|
||||
tracker.load(data)
|
||||
|
||||
expect(tracker.getRelays("event1")).toEqual(new Set(["relay1", "relay2"]))
|
||||
expect(tracker.getIds("relay2")).toEqual(new Set(["event1", "event2"]))
|
||||
})
|
||||
|
||||
it("should clear existing data before loading", () => {
|
||||
tracker.addRelay("oldEvent", "oldRelay")
|
||||
|
||||
tracker.load(new Map([["event1", new Set(["relay1"])]]))
|
||||
|
||||
expect(tracker.hasRelay("oldEvent", "oldRelay")).toBe(undefined)
|
||||
})
|
||||
|
||||
it("should emit update event", () => {
|
||||
const updateSpy = vi.fn()
|
||||
tracker.on("update", updateSpy)
|
||||
|
||||
tracker.load(new Map())
|
||||
|
||||
expect(updateSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear", () => {
|
||||
beforeEach(() => {
|
||||
tracker.addRelay("event1", "relay1")
|
||||
tracker.addRelay("event2", "relay2")
|
||||
})
|
||||
|
||||
it("should clear all data", () => {
|
||||
tracker.clear()
|
||||
|
||||
expect(tracker.relaysById.size).toBe(0)
|
||||
expect(tracker.idsByRelay.size).toBe(0)
|
||||
})
|
||||
|
||||
it("should emit update event", () => {
|
||||
const updateSpy = vi.fn()
|
||||
tracker.on("update", updateSpy)
|
||||
|
||||
tracker.clear()
|
||||
|
||||
expect(updateSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle removing non-existent pairs", () => {
|
||||
expect(() => tracker.removeRelay("nonexistent", "relay1")).not.toThrow()
|
||||
})
|
||||
|
||||
it("should maintain bidirectional consistency", () => {
|
||||
tracker.addRelay("event1", "relay1")
|
||||
|
||||
// Check both maps are consistent
|
||||
expect(tracker.relaysById.get("event1")?.has("relay1")).toBe(true)
|
||||
// expect(tracker.idsByRelay.get("relay1")?.has("event1")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "@welshman/net2",
|
||||
"version": "0.0.48",
|
||||
"author": "hodlbod",
|
||||
"license": "MIT",
|
||||
"description": "Utilities for connecting with nostr relays.",
|
||||
"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",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"typed-emitter": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export * from "./adapter.js"
|
||||
export * from "./auth.js"
|
||||
export * from "./diff.js"
|
||||
export * from "./message.js"
|
||||
export * from "./negentropy.js"
|
||||
export * from "./policy.js"
|
||||
export * from "./pool.js"
|
||||
export * from "./publish.js"
|
||||
export * from "./socket.js"
|
||||
export * from "./request.js"
|
||||
export * from "./tracker.js"
|
||||
@@ -1,590 +0,0 @@
|
||||
// (C) 2023 Doug Hoyte. MIT license
|
||||
// @ts-nocheck
|
||||
|
||||
const PROTOCOL_VERSION = 0x61 // Version 1
|
||||
const ID_SIZE = 32
|
||||
const FINGERPRINT_SIZE = 16
|
||||
|
||||
const Mode = {
|
||||
Skip: 0,
|
||||
Fingerprint: 1,
|
||||
IdList: 2,
|
||||
}
|
||||
|
||||
class WrappedBuffer {
|
||||
constructor(buffer) {
|
||||
this._raw = new Uint8Array(buffer || 512)
|
||||
this.length = buffer ? buffer.length : 0
|
||||
}
|
||||
|
||||
unwrap() {
|
||||
return this._raw.subarray(0, this.length)
|
||||
}
|
||||
|
||||
get capacity() {
|
||||
return this._raw.byteLength
|
||||
}
|
||||
|
||||
extend(buf) {
|
||||
if (buf._raw) buf = buf.unwrap()
|
||||
if (typeof(buf.length) !== 'number') throw Error("bad length")
|
||||
const targetSize = buf.length + this.length
|
||||
if (this.capacity < targetSize) {
|
||||
const oldRaw = this._raw
|
||||
const newCapacity = Math.max(this.capacity * 2, targetSize)
|
||||
this._raw = new Uint8Array(newCapacity)
|
||||
this._raw.set(oldRaw)
|
||||
}
|
||||
|
||||
this._raw.set(buf, this.length)
|
||||
this.length += buf.length
|
||||
}
|
||||
|
||||
shift() {
|
||||
const first = this._raw[0]
|
||||
this._raw = this._raw.subarray(1)
|
||||
this.length--
|
||||
return first
|
||||
}
|
||||
|
||||
shiftN(n = 1) {
|
||||
const firstSubarray = this._raw.subarray(0, n)
|
||||
this._raw = this._raw.subarray(n)
|
||||
this.length -= n
|
||||
return firstSubarray
|
||||
}
|
||||
}
|
||||
|
||||
function decodeVarInt(buf) {
|
||||
let res = 0
|
||||
|
||||
while (1) {
|
||||
if (buf.length === 0) throw Error("parse ends prematurely")
|
||||
const byte = buf.shift()
|
||||
res = (res << 7) | (byte & 127)
|
||||
if ((byte & 128) === 0) break
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
function encodeVarInt(n) {
|
||||
if (n === 0) return new WrappedBuffer([0])
|
||||
|
||||
const o = []
|
||||
|
||||
while (n !== 0) {
|
||||
o.push(n & 127)
|
||||
n >>>= 7
|
||||
}
|
||||
|
||||
o.reverse()
|
||||
|
||||
for (let i = 0; i < o.length - 1; i++) o[i] |= 128
|
||||
|
||||
return new WrappedBuffer(o)
|
||||
}
|
||||
|
||||
function getByte(buf) {
|
||||
return getBytes(buf, 1)[0]
|
||||
}
|
||||
|
||||
function getBytes(buf, n) {
|
||||
if (buf.length < n) throw Error("parse ends prematurely")
|
||||
return buf.shiftN(n)
|
||||
}
|
||||
|
||||
|
||||
class Accumulator {
|
||||
constructor() {
|
||||
this.setToZero()
|
||||
|
||||
if (typeof window === 'undefined') { // node.js
|
||||
const crypto = require('crypto')
|
||||
this.sha256 = async (slice) => new Uint8Array(crypto.createHash('sha256').update(slice).digest())
|
||||
} else { // browser
|
||||
this.sha256 = async (slice) => new Uint8Array(await crypto.subtle.digest("SHA-256", slice))
|
||||
}
|
||||
}
|
||||
|
||||
setToZero() {
|
||||
this.buf = new Uint8Array(ID_SIZE)
|
||||
}
|
||||
|
||||
add(otherBuf) {
|
||||
let currCarry = 0, nextCarry = 0
|
||||
const p = new DataView(this.buf.buffer)
|
||||
const po = new DataView(otherBuf.buffer)
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const offset = i * 4
|
||||
const orig = p.getUint32(offset, true)
|
||||
const otherV = po.getUint32(offset, true)
|
||||
|
||||
let next = orig
|
||||
|
||||
next += currCarry
|
||||
next += otherV
|
||||
if (next > 0xFFFFFFFF) nextCarry = 1
|
||||
|
||||
p.setUint32(offset, next & 0xFFFFFFFF, true)
|
||||
currCarry = nextCarry
|
||||
nextCarry = 0
|
||||
}
|
||||
}
|
||||
|
||||
negate() {
|
||||
const p = new DataView(this.buf.buffer)
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const offset = i * 4
|
||||
p.setUint32(offset, ~p.getUint32(offset, true))
|
||||
}
|
||||
|
||||
const one = new Uint8Array(ID_SIZE)
|
||||
one[0] = 1
|
||||
this.add(one)
|
||||
}
|
||||
|
||||
async getFingerprint(n) {
|
||||
const input = new WrappedBuffer()
|
||||
input.extend(this.buf)
|
||||
input.extend(encodeVarInt(n))
|
||||
|
||||
const hash = await this.sha256(input.unwrap())
|
||||
|
||||
return hash.subarray(0, FINGERPRINT_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NegentropyStorageVector {
|
||||
constructor() {
|
||||
this.items = []
|
||||
this.sealed = false
|
||||
}
|
||||
|
||||
insert(timestamp, id) {
|
||||
if (this.sealed) throw Error("already sealed")
|
||||
id = loadInputBuffer(id)
|
||||
if (id.byteLength !== ID_SIZE) throw Error("bad id size for added item")
|
||||
this.items.push({timestamp, id})
|
||||
}
|
||||
|
||||
seal() {
|
||||
if (this.sealed) throw Error("already sealed")
|
||||
this.sealed = true
|
||||
|
||||
this.items.sort(itemCompare)
|
||||
|
||||
for (let i = 1; i < this.items.length; i++) {
|
||||
if (itemCompare(this.items[i - 1], this.items[i]) === 0) throw Error("duplicate item inserted")
|
||||
}
|
||||
}
|
||||
|
||||
unseal() {
|
||||
this.sealed = false
|
||||
}
|
||||
|
||||
size() {
|
||||
this._checkSealed()
|
||||
return this.items.length
|
||||
}
|
||||
|
||||
getItem(i) {
|
||||
this._checkSealed()
|
||||
if (i >= this.items.length) throw Error("out of range")
|
||||
return this.items[i]
|
||||
}
|
||||
|
||||
iterate(begin, end, cb) {
|
||||
this._checkSealed()
|
||||
this._checkBounds(begin, end)
|
||||
|
||||
for (let i = begin; i < end; ++i) {
|
||||
if (!cb(this.items[i], i)) break
|
||||
}
|
||||
}
|
||||
|
||||
findLowerBound(begin, end, bound) {
|
||||
this._checkSealed()
|
||||
this._checkBounds(begin, end)
|
||||
|
||||
return this._binarySearch(this.items, begin, end, (a) => itemCompare(a, bound) < 0)
|
||||
}
|
||||
|
||||
async fingerprint(begin, end) {
|
||||
const out = new Accumulator()
|
||||
out.setToZero()
|
||||
|
||||
this.iterate(begin, end, (item, i) => {
|
||||
out.add(item.id)
|
||||
return true
|
||||
})
|
||||
|
||||
return await out.getFingerprint(end - begin)
|
||||
}
|
||||
|
||||
_checkSealed() {
|
||||
if (!this.sealed) throw Error("not sealed")
|
||||
}
|
||||
|
||||
_checkBounds(begin, end) {
|
||||
if (begin > end || end > this.items.length) throw Error("bad range")
|
||||
}
|
||||
|
||||
_binarySearch(arr, first, last, cmp) {
|
||||
let count = last - first
|
||||
|
||||
while (count > 0) {
|
||||
let it = first
|
||||
const step = Math.floor(count / 2)
|
||||
it += step
|
||||
|
||||
if (cmp(arr[it])) {
|
||||
first = ++it
|
||||
count -= step + 1
|
||||
} else {
|
||||
count = step
|
||||
}
|
||||
}
|
||||
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Negentropy {
|
||||
constructor(storage, frameSizeLimit = 0) {
|
||||
if (frameSizeLimit !== 0 && frameSizeLimit < 4096) throw Error("frameSizeLimit too small")
|
||||
|
||||
this.storage = storage
|
||||
this.frameSizeLimit = frameSizeLimit
|
||||
|
||||
this.lastTimestampIn = 0
|
||||
this.lastTimestampOut = 0
|
||||
}
|
||||
|
||||
_bound(timestamp, id) {
|
||||
return {timestamp, id: id ? id : new Uint8Array(0)}
|
||||
}
|
||||
|
||||
async initiate() {
|
||||
if (this.isInitiator) throw Error("already initiated")
|
||||
this.isInitiator = true
|
||||
|
||||
const output = new WrappedBuffer()
|
||||
output.extend([PROTOCOL_VERSION])
|
||||
|
||||
await this.splitRange(0, this.storage.size(), this._bound(Number.MAX_VALUE), output)
|
||||
|
||||
return this._renderOutput(output)
|
||||
}
|
||||
|
||||
setInitiator() {
|
||||
this.isInitiator = true
|
||||
}
|
||||
|
||||
async reconcile(query) {
|
||||
const haveIds = [], needIds = []
|
||||
query = new WrappedBuffer(loadInputBuffer(query))
|
||||
|
||||
this.lastTimestampIn = this.lastTimestampOut = 0 // reset for each message
|
||||
|
||||
const fullOutput = new WrappedBuffer()
|
||||
fullOutput.extend([PROTOCOL_VERSION])
|
||||
|
||||
const protocolVersion = getByte(query)
|
||||
if (protocolVersion < 0x60 || protocolVersion > 0x6F) throw Error("invalid negentropy protocol version byte")
|
||||
if (protocolVersion !== PROTOCOL_VERSION) {
|
||||
if (this.isInitiator) throw Error("unsupported negentropy protocol version requested: " + (protocolVersion - 0x60))
|
||||
else return [this._renderOutput(fullOutput), haveIds, needIds]
|
||||
}
|
||||
|
||||
const storageSize = this.storage.size()
|
||||
let prevBound = this._bound(0)
|
||||
let prevIndex = 0
|
||||
let skip = false
|
||||
|
||||
while (query.length !== 0) {
|
||||
let o = new WrappedBuffer()
|
||||
|
||||
const doSkip = () => {
|
||||
if (skip) {
|
||||
skip = false
|
||||
o.extend(this.encodeBound(prevBound))
|
||||
o.extend(encodeVarInt(Mode.Skip))
|
||||
}
|
||||
}
|
||||
|
||||
const currBound = this.decodeBound(query)
|
||||
const mode = decodeVarInt(query)
|
||||
|
||||
const lower = prevIndex
|
||||
let upper = this.storage.findLowerBound(prevIndex, storageSize, currBound)
|
||||
|
||||
if (mode === Mode.Skip) {
|
||||
skip = true
|
||||
} else if (mode === Mode.Fingerprint) {
|
||||
const theirFingerprint = getBytes(query, FINGERPRINT_SIZE)
|
||||
const ourFingerprint = await this.storage.fingerprint(lower, upper)
|
||||
|
||||
if (compareUint8Array(theirFingerprint, ourFingerprint) !== 0) {
|
||||
doSkip()
|
||||
await this.splitRange(lower, upper, currBound, o)
|
||||
} else {
|
||||
skip = true
|
||||
}
|
||||
} else if (mode === Mode.IdList) {
|
||||
const numIds = decodeVarInt(query)
|
||||
|
||||
const theirElems = {} // stringified Uint8Array -> original Uint8Array (or hex)
|
||||
for (let i = 0; i < numIds; i++) {
|
||||
const e = getBytes(query, ID_SIZE)
|
||||
if (this.isInitiator) theirElems[e] = e
|
||||
}
|
||||
|
||||
if (this.isInitiator) {
|
||||
skip = true
|
||||
|
||||
this.storage.iterate(lower, upper, (item) => {
|
||||
const k = item.id
|
||||
|
||||
if (!theirElems[k]) {
|
||||
// ID exists on our side, but not their side
|
||||
if (this.isInitiator) haveIds.push(this.wantUint8ArrayOutput ? k : uint8ArrayToHex(k))
|
||||
} else {
|
||||
// ID exists on both sides
|
||||
delete theirElems[k]
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
for (const v of Object.values(theirElems)) {
|
||||
// ID exists on their side, but not our side
|
||||
needIds.push(this.wantUint8ArrayOutput ? v : uint8ArrayToHex(v))
|
||||
}
|
||||
} else {
|
||||
doSkip()
|
||||
|
||||
const responseIds = new WrappedBuffer()
|
||||
let numResponseIds = 0
|
||||
let endBound = currBound
|
||||
|
||||
this.storage.iterate(lower, upper, (item, index) => {
|
||||
if (this.exceededFrameSizeLimit(fullOutput.length + responseIds.length)) {
|
||||
endBound = item
|
||||
upper = index // shrink upper so that remaining range gets correct fingerprint
|
||||
return false
|
||||
}
|
||||
|
||||
responseIds.extend(item.id)
|
||||
numResponseIds++
|
||||
return true
|
||||
})
|
||||
|
||||
o.extend(this.encodeBound(endBound))
|
||||
o.extend(encodeVarInt(Mode.IdList))
|
||||
o.extend(encodeVarInt(numResponseIds))
|
||||
o.extend(responseIds)
|
||||
|
||||
fullOutput.extend(o)
|
||||
o = new WrappedBuffer()
|
||||
}
|
||||
} else {
|
||||
throw Error("unexpected mode")
|
||||
}
|
||||
|
||||
if (this.exceededFrameSizeLimit(fullOutput.length + o.length)) {
|
||||
// frameSizeLimit exceeded: Stop range processing and return a fingerprint for the remaining range
|
||||
const remainingFingerprint = await this.storage.fingerprint(upper, storageSize)
|
||||
|
||||
fullOutput.extend(this.encodeBound(this._bound(Number.MAX_VALUE)))
|
||||
fullOutput.extend(encodeVarInt(Mode.Fingerprint))
|
||||
fullOutput.extend(remainingFingerprint)
|
||||
break
|
||||
} else {
|
||||
fullOutput.extend(o)
|
||||
}
|
||||
|
||||
prevIndex = upper
|
||||
prevBound = currBound
|
||||
}
|
||||
|
||||
return [fullOutput.length === 1 && this.isInitiator ? null : this._renderOutput(fullOutput), haveIds, needIds]
|
||||
}
|
||||
|
||||
async splitRange(lower, upper, upperBound, o) {
|
||||
const numElems = upper - lower
|
||||
const buckets = 16
|
||||
|
||||
if (numElems < buckets * 2) {
|
||||
o.extend(this.encodeBound(upperBound))
|
||||
o.extend(encodeVarInt(Mode.IdList))
|
||||
|
||||
o.extend(encodeVarInt(numElems))
|
||||
this.storage.iterate(lower, upper, (item) => {
|
||||
o.extend(item.id)
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
const itemsPerBucket = Math.floor(numElems / buckets)
|
||||
const bucketsWithExtra = numElems % buckets
|
||||
let curr = lower
|
||||
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
const bucketSize = itemsPerBucket + (i < bucketsWithExtra ? 1 : 0)
|
||||
const ourFingerprint = await this.storage.fingerprint(curr, curr + bucketSize)
|
||||
curr += bucketSize
|
||||
|
||||
let nextBound
|
||||
|
||||
if (curr === upper) {
|
||||
nextBound = upperBound
|
||||
} else {
|
||||
let prevItem, currItem
|
||||
|
||||
this.storage.iterate(curr - 1, curr + 1, (item, index) => {
|
||||
if (index === curr - 1) prevItem = item
|
||||
else currItem = item
|
||||
return true
|
||||
})
|
||||
|
||||
nextBound = this.getMinimalBound(prevItem, currItem)
|
||||
}
|
||||
|
||||
o.extend(this.encodeBound(nextBound))
|
||||
o.extend(encodeVarInt(Mode.Fingerprint))
|
||||
o.extend(ourFingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_renderOutput(o) {
|
||||
o = o.unwrap()
|
||||
if (!this.wantUint8ArrayOutput) o = uint8ArrayToHex(o)
|
||||
return o
|
||||
}
|
||||
|
||||
exceededFrameSizeLimit(n) {
|
||||
return this.frameSizeLimit && n > this.frameSizeLimit - 200
|
||||
}
|
||||
|
||||
// Decoding
|
||||
|
||||
decodeTimestampIn(encoded) {
|
||||
let timestamp = decodeVarInt(encoded)
|
||||
timestamp = timestamp === 0 ? Number.MAX_VALUE : timestamp - 1
|
||||
if (this.lastTimestampIn === Number.MAX_VALUE || timestamp === Number.MAX_VALUE) {
|
||||
this.lastTimestampIn = Number.MAX_VALUE
|
||||
return Number.MAX_VALUE
|
||||
}
|
||||
timestamp += this.lastTimestampIn
|
||||
this.lastTimestampIn = timestamp
|
||||
return timestamp
|
||||
}
|
||||
|
||||
decodeBound(encoded) {
|
||||
const timestamp = this.decodeTimestampIn(encoded)
|
||||
const len = decodeVarInt(encoded)
|
||||
if (len > ID_SIZE) throw Error("bound key too long")
|
||||
const id = getBytes(encoded, len)
|
||||
return {timestamp, id}
|
||||
}
|
||||
|
||||
// Encoding
|
||||
|
||||
encodeTimestampOut(timestamp) {
|
||||
if (timestamp === Number.MAX_VALUE) {
|
||||
this.lastTimestampOut = Number.MAX_VALUE
|
||||
return encodeVarInt(0)
|
||||
}
|
||||
|
||||
const temp = timestamp
|
||||
timestamp -= this.lastTimestampOut
|
||||
this.lastTimestampOut = temp
|
||||
return encodeVarInt(timestamp + 1)
|
||||
}
|
||||
|
||||
encodeBound(key) {
|
||||
const output = new WrappedBuffer()
|
||||
|
||||
output.extend(this.encodeTimestampOut(key.timestamp))
|
||||
output.extend(encodeVarInt(key.id.length))
|
||||
output.extend(key.id)
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
getMinimalBound(prev, curr) {
|
||||
if (curr.timestamp !== prev.timestamp) {
|
||||
return this._bound(curr.timestamp)
|
||||
} else {
|
||||
let sharedPrefixBytes = 0
|
||||
const currKey = curr.id
|
||||
const prevKey = prev.id
|
||||
|
||||
for (let i = 0; i < ID_SIZE; i++) {
|
||||
if (currKey[i] !== prevKey[i]) break
|
||||
sharedPrefixBytes++
|
||||
}
|
||||
|
||||
return this._bound(curr.timestamp, curr.id.subarray(0, sharedPrefixBytes + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadInputBuffer(inp) {
|
||||
if (typeof(inp) === 'string') inp = hexToUint8Array(inp)
|
||||
else if (__proto__ !== Uint8Array.prototype) inp = new Uint8Array(inp) // node Buffer?
|
||||
return inp
|
||||
}
|
||||
|
||||
function hexToUint8Array(h) {
|
||||
if (h.startsWith('0x')) h = h.substr(2)
|
||||
if (h.length % 2 === 1) throw Error("odd length of hex string")
|
||||
const arr = new Uint8Array(h.length / 2)
|
||||
for (let i = 0; i < arr.length; i++) arr[i] = parseInt(h.substr(i * 2, 2), 16)
|
||||
return arr
|
||||
}
|
||||
|
||||
const uint8ArrayToHexLookupTable = new Array(256)
|
||||
{
|
||||
const hexAlphabet = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']
|
||||
for (let i = 0; i < 256; i++) {
|
||||
uint8ArrayToHexLookupTable[i] = hexAlphabet[(i >>> 4) & 0xF] + hexAlphabet[i & 0xF]
|
||||
}
|
||||
}
|
||||
|
||||
function uint8ArrayToHex(arr) {
|
||||
let out = ''
|
||||
for (let i = 0, edx = arr.length; i < edx; i++) {
|
||||
out += uint8ArrayToHexLookupTable[arr[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
function compareUint8Array(a, b) {
|
||||
for (let i = 0; i < a.byteLength; i++) {
|
||||
if (a[i] < b[i]) return -1
|
||||
if (a[i] > b[i]) return 1
|
||||
}
|
||||
|
||||
if (a.byteLength > b.byteLength) return 1
|
||||
if (a.byteLength < b.byteLength) return -1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function itemCompare(a, b) {
|
||||
if (a.timestamp === b.timestamp) {
|
||||
return compareUint8Array(a.id, b.id)
|
||||
}
|
||||
|
||||
return a.timestamp - b.timestamp
|
||||
}
|
||||
|
||||
|
||||
export {Negentropy, NegentropyStorageVector,}
|
||||
@@ -1,82 +0,0 @@
|
||||
import {remove} from "@welshman/lib"
|
||||
import {normalizeRelayUrl} from "@welshman/util"
|
||||
import {Socket} from "./socket.js"
|
||||
import {defaultSocketPolicies} from "./policy.js"
|
||||
|
||||
export const makeSocket = (url: string, policies = defaultSocketPolicies) => {
|
||||
const socket = new Socket(url)
|
||||
|
||||
for (const applyPolicy of policies) {
|
||||
applyPolicy(socket)
|
||||
}
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
export type PoolSubscription = (socket: Socket) => void
|
||||
|
||||
export type PoolOptions = {
|
||||
makeSocket?: (url: string) => Socket
|
||||
}
|
||||
|
||||
export class Pool {
|
||||
_data = new Map<string, Socket>()
|
||||
_subs: PoolSubscription[] = []
|
||||
|
||||
constructor(readonly options: PoolOptions = {}) {}
|
||||
|
||||
has(url: string) {
|
||||
return this._data.has(normalizeRelayUrl(url))
|
||||
}
|
||||
|
||||
makeSocket(url: string) {
|
||||
if (this.options.makeSocket) {
|
||||
return this.options.makeSocket(url)
|
||||
}
|
||||
|
||||
return makeSocket(url)
|
||||
}
|
||||
|
||||
get(_url: string): Socket {
|
||||
const url = normalizeRelayUrl(_url)
|
||||
const oldSocket = this._data.get(url)
|
||||
|
||||
if (oldSocket) {
|
||||
return oldSocket
|
||||
}
|
||||
|
||||
const socket = this.makeSocket(url)
|
||||
|
||||
this._data.set(url, socket)
|
||||
|
||||
for (const cb of this._subs) {
|
||||
cb(socket)
|
||||
}
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
subscribe(cb: PoolSubscription) {
|
||||
this._subs.push(cb)
|
||||
|
||||
return () => {
|
||||
this._subs = remove(cb, this._subs)
|
||||
}
|
||||
}
|
||||
|
||||
remove(url: string) {
|
||||
const socket = this._data.get(url)
|
||||
|
||||
if (socket) {
|
||||
socket.cleanup()
|
||||
|
||||
this._data.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const url of this._data.keys()) {
|
||||
this.remove(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import {EventEmitter} from "events"
|
||||
import {on, fromPairs, sleep, yieldThread} from "@welshman/lib"
|
||||
import {SignedEvent} from "@welshman/util"
|
||||
import {RelayMessage, ClientMessageType, isRelayOk} from "./message.js"
|
||||
import {AbstractAdapter, AdapterEventType, AdapterContext, getAdapter} from "./adapter.js"
|
||||
import {TypedEmitter} from "./util.js"
|
||||
|
||||
export enum PublishStatus {
|
||||
Pending = "publish:status:pending",
|
||||
Success = "publish:status:success",
|
||||
Failure = "publish:status:failure",
|
||||
Timeout = "publish:status:timeout",
|
||||
Aborted = "publish:status:aborted",
|
||||
}
|
||||
|
||||
export enum PublishEventType {
|
||||
Success = "publish:event:success",
|
||||
Failure = "publish:event:failure",
|
||||
Timeout = "publish:event:timeout",
|
||||
Aborted = "publish:event:aborted",
|
||||
Complete = "publish:event:complete",
|
||||
}
|
||||
|
||||
// Unicast
|
||||
|
||||
export type UnicastEvents = {
|
||||
[PublishEventType.Success]: (id: string, detail: string) => void
|
||||
[PublishEventType.Failure]: (id: string, detail: string) => void
|
||||
[PublishEventType.Timeout]: () => void
|
||||
[PublishEventType.Aborted]: () => void
|
||||
[PublishEventType.Complete]: () => void
|
||||
}
|
||||
|
||||
export type UnicastOptions = {
|
||||
event: SignedEvent
|
||||
relay: string
|
||||
context: AdapterContext
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export class Unicast extends (EventEmitter as new () => TypedEmitter<UnicastEvents>) {
|
||||
status = PublishStatus.Pending
|
||||
|
||||
_unsubscriber: () => void
|
||||
_adapter: AbstractAdapter
|
||||
|
||||
constructor(readonly options: UnicastOptions) {
|
||||
super()
|
||||
|
||||
// Set up our adapter
|
||||
this._adapter = getAdapter(this.options.relay, this.options.context)
|
||||
|
||||
// Listen for Unicast result
|
||||
this._unsubscriber = on(
|
||||
this._adapter,
|
||||
AdapterEventType.Receive,
|
||||
(message: RelayMessage, url: string) => {
|
||||
if (isRelayOk(message)) {
|
||||
const [_, id, ok, detail] = message
|
||||
|
||||
|
||||
if (id !== this.options.event.id) return
|
||||
|
||||
if (ok) {
|
||||
this.status = PublishStatus.Success
|
||||
this.emit(PublishEventType.Success, id, detail)
|
||||
} else {
|
||||
this.status = PublishStatus.Failure
|
||||
this.emit(PublishEventType.Failure, id, detail)
|
||||
}
|
||||
|
||||
this.cleanup()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Set timeout
|
||||
sleep(this.options.timeout || 10_000).then(() => {
|
||||
if (this.status === PublishStatus.Pending) {
|
||||
this.status = PublishStatus.Timeout
|
||||
this.emit(PublishEventType.Timeout)
|
||||
}
|
||||
|
||||
this.cleanup()
|
||||
})
|
||||
|
||||
// Start asynchronously so the caller can set up listeners
|
||||
yieldThread().then(() => {
|
||||
this._adapter.send([ClientMessageType.Event, this.options.event])
|
||||
})
|
||||
}
|
||||
|
||||
abort = () => {
|
||||
if (this.status === PublishStatus.Pending) {
|
||||
this.status = PublishStatus.Aborted
|
||||
this.emit(PublishEventType.Aborted)
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.emit(PublishEventType.Complete)
|
||||
this.removeAllListeners()
|
||||
this._adapter.cleanup()
|
||||
this._unsubscriber()
|
||||
}
|
||||
}
|
||||
|
||||
// Multicast
|
||||
|
||||
export type MulticastEvents = {
|
||||
[PublishEventType.Success]: (id: string, detail: string, url: string) => void
|
||||
[PublishEventType.Failure]: (id: string, detail: string, url: string) => void
|
||||
[PublishEventType.Timeout]: (url: string) => void
|
||||
[PublishEventType.Aborted]: (url: string) => void
|
||||
[PublishEventType.Complete]: () => void
|
||||
}
|
||||
|
||||
export type MulticastOptions = Omit<UnicastOptions, "relay"> & {
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
export class Multicast extends (EventEmitter as new () => TypedEmitter<MulticastEvents>) {
|
||||
status: Record<string, PublishStatus>
|
||||
|
||||
_children: Unicast[] = []
|
||||
_completed = new Set<string>()
|
||||
|
||||
constructor({relays, ...options}: MulticastOptions) {
|
||||
super()
|
||||
|
||||
this.status = fromPairs(relays.map(relay => [relay, PublishStatus.Pending]))
|
||||
|
||||
for (const relay of relays) {
|
||||
const unicast = new Unicast({relay, ...options})
|
||||
|
||||
unicast.on(PublishEventType.Success, (id: string, detail: string) => {
|
||||
this.status[relay] = unicast.status
|
||||
this.emit(PublishEventType.Success, id, detail, relay)
|
||||
})
|
||||
|
||||
unicast.on(PublishEventType.Failure, (id: string, detail: string) => {
|
||||
this.status[relay] = unicast.status
|
||||
this.emit(PublishEventType.Failure, id, detail, relay)
|
||||
})
|
||||
|
||||
unicast.on(PublishEventType.Timeout, () => {
|
||||
this.status[relay] = unicast.status
|
||||
this.emit(PublishEventType.Timeout, relay)
|
||||
})
|
||||
|
||||
unicast.on(PublishEventType.Aborted, () => {
|
||||
this.status[relay] = unicast.status
|
||||
this.emit(PublishEventType.Aborted, relay)
|
||||
})
|
||||
|
||||
unicast.on(PublishEventType.Complete, () => {
|
||||
this._completed.add(relay)
|
||||
this.status[relay] = unicast.status
|
||||
|
||||
if (this._completed.size === relays.length) {
|
||||
this.emit(PublishEventType.Complete)
|
||||
this.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
this._children.push(unicast)
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
for (const child of this._children) {
|
||||
child.abort()
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
|
||||
export const unicast = (options: UnicastOptions) => new Unicast(options)
|
||||
|
||||
export const multicast = (options: MulticastOptions) => new Multicast(options)
|
||||
@@ -1,130 +0,0 @@
|
||||
import WebSocket from "isomorphic-ws"
|
||||
import EventEmitter from "events"
|
||||
import {TaskQueue} from "@welshman/lib"
|
||||
import {RelayMessage, ClientMessage} from "./message.js"
|
||||
import {TypedEmitter} from "./util.js"
|
||||
|
||||
export enum SocketStatus {
|
||||
Open = "socket:status:open",
|
||||
Opening = "socket:status:opening",
|
||||
Closing = "socket:status:closing",
|
||||
Closed = "socket:status:closed",
|
||||
Error = "socket:status:error",
|
||||
Invalid = "socket:status:invalid",
|
||||
}
|
||||
|
||||
export enum SocketEventType {
|
||||
Error = "socket:event:error",
|
||||
Status = "socket:event:status",
|
||||
Send = "socket:event:send",
|
||||
Enqueue = "socket:event:enqueue",
|
||||
Receive = "socket:event:receive",
|
||||
}
|
||||
|
||||
export type SocketEvents = {
|
||||
[SocketEventType.Error]: (error: string, url: string) => void
|
||||
[SocketEventType.Status]: (status: SocketStatus, url: string) => void
|
||||
[SocketEventType.Send]: (message: ClientMessage, url: string) => void
|
||||
[SocketEventType.Enqueue]: (message: ClientMessage, url: string) => void
|
||||
[SocketEventType.Receive]: (message: RelayMessage, url: string) => void
|
||||
}
|
||||
|
||||
export class Socket extends (EventEmitter as new () => TypedEmitter<SocketEvents>) {
|
||||
readonly status = SocketStatus.Closed
|
||||
|
||||
_ws?: WebSocket
|
||||
_sendQueue: TaskQueue<ClientMessage>
|
||||
_recvQueue: TaskQueue<RelayMessage>
|
||||
|
||||
constructor(readonly url: string) {
|
||||
super()
|
||||
|
||||
this._sendQueue = new TaskQueue<ClientMessage>({
|
||||
batchSize: 50,
|
||||
processItem: (message: ClientMessage) => {
|
||||
this._ws?.send(JSON.stringify(message))
|
||||
this.emit(SocketEventType.Send, message, this.url)
|
||||
},
|
||||
})
|
||||
|
||||
this._recvQueue = new TaskQueue<RelayMessage>({
|
||||
batchSize: 50,
|
||||
processItem: (message: RelayMessage) => {
|
||||
this.emit(SocketEventType.Receive, message, this.url)
|
||||
},
|
||||
})
|
||||
|
||||
this.on(SocketEventType.Status, (status: SocketStatus) => {
|
||||
this.status = status
|
||||
})
|
||||
}
|
||||
|
||||
open = () => {
|
||||
if (this._ws) {
|
||||
throw new Error("Attempted to open a websocket that has not been closed")
|
||||
}
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(this.url)
|
||||
this.emit(SocketEventType.Status, SocketStatus.Opening, this.url)
|
||||
|
||||
this._ws.onopen = () => {
|
||||
this.emit(SocketEventType.Status, SocketStatus.Open, this.url)
|
||||
this._sendQueue.start()
|
||||
}
|
||||
|
||||
this._ws.onerror = () => {
|
||||
this.emit(SocketEventType.Status, SocketStatus.Error, this.url)
|
||||
this._sendQueue.stop()
|
||||
this._ws = undefined
|
||||
}
|
||||
|
||||
this._ws.onclose = () => {
|
||||
this.emit(SocketEventType.Status, SocketStatus.Closed, this.url)
|
||||
this._sendQueue.stop()
|
||||
this._ws = undefined
|
||||
}
|
||||
|
||||
this._ws.onmessage = (event: any) => {
|
||||
const data = event.data as string
|
||||
|
||||
try {
|
||||
const message = JSON.parse(data)
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
this._recvQueue.push(message as RelayMessage)
|
||||
} else {
|
||||
this.emit(SocketEventType.Error, "Invalid message received", this.url)
|
||||
}
|
||||
} catch (e) {
|
||||
this.emit(SocketEventType.Error, "Invalid message received", this.url)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.emit(SocketEventType.Status, SocketStatus.Invalid, this.url)
|
||||
}
|
||||
}
|
||||
|
||||
attemptToOpen = () => {
|
||||
if (!this._ws) {
|
||||
this.open()
|
||||
}
|
||||
}
|
||||
|
||||
close = () => {
|
||||
this._ws?.close()
|
||||
this._ws = undefined
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.close()
|
||||
this._recvQueue.clear()
|
||||
this._sendQueue.clear()
|
||||
this.removeAllListeners()
|
||||
}
|
||||
|
||||
send = (message: ClientMessage) => {
|
||||
this._sendQueue.push(message)
|
||||
this.emit(SocketEventType.Enqueue, message, this.url)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import {Emitter, addToMapKey} from "@welshman/lib"
|
||||
|
||||
export class Tracker extends Emitter {
|
||||
relaysById = new Map<string, Set<string>>()
|
||||
idsByRelay = new Map<string, Set<string>>()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.setMaxListeners(100)
|
||||
}
|
||||
|
||||
getIds = (relay: string) => this.idsByRelay.get(relay) || new Set<string>()
|
||||
|
||||
getRelays = (eventId: string) => this.relaysById.get(eventId) || new Set<string>()
|
||||
|
||||
hasRelay = (eventId: string, relay: string) => this.relaysById.get(eventId)?.has(relay)
|
||||
|
||||
addRelay = (eventId: string, relay: string) => {
|
||||
let relays = this.relaysById.get(eventId)
|
||||
let ids = this.idsByRelay.get(relay)
|
||||
|
||||
if (relays?.has(relay) && ids?.has(eventId)) return
|
||||
|
||||
if (!relays) {
|
||||
relays = new Set()
|
||||
}
|
||||
|
||||
if (!ids) {
|
||||
ids = new Set()
|
||||
}
|
||||
|
||||
relays.add(relay)
|
||||
ids.add(eventId)
|
||||
|
||||
this.relaysById.set(eventId, relays)
|
||||
this.idsByRelay.set(relay, ids)
|
||||
|
||||
this.emit("update")
|
||||
}
|
||||
|
||||
removeRelay = (eventId: string, relay: string) => {
|
||||
const didDeleteRelay = this.relaysById.get(eventId)?.delete(relay)
|
||||
const didDeleteId = this.idsByRelay.get(relay)?.delete(eventId)
|
||||
|
||||
if (!didDeleteRelay && !didDeleteId) return
|
||||
|
||||
this.emit("update")
|
||||
}
|
||||
|
||||
track = (eventId: string, relay: string) => {
|
||||
const seen = this.relaysById.has(eventId)
|
||||
|
||||
this.addRelay(eventId, relay)
|
||||
|
||||
return seen
|
||||
}
|
||||
|
||||
copy = (eventId1: string, eventId2: string) => {
|
||||
for (const relay of this.getRelays(eventId1)) {
|
||||
this.addRelay(eventId2, relay)
|
||||
}
|
||||
}
|
||||
|
||||
load = (relaysById: Tracker["relaysById"]) => {
|
||||
this.relaysById.clear()
|
||||
this.idsByRelay.clear()
|
||||
|
||||
for (const [id, relays] of relaysById.entries()) {
|
||||
for (const relay of relays) {
|
||||
addToMapKey(this.relaysById, id, relay)
|
||||
addToMapKey(this.idsByRelay, relay, id)
|
||||
}
|
||||
}
|
||||
|
||||
this.emit("update")
|
||||
}
|
||||
|
||||
clear = () => {
|
||||
this.relaysById.clear()
|
||||
this.idsByRelay.clear()
|
||||
|
||||
this.emit("update")
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "../../node_modules/gts/tsconfig-google.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "build",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"lib": ["esnext", "dom"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["src/index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user