Move net2 to net, update dvm

This commit is contained in:
Jon Staab
2025-03-31 09:13:10 -07:00
parent 3081d1e6b4
commit 7389b3a6c3
67 changed files with 819 additions and 6330 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
build
normalize-url
Negentropy.ts
__tests__
__tests__
-40
View File
@@ -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)
})
})
})
-192
View File
@@ -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)
})
})
})
-256
View File
@@ -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
})
})
})
+79 -74
View File
@@ -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()
})
})
})
+193 -149
View File
@@ -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")
})
})
+123 -172
View File
@@ -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)
})
})
})
-273
View File
@@ -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"],
})
})
})
})
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { Socket, SocketEventType } from "../src/socket"
import { Relay, LOCAL_RELAY_URL, isRelayUrl } from "@welshman/util"
import { AdapterEventType, SocketAdapter, LocalAdapter, getAdapter } from "../src/adapter"
import { Pool } from "../src/pool"
import { ClientMessage, RelayMessage } from "../src/message"
import EventEmitter from "events"
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("SocketAdapter", () => {
let socket: Socket
let adapter: SocketAdapter
beforeEach(() => {
vi.useFakeTimers()
socket = new Socket('wss://test.relay')
adapter = new SocketAdapter(socket)
})
afterEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
socket.cleanup()
adapter.cleanup()
})
it("should initialize with correct socket", () => {
expect(adapter.socket).toBe(socket)
expect(adapter.urls).toEqual(["wss://test.relay"])
expect(adapter.sockets).toEqual([socket])
})
it("should forward received messages", () => {
const receiveSpy = vi.fn()
adapter.on(AdapterEventType.Receive, receiveSpy)
const message: RelayMessage = ["EVENT", "123", { id: "123", kind: 1 }]
socket.emit(SocketEventType.Receive, message, "wss://test.relay")
expect(receiveSpy).toHaveBeenCalledWith(message, "wss://test.relay")
})
it("should send messages to socket", () => {
const sendSpy = vi.spyOn(socket, 'send')
const message: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
adapter.send(message)
expect(sendSpy).toHaveBeenCalledWith(message)
})
it("should cleanup properly", () => {
const removeListenersSpy = vi.spyOn(adapter, "removeAllListeners")
adapter.cleanup()
expect(removeListenersSpy).toHaveBeenCalled()
})
})
describe("LocalAdapter", () => {
let relay: Relay & EventEmitter
let adapter: LocalAdapter
beforeEach(() => {
const mockRelay = new EventEmitter()
Object.assign(mockRelay, {
send: vi.fn(),
removeAllListeners: vi.fn()
})
relay = mockRelay as unknown as Relay & EventEmitter
adapter = new LocalAdapter(relay)
})
afterEach(() => {
adapter.cleanup()
vi.clearAllMocks()
})
it("should initialize with correct relay", () => {
expect(adapter.relay).toBe(relay)
expect(adapter.urls).toEqual([LOCAL_RELAY_URL])
expect(adapter.sockets).toEqual([])
})
it("should forward received messages", () => {
const receiveSpy = vi.fn()
adapter.on(AdapterEventType.Receive, receiveSpy)
const message: RelayMessage = ["EVENT", "123", { id: "123", kind: 1 }]
relay.emit("*", ...message)
expect(receiveSpy).toHaveBeenCalledWith(message, LOCAL_RELAY_URL)
})
it("should send messages to relay", () => {
const message: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
adapter.send(message)
expect(relay.send).toHaveBeenCalledWith("EVENT", message[1])
})
it("should cleanup properly", () => {
const removeListenersSpy = vi.spyOn(adapter, "removeAllListeners")
adapter.cleanup()
expect(removeListenersSpy).toHaveBeenCalled()
})
})
describe("getAdapter", () => {
let pool: Pool
let relay: Relay
beforeEach(() => {
pool = new Pool()
relay = new Relay()
pool.get = vi.fn().mockReturnValue(new Socket("wss://test.relay"))
})
afterEach(() => {
vi.clearAllMocks()
})
it("should return LocalAdapter for local relay URL", () => {
const url = LOCAL_RELAY_URL
const adapter = getAdapter(url, { relay })
expect(adapter).toBeInstanceOf(LocalAdapter)
})
it("should return SocketAdapter for remote relay URL", () => {
const url = "wss://test.relay"
const adapter = getAdapter(url, { pool })
expect(adapter).toBeInstanceOf(SocketAdapter)
})
it("should throw error for invalid relay URL", () => {
expect(() => getAdapter("invalid-url", {})).toThrow("Invalid relay url invalid-url")
})
it("should throw error for local relay URL without relay context", () => {
const url = LOCAL_RELAY_URL
expect(() => getAdapter(url, {})).toThrow(`Unable to get local relay for ${url}`)
})
it("should throw error for remote relay URL without pool context", () => {
const url = "wss://test.relay"
expect(() => getAdapter(url, {})).toThrow(`Unable to get socket for ${url}`)
})
it("should use custom adapter if provided", () => {
const customAdapter = new SocketAdapter(new Socket("wss://test.relay"))
const getCustomAdapter = vi.fn().mockReturnValue(customAdapter)
const url = "wss://test.relay"
const adapter = getAdapter(url, { getAdapter: getCustomAdapter })
expect(getCustomAdapter).toHaveBeenCalledWith(url, { getAdapter: getCustomAdapter })
expect(adapter).toBe(customAdapter)
})
})
+204
View File
@@ -0,0 +1,204 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { Socket, SocketStatus, SocketEventType } from "../src/socket"
import { makeEvent, CLIENT_AUTH } from "@welshman/util"
import { Nip01Signer } from "@welshman/signer"
import { AuthState, AuthStatus, AuthStateEventType, AuthManager, makeAuthEvent } from "../src/auth"
import EventEmitter from "events"
import { 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('auth', () => {
let socket: Socket
let authManager: AuthManager
let sign = vi.fn(Nip01Signer.ephemeral().sign)
beforeEach(() => {
socket = new Socket('wss://test.relay')
authManager = new AuthManager(socket, { sign })
})
afterEach(() => {
vi.clearAllMocks()
socket.cleanup()
authManager.cleanup()
})
describe("AuthState", () => {
it("should initialize with None status", () => {
expect(authManager.state.status).toBe(AuthStatus.None)
})
it("should handle AUTH message from relay", () => {
const message: RelayMessage = ["AUTH", "challenge123"]
socket.emit(SocketEventType.Receive, message)
expect(authManager.state.challenge).toBe("challenge123")
expect(authManager.state.status).toBe(AuthStatus.Requested)
})
it("should handle successful OK message", () => {
authManager.state.request = "request123"
const message: RelayMessage = ["OK", "request123", true, "success"]
socket.emit(SocketEventType.Receive, message)
expect(authManager.state.status).toBe(AuthStatus.Ok)
expect(authManager.state.details).toBe("success")
})
it("should handle failed OK message", () => {
authManager.state.request = "request123"
const message: RelayMessage = ["OK", "request123", false, "forbidden"]
socket.emit(SocketEventType.Receive, message)
expect(authManager.state.status).toBe(AuthStatus.Forbidden)
expect(authManager.state.details).toBe("forbidden")
})
it("should ignore OK messages for different requests", () => {
authManager.state.request = "request123"
const message: RelayMessage = ["OK", "different-request", true, "success"]
socket.emit(SocketEventType.Receive, message)
expect(authManager.state.status).toBe(AuthStatus.None)
})
it("should handle client AUTH message", () => {
const message: RelayMessage = ["AUTH", { id: "123", kind: CLIENT_AUTH }]
socket.emit(SocketEventType.Enqueue, message)
expect(authManager.state.status).toBe(AuthStatus.PendingResponse)
})
it("should reset state on socket close", () => {
authManager.state.challenge = "challenge123"
authManager.state.request = "request123"
authManager.state.details = "details"
authManager.state.status = AuthStatus.PendingResponse
socket.emit(SocketEventType.Status, SocketStatus.Closed)
expect(authManager.state.challenge).toBeUndefined()
expect(authManager.state.request).toBeUndefined()
expect(authManager.state.details).toBeUndefined()
expect(authManager.state.status).toBe(AuthStatus.None)
})
it("should emit status changes", () => {
const statusSpy = vi.fn()
authManager.state.on(AuthStateEventType.Status, statusSpy)
authManager.state.setStatus(AuthStatus.Requested)
expect(statusSpy).toHaveBeenCalledWith(AuthStatus.Requested)
})
it("should cleanup properly", () => {
const removeListenersSpy = vi.spyOn(authManager.state, "removeAllListeners")
authManager.state.cleanup()
expect(removeListenersSpy).toHaveBeenCalled()
})
})
describe("AuthManager", () => {
it("should create AuthState instance", () => {
expect(authManager.state).toBeInstanceOf(AuthState)
})
it("should respond automatically when eager is true", () => {
const respondSpy = vi.spyOn(AuthManager.prototype, "respond")
const eagerManager = new AuthManager(socket, { sign, eager: true })
socket.emit(SocketEventType.Receive, ["AUTH", "challenge123"])
expect(respondSpy).toHaveBeenCalled()
})
it("should not respond automatically when eager is false", () => {
const respondSpy = vi.spyOn(AuthManager.prototype, "respond")
socket.emit(SocketEventType.Receive, ["AUTH", "challenge123"])
expect(respondSpy).not.toHaveBeenCalled()
})
describe("respond", () => {
it("should throw error if no challenge", async () => {
await expect(authManager.respond()).rejects.toThrow("Attempted to authenticate with no challenge")
})
it("should throw error if status is not Requested", async () => {
authManager.state.challenge = "challenge123"
authManager.state.status = AuthStatus.PendingSignature
await expect(authManager.respond()).rejects.toThrow("Attempted to authenticate when auth is already auth:status:pending_signature")
})
it("should handle successful sign", async () => {
const sendSpy = vi.spyOn(socket, 'send')
authManager.state.challenge = "challenge123"
authManager.state.status = AuthStatus.Requested
const signedEvent = { id: "signed-event-id", kind: CLIENT_AUTH }
sign.mockResolvedValue(signedEvent)
await authManager.respond()
expect(authManager.state.request).toBe("signed-event-id")
expect(sendSpy).toHaveBeenCalledWith(["AUTH", signedEvent])
})
it("should handle denied signature", async () => {
const sendSpy = vi.spyOn(socket, 'send')
authManager.state.challenge = "challenge123"
authManager.state.status = AuthStatus.Requested
sign.mockResolvedValue(null)
await authManager.respond()
expect(authManager.state.status).toBe(AuthStatus.DeniedSignature)
expect(sendSpy).not.toHaveBeenCalled()
})
})
describe("attempt", () => {
it("should attempt to open socket", async () => {
const attemptToOpenSpy = vi.spyOn(socket, 'attemptToOpen')
await authManager.attempt()
expect(attemptToOpenSpy).toHaveBeenCalled()
})
it("should wait for challenge", async () => {
const waitForChallengeSpy = vi.spyOn(authManager, "waitForChallenge")
await authManager.attempt()
expect(waitForChallengeSpy).toHaveBeenCalled()
})
it("should respond if challenge received", async () => {
const respondSpy = vi.spyOn(authManager, "respond")
authManager.state.challenge = "challenge123"
authManager.state.status = AuthStatus.Requested
await authManager.attempt()
expect(respondSpy).toHaveBeenCalled()
})
it("should wait for resolution", async () => {
const waitForResolutionSpy = vi.spyOn(authManager, "waitForResolution")
await authManager.attempt()
expect(waitForResolutionSpy).toHaveBeenCalled()
})
})
})
})
+466
View File
@@ -0,0 +1,466 @@
import { AUTH_JOIN } from "@welshman/util"
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { Socket, SocketStatus, SocketEventType } from "../src/socket"
import { AuthStatus, AuthStateEventType } from "../src/auth"
import {
socketPolicySendWhenOpen,
socketPolicyDeferOnAuth,
socketPolicyRetryAuthRequired,
socketPolicyConnectOnSend,
socketPolicyCloseOnTimeout,
socketPolicyReopenActive
} from "../src/policy"
import { ClientMessage, RelayMessage } from "../src/message"
// Hoist mock definition to top level
const mockWs = vi.hoisted(() => ({
close: vi.fn(),
send: vi.fn(),
onopen: vi.fn(),
onclose: null,
onerror: null,
onmessage: null,
}))
// Mock the WebSocket module
vi.mock('isomorphic-ws', () => ({
default: mockWs
}))
describe('policy', () => {
let socket: Socket
beforeEach(() => {
vi.useFakeTimers()
socket = new Socket("wss://test.relay")
})
afterEach(() => {
socket.cleanup()
vi.useRealTimers()
vi.clearAllMocks()
})
describe("socketPolicyDeferOnAuth", () => {
it("should buffer messages when not authenticated", () => {
const cleanup = socketPolicyDeferOnAuth(socket)
const removeSpy = vi.spyOn(socket._sendQueue, 'remove')
socket.emit(SocketEventType.Receive, ["AUTH", "challenge"])
// Regular event should be buffered
const event: ClientMessage = ["EVENT", { id: "123"}]
socket.send(event)
expect(removeSpy).toHaveBeenCalledWith(event)
// Auth event should not be buffered
const authEvent: ClientMessage = ["AUTH", { id: "456" }]
socket.send(authEvent)
expect(removeSpy).not.toHaveBeenCalledWith(authEvent)
// Auth join event should not be buffered
const joinEvent: ClientMessage = ["EVENT", { id: "789", kind: AUTH_JOIN }]
socket.send(joinEvent)
expect(removeSpy).not.toHaveBeenCalledWith(joinEvent)
cleanup()
})
it("should send buffered messages when auth succeeds", () => {
const cleanup = socketPolicyDeferOnAuth(socket)
const sendSpy = vi.spyOn(socket, 'send')
socket.emit(SocketEventType.Receive, ["AUTH", "challenge"])
// Buffer some messages
const event1: ClientMessage = ["EVENT", { id: "123"}]
const event2: ClientMessage = ["EVENT", { id: "456"}]
socket.send(event1)
socket.send(event2)
// Auth succeeds
socket.send(["AUTH", { id: "auth" }])
socket.emit(AuthStateEventType.Status, AuthStatus.Ok)
expect(sendSpy).toHaveBeenCalledWith(event1)
expect(sendSpy).toHaveBeenCalledWith(event2)
cleanup()
})
it("should handle CLOSE messages properly", () => {
const cleanup = socketPolicyDeferOnAuth(socket)
const removeSpy = vi.spyOn(socket._sendQueue, 'remove')
socket.emit(SocketEventType.Receive, ["AUTH", "challenge"])
// Buffer a REQ message
const req: ClientMessage = ["REQ", "123", { kinds: [1] }]
socket.send(req)
// Send CLOSE for buffered REQ
const close: ClientMessage = ["CLOSE", "123"]
socket.send(close)
// Both messages should be removed
expect(removeSpy).toHaveBeenCalledWith(req)
expect(removeSpy).toHaveBeenCalledWith(close)
cleanup()
})
})
describe("socketPolicyRetryAuthRequired", () => {
it("should retry events once when auth-required", () => {
const cleanup = socketPolicyRetryAuthRequired(socket)
const sendSpy = vi.spyOn(socket, 'send')
// Send an event
const event: ClientMessage = ["EVENT", { id: "123", kind: 1, content: "", tags: [], pubkey: "", sig: "" }]
socket.emit(SocketEventType.Send, event)
// Receive auth-required rejection
socket.emit(SocketEventType.Receive, ["OK", "123", false, "auth-required: need to auth first"])
// Should retry the event
expect(sendSpy).toHaveBeenCalledWith(event)
// Receive another auth-required rejection
socket.emit(SocketEventType.Receive, ["OK", "123", false, "auth-required: need to auth first"])
// Should not retry again
expect(sendSpy).toHaveBeenCalledTimes(1)
cleanup()
})
it("should retry REQ once when auth-required", () => {
const cleanup = socketPolicyRetryAuthRequired(socket)
const sendSpy = vi.spyOn(socket, 'send')
// Send a REQ
const req: ClientMessage = ["REQ", "123", { kinds: [1] }]
socket.emit(SocketEventType.Send, req)
// Receive auth-required rejection via CLOSED
socket.emit(SocketEventType.Receive, ["CLOSED", "123", "auth-required: need to auth first"])
// Should retry the request
expect(sendSpy).toHaveBeenCalledWith(req)
// Receive another auth-required rejection
socket.emit(SocketEventType.Receive, ["CLOSED", "123", "auth-required: need to auth first"])
// Should not retry again
expect(sendSpy).toHaveBeenCalledTimes(1)
cleanup()
})
it("should not retry AUTH_JOIN events", () => {
const cleanup = socketPolicyRetryAuthRequired(socket)
const sendSpy = vi.spyOn(socket, 'send')
// Send an AUTH_JOIN event
const event: ClientMessage = ["EVENT", { id: "123", kind: AUTH_JOIN, content: "", tags: [], pubkey: "", sig: "" }]
socket.emit(SocketEventType.Send, event)
// Receive auth-required rejection
socket.emit(SocketEventType.Receive, ["OK", "123", false, "auth-required: need to auth first"])
// Should not retry AUTH_JOIN events
expect(sendSpy).not.toHaveBeenCalled()
cleanup()
})
it("should clear pending messages on successful response", () => {
const cleanup = socketPolicyRetryAuthRequired(socket)
const sendSpy = vi.spyOn(socket, 'send')
// Send an event
const event: ClientMessage = ["EVENT", { id: "123", kind: 1, content: "", tags: [], pubkey: "", sig: "" }]
socket.emit(SocketEventType.Send, event)
// Receive successful response
socket.emit(SocketEventType.Receive, ["OK", "123", true, ""])
// Receive auth-required rejection (should not trigger retry since message was cleared)
socket.emit(SocketEventType.Receive, ["OK", "123", false, "auth-required: need to auth first"])
// Should not retry
expect(sendSpy).not.toHaveBeenCalled()
cleanup()
})
})
describe("socketPolicyConnectOnSend", () => {
it("should open socket on send when closed", () => {
const cleanup = socketPolicyConnectOnSend(socket)
const openSpy = vi.spyOn(socket, 'open')
// Socket starts closed
socket.emit(SocketEventType.Status, SocketStatus.Closed)
// Send a message
const event: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
socket.emit(SocketEventType.Enqueue, event)
// Should open the socket
expect(openSpy).toHaveBeenCalled()
cleanup()
})
it("should not open socket if already open", () => {
const cleanup = socketPolicyConnectOnSend(socket)
const openSpy = vi.spyOn(socket, 'open')
// Socket is open
socket.emit(SocketEventType.Status, SocketStatus.Open)
// Send a message
const event: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
socket.emit(SocketEventType.Enqueue, event)
// Should not try to open the socket
expect(openSpy).not.toHaveBeenCalled()
cleanup()
})
it("should not open socket if there was a recent error", () => {
const cleanup = socketPolicyConnectOnSend(socket)
const openSpy = vi.spyOn(socket, 'open')
// Socket has an error
socket.emit(SocketEventType.Status, SocketStatus.Error)
socket.emit(SocketEventType.Status, SocketStatus.Closed)
// Send a message
const event: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
socket.emit(SocketEventType.Enqueue, event)
// Should not try to open the socket due to recent error
expect(openSpy).not.toHaveBeenCalled()
// Advance time past the error timeout
vi.advanceTimersByTime(31000)
// Send another message
socket.emit(SocketEventType.Enqueue, event)
// Now it should try to open
expect(openSpy).toHaveBeenCalled()
cleanup()
})
})
describe("socketPolicyCloseOnTimeout", () => {
it("should close socket after 30 seconds of inactivity", async () => {
const cleanup = socketPolicyCloseOnTimeout(socket)
const closeSpy = vi.spyOn(socket, 'close')
// Set socket as open
socket.emit(SocketEventType.Status, SocketStatus.Open)
// Advance time past the timeout
await vi.advanceTimersByTimeAsync(35000)
// Socket should be closed
expect(closeSpy).toHaveBeenCalled()
cleanup()
})
it("should reset timer on send activity", () => {
const cleanup = socketPolicyCloseOnTimeout(socket)
const closeSpy = vi.spyOn(socket, 'close')
// Set socket as open
socket.emit(SocketEventType.Status, SocketStatus.Open)
// Advance time partially
vi.advanceTimersByTime(20000)
// Send a message
socket.emit(SocketEventType.Send, ["EVENT", { id: "123" }])
// Advance time partially again
vi.advanceTimersByTime(20000)
// Socket should not be closed yet
expect(closeSpy).not.toHaveBeenCalled()
// Advance remaining time
vi.advanceTimersByTime(11000)
// Now socket should be closed
expect(closeSpy).toHaveBeenCalled()
cleanup()
})
it("should reset timer on receive activity", () => {
const cleanup = socketPolicyCloseOnTimeout(socket)
const closeSpy = vi.spyOn(socket, 'close')
// Set socket as open
socket.emit(SocketEventType.Status, SocketStatus.Open)
// Advance time partially
vi.advanceTimersByTime(20000)
// Receive a message
socket.emit(SocketEventType.Receive, ["EVENT", "123", { id: "123" }])
// Advance time partially again
vi.advanceTimersByTime(20000)
// Socket should not be closed yet
expect(closeSpy).not.toHaveBeenCalled()
// Advance remaining time
vi.advanceTimersByTime(11000)
// Now socket should be closed
expect(closeSpy).toHaveBeenCalled()
cleanup()
})
it("should not close socket if not open", () => {
const cleanup = socketPolicyCloseOnTimeout(socket)
const closeSpy = vi.spyOn(socket, 'close')
// Set socket as closed
socket.emit(SocketEventType.Status, SocketStatus.Closed)
// Advance time past the timeout
vi.advanceTimersByTime(31000)
// Socket should not be closed
expect(closeSpy).not.toHaveBeenCalled()
cleanup()
})
})
describe("socketPolicyReopenActive", () => {
it("should reopen socket when closed with pending messages", async () => {
const cleanup = socketPolicyReopenActive(socket)
const sendSpy = vi.spyOn(socket, 'send')
// Send an event that will be pending
const event: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
socket.emit(SocketEventType.Send, event)
// Socket closes
socket.emit(SocketEventType.Status, SocketStatus.Closed)
// Advance past the reopen delay
await vi.advanceTimersByTimeAsync(30000)
// Should resend the pending event
expect(sendSpy).toHaveBeenCalledWith(event)
cleanup()
})
it("should reopen socket when closed with pending requests", async () => {
const cleanup = socketPolicyReopenActive(socket)
const sendSpy = vi.spyOn(socket, 'send')
// Send a request that will be pending
const req: ClientMessage = ["REQ", "123", { kinds: [1] }]
socket.emit(SocketEventType.Send, req)
// Socket closes
socket.emit(SocketEventType.Status, SocketStatus.Closed)
// Advance past the reopen delay
await vi.advanceTimersByTimeAsync(30000)
// Should resend the pending request
expect(sendSpy).toHaveBeenCalledWith(req)
cleanup()
})
it("should not reopen socket immediately after previous open", async () => {
const cleanup = socketPolicyReopenActive(socket)
const sendSpy = vi.spyOn(socket, 'send')
// Send an event that will be pending
const event: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
socket.emit(SocketEventType.Send, event)
// Socket opens then closes quickly
socket.emit(SocketEventType.Status, SocketStatus.Open)
socket.emit(SocketEventType.Status, SocketStatus.Closed)
// Advance a short time
vi.advanceTimersByTime(5000)
// Should not resend yet to prevent flapping
expect(sendSpy).not.toHaveBeenCalled()
// Advance remaining time
await vi.advanceTimersByTimeAsync(25000)
// Now should resend
expect(sendSpy).toHaveBeenCalledWith(event)
cleanup()
})
it("should remove pending messages when they complete", () => {
const cleanup = socketPolicyReopenActive(socket)
const sendSpy = vi.spyOn(socket, 'send')
// Send an event that will be pending
const event: ClientMessage = ["EVENT", { id: "123", kind: 1 }]
socket.emit(SocketEventType.Send, event)
// Event completes successfully
socket.emit(SocketEventType.Receive, ["OK", "123", true])
// Socket closes
socket.emit(SocketEventType.Status, SocketStatus.Closed)
// Advance past the reopen delay
vi.advanceTimersByTime(30000)
// Should not resend since event was completed
expect(sendSpy).not.toHaveBeenCalled()
cleanup()
})
it("should remove pending messages when closed", () => {
const cleanup = socketPolicyReopenActive(socket)
const sendSpy = vi.spyOn(socket, 'send')
// Send a request that will be pending
const req: ClientMessage = ["REQ", "123", { kinds: [1] }]
socket.emit(SocketEventType.Send, req)
// Send close for the request
const close: ClientMessage = ["CLOSE", "123"]
socket.emit(SocketEventType.Send, close)
// Socket closes
socket.emit(SocketEventType.Status, SocketStatus.Closed)
// Advance past the reopen delay
vi.advanceTimersByTime(30000)
// Should not resend since request was closed
expect(sendSpy).not.toHaveBeenCalled()
cleanup()
})
})
})
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { Nip01Signer } from '@welshman/signer'
import { LOCAL_RELAY_URL, makeEvent } from '@welshman/util'
import { ClientMessageType, RelayMessage } from "../src/message"
import { AdapterContext, AbstractAdapter, AdapterEventType } from "../src/adapter"
import { unireq, multireq, RequestEventType } from "../src/request"
import { Tracker } from "../src/tracker"
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("Unireq", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("everything basically works", async () => {
const sendSpy = vi.fn()
const adapter = new MockAdapter('1', sendSpy)
const req = unireq({
relay: 'whatever',
filter: {kinds: [1]},
context: {getAdapter: () => adapter},
autoClose: true,
})
const duplicateSpy = vi.fn()
const invalidSpy = vi.fn()
const filteredSpy = vi.fn()
const eventSpy = vi.fn()
const eoseSpy = vi.fn()
const closeSpy = vi.fn()
req.on(RequestEventType.Duplicate, duplicateSpy)
req.on(RequestEventType.Invalid, invalidSpy)
req.on(RequestEventType.Filtered, filteredSpy)
req.on(RequestEventType.Event, eventSpy)
req.on(RequestEventType.Eose, eoseSpy)
req.on(RequestEventType.Close, closeSpy)
await vi.runAllTimers()
expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Req, req._id, {kinds: [1]}])
const signer = Nip01Signer.ephemeral()
const event1 = await signer.sign(makeEvent(1))
const event2 = await signer.sign(makeEvent(7))
const event3 = makeEvent(1)
adapter.receive(["EVENT", req._id, event1])
adapter.receive(["EVENT", req._id, event2])
adapter.receive(["EVENT", req._id, event1])
adapter.receive(["EVENT", req._id, event3])
await vi.runAllTimers()
expect(duplicateSpy).toHaveBeenCalledWith(event1)
expect(filteredSpy).toHaveBeenCalledWith(event2)
expect(invalidSpy).toHaveBeenCalledWith(event3)
expect(eventSpy).toHaveBeenCalledWith(event1)
expect(eoseSpy).toHaveBeenCalledTimes(0)
adapter.receive(["EOSE", req._id])
expect(eoseSpy).toHaveBeenCalledTimes(1)
expect(closeSpy).toHaveBeenCalledTimes(1)
})
})
describe("Multireq", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("everything basically works", async () => {
const send1Spy = vi.fn()
const adapter1 = new MockAdapter('1', send1Spy)
const send2Spy = vi.fn()
const adapter2 = new MockAdapter('2', send2Spy)
const req = multireq({
autoClose: true,
relays: ['1', '2'],
filter: {kinds: [1]},
context: {
getAdapter: (url: string) => url === '1' ? adapter1 : adapter2
},
})
const duplicateSpy = vi.fn()
const invalidSpy = vi.fn()
const filteredSpy = vi.fn()
const eventSpy = vi.fn()
const eoseSpy = vi.fn()
const closeSpy = vi.fn()
req.on(RequestEventType.Duplicate, duplicateSpy)
req.on(RequestEventType.Invalid, invalidSpy)
req.on(RequestEventType.Filtered, filteredSpy)
req.on(RequestEventType.Event, eventSpy)
req.on(RequestEventType.Eose, eoseSpy)
req.on(RequestEventType.Close, closeSpy)
await vi.runAllTimers()
expect(send1Spy).toHaveBeenCalledWith([ClientMessageType.Req, req._children[0]._id, {kinds: [1]}])
expect(send2Spy).toHaveBeenCalledWith([ClientMessageType.Req, req._children[1]._id, {kinds: [1]}])
const signer = Nip01Signer.ephemeral()
const event1 = await signer.sign(makeEvent(1))
const event2 = await signer.sign(makeEvent(7))
const event3 = makeEvent(1)
const event4 = await signer.sign(makeEvent(1))
adapter1.receive(["EVENT", req._children[0]._id, event1])
adapter1.receive(["EVENT", req._children[0]._id, event2])
adapter1.receive(["EVENT", req._children[0]._id, event3])
adapter2.receive(["EVENT", req._children[1]._id, event1])
adapter2.receive(["EVENT", req._children[1]._id, event4])
await vi.runAllTimers()
expect(duplicateSpy).toHaveBeenCalledWith(event1, '2')
expect(filteredSpy).toHaveBeenCalledWith(event2, '1')
expect(invalidSpy).toHaveBeenCalledWith(event3, '1')
expect(eventSpy).toHaveBeenCalledWith(event1, '1')
expect(eoseSpy).toHaveBeenCalledTimes(0)
adapter1.receive(["EOSE", req._children[0]._id])
adapter2.receive(["EOSE", req._children[1]._id])
expect(eoseSpy).toHaveBeenCalledTimes(2)
expect(closeSpy).toHaveBeenCalledTimes(1)
})
})
@@ -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()
})
})
})
-193
View File
@@ -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()
})
})
})
+3 -2
View File
@@ -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"
}
}
-70
View File
@@ -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()
}
}
-120
View File
@@ -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))
}
}
-11
View File
@@ -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",
}
-57
View File
@@ -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)
}
}
-112
View File
@@ -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])
}
})
}
}
-98
View File
@@ -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
}
-70
View File
@@ -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,
})
-154
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
-390
View File
@@ -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
}
-208
View File
@@ -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)
}
+112
View File
@@ -0,0 +1,112 @@
import EventEmitter from "events"
import {call, on} from "@welshman/lib"
import {Relay, LOCAL_RELAY_URL, isRelayUrl} from "@welshman/util"
import {RelayMessage, ClientMessage} from "./message.js"
import {Socket, SocketEventType} from "./socket.js"
import {TypedEmitter, Unsubscriber} from "./util.js"
import {Pool} from "./pool.js"
export enum AdapterEventType {
Receive = "adapter:event:receive",
}
export type AdapterEvents = {
[AdapterEventType.Receive]: (message: RelayMessage, url: string) => void
}
export abstract class AbstractAdapter extends (EventEmitter as new () => TypedEmitter<AdapterEvents>) {
_unsubscribers: Unsubscriber[] = []
abstract urls: string[]
abstract sockets: Socket[]
abstract send(message: ClientMessage): void
cleanup() {
this.removeAllListeners()
this._unsubscribers.splice(0).forEach(call)
}
}
export class SocketAdapter extends AbstractAdapter {
constructor(readonly socket: Socket) {
super()
this._unsubscribers.push(
on(socket, SocketEventType.Receive, (message: RelayMessage, url: string) => {
this.emit(AdapterEventType.Receive, message, url)
}),
)
}
get sockets() {
return [this.socket]
}
get urls() {
return [this.socket.url]
}
send(message: ClientMessage) {
this.socket.send(message)
}
}
export class LocalAdapter extends AbstractAdapter {
constructor(readonly relay: Relay) {
super()
this._unsubscribers.push(
on(relay, "*", (...message: RelayMessage) => {
this.emit(AdapterEventType.Receive, message, LOCAL_RELAY_URL)
}),
)
}
get sockets() {
return []
}
get urls() {
return [LOCAL_RELAY_URL]
}
send(message: ClientMessage) {
const [type, ...rest] = message
this.relay.send(type, ...rest)
}
}
export type AdapterContext = {
pool?: Pool
relay?: Relay
getAdapter?: (url: string, context: AdapterContext) => AbstractAdapter
}
export const getAdapter = (url: string, context: AdapterContext) => {
if (context.getAdapter) {
const adapter = context.getAdapter(url, context)
if (adapter) {
return adapter
}
}
if (url === LOCAL_RELAY_URL) {
if (!context.relay) {
throw new Error(`Unable to get local relay for ${url}`)
}
return new LocalAdapter(context.relay)
}
if (isRelayUrl(url)) {
if (!context.pool) {
throw new Error(`Unable to get socket for ${url}`)
}
return new SocketAdapter(context.pool.get(url))
}
throw new Error(`Invalid relay url ${url}`)
}
+184
View File
@@ -0,0 +1,184 @@
import EventEmitter from "events"
import {on, call, sleep} from "@welshman/lib"
import type {SignedEvent, StampedEvent} from "@welshman/util"
import {makeEvent, CLIENT_AUTH} from "@welshman/util"
import {isRelayAuth, isClientAuth, isRelayOk, RelayMessage} from "./message.js"
import {Socket, SocketStatus, SocketEventType} from "./socket.js"
import {TypedEmitter, Unsubscriber} from "./util.js"
export const makeAuthEvent = (url: string, challenge: string) =>
makeEvent(CLIENT_AUTH, {
tags: [
["relay", url],
["challenge", challenge],
],
})
export enum AuthStatus {
None = "auth:status:none",
Requested = "auth:status:requested",
PendingSignature = "auth:status:pending_signature",
DeniedSignature = "auth:status:denied_signature",
PendingResponse = "auth:status:pending_response",
Forbidden = "auth:status:forbidden",
Ok = "auth:status:ok",
}
export type AuthResult = {
ok: boolean
reason?: string
}
export enum AuthStateEventType {
Status = "auth:event:status",
}
export type AuthStateEvents = {
[AuthStateEventType.Status]: (status: AuthStatus) => void
}
export class AuthState extends (EventEmitter as new () => TypedEmitter<AuthStateEvents>) {
challenge: string | undefined
request: string | undefined
details: string | undefined
status = AuthStatus.None
_unsubscribers: Unsubscriber[] = []
constructor(readonly socket: Socket) {
super()
this._unsubscribers.push(
on(socket, SocketEventType.Receive, (message: RelayMessage) => {
if (isRelayOk(message)) {
const [_, id, ok, details] = message
if (id === this.request) {
this.details = details
if (ok) {
this.setStatus(AuthStatus.Ok)
} else {
this.setStatus(AuthStatus.Forbidden)
}
}
}
if (isRelayAuth(message)) {
const [_, challenge] = message
this.challenge = challenge
this.request = undefined
this.details = undefined
this.setStatus(AuthStatus.Requested)
}
}),
on(socket, SocketEventType.Enqueue, (message: RelayMessage) => {
if (isClientAuth(message)) {
this.setStatus(AuthStatus.PendingResponse)
}
}),
on(socket, SocketEventType.Status, (status: SocketStatus) => {
if (status === SocketStatus.Closed) {
this.challenge = undefined
this.request = undefined
this.details = undefined
this.setStatus(AuthStatus.None)
}
}),
)
}
setStatus(status: AuthStatus) {
this.status = status
this.emit(AuthStateEventType.Status, status)
}
cleanup() {
this.removeAllListeners()
this._unsubscribers.forEach(call)
}
}
export type AuthManagerOptions = {
sign: (event: StampedEvent) => Promise<SignedEvent>
eager?: boolean
}
export class AuthManager {
state: AuthState
constructor(
readonly socket: Socket,
readonly options: AuthManagerOptions,
) {
this.state = new AuthState(socket)
this.state.on(AuthStateEventType.Status, (status: string) => {
if (status === AuthStatus.Requested && options.eager) {
this.respond()
}
})
}
async waitFor(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)))
}
}
async waitForChallenge(timeout = 300) {
await this.waitFor(() => Boolean(this.state.challenge), timeout)
}
async waitForResolution(timeout = 300) {
await this.waitFor(
() =>
[AuthStatus.None, AuthStatus.DeniedSignature, AuthStatus.Forbidden, AuthStatus.Ok].includes(
this.state.status,
),
timeout,
)
}
async attempt(timeout = 300) {
await this.socket.attemptToOpen()
await this.waitForChallenge(Math.ceil(timeout / 2))
if (this.state.status === AuthStatus.Requested) {
await this.respond()
}
await this.waitForResolution(Math.ceil(timeout / 2))
}
async respond() {
if (!this.state.challenge) {
throw new Error("Attempted to authenticate with no challenge")
}
if (this.state.status !== AuthStatus.Requested) {
throw new Error(`Attempted to authenticate when auth is already ${this.state.status}`)
}
this.state.setStatus(AuthStatus.PendingSignature)
const template = makeAuthEvent(this.socket.url, this.state.challenge)
const event = await this.options.sign(template)
if (event) {
this.state.request = event.id
this.socket.send(["AUTH", event])
} else {
this.state.setStatus(AuthStatus.DeniedSignature)
}
}
cleanup() {
this.state.cleanup()
}
}
+244
View File
@@ -0,0 +1,244 @@
import {EventEmitter} from "events"
import {on, sleep, randomId, groupBy, pushToMapKey, inc, flatten, chunk} from "@welshman/lib"
import {SignedEvent, Filter} from "@welshman/util"
import {TypedEmitter} from "./util.js"
import {
RelayMessage,
isRelayNegErr,
isRelayNegMsg,
RelayMessageType,
ClientMessageType,
} from "./message.js"
import {getAdapter, AdapterContext, AbstractAdapter, AdapterEventType} from "./adapter.js"
import {Negentropy, NegentropyStorageVector} from "./negentropy.js"
import {unireq, RequestEventType} from "./request.js"
import {multicast, PublishEventType} from "./publish.js"
export enum DifferenceEventType {
Message = "difference:event:message",
Error = "difference:event:error",
Close = "difference:event:close",
}
export type DifferenceEvents = {
[DifferenceEventType.Message]: (payload: {have: string[]; need: string[]}, url: string) => void
[DifferenceEventType.Error]: (error: string, url: string) => void
[DifferenceEventType.Close]: () => void
}
export type DifferenceOptions = {
relay: string
filter: Filter
events: SignedEvent[]
context: AdapterContext
}
export class Difference extends (EventEmitter as new () => TypedEmitter<DifferenceEvents>) {
have = new Set<string>()
need = new Set<string>()
_id = `NEG-${randomId().slice(0, 8)}`
_unsubscriber: () => void
_adapter: AbstractAdapter
_closed = false
constructor(readonly options: DifferenceOptions) {
super()
// Set up our adapter
this._adapter = getAdapter(this.options.relay, this.options.context)
// Set up negentropy
const storage = new NegentropyStorageVector()
const neg = new Negentropy(storage, 50_000)
for (const event of this.options.events) {
storage.insert(event.created_at, event.id)
}
storage.seal()
// Add listeners
this._unsubscriber = on(
this._adapter,
AdapterEventType.Receive,
async (message: RelayMessage, url: string) => {
if (isRelayNegMsg(message)) {
const [_, negid, msg] = message
if (negid === this._id) {
const [newMsg, have, need] = await neg.reconcile(msg)
for (const id of have) {
this.have.add(id)
}
for (const id of need) {
this.need.add(id)
}
this.emit(DifferenceEventType.Message, {have, need}, url)
if (newMsg) {
this._adapter.send([RelayMessageType.NegMsg, this._id, newMsg])
}
}
}
if (isRelayNegErr(message)) {
const [_, negid, msg] = message
if (negid === this._id) {
this.emit(DifferenceEventType.Error, msg, url)
}
}
},
)
neg.initiate().then((msg: string) => {
this._adapter.send([ClientMessageType.NegOpen, this._id, this.options.filter, msg])
})
}
close() {
if (this._closed) return
this._adapter.send([ClientMessageType.NegClose, this._id])
this.emit(DifferenceEventType.Close)
this.removeAllListeners()
this._adapter.cleanup()
this._unsubscriber()
this._closed = true
}
}
// diff is a shortcut for diffing multiple filters across multiple relays
export type DiffOptions = {
relays: string[]
filters: Filter[]
events: SignedEvent[]
context: AdapterContext
}
export type DiffItem = {
relay: string
have: Set<string>
need: Set<string>
}
export const diff = async ({relays, filters, ...options}: DiffOptions) => {
const diffs = flatten(
await Promise.all(
relays.flatMap(async relay => {
return await Promise.all(
filters.map(
async filter =>
new Promise<DiffItem>((resolve, reject) => {
const diff = new Difference({relay, filter, ...options})
diff.on(DifferenceEventType.Close, () => {
resolve({relay, have: diff.have, need: diff.need})
diff.close()
})
diff.on(DifferenceEventType.Error, (url, message) => {
reject(message)
diff.close()
})
sleep(30_000).then(() => {
reject("timeout")
diff.close()
})
}),
),
)
}),
),
)
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)}
})
}
// Pull diffs multiple arrays and fetches missing events
export type PullOptions = DiffOptions
export const pull = async ({context, ...options}: PullOptions) => {
const countById = new Map<string, number>()
const idsByRelay = new Map<string, string[]>()
for (const {relay, need} of await diff({context, ...options})) {
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: SignedEvent[] = []
await Promise.all(
Array.from(idsByRelay.entries()).map(([relay, allIds]) => {
return Promise.all(
chunk(500, allIds).map(ids => {
return new Promise<void>(resolve => {
const req = unireq({relay, context, filter: {ids}, autoClose: true})
req.on(RequestEventType.Close, resolve)
req.on(RequestEventType.Event, event => result.push(event))
})
}),
)
}),
)
return result
}
// Push diffs multiple relays and publishes missing events
export type PushOptions = DiffOptions
export const push = async ({context, events, ...options}: PushOptions) => {
const relaysById = new Map<string, string[]>()
for (const {relay, have} of await diff({context, events, ...options})) {
for (const id of have) {
pushToMapKey(relaysById, id, relay)
}
}
await Promise.all(
events.map(async event => {
const relays = relaysById.get(event.id)
if (relays) {
new Promise<void>(resolve => {
multicast({event, relays, context}).on(PublishEventType.Complete, resolve)
})
}
}),
)
}
+11 -27
View File
@@ -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"
+110
View File
@@ -0,0 +1,110 @@
import type {SignedEvent, Filter} from "@welshman/util"
// relay -> client
export enum RelayMessageType {
Auth = "AUTH",
Closed = "CLOSED",
Eose = "EOSE",
Event = "EVENT",
NegErr = "NEG-ERR",
NegMsg = "NEG-MSG",
Ok = "OK",
}
export type RelayMessage = any[]
export type RelayAuthPayload = [string]
export type RelayClosedPayload = [string, string]
export type RelayEosePayload = [string, SignedEvent]
export type RelayEventPayload = [string, SignedEvent]
export type RelayNegErrPayload = [string, string]
export type RelayNegMsgPayload = [string, string]
export type RelayOkPayload = [string, boolean, string]
export type RelayAuth = [RelayMessageType.Auth, ...RelayAuthPayload]
export type RelayClosed = [RelayMessageType.Closed, ...RelayClosedPayload]
export type RelayEose = [RelayMessageType.Eose, ...RelayEosePayload]
export type RelayEvent = [RelayMessageType.Event, ...RelayEventPayload]
export type RelayNegErr = [RelayMessageType.NegErr, ...RelayNegErrPayload]
export type RelayNegMsg = [RelayMessageType.NegMsg, ...RelayNegMsgPayload]
export type RelayOk = [RelayMessageType.Ok, ...RelayOkPayload]
export const isRelayAuth = (m: RelayMessage): m is RelayAuth => m[0] === RelayMessageType.Auth
export const isRelayClosed = (m: RelayMessage): m is RelayClosed => m[0] === RelayMessageType.Closed
export const isRelayEose = (m: RelayMessage): m is RelayEose => m[0] === RelayMessageType.Eose
export const isRelayEvent = (m: RelayMessage): m is RelayEvent => m[0] === RelayMessageType.Event
export const isRelayNegErr = (m: RelayMessage): m is RelayNegErr => m[0] === RelayMessageType.NegErr
export const isRelayNegMsg = (m: RelayMessage): m is RelayNegMsg => m[0] === RelayMessageType.NegMsg
export const isRelayOk = (m: RelayMessage): m is RelayOk => m[0] === RelayMessageType.Ok
// client -> relay
export enum ClientMessageType {
Auth = "AUTH",
Close = "CLOSE",
Event = "EVENT",
NegClose = "NEG-CLOSE",
NegOpen = "NEG-OPEN",
Req = "REQ",
}
export type ClientMessage = any[]
export type ClientAuthPayload = [string]
export type ClientClosePayload = [string]
export type ClientEventPayload = [SignedEvent]
export type ClientNegClosePayload = [string]
export type ClientNegOpenPayload = [string, Filter, string]
export type ClientReqPayload = [string, Filter]
export type ClientAuth = [ClientMessageType.Auth, ...ClientAuthPayload]
export type ClientClose = [ClientMessageType.Close, ...ClientClosePayload]
export type ClientEvent = [ClientMessageType.Event, ...ClientEventPayload]
export type ClientNegClose = [ClientMessageType.NegClose, ...ClientNegClosePayload]
export type ClientNegOpen = [ClientMessageType.NegOpen, ...ClientNegOpenPayload]
export type ClientReq = [ClientMessageType.Req, ...ClientReqPayload]
export const isClientAuth = (m: ClientMessage): m is ClientAuth => m[0] === ClientMessageType.Auth
export const isClientClose = (m: ClientMessage): m is ClientClose =>
m[0] === ClientMessageType.Close
export const isClientEvent = (m: ClientMessage): m is ClientEvent =>
m[0] === ClientMessageType.Event
export const isClientNegClose = (m: ClientMessage): m is ClientNegClose =>
m[0] === ClientMessageType.NegClose
export const isClientNegOpen = (m: ClientMessage): m is ClientNegOpen =>
m[0] === ClientMessageType.NegOpen
export const isClientReq = (m: ClientMessage): m is ClientReq => m[0] === ClientMessageType.Req
+246
View File
@@ -0,0 +1,246 @@
import {on, call, sleep, spec, ago, now} from "@welshman/lib"
import {AUTH_JOIN} from "@welshman/util"
import {
ClientMessage,
isClientAuth,
isClientClose,
isClientEvent,
isClientReq,
ClientMessageType,
RelayMessage,
isRelayOk,
isRelayClosed,
} from "./message.js"
import {Socket, SocketStatus, SocketEventType} from "./socket.js"
import {AuthState, AuthStatus, AuthStateEventType} from "./auth.js"
/**
* Defers sending messages when a challenge has been presented and not answered yet
* @param socket - a Socket object
* @return a cleanup function
*/
export const socketPolicyDeferOnAuth = (socket: Socket) => {
const buffer: ClientMessage[] = []
const authState = new AuthState(socket)
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
const unsubscribers = [
// Pause sending certain messages when we're not authenticated
on(socket, SocketEventType.Enqueue, (message: ClientMessage) => {
// If we're closing a request, but it never got sent, remove both from the queue
// Otherwise, always send CLOSE
if (isClientClose(message)) {
const req = buffer.find(spec([ClientMessageType.Req, message[1]]))
if (req) {
socket._sendQueue.remove(req)
socket._sendQueue.remove(message)
}
return
}
// Always allow sending auth
if (isClientAuth(message)) return
// Always allow sending join requests
if (isClientEvent(message) && message[1].kind === AUTH_JOIN) return
// If we're not ok, remove the message and save it for later
if (!okStatuses.includes(authState.status)) {
buffer.push(message)
socket._sendQueue.remove(message)
}
}),
// Send buffered messages when we get successful auth
on(authState, AuthStateEventType.Status, (status: AuthStatus) => {
if (okStatuses.includes(status) && buffer.length > 0) {
for (const message of buffer.splice(0)) {
socket.send(message)
}
}
}),
]
return () => {
unsubscribers.forEach(call)
authState.cleanup()
}
}
/**
* Re-enqueues event/req messages once if rejected due to auth-required
* @param socket - a Socket object
* @return a cleanup function
*/
export const socketPolicyRetryAuthRequired = (socket: Socket) => {
const retried = new Set<string>()
const pending = new Map<string, ClientMessage>()
const unsubscribers = [
// Watch outgoing events and requests and keep a copy
on(socket, SocketEventType.Send, (message: ClientMessage) => {
if (isClientEvent(message)) {
const [_, event] = message
if (!retried.has(event.id) && event.kind !== AUTH_JOIN) {
pending.set(event.id, message)
}
}
if (isClientReq(message)) {
const [_, id] = message
if (!retried.has(id)) {
pending.set(id, message)
}
}
}),
// If a message is rejected with auth-required, re-enqueue it one time
on(socket, SocketEventType.Receive, (message: RelayMessage) => {
if (isRelayOk(message)) {
const [_, id, ok, detail] = message
const pendingMessage = pending.get(id)
if (pendingMessage && !ok && detail?.startsWith("auth-required:")) {
socket.send(pendingMessage)
retried.add(id)
}
pending.delete(id)
}
if (isRelayClosed(message)) {
const [_, id, detail] = message
const pendingMessage = pending.get(id)
if (pendingMessage && detail?.startsWith("auth-required:")) {
socket.send(pendingMessage)
retried.add(id)
}
pending.delete(id)
}
}),
]
return () => unsubscribers.forEach(call)
}
/**
* Auto-connects a closed socket when a message is sent unless there was a recent error
* @param socket - a Socket object
* @return a cleanup function
*/
export const socketPolicyConnectOnSend = (socket: Socket) => {
let lastError = 0
let currentStatus = SocketStatus.Closed
const unsubscribers = [
on(socket, SocketEventType.Status, (newStatus: SocketStatus) => {
// Keep track of the most recent error
if (newStatus === SocketStatus.Error) {
lastError = now()
}
// Keep track of the current status
currentStatus = newStatus
}),
on(socket, SocketEventType.Enqueue, (message: ClientMessage) => {
// When a new message is sent, make sure the socket is open (unless there was a recent error)
if (currentStatus === SocketStatus.Closed && lastError < ago(30)) {
socket.open()
}
}),
]
return () => unsubscribers.forEach(call)
}
/**
* Auto-closes a socket after 30 seconds of inactivity
* @param socket - a Socket object
* @return a cleanup function
*/
export const socketPolicyCloseOnTimeout = (socket: Socket) => {
let lastActivity = now()
const unsubscribers = [
on(socket, SocketEventType.Send, (message: ClientMessage) => {
lastActivity = now()
}),
on(socket, SocketEventType.Receive, (message: RelayMessage) => {
lastActivity = now()
}),
]
const interval = setInterval(() => {
if (socket.status === SocketStatus.Open && lastActivity < ago(30)) {
socket.close()
}
}, 3000)
return () => {
unsubscribers.forEach(call)
clearInterval(interval)
}
}
/**
* Automatically re-opens a socket if there are active requests or publishes
* @param socket - a Socket object
* @return a cleanup function
*/
export const socketPolicyReopenActive = (socket: Socket) => {
const pending = new Map<string, ClientMessage>()
let lastOpen = Date.now()
const unsubscribers = [
on(socket, SocketEventType.Status, (newStatus: SocketStatus) => {
// Keep track of the most recent error
if (newStatus === SocketStatus.Open) {
lastOpen = Date.now()
}
// If the socket closed and we have no error, reopen it but don't flap
if (newStatus === SocketStatus.Closed && pending.size) {
console.log("1")
sleep(Math.max(0, 30_000 - (Date.now() - lastOpen))).then(() => {
console.log("2")
for (const message of pending.values()) {
socket.send(message)
}
})
}
}),
on(socket, SocketEventType.Send, (message: ClientMessage) => {
if (isClientEvent(message)) {
pending.set(message[1].id, message)
}
if (isClientReq(message)) {
pending.set(message[1], message)
}
if (isClientClose(message)) {
pending.delete(message[1])
}
}),
on(socket, SocketEventType.Receive, (message: RelayMessage) => {
if (isRelayClosed(message) || isRelayOk(message)) {
pending.delete(message[1])
}
}),
]
return () => unsubscribers.forEach(call)
}
export const defaultSocketPolicies = [
socketPolicyDeferOnAuth,
socketPolicyRetryAuthRequired,
socketPolicyConnectOnSend,
socketPolicyCloseOnTimeout,
socketPolicyReopenActive,
]
+213
View File
@@ -0,0 +1,213 @@
import {EventEmitter} from "events"
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"
import {getAdapter, AdapterContext, AbstractAdapter, AdapterEventType} from "./adapter.js"
import {SocketEventType, SocketStatus} from "./socket.js"
import {TypedEmitter, Unsubscriber} from "./util.js"
import {Tracker} from "./tracker.js"
export const defaultVerifyEvent = (event: SignedEvent) => {
try {
return nostrToolsVerifyEvent(event)
} catch (e) {
return false
}
}
export enum RequestEventType {
Close = "request:event:close",
Disconnect = "request:event:disconnect",
Duplicate = "request:event:duplicate",
Eose = "request:event:eose",
Event = "request:event:event",
Filtered = "request:event:filtered",
Invalid = "request:event:invalid",
}
// Unireq
export type UnireqEvents = {
[RequestEventType.Event]: (event: SignedEvent) => void
[RequestEventType.Invalid]: (event: SignedEvent) => void
[RequestEventType.Filtered]: (event: SignedEvent) => void
[RequestEventType.Duplicate]: (event: SignedEvent) => void
[RequestEventType.Disconnect]: () => void
[RequestEventType.Close]: () => void
[RequestEventType.Eose]: () => void
}
export type UnireqOptions = {
relay: string
filter: Filter
context: AdapterContext
timeout?: number
tracker?: Tracker
autoClose?: boolean
verifyEvent?: (event: SignedEvent) => boolean
}
export class Unireq extends (EventEmitter as new () => TypedEmitter<UnireqEvents>) {
_id = `REQ-${randomId().slice(0, 8)}`
_unsubscribers: Unsubscriber[] = []
_adapter: AbstractAdapter
_closed = false
constructor(readonly options: UnireqOptions) {
super()
const tracker = options.tracker || new Tracker()
const verifyEvent = options.verifyEvent || defaultVerifyEvent
// Set up our adapter
this._adapter = getAdapter(this.options.relay, this.options.context)
// Listen for event/eose messages from the adapter
this._unsubscribers.push(
on(this._adapter, AdapterEventType.Receive, (message: RelayMessage, url: string) => {
if (isRelayEvent(message)) {
const [_, id, event] = message
if (id !== this._id) return
if (tracker.track(event.id, url)) {
this.emit(RequestEventType.Duplicate, event)
} else if (verifyEvent?.(event) === false) {
this.emit(RequestEventType.Invalid, event)
} else if (!matchFilter(this.options.filter, event)) {
this.emit(RequestEventType.Filtered, event)
} else {
this.emit(RequestEventType.Event, event)
}
}
if (isRelayEose(message)) {
const [_, id] = message
if (id === this._id) {
this.emit(RequestEventType.Eose)
if (this.options.autoClose) {
this.close()
}
}
}
}),
)
// Listen to disconnects from any sockets
for (const socket of this._adapter.sockets) {
this._unsubscribers.push(
on(socket, SocketEventType.Status, (status: SocketStatus) => {
if (![SocketStatus.Open, SocketStatus.Opening].includes(status)) {
this.emit(RequestEventType.Disconnect)
if (this.options.autoClose) {
this.close()
}
}
}),
)
}
// Timeout our subscription
if (this.options.timeout) {
setTimeout(() => this.close(), this.options.timeout)
}
// Start asynchronously so the caller can set up listeners
yieldThread().then(() => {
this._adapter.send([ClientMessageType.Req, this._id, this.options.filter])
})
}
close() {
if (this._closed) return
this._adapter.send(["CLOSE", this._id])
this.emit(RequestEventType.Close)
this.removeAllListeners()
this._unsubscribers.map(call)
this._adapter.cleanup()
this._closed = true
}
}
// Multireq
export type MultireqEvents = {
[RequestEventType.Event]: (event: SignedEvent, url: string) => void
[RequestEventType.Invalid]: (event: SignedEvent, url: string) => void
[RequestEventType.Filtered]: (event: SignedEvent, url: string) => void
[RequestEventType.Duplicate]: (event: SignedEvent, url: string) => void
[RequestEventType.Disconnect]: (url: string) => void
[RequestEventType.Eose]: (url: string) => void
[RequestEventType.Close]: () => void
}
export type MultireqOptions = Omit<UnireqOptions, "relay"> & {
relays: string[]
}
export class Multireq extends (EventEmitter as new () => TypedEmitter<MultireqEvents>) {
_children: Unireq[] = []
_closed = new Set<string>()
constructor({relays, ...options}: MultireqOptions) {
super()
const tracker = new Tracker()
for (const relay of relays) {
const req = new Unireq({relay, tracker, ...options})
req.on(RequestEventType.Event, (event: SignedEvent) => {
this.emit(RequestEventType.Event, event, relay)
})
req.on(RequestEventType.Invalid, (event: SignedEvent) => {
this.emit(RequestEventType.Invalid, event, relay)
})
req.on(RequestEventType.Filtered, (event: SignedEvent) => {
this.emit(RequestEventType.Filtered, event, relay)
})
req.on(RequestEventType.Duplicate, (event: SignedEvent) => {
this.emit(RequestEventType.Duplicate, event, relay)
})
req.on(RequestEventType.Disconnect, () => {
this.emit(RequestEventType.Disconnect, relay)
})
req.on(RequestEventType.Eose, () => {
this.emit(RequestEventType.Eose, relay)
})
req.on(RequestEventType.Close, () => {
this._closed.add(relay)
if (this._closed.size === relays.length) {
this.emit(RequestEventType.Close)
}
})
this._children.push(req)
}
}
close() {
for (const child of this._children) {
child.close()
}
}
}
// Convenience functions
export const unireq = (options: UnireqOptions) => new Unireq(options)
export const multireq = (options: MultireqOptions) => new Multireq(options)
-16
View File
@@ -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()
}
}
-30
View File
@@ -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)
}
}
-26
View File
@@ -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())
}
}
-29
View File
@@ -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)
}
}
-29
View File
@@ -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)
})
}
}
+5
View File
@@ -0,0 +1,5 @@
import TypedEventEmitter, {EventMap} from "typed-emitter"
export type TypedEmitter<T extends EventMap> = TypedEventEmitter.default<T>
export type Unsubscriber = () => void
-37
View File
@@ -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)
})
})