From 7389b3a6c3434b4b227e89acb2868942a93e8b92 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 31 Mar 2025 09:13:10 -0700 Subject: [PATCH] Move net2 to net, update dvm --- package-lock.json | 52 +- packages/content/src/index.ts | 5 +- packages/dvm/src/handler.ts | 24 +- packages/dvm/src/request.ts | 34 +- packages/net/.eslintignore | 2 +- packages/net/__tests__/Connection.test.ts | 40 -- packages/net/__tests__/ConnectionAuth.test.ts | 261 -------- .../net/__tests__/ConnectionSender.test.ts | 202 ------ .../net/__tests__/ConnectionState.test.ts | 201 ------ .../net/__tests__/ConnectionStats.test.ts | 220 ------- packages/net/__tests__/Context.test.ts | 192 ------ packages/net/__tests__/Executor.test.ts | 256 -------- packages/net/__tests__/Pool.test.ts | 153 ++--- packages/net/__tests__/Publish.test.ts | 342 +++++----- packages/net/__tests__/Socket.test.ts | 295 ++++----- packages/net/__tests__/Sync.test.ts | 273 -------- .../{net2 => net}/__tests__/adapter.test.ts | 0 packages/{net2 => net}/__tests__/auth.test.ts | 0 .../{net2 => net}/__tests__/policy.test.ts | 0 .../{net2 => net}/__tests__/request.test.ts | 0 .../__tests__/subscribe/Subscription.test.ts | 258 -------- .../SubscriptionOptimization.test.ts | 173 ----- packages/net/__tests__/target.test.ts | 193 ------ packages/net/package.json | 5 +- packages/net/src/Connection.ts | 70 --- packages/net/src/ConnectionAuth.ts | 120 ---- packages/net/src/ConnectionEvent.ts | 11 - packages/net/src/ConnectionSender.ts | 57 -- packages/net/src/ConnectionState.ts | 112 ---- packages/net/src/ConnectionStats.ts | 98 --- packages/net/src/Context.ts | 70 --- packages/net/src/Executor.ts | 154 ----- packages/net/src/Pool.ts | 81 ++- packages/net/src/Publish.ts | 247 +++++--- packages/net/src/Socket.ts | 196 +++--- packages/net/src/Subscribe.ts | 390 ------------ packages/net/src/Sync.ts | 208 ------ packages/{net2 => net}/src/adapter.ts | 0 packages/{net2 => net}/src/auth.ts | 0 packages/{net2 => net}/src/diff.ts | 0 packages/net/src/index.ts | 38 +- packages/{net2 => net}/src/message.ts | 0 packages/{net2 => net}/src/policy.ts | 4 +- packages/{net2 => net}/src/request.ts | 2 +- packages/net/src/target/Echo.ts | 16 - packages/net/src/target/Local.ts | 30 - packages/net/src/target/Multi.ts | 26 - packages/net/src/target/Relay.ts | 29 - packages/net/src/target/Relays.ts | 29 - packages/{net2 => net}/src/util.ts | 0 packages/net/test/Executor.test.cjs | 37 -- packages/net2/.eslintignore | 4 - packages/net2/README.md | 61 -- packages/net2/__tests__/Socket.test.ts | 195 ------ packages/net2/__tests__/pool.test.ts | 130 ---- packages/net2/__tests__/publish.test.ts | 228 ------- packages/net2/__tests__/tracker.test.ts | 189 ------ packages/net2/package.json | 34 - packages/net2/src/index.ts | 11 - packages/net2/src/negentropy.ts | 590 ------------------ packages/net2/src/pool.ts | 82 --- packages/net2/src/publish.ts | 186 ------ packages/net2/src/socket.ts | 130 ---- packages/net2/src/tracker.ts | 85 --- packages/net2/tsconfig.json | 14 - packages/net2/typedoc.json | 3 - vitest.config.ts | 1 - 67 files changed, 819 insertions(+), 6330 deletions(-) delete mode 100644 packages/net/__tests__/Connection.test.ts delete mode 100644 packages/net/__tests__/ConnectionAuth.test.ts delete mode 100644 packages/net/__tests__/ConnectionSender.test.ts delete mode 100644 packages/net/__tests__/ConnectionState.test.ts delete mode 100644 packages/net/__tests__/ConnectionStats.test.ts delete mode 100644 packages/net/__tests__/Context.test.ts delete mode 100644 packages/net/__tests__/Executor.test.ts delete mode 100644 packages/net/__tests__/Sync.test.ts rename packages/{net2 => net}/__tests__/adapter.test.ts (100%) rename packages/{net2 => net}/__tests__/auth.test.ts (100%) rename packages/{net2 => net}/__tests__/policy.test.ts (100%) rename packages/{net2 => net}/__tests__/request.test.ts (100%) delete mode 100644 packages/net/__tests__/subscribe/Subscription.test.ts delete mode 100644 packages/net/__tests__/subscribe/SubscriptionOptimization.test.ts delete mode 100644 packages/net/__tests__/target.test.ts delete mode 100644 packages/net/src/Connection.ts delete mode 100644 packages/net/src/ConnectionAuth.ts delete mode 100644 packages/net/src/ConnectionEvent.ts delete mode 100644 packages/net/src/ConnectionSender.ts delete mode 100644 packages/net/src/ConnectionState.ts delete mode 100644 packages/net/src/ConnectionStats.ts delete mode 100644 packages/net/src/Context.ts delete mode 100644 packages/net/src/Executor.ts delete mode 100644 packages/net/src/Subscribe.ts delete mode 100644 packages/net/src/Sync.ts rename packages/{net2 => net}/src/adapter.ts (100%) rename packages/{net2 => net}/src/auth.ts (100%) rename packages/{net2 => net}/src/diff.ts (100%) rename packages/{net2 => net}/src/message.ts (100%) rename packages/{net2 => net}/src/policy.ts (99%) rename packages/{net2 => net}/src/request.ts (98%) delete mode 100644 packages/net/src/target/Echo.ts delete mode 100644 packages/net/src/target/Local.ts delete mode 100644 packages/net/src/target/Multi.ts delete mode 100644 packages/net/src/target/Relay.ts delete mode 100644 packages/net/src/target/Relays.ts rename packages/{net2 => net}/src/util.ts (100%) delete mode 100644 packages/net/test/Executor.test.cjs delete mode 100644 packages/net2/.eslintignore delete mode 100644 packages/net2/README.md delete mode 100644 packages/net2/__tests__/Socket.test.ts delete mode 100644 packages/net2/__tests__/pool.test.ts delete mode 100644 packages/net2/__tests__/publish.test.ts delete mode 100644 packages/net2/__tests__/tracker.test.ts delete mode 100644 packages/net2/package.json delete mode 100644 packages/net2/src/index.ts delete mode 100644 packages/net2/src/negentropy.ts delete mode 100644 packages/net2/src/pool.ts delete mode 100644 packages/net2/src/publish.ts delete mode 100644 packages/net2/src/socket.ts delete mode 100644 packages/net2/src/tracker.ts delete mode 100644 packages/net2/tsconfig.json delete mode 100644 packages/net2/typedoc.json diff --git a/package-lock.json b/package-lock.json index c64ca7c..d7c4180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2695,10 +2695,6 @@ "resolved": "packages/net", "link": true }, - "node_modules/@welshman/net2": { - "resolved": "packages/net2", - "link": true - }, "node_modules/@welshman/signer": { "resolved": "packages/signer", "link": true @@ -5310,9 +5306,9 @@ } }, "node_modules/nostr-tools": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz", - "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.11.0.tgz", + "integrity": "sha512-kRtXI9j5f45NvIcdJacQ0UEAfEb7p/jhZqhAGLQWtUd5idZJPYdSyR8hdw+MmpGH4TCMH5plZrXzFltIIZrkEA==", "license": "Unlicense", "dependencies": { "@noble/ciphers": "^0.5.1", @@ -7702,6 +7698,18 @@ "events": "^3.3.0" } }, + "packages/app/node_modules/@welshman/net": { + "version": "0.0.49", + "resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.49.tgz", + "integrity": "sha512-DvsBh+MGIZtRd08itpxM8H40tNB2CuEx1ayydnIjk/bX9J2SgfjoFcMhr0mJ8PdSYvwmfRV95T4gF19ni8ANeQ==", + "license": "MIT", + "dependencies": { + "@welshman/lib": "^0.1.0", + "@welshman/util": "^0.1.0", + "isomorphic-ws": "^5.0.0", + "ws": "^8.16.0" + } + }, "packages/app/node_modules/@welshman/signer/node_modules/@welshman/lib": { "version": "0.0.41", "resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.41.tgz", @@ -7773,6 +7781,18 @@ "url": "https://paulmillr.com/funding/" } }, + "packages/dvm/node_modules/@welshman/net": { + "version": "0.0.49", + "resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.49.tgz", + "integrity": "sha512-DvsBh+MGIZtRd08itpxM8H40tNB2CuEx1ayydnIjk/bX9J2SgfjoFcMhr0mJ8PdSYvwmfRV95T4gF19ni8ANeQ==", + "license": "MIT", + "dependencies": { + "@welshman/lib": "^0.1.0", + "@welshman/util": "^0.1.0", + "isomorphic-ws": "^5.0.0", + "ws": "^8.16.0" + } + }, "packages/editor": { "name": "@welshman/editor", "version": "0.1.0", @@ -7829,18 +7849,20 @@ }, "packages/net": { "name": "@welshman/net", - "version": "0.0.49", + "version": "0.0.48", "license": "MIT", "dependencies": { "@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" } }, "packages/net2": { "name": "@welshman/net2", "version": "0.0.48", + "extraneous": true, "license": "MIT", "dependencies": { "@welshman/lib": "^0.1.0", @@ -7901,6 +7923,18 @@ "url": "https://paulmillr.com/funding/" } }, + "packages/signer/node_modules/@welshman/net": { + "version": "0.0.49", + "resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.49.tgz", + "integrity": "sha512-DvsBh+MGIZtRd08itpxM8H40tNB2CuEx1ayydnIjk/bX9J2SgfjoFcMhr0mJ8PdSYvwmfRV95T4gF19ni8ANeQ==", + "license": "MIT", + "dependencies": { + "@welshman/lib": "^0.1.0", + "@welshman/util": "^0.1.0", + "isomorphic-ws": "^5.0.0", + "ws": "^8.16.0" + } + }, "packages/store": { "name": "@welshman/store", "version": "0.1.0", diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts index 9980f89..0c100a6 100644 --- a/packages/content/src/index.ts +++ b/packages/content/src/index.ts @@ -424,12 +424,13 @@ export const truncate = ( currentSize += size if (currentSize > minLength) { - content = content.slice(0, Math.max(1, i)).concat({type: ParsedType.Ellipsis, value: "…", raw: ""}) + content = content + .slice(0, Math.max(1, i)) + .concat({type: ParsedType.Ellipsis, value: "…", raw: ""}) return false } - return true }) diff --git a/packages/dvm/src/handler.ts b/packages/dvm/src/handler.ts index 47b9187..106f779 100644 --- a/packages/dvm/src/handler.ts +++ b/packages/dvm/src/handler.ts @@ -1,8 +1,14 @@ import {hexToBytes} from "@noble/hashes/utils" import {getPublicKey, finalizeEvent} from "nostr-tools/pure" import {now} from "@welshman/lib" -import type {TrustedEvent, StampedEvent, Filter} from "@welshman/util" -import {subscribe, publish} from "@welshman/net" +import {TrustedEvent, StampedEvent, Filter} from "@welshman/util" +import { + multireq, + multicast, + PublishEventType, + RequestEventType, + AdapterContext, +} from "@welshman/net" export type DVMHandler = { stop?: () => void @@ -14,6 +20,7 @@ export type CreateDVMHandler = (dvm: DVM) => DVMHandler export type DVMOpts = { sk: string relays: string[] + context: AdapterContext handlers: Record expireAfter?: number requireMention?: boolean @@ -34,7 +41,7 @@ export class DVM { async start() { this.active = true - const {sk, relays, requireMention = false} = this.opts + const {sk, relays, context, requireMention = false} = this.opts while (this.active) { await new Promise(resolve => { @@ -46,11 +53,10 @@ export class DVM { filter["#p"] = [getPublicKey(hexToBytes(sk))] } - const filters = [filter] - const sub = subscribe({relays, filters}) + const sub = multireq({relays, filter, context}) - sub.on("event", (url: string, e: TrustedEvent) => this.onEvent(e)) - sub.on("complete", () => resolve()) + sub.on(RequestEventType.Event, (e: TrustedEvent, url: string) => this.onEvent(e)) + sub.on(RequestEventType.Close, () => resolve()) }) } } @@ -109,11 +115,11 @@ export class DVM { } async publish(template: StampedEvent) { - const {sk, relays} = this.opts + const {sk, relays, context} = this.opts const event = finalizeEvent(template, hexToBytes(sk)) await new Promise(resolve => { - publish({event, relays}).emitter.on("success", () => resolve()) + multicast({event, relays, context}).on(PublishEventType.Complete, resolve) }) } } diff --git a/packages/dvm/src/request.ts b/packages/dvm/src/request.ts index ebc21ea..c446a77 100644 --- a/packages/dvm/src/request.ts +++ b/packages/dvm/src/request.ts @@ -1,7 +1,13 @@ import {Emitter, now} from "@welshman/lib" -import type {TrustedEvent, SignedEvent, Filter} from "@welshman/util" -import {subscribe, publish, SubscriptionEvent} from "@welshman/net" -import type {Subscription, Publish} from "@welshman/net" +import {TrustedEvent, SignedEvent, Filter} from "@welshman/util" +import { + multireq, + multicast, + Multireq, + Multicast, + RequestEventType, + AdapterContext, +} from "@welshman/net" export enum DVMEvent { Progress = "progress", @@ -11,6 +17,7 @@ export enum DVMEvent { export type DVMRequestOptions = { event: SignedEvent relays: string[] + context: AdapterContext timeout?: number autoClose?: boolean reportProgress?: boolean @@ -19,21 +26,28 @@ export type DVMRequestOptions = { export type DVMRequest = { request: DVMRequestOptions emitter: Emitter - sub: Subscription - pub: Publish + sub: Multireq + pub: Multicast } export const makeDvmRequest = (request: DVMRequestOptions) => { const emitter = new Emitter() - const {event, relays, timeout = 30_000, autoClose = true, reportProgress = true} = request + const { + event, + relays, + context, + timeout = 30_000, + autoClose = true, + reportProgress = true, + } = request const kind = event.kind + 1000 const kinds = reportProgress ? [kind, 7000] : [kind] - const filters: Filter[] = [{kinds, since: now() - 60, "#e": [event.id]}] + const filter: Filter = {kinds, since: now() - 60, "#e": [event.id]} - const sub = subscribe({relays, timeout, filters}) - const pub = publish({event, relays, timeout}) + const sub = multireq({relays, filter, timeout, context}) + const pub = multicast({relays, event, timeout, context}) - sub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => { + sub.on(RequestEventType.Event, (event: TrustedEvent, url: string) => { if (event.kind === 7000) { emitter.emit(DVMEvent.Progress, url, event) } else { diff --git a/packages/net/.eslintignore b/packages/net/.eslintignore index 2e4a567..22d79d5 100644 --- a/packages/net/.eslintignore +++ b/packages/net/.eslintignore @@ -1,4 +1,4 @@ build normalize-url Negentropy.ts -__tests__ \ No newline at end of file +__tests__ diff --git a/packages/net/__tests__/Connection.test.ts b/packages/net/__tests__/Connection.test.ts deleted file mode 100644 index d1d3607..0000000 --- a/packages/net/__tests__/Connection.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/packages/net/__tests__/ConnectionAuth.test.ts b/packages/net/__tests__/ConnectionAuth.test.ts deleted file mode 100644 index 2103ebf..0000000 --- a/packages/net/__tests__/ConnectionAuth.test.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/packages/net/__tests__/ConnectionSender.test.ts b/packages/net/__tests__/ConnectionSender.test.ts deleted file mode 100644 index 16045e8..0000000 --- a/packages/net/__tests__/ConnectionSender.test.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/packages/net/__tests__/ConnectionState.test.ts b/packages/net/__tests__/ConnectionState.test.ts deleted file mode 100644 index 059ff1f..0000000 --- a/packages/net/__tests__/ConnectionState.test.ts +++ /dev/null @@ -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() - }) - }) -}) diff --git a/packages/net/__tests__/ConnectionStats.test.ts b/packages/net/__tests__/ConnectionStats.test.ts deleted file mode 100644 index 277e590..0000000 --- a/packages/net/__tests__/ConnectionStats.test.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/packages/net/__tests__/Context.test.ts b/packages/net/__tests__/Context.test.ts deleted file mode 100644 index cad97d2..0000000 --- a/packages/net/__tests__/Context.test.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/packages/net/__tests__/Executor.test.ts b/packages/net/__tests__/Executor.test.ts deleted file mode 100644 index 4e3f613..0000000 --- a/packages/net/__tests__/Executor.test.ts +++ /dev/null @@ -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 - }) - }) -}) diff --git a/packages/net/__tests__/Pool.test.ts b/packages/net/__tests__/Pool.test.ts index ba04610..3683363 100644 --- a/packages/net/__tests__/Pool.test.ts +++ b/packages/net/__tests__/Pool.test.ts @@ -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() - }) }) }) diff --git a/packages/net/__tests__/Publish.test.ts b/packages/net/__tests__/Publish.test.ts index 07224d5..70157ed 100644 --- a/packages/net/__tests__/Publish.test.ts +++ b/packages/net/__tests__/Publish.test.ts @@ -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") }) }) diff --git a/packages/net/__tests__/Socket.test.ts b/packages/net/__tests__/Socket.test.ts index f6ae1e5..a61ca33 100644 --- a/packages/net/__tests__/Socket.test.ts +++ b/packages/net/__tests__/Socket.test.ts @@ -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) }) }) }) diff --git a/packages/net/__tests__/Sync.test.ts b/packages/net/__tests__/Sync.test.ts deleted file mode 100644 index a47236e..0000000 --- a/packages/net/__tests__/Sync.test.ts +++ /dev/null @@ -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"], - }) - }) - }) -}) diff --git a/packages/net2/__tests__/adapter.test.ts b/packages/net/__tests__/adapter.test.ts similarity index 100% rename from packages/net2/__tests__/adapter.test.ts rename to packages/net/__tests__/adapter.test.ts diff --git a/packages/net2/__tests__/auth.test.ts b/packages/net/__tests__/auth.test.ts similarity index 100% rename from packages/net2/__tests__/auth.test.ts rename to packages/net/__tests__/auth.test.ts diff --git a/packages/net2/__tests__/policy.test.ts b/packages/net/__tests__/policy.test.ts similarity index 100% rename from packages/net2/__tests__/policy.test.ts rename to packages/net/__tests__/policy.test.ts diff --git a/packages/net2/__tests__/request.test.ts b/packages/net/__tests__/request.test.ts similarity index 100% rename from packages/net2/__tests__/request.test.ts rename to packages/net/__tests__/request.test.ts diff --git a/packages/net/__tests__/subscribe/Subscription.test.ts b/packages/net/__tests__/subscribe/Subscription.test.ts deleted file mode 100644 index 30f5588..0000000 --- a/packages/net/__tests__/subscribe/Subscription.test.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/packages/net/__tests__/subscribe/SubscriptionOptimization.test.ts b/packages/net/__tests__/subscribe/SubscriptionOptimization.test.ts deleted file mode 100644 index 7565d86..0000000 --- a/packages/net/__tests__/subscribe/SubscriptionOptimization.test.ts +++ /dev/null @@ -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() - }) - }) -}) diff --git a/packages/net/__tests__/target.test.ts b/packages/net/__tests__/target.test.ts deleted file mode 100644 index 1b66c37..0000000 --- a/packages/net/__tests__/target.test.ts +++ /dev/null @@ -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() - }) - }) -}) diff --git a/packages/net/package.json b/packages/net/package.json index a5181e8..b445183 100644 --- a/packages/net/package.json +++ b/packages/net/package.json @@ -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" } } diff --git a/packages/net/src/Connection.ts b/packages/net/src/Connection.ts deleted file mode 100644 index c253d22..0000000 --- a/packages/net/src/Connection.ts +++ /dev/null @@ -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() - } -} diff --git a/packages/net/src/ConnectionAuth.ts b/packages/net/src/ConnectionAuth.ts deleted file mode 100644 index d2a6246..0000000 --- a/packages/net/src/ConnectionAuth.ts +++ /dev/null @@ -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)) - } -} diff --git a/packages/net/src/ConnectionEvent.ts b/packages/net/src/ConnectionEvent.ts deleted file mode 100644 index dfbcd55..0000000 --- a/packages/net/src/ConnectionEvent.ts +++ /dev/null @@ -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", -} diff --git a/packages/net/src/ConnectionSender.ts b/packages/net/src/ConnectionSender.ts deleted file mode 100644 index 3420f06..0000000 --- a/packages/net/src/ConnectionSender.ts +++ /dev/null @@ -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 - - 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) - } -} diff --git a/packages/net/src/ConnectionState.ts b/packages/net/src/ConnectionState.ts deleted file mode 100644 index 1c87d6b..0000000 --- a/packages/net/src/ConnectionState.ts +++ /dev/null @@ -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() - pendingRequests = new Map() - - 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]) - } - }) - } -} diff --git a/packages/net/src/ConnectionStats.ts b/packages/net/src/ConnectionStats.ts deleted file mode 100644 index 332b154..0000000 --- a/packages/net/src/ConnectionStats.ts +++ /dev/null @@ -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 -} diff --git a/packages/net/src/Context.ts b/packages/net/src/Context.ts deleted file mode 100644 index b3343a9..0000000 --- a/packages/net/src/Context.ts +++ /dev/null @@ -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 - 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() - -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 = {}) => ({ - 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, -}) diff --git a/packages/net/src/Executor.ts b/packages/net/src/Executor.ts deleted file mode 100644 index 1732e08..0000000 --- a/packages/net/src/Executor.ts +++ /dev/null @@ -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 - 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, - } - } -} diff --git a/packages/net/src/Pool.ts b/packages/net/src/Pool.ts index ff2b412..190b2de 100644 --- a/packages/net/src/Pool.ts +++ b/packages/net/src/Pool.ts @@ -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 +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() + _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) } } diff --git a/packages/net/src/Publish.ts b/packages/net/src/Publish.ts index 6f02d79..ff52555 100644 --- a/packages/net/src/Publish.ts +++ b/packages/net/src/Publish.ts @@ -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 +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 -} +export class Unicast extends (EventEmitter as new () => TypedEmitter) { + 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 & { + relays: string[] +} + +export class Multicast extends (EventEmitter as new () => TypedEmitter) { + status: Record + + _children: Unicast[] = [] + _completed = new Set() + + 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) diff --git a/packages/net/src/Socket.ts b/packages/net/src/Socket.ts index e89777d..7d80f67 100644 --- a/packages/net/src/Socket.ts +++ b/packages/net/src/Socket.ts @@ -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() - 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) { + status = SocketStatus.Closed + + _ws?: WebSocket + _sendQueue: TaskQueue + _recvQueue: TaskQueue + + constructor(readonly url: string) { + super() + + this._sendQueue = new TaskQueue({ + batchSize: 50, + processItem: (message: ClientMessage) => { + this._ws?.send(JSON.stringify(message)) + this.emit(SocketEventType.Send, message, this.url) + }, + }) + + this._recvQueue = new TaskQueue({ + 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) + } } diff --git a/packages/net/src/Subscribe.ts b/packages/net/src/Subscribe.ts deleted file mode 100644 index 8fceb18..0000000 --- a/packages/net/src/Subscribe.ts +++ /dev/null @@ -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() - const abortedSubs = new Set() - const closedSubs = new Set() - const eosedSubs = new Set() - const sentSubs = new Set() - 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) => - 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 -} diff --git a/packages/net/src/Sync.ts b/packages/net/src/Sync.ts deleted file mode 100644 index 61810af..0000000 --- a/packages/net/src/Sync.ts +++ /dev/null @@ -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() - const need = new Set() - - await new Promise((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() - const need = new Set() - - 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() - const idsByRelay = new Map() - - 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() - - 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(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) -} diff --git a/packages/net2/src/adapter.ts b/packages/net/src/adapter.ts similarity index 100% rename from packages/net2/src/adapter.ts rename to packages/net/src/adapter.ts diff --git a/packages/net2/src/auth.ts b/packages/net/src/auth.ts similarity index 100% rename from packages/net2/src/auth.ts rename to packages/net/src/auth.ts diff --git a/packages/net2/src/diff.ts b/packages/net/src/diff.ts similarity index 100% rename from packages/net2/src/diff.ts rename to packages/net/src/diff.ts diff --git a/packages/net/src/index.ts b/packages/net/src/index.ts index d4414ef..924a8f2 100644 --- a/packages/net/src/index.ts +++ b/packages/net/src/index.ts @@ -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" diff --git a/packages/net2/src/message.ts b/packages/net/src/message.ts similarity index 100% rename from packages/net2/src/message.ts rename to packages/net/src/message.ts diff --git a/packages/net2/src/policy.ts b/packages/net/src/policy.ts similarity index 99% rename from packages/net2/src/policy.ts rename to packages/net/src/policy.ts index 0f81dbb..07bb9c2 100644 --- a/packages/net2/src/policy.ts +++ b/packages/net/src/policy.ts @@ -205,9 +205,9 @@ export const socketPolicyReopenActive = (socket: Socket) => { // If the socket closed and we have no error, reopen it but don't flap if (newStatus === SocketStatus.Closed && pending.size) { - console.log('1') + console.log("1") sleep(Math.max(0, 30_000 - (Date.now() - lastOpen))).then(() => { - console.log('2') + console.log("2") for (const message of pending.values()) { socket.send(message) } diff --git a/packages/net2/src/request.ts b/packages/net/src/request.ts similarity index 98% rename from packages/net2/src/request.ts rename to packages/net/src/request.ts index 532cf8c..0946050 100644 --- a/packages/net2/src/request.ts +++ b/packages/net/src/request.ts @@ -1,5 +1,5 @@ import {EventEmitter} from "events" -import {verifyEvent as nostrToolsVerifyEvent} from 'nostr-tools' +import {verifyEvent as nostrToolsVerifyEvent} from "nostr-tools/pure" import {on, call, randomId, yieldThread} from "@welshman/lib" import {Filter, matchFilter, SignedEvent} from "@welshman/util" import {RelayMessage, ClientMessageType, isRelayEvent, isRelayEose} from "./message.js" diff --git a/packages/net/src/target/Echo.ts b/packages/net/src/target/Echo.ts deleted file mode 100644 index f0c05be..0000000 --- a/packages/net/src/target/Echo.ts +++ /dev/null @@ -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() - } -} diff --git a/packages/net/src/target/Local.ts b/packages/net/src/target/Local.ts deleted file mode 100644 index e7f442b..0000000 --- a/packages/net/src/target/Local.ts +++ /dev/null @@ -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) - } -} diff --git a/packages/net/src/target/Multi.ts b/packages/net/src/target/Multi.ts deleted file mode 100644 index 5a3b84f..0000000 --- a/packages/net/src/target/Multi.ts +++ /dev/null @@ -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()) - } -} diff --git a/packages/net/src/target/Relay.ts b/packages/net/src/target/Relay.ts deleted file mode 100644 index ff9f256..0000000 --- a/packages/net/src/target/Relay.ts +++ /dev/null @@ -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) - } -} diff --git a/packages/net/src/target/Relays.ts b/packages/net/src/target/Relays.ts deleted file mode 100644 index 581bfb6..0000000 --- a/packages/net/src/target/Relays.ts +++ /dev/null @@ -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) - }) - } -} diff --git a/packages/net2/src/util.ts b/packages/net/src/util.ts similarity index 100% rename from packages/net2/src/util.ts rename to packages/net/src/util.ts diff --git a/packages/net/test/Executor.test.cjs b/packages/net/test/Executor.test.cjs deleted file mode 100644 index 1e8ad1e..0000000 --- a/packages/net/test/Executor.test.cjs +++ /dev/null @@ -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) - }) -}) diff --git a/packages/net2/.eslintignore b/packages/net2/.eslintignore deleted file mode 100644 index 22d79d5..0000000 --- a/packages/net2/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -build -normalize-url -Negentropy.ts -__tests__ diff --git a/packages/net2/README.md b/packages/net2/README.md deleted file mode 100644 index b543670..0000000 --- a/packages/net2/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# @welshman/net [![version](https://badgen.net/npm/v/@welshman/net)](https://npmjs.com/package/@welshman/net) - -Utilities having to do with connection management and nostr messages. - -```typescript -import {ctx, setContext} from '@welshman/lib' -import {type TrustedEvent, createEvent, NOTE} from '@welshman/util' -import {subscribe, publish, getDefaultNetContext} from '@welshman/net' - -// Sets up customizable event valdation, handlers, etc -setContext(getDefaultNetContext()) - -// Send a subscription -const sub = subscribe({ - relays: ['wss://relay.example.com/'], - filters: [{kinds: [1], limit: 1}], - closeOnEose: true, - timeout: 10000, -}) - -sub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => { - console.log(url, event) - sub.close() -}) - -// Publish an event -const pub = publish({ - relays: ['wss://relay.example.com/'], - event: createEvent(NOTE, {content: 'hi'}), -}) - -pub.emitter.on('*', (status: PublishStatus, url: string) => { - console.log(status, url) -}) - -// The Tracker class can tell you which relays an event was read from or published to -console.log(ctx.net.tracker.getRelays(event.id)) -``` - -The main reason this module exists is to support different backends via Executor and different `target` classes. For example, to add a local relay that automatically gets used: - -```typescript -import {setContext} from '@welshman/lib' -import {LOCAL_RELAY_URL, Relay, Repository} from '@welshman/util' -import {getDefaultNetContext, Multi, Local, Relays, Executor} from '@welshman/net' - -const repository = new Repository() - -const relay = new Relay(repository) - -setContext(getDefaultNetContext({ - getExecutor: (relays: string[]) => { - return new Executor( - new Multi([ - new Local(relay), - new Relays(remoteUrls.map(url => ctx.net.pool.get(url))), - ]) - ) - }, -})) -``` diff --git a/packages/net2/__tests__/Socket.test.ts b/packages/net2/__tests__/Socket.test.ts deleted file mode 100644 index a61ca33..0000000 --- a/packages/net2/__tests__/Socket.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { sleep } from "@welshman/lib" -import WebSocket from 'isomorphic-ws' -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { Socket, SocketStatus, SocketEventType } from "../src/socket" -import { ClientMessage, RelayMessage } from "../src/message" - -vi.mock('isomorphic-ws', () => { - const WebSocket = vi.fn(function () { - setTimeout(() => this.onopen()) - }) - - WebSocket.prototype.send = vi.fn() - - WebSocket.prototype.close = vi.fn(function () { - this.onclose() - }) - - return { default: WebSocket } -}) - -describe("Socket", () => { - let socket: Socket - - beforeEach(() => { - vi.useFakeTimers() - socket = new Socket("wss://test.relay") - }) - - afterEach(() => { - vi.clearAllMocks() - vi.useRealTimers() - socket.cleanup() - }) - - it("should initialize with correct url", () => { - expect(socket.url).toBe("wss://test.relay") - }) - - describe("open", () => { - it("should create websocket and emit opening status", () => { - const statusSpy = vi.fn() - socket.on(SocketEventType.Status, statusSpy) - - socket.open() - - expect(socket._ws).toBeDefined() - expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Opening, "wss://test.relay") - - vi.runAllTimers() - - expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Open, "wss://test.relay") - }) - - it("should throw error if socket already exists", () => { - socket.open() - expect(() => socket.open()).toThrow("Attempted to open a websocket that has not been closed") - }) - - it("should emit invalid status on invalid URL", () => { - const statusSpy = vi.fn() - socket.on(SocketEventType.Status, statusSpy) - - vi.mocked(WebSocket).mockImplementationOnce(() => { - throw new Error() - }) - - socket.open() - - expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Invalid, "wss://test.relay") - }) - }) - - describe("close", () => { - it("should close websocket and emit closed status", () => { - const statusSpy = vi.fn() - socket.on(SocketEventType.Status, statusSpy) - - socket.open() - - const ws = socket._ws - - socket.close() - - expect(ws.close).toHaveBeenCalled() - expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Closed, "wss://test.relay") - }) - }) - - describe("send", () => { - it("should queue messages and emit enqueue event", () => { - const enqueueSpy = vi.fn() - socket.on(SocketEventType.Enqueue, enqueueSpy) - - const message: ClientMessage = ["EVENT", { id: "123", kind: 1 }] - socket.send(message) - - expect(enqueueSpy).toHaveBeenCalledWith(message, "wss://test.relay") - }) - - it("should send messages when socket is open", async () => { - const sendSpy = vi.fn() - socket.on(SocketEventType.Send, sendSpy) - - socket.open() - socket._ws.onopen() - - const message: ClientMessage = ["EVENT", { id: "123", kind: 1 }] - socket.send(message) - - await vi.runAllTimers() - - expect(socket._ws.send).toHaveBeenCalledWith(JSON.stringify(message)) - expect(sendSpy).toHaveBeenCalledWith(message, "wss://test.relay") - }) - }) - - describe("receive", () => { - it("should handle valid relay messages", async () => { - const receiveSpy = vi.fn() - socket.on(SocketEventType.Receive, receiveSpy) - - socket.open() - const message: RelayMessage = ["EVENT", "123", { id: "123", kind: 1 }] - socket._ws.onmessage({ data: JSON.stringify(message) }) - - // Allow task queue to process - await vi.runAllTimers() - - expect(receiveSpy).toHaveBeenCalledWith(message, "wss://test.relay") - }) - - it("should emit error on invalid JSON", () => { - const errorSpy = vi.fn() - socket.on(SocketEventType.Error, errorSpy) - - socket.open() - socket._ws.onmessage({ data: "invalid json" }) - - expect(errorSpy).toHaveBeenCalledWith("Invalid message received", "wss://test.relay") - }) - - it("should emit error on non-array message", () => { - const errorSpy = vi.fn() - socket.on(SocketEventType.Error, errorSpy) - - socket.open() - socket._ws.onmessage({ data: JSON.stringify({ not: "an array" }) }) - - expect(errorSpy).toHaveBeenCalledWith("Invalid message received", "wss://test.relay") - }) - }) - - describe("cleanup", () => { - it("should close socket and clear queues", () => { - socket.open() - - const ws = socket._ws - - socket.cleanup() - - expect(ws.close).toHaveBeenCalled() - expect(socket.listenerCount(SocketEventType.Send)).toBe(0) - }) - }) - - describe("error handling", () => { - it("should emit error status on websocket error", () => { - const statusSpy = vi.fn() - socket.on(SocketEventType.Status, statusSpy) - - socket.open() - socket._ws.onerror() - - expect(statusSpy).toHaveBeenCalledWith(SocketStatus.Error, "wss://test.relay") - }) - }) - - describe("attemptToOpen", () => { - it("should open socket if not already open", () => { - const openSpy = vi.spyOn(socket, "open") - - socket.attemptToOpen() - expect(openSpy).toHaveBeenCalled() - }) - - it("should not open socket if already open", () => { - const openSpy = vi.spyOn(socket, "open") - - socket.open() - socket.attemptToOpen() - - expect(openSpy).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/packages/net2/__tests__/pool.test.ts b/packages/net2/__tests__/pool.test.ts deleted file mode 100644 index 3683363..0000000 --- a/packages/net2/__tests__/pool.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" -import { Socket } from "../src/socket" -import { Pool, makeSocket } from "../src/pool" -import { normalizeRelayUrl } from "@welshman/util" - -vi.mock('isomorphic-ws', () => { - const WebSocket = vi.fn(function () { - setTimeout(() => this.onopen()) - }) - - WebSocket.prototype.send = vi.fn() - - WebSocket.prototype.close = vi.fn(function () { - this.onclose() - }) - - return { default: WebSocket } -}) - -describe("Pool", () => { - let pool: Pool - - beforeEach(() => { - pool = new Pool() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe("has", () => { - it("should return false for non-existent socket", () => { - expect(pool.has("wss://test.relay")).toBe(false) - }) - - it("should return true for existing socket, normalizing the url", () => { - pool.get("wss://test.relay/") - expect(pool.has("wss://test.relay")).toBe(true) - }) - }) - - describe("get", () => { - it("should create new socket if none exists, normalizing the relay url", () => { - const socket = pool.get("wss://test.relay") - - expect(socket.url).toEqual("wss://test.relay/") - }) - - it("should return existing socket if it exists", () => { - const firstSocket = pool.get("wss://test.relay") - const secondSocket = pool.get("wss://test.relay") - - expect(firstSocket).toBe(secondSocket) - }) - }) - - describe("subscribe", () => { - it("should notify subscribers of new sockets", () => { - const sub1 = vi.fn() - const sub2 = vi.fn() - - pool.subscribe(sub1) - pool.subscribe(sub2) - pool.get("wss://test.relay") - - expect(sub1).toHaveBeenCalledTimes(1) - expect(sub2).toHaveBeenCalledTimes(1) - }) - - it("should not notify subscribers for existing sockets", () => { - pool.get("wss://test.relay") - - const sub = vi.fn() - pool.subscribe(sub) - pool.get("wss://test.relay") - - expect(sub).not.toHaveBeenCalled() - }) - - it("should add subscription", () => { - const sub = vi.fn() - pool.subscribe(sub) - expect(pool._subs).toContain(sub) - }) - - it("should return unsubscribe function", () => { - const sub = vi.fn() - const unsubscribe = pool.subscribe(sub) - - unsubscribe() - - expect(pool._subs).not.toContain(sub) - }) - }) - - describe("remove", () => { - it("should remove and cleanup existing socket", () => { - const mockSocket = { url: "wss://test.relay", cleanup: vi.fn() } - - pool._data.set(mockSocket.url, mockSocket) - pool.remove(mockSocket.url) - - expect(mockSocket.cleanup).toHaveBeenCalled() - expect(pool._data.has(mockSocket.url)).toBe(false) - }) - - it("should do nothing for non-existent socket", () => { - pool.remove("wss://test.relay") - expect(pool._data.has("wss://test.relay")).toBe(false) - }) - }) - - describe("clear", () => { - it("should remove all sockets", () => { - const urls = ["wss://test1.relay", "wss://test2.relay"] - const mockSockets = urls.map(url => ({ url, cleanup: vi.fn() })) - - for (const mockSocket of mockSockets) { - pool._data.set(mockSocket.url, mockSocket) - } - - pool.clear() - - expect(pool._data.size).toBe(0) - mockSockets.forEach(socket => { - expect(socket.cleanup).toHaveBeenCalled() - }) - }) - }) -}) diff --git a/packages/net2/__tests__/publish.test.ts b/packages/net2/__tests__/publish.test.ts deleted file mode 100644 index 70157ed..0000000 --- a/packages/net2/__tests__/publish.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" -import { EventEmitter } from "events" -import { Unicast, Multicast, PublishEventType, PublishStatus, unicast, multicast } from "../src/publish" -import { AbstractAdapter, AdapterEventType } from "../src/adapter" -import { ClientMessageType, RelayMessage } from "../src/message" -import { SignedEvent, makeEvent } from "@welshman/util" -import { Nip01Signer } from '@welshman/signer' - -class MockAdapter extends AbstractAdapter { - constructor(readonly url: string, readonly send) { - super() - } - - get sockets() { - return [] - } - - get urls() { - return [this.url] - } - - receive = (message: RelayMessage) => { - this.emit(AdapterEventType.Receive, message, this.url) - } -} - -describe("Unicast", () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it("success works", async () => { - const sendSpy = vi.fn() - const adapter = new MockAdapter('1', sendSpy) - const signer = Nip01Signer.ephemeral() - const event = await signer.sign(makeEvent(1)) - - const pub = unicast({ - relay: '1', - context: {getAdapter: () => adapter}, - event, - }) - - const successSpy = vi.fn() - const failureSpy = vi.fn() - const completeSpy = vi.fn() - - pub.on(PublishEventType.Success, successSpy) - pub.on(PublishEventType.Failure, failureSpy) - pub.on(PublishEventType.Complete, completeSpy) - - await vi.advanceTimersByTimeAsync(200) - - expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event]) - - adapter.receive(["OK", event.id, true, "hi"]) - - await vi.runAllTimers() - - expect(successSpy).toHaveBeenCalledWith(event.id, "hi") - expect(failureSpy).not.toHaveBeenCalled() - expect(completeSpy).toHaveBeenCalled() - }) - - it("failure works", async () => { - const sendSpy = vi.fn() - const adapter = new MockAdapter('1', sendSpy) - const signer = Nip01Signer.ephemeral() - const event = await signer.sign(makeEvent(1)) - - const pub = unicast({ - relay: '1', - context: {getAdapter: () => adapter}, - event, - }) - - const successSpy = vi.fn() - const failureSpy = vi.fn() - const completeSpy = vi.fn() - - pub.on(PublishEventType.Success, successSpy) - pub.on(PublishEventType.Failure, failureSpy) - pub.on(PublishEventType.Complete, completeSpy) - - await vi.advanceTimersByTimeAsync(200) - - expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event]) - - adapter.receive(["OK", event.id, false, "hi"]) - - await vi.runAllTimers() - - expect(successSpy).not.toHaveBeenCalled() - expect(failureSpy).toHaveBeenCalledWith(event.id, "hi") - expect(completeSpy).toHaveBeenCalled() - }) - - it("timeout works", async () => { - const sendSpy = vi.fn() - const adapter = new MockAdapter('1', sendSpy) - const signer = Nip01Signer.ephemeral() - const event = await signer.sign(makeEvent(1)) - - const pub = unicast({ - relay: '1', - context: {getAdapter: () => adapter}, - event, - }) - - const successSpy = vi.fn() - const failureSpy = vi.fn() - const completeSpy = vi.fn() - const timeoutSpy = vi.fn() - - pub.on(PublishEventType.Success, successSpy) - pub.on(PublishEventType.Failure, failureSpy) - pub.on(PublishEventType.Complete, completeSpy) - pub.on(PublishEventType.Timeout, timeoutSpy) - - await vi.runAllTimers(200) - - expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event]) - - await vi.runAllTimers() - - expect(successSpy).not.toHaveBeenCalled() - expect(failureSpy).not.toHaveBeenCalled(event.id, "hi") - expect(completeSpy).toHaveBeenCalled() - expect(timeoutSpy).toHaveBeenCalled() - }) - - it("abort works", async () => { - const sendSpy = vi.fn() - const adapter = new MockAdapter('1', sendSpy) - const signer = Nip01Signer.ephemeral() - const event = await signer.sign(makeEvent(1)) - - const pub = unicast({ - relay: '1', - context: {getAdapter: () => adapter}, - event, - }) - - const successSpy = vi.fn() - const failureSpy = vi.fn() - const completeSpy = vi.fn() - const abortSpy = vi.fn() - - pub.on(PublishEventType.Success, successSpy) - pub.on(PublishEventType.Failure, failureSpy) - pub.on(PublishEventType.Complete, completeSpy) - pub.on(PublishEventType.Timeout, abortSpy) - - await vi.runAllTimers(200) - - expect(sendSpy).toHaveBeenCalledWith([ClientMessageType.Event, event]) - - pub.abort() - - await vi.runAllTimers() - - expect(successSpy).not.toHaveBeenCalled() - expect(failureSpy).not.toHaveBeenCalled(event.id, "hi") - expect(completeSpy).toHaveBeenCalled() - expect(abortSpy).toHaveBeenCalled() - }) -}) - -describe("Multicast", () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it("should all basically work", async () => { - const send1Spy = vi.fn() - const adapter1 = new MockAdapter('1', send1Spy) - const send2Spy = vi.fn() - const adapter2 = new MockAdapter('2', send2Spy) - const send3Spy = vi.fn() - const adapter3 = new MockAdapter('3', send3Spy) - const signer = Nip01Signer.ephemeral() - const event = await signer.sign(makeEvent(1)) - - const pub = multicast({ - event, - relays: ['1', '2', '3'], - context: { - getAdapter: (url: string) => { - switch(url) { - case '1': return adapter1 - case '2': return adapter2 - case '3': return adapter3 - default: throw new Error(`Unknown relay: ${url}`) - } - }, - } - }) - - const successSpy = vi.fn() - const failureSpy = vi.fn() - const completeSpy = vi.fn() - const timeoutSpy = vi.fn() - - pub.on(PublishEventType.Success, successSpy) - pub.on(PublishEventType.Failure, failureSpy) - pub.on(PublishEventType.Complete, completeSpy) - pub.on(PublishEventType.Timeout, timeoutSpy) - - adapter1.receive(["OK", event.id, true, "hi"]) - adapter2.receive(["OK", event.id, false, "hi"]) - - - await vi.runAllTimers() - - expect(successSpy).toHaveBeenCalledWith(event.id, "hi", "1") - expect(failureSpy).toHaveBeenCalledWith(event.id, "hi", "2") - expect(completeSpy).toHaveBeenCalledTimes(1) - expect(timeoutSpy).toHaveBeenCalledWith("3") - }) -}) diff --git a/packages/net2/__tests__/tracker.test.ts b/packages/net2/__tests__/tracker.test.ts deleted file mode 100644 index 558518d..0000000 --- a/packages/net2/__tests__/tracker.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import {Tracker} from "../src/Tracker" -import {vi, describe, it, expect, beforeEach} from "vitest" - -describe("Tracker", () => { - let tracker: Tracker - - beforeEach(() => { - tracker = new Tracker() - }) - - describe("basic operations", () => { - it("should initialize with empty maps", () => { - expect(tracker.relaysById.size).toBe(0) - expect(tracker.idsByRelay.size).toBe(0) - }) - - it("should return empty set for non-existent relay", () => { - expect(tracker.getIds("relay1")).toEqual(new Set()) - }) - - it("should return empty set for non-existent event", () => { - expect(tracker.getRelays("event1")).toEqual(new Set()) - }) - }) - - describe("addRelay", () => { - it("should add new relay-event pair", () => { - tracker.addRelay("event1", "relay1") - - expect(tracker.hasRelay("event1", "relay1")).toBe(true) - expect(tracker.getRelays("event1")).toEqual(new Set(["relay1"])) - // expect(tracker.getIds("relay1")).toEqual(new Set(["event1"])) - }) - - it("should not duplicate existing pairs", () => { - const updateSpy = vi.fn() - tracker.on("update", updateSpy) - - tracker.addRelay("event1", "relay1") - tracker.addRelay("event1", "relay1") - - // expect(updateSpy).toHaveBeenCalledTimes(1) - expect(tracker.getRelays("event1").size).toBe(1) - }) - - it("should emit update event", () => { - const updateSpy = vi.fn() - tracker.on("update", updateSpy) - - tracker.addRelay("event1", "relay1") - - expect(updateSpy).toHaveBeenCalled() - }) - }) - - describe("removeRelay", () => { - beforeEach(() => { - tracker.addRelay("event1", "relay1") - }) - - it("should remove existing relay-event pair", () => { - tracker.removeRelay("event1", "relay1") - - expect(tracker.hasRelay("event1", "relay1")).toBe(false) - expect(tracker.getRelays("event1").size).toBe(0) - expect(tracker.getIds("relay1").size).toBe(0) - }) - - it("should emit update event on successful removal", () => { - const updateSpy = vi.fn() - tracker.on("update", updateSpy) - - tracker.removeRelay("event1", "relay1") - - expect(updateSpy).toHaveBeenCalled() - }) - - it("should not emit update event if nothing was removed", () => { - const updateSpy = vi.fn() - tracker.on("update", updateSpy) - - tracker.removeRelay("nonexistent", "relay1") - - expect(updateSpy).not.toHaveBeenCalled() - }) - }) - - describe("track", () => { - it("should return false for first occurrence", () => { - const seen = tracker.track("event1", "relay1") - expect(seen).toBe(false) - }) - - it("should return true for subsequent occurrences", () => { - tracker.track("event1", "relay1") - const seen = tracker.track("event1", "relay2") - expect(seen).toBe(true) - }) - - it("should add relay-event pair", () => { - tracker.track("event1", "relay1") - expect(tracker.hasRelay("event1", "relay1")).toBe(true) - }) - }) - - describe("copy", () => { - it("should copy relays from one event to another", () => { - tracker.addRelay("event1", "relay1") - tracker.addRelay("event1", "relay2") - - tracker.copy("event1", "event2") - - expect(tracker.getRelays("event2")).toEqual(tracker.getRelays("event1")) - }) - - it("should handle copying from non-existent event", () => { - tracker.copy("nonexistent", "event2") - expect(tracker.getRelays("event2").size).toBe(0) - }) - }) - - describe("load", () => { - it("should load data from relaysById map", () => { - const data = new Map([ - ["event1", new Set(["relay1", "relay2"])], - ["event2", new Set(["relay2", "relay3"])], - ]) - - tracker.load(data) - - expect(tracker.getRelays("event1")).toEqual(new Set(["relay1", "relay2"])) - expect(tracker.getIds("relay2")).toEqual(new Set(["event1", "event2"])) - }) - - it("should clear existing data before loading", () => { - tracker.addRelay("oldEvent", "oldRelay") - - tracker.load(new Map([["event1", new Set(["relay1"])]])) - - expect(tracker.hasRelay("oldEvent", "oldRelay")).toBe(undefined) - }) - - it("should emit update event", () => { - const updateSpy = vi.fn() - tracker.on("update", updateSpy) - - tracker.load(new Map()) - - expect(updateSpy).toHaveBeenCalled() - }) - }) - - describe("clear", () => { - beforeEach(() => { - tracker.addRelay("event1", "relay1") - tracker.addRelay("event2", "relay2") - }) - - it("should clear all data", () => { - tracker.clear() - - expect(tracker.relaysById.size).toBe(0) - expect(tracker.idsByRelay.size).toBe(0) - }) - - it("should emit update event", () => { - const updateSpy = vi.fn() - tracker.on("update", updateSpy) - - tracker.clear() - - expect(updateSpy).toHaveBeenCalled() - }) - }) - - describe("edge cases", () => { - it("should handle removing non-existent pairs", () => { - expect(() => tracker.removeRelay("nonexistent", "relay1")).not.toThrow() - }) - - it("should maintain bidirectional consistency", () => { - tracker.addRelay("event1", "relay1") - - // Check both maps are consistent - expect(tracker.relaysById.get("event1")?.has("relay1")).toBe(true) - // expect(tracker.idsByRelay.get("relay1")?.has("event1")).toBe(true) - }) - }) -}) diff --git a/packages/net2/package.json b/packages/net2/package.json deleted file mode 100644 index 810ace7..0000000 --- a/packages/net2/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@welshman/net2", - "version": "0.0.48", - "author": "hodlbod", - "license": "MIT", - "description": "Utilities for connecting with nostr relays.", - "publishConfig": { - "access": "public" - }, - "type": "module", - "files": [ - "build" - ], - "types": "./build/src/index.d.ts", - "exports": { - ".": { - "types": "./build/src/index.d.ts", - "import": "./build/src/index.js", - "require": "./build/src/index.js" - } - }, - "scripts": { - "pub": "npm run lint && npm run build && npm publish", - "build": "gts clean && tsc", - "lint": "gts lint", - "fix": "gts fix" - }, - "dependencies": { - "@welshman/lib": "^0.1.0", - "@welshman/util": "^0.1.0", - "isomorphic-ws": "^5.0.0", - "typed-emitter": "^2.1.0" - } -} diff --git a/packages/net2/src/index.ts b/packages/net2/src/index.ts deleted file mode 100644 index 924a8f2..0000000 --- a/packages/net2/src/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * from "./adapter.js" -export * from "./auth.js" -export * from "./diff.js" -export * from "./message.js" -export * from "./negentropy.js" -export * from "./policy.js" -export * from "./pool.js" -export * from "./publish.js" -export * from "./socket.js" -export * from "./request.js" -export * from "./tracker.js" diff --git a/packages/net2/src/negentropy.ts b/packages/net2/src/negentropy.ts deleted file mode 100644 index 73a91b4..0000000 --- a/packages/net2/src/negentropy.ts +++ /dev/null @@ -1,590 +0,0 @@ -// (C) 2023 Doug Hoyte. MIT license -// @ts-nocheck - -const PROTOCOL_VERSION = 0x61 // Version 1 -const ID_SIZE = 32 -const FINGERPRINT_SIZE = 16 - -const Mode = { - Skip: 0, - Fingerprint: 1, - IdList: 2, -} - -class WrappedBuffer { - constructor(buffer) { - this._raw = new Uint8Array(buffer || 512) - this.length = buffer ? buffer.length : 0 - } - - unwrap() { - return this._raw.subarray(0, this.length) - } - - get capacity() { - return this._raw.byteLength - } - - extend(buf) { - if (buf._raw) buf = buf.unwrap() - if (typeof(buf.length) !== 'number') throw Error("bad length") - const targetSize = buf.length + this.length - if (this.capacity < targetSize) { - const oldRaw = this._raw - const newCapacity = Math.max(this.capacity * 2, targetSize) - this._raw = new Uint8Array(newCapacity) - this._raw.set(oldRaw) - } - - this._raw.set(buf, this.length) - this.length += buf.length - } - - shift() { - const first = this._raw[0] - this._raw = this._raw.subarray(1) - this.length-- - return first - } - - shiftN(n = 1) { - const firstSubarray = this._raw.subarray(0, n) - this._raw = this._raw.subarray(n) - this.length -= n - return firstSubarray - } -} - -function decodeVarInt(buf) { - let res = 0 - - while (1) { - if (buf.length === 0) throw Error("parse ends prematurely") - const byte = buf.shift() - res = (res << 7) | (byte & 127) - if ((byte & 128) === 0) break - } - - return res -} - -function encodeVarInt(n) { - if (n === 0) return new WrappedBuffer([0]) - - const o = [] - - while (n !== 0) { - o.push(n & 127) - n >>>= 7 - } - - o.reverse() - - for (let i = 0; i < o.length - 1; i++) o[i] |= 128 - - return new WrappedBuffer(o) -} - -function getByte(buf) { - return getBytes(buf, 1)[0] -} - -function getBytes(buf, n) { - if (buf.length < n) throw Error("parse ends prematurely") - return buf.shiftN(n) -} - - -class Accumulator { - constructor() { - this.setToZero() - - if (typeof window === 'undefined') { // node.js - const crypto = require('crypto') - this.sha256 = async (slice) => new Uint8Array(crypto.createHash('sha256').update(slice).digest()) - } else { // browser - this.sha256 = async (slice) => new Uint8Array(await crypto.subtle.digest("SHA-256", slice)) - } - } - - setToZero() { - this.buf = new Uint8Array(ID_SIZE) - } - - add(otherBuf) { - let currCarry = 0, nextCarry = 0 - const p = new DataView(this.buf.buffer) - const po = new DataView(otherBuf.buffer) - - for (let i = 0; i < 8; i++) { - const offset = i * 4 - const orig = p.getUint32(offset, true) - const otherV = po.getUint32(offset, true) - - let next = orig - - next += currCarry - next += otherV - if (next > 0xFFFFFFFF) nextCarry = 1 - - p.setUint32(offset, next & 0xFFFFFFFF, true) - currCarry = nextCarry - nextCarry = 0 - } - } - - negate() { - const p = new DataView(this.buf.buffer) - - for (let i = 0; i < 8; i++) { - const offset = i * 4 - p.setUint32(offset, ~p.getUint32(offset, true)) - } - - const one = new Uint8Array(ID_SIZE) - one[0] = 1 - this.add(one) - } - - async getFingerprint(n) { - const input = new WrappedBuffer() - input.extend(this.buf) - input.extend(encodeVarInt(n)) - - const hash = await this.sha256(input.unwrap()) - - return hash.subarray(0, FINGERPRINT_SIZE) - } -} - - -class NegentropyStorageVector { - constructor() { - this.items = [] - this.sealed = false - } - - insert(timestamp, id) { - if (this.sealed) throw Error("already sealed") - id = loadInputBuffer(id) - if (id.byteLength !== ID_SIZE) throw Error("bad id size for added item") - this.items.push({timestamp, id}) - } - - seal() { - if (this.sealed) throw Error("already sealed") - this.sealed = true - - this.items.sort(itemCompare) - - for (let i = 1; i < this.items.length; i++) { - if (itemCompare(this.items[i - 1], this.items[i]) === 0) throw Error("duplicate item inserted") - } - } - - unseal() { - this.sealed = false - } - - size() { - this._checkSealed() - return this.items.length - } - - getItem(i) { - this._checkSealed() - if (i >= this.items.length) throw Error("out of range") - return this.items[i] - } - - iterate(begin, end, cb) { - this._checkSealed() - this._checkBounds(begin, end) - - for (let i = begin; i < end; ++i) { - if (!cb(this.items[i], i)) break - } - } - - findLowerBound(begin, end, bound) { - this._checkSealed() - this._checkBounds(begin, end) - - return this._binarySearch(this.items, begin, end, (a) => itemCompare(a, bound) < 0) - } - - async fingerprint(begin, end) { - const out = new Accumulator() - out.setToZero() - - this.iterate(begin, end, (item, i) => { - out.add(item.id) - return true - }) - - return await out.getFingerprint(end - begin) - } - - _checkSealed() { - if (!this.sealed) throw Error("not sealed") - } - - _checkBounds(begin, end) { - if (begin > end || end > this.items.length) throw Error("bad range") - } - - _binarySearch(arr, first, last, cmp) { - let count = last - first - - while (count > 0) { - let it = first - const step = Math.floor(count / 2) - it += step - - if (cmp(arr[it])) { - first = ++it - count -= step + 1 - } else { - count = step - } - } - - return first - } -} - - -class Negentropy { - constructor(storage, frameSizeLimit = 0) { - if (frameSizeLimit !== 0 && frameSizeLimit < 4096) throw Error("frameSizeLimit too small") - - this.storage = storage - this.frameSizeLimit = frameSizeLimit - - this.lastTimestampIn = 0 - this.lastTimestampOut = 0 - } - - _bound(timestamp, id) { - return {timestamp, id: id ? id : new Uint8Array(0)} - } - - async initiate() { - if (this.isInitiator) throw Error("already initiated") - this.isInitiator = true - - const output = new WrappedBuffer() - output.extend([PROTOCOL_VERSION]) - - await this.splitRange(0, this.storage.size(), this._bound(Number.MAX_VALUE), output) - - return this._renderOutput(output) - } - - setInitiator() { - this.isInitiator = true - } - - async reconcile(query) { - const haveIds = [], needIds = [] - query = new WrappedBuffer(loadInputBuffer(query)) - - this.lastTimestampIn = this.lastTimestampOut = 0 // reset for each message - - const fullOutput = new WrappedBuffer() - fullOutput.extend([PROTOCOL_VERSION]) - - const protocolVersion = getByte(query) - if (protocolVersion < 0x60 || protocolVersion > 0x6F) throw Error("invalid negentropy protocol version byte") - if (protocolVersion !== PROTOCOL_VERSION) { - if (this.isInitiator) throw Error("unsupported negentropy protocol version requested: " + (protocolVersion - 0x60)) - else return [this._renderOutput(fullOutput), haveIds, needIds] - } - - const storageSize = this.storage.size() - let prevBound = this._bound(0) - let prevIndex = 0 - let skip = false - - while (query.length !== 0) { - let o = new WrappedBuffer() - - const doSkip = () => { - if (skip) { - skip = false - o.extend(this.encodeBound(prevBound)) - o.extend(encodeVarInt(Mode.Skip)) - } - } - - const currBound = this.decodeBound(query) - const mode = decodeVarInt(query) - - const lower = prevIndex - let upper = this.storage.findLowerBound(prevIndex, storageSize, currBound) - - if (mode === Mode.Skip) { - skip = true - } else if (mode === Mode.Fingerprint) { - const theirFingerprint = getBytes(query, FINGERPRINT_SIZE) - const ourFingerprint = await this.storage.fingerprint(lower, upper) - - if (compareUint8Array(theirFingerprint, ourFingerprint) !== 0) { - doSkip() - await this.splitRange(lower, upper, currBound, o) - } else { - skip = true - } - } else if (mode === Mode.IdList) { - const numIds = decodeVarInt(query) - - const theirElems = {} // stringified Uint8Array -> original Uint8Array (or hex) - for (let i = 0; i < numIds; i++) { - const e = getBytes(query, ID_SIZE) - if (this.isInitiator) theirElems[e] = e - } - - if (this.isInitiator) { - skip = true - - this.storage.iterate(lower, upper, (item) => { - const k = item.id - - if (!theirElems[k]) { - // ID exists on our side, but not their side - if (this.isInitiator) haveIds.push(this.wantUint8ArrayOutput ? k : uint8ArrayToHex(k)) - } else { - // ID exists on both sides - delete theirElems[k] - } - - return true - }) - - for (const v of Object.values(theirElems)) { - // ID exists on their side, but not our side - needIds.push(this.wantUint8ArrayOutput ? v : uint8ArrayToHex(v)) - } - } else { - doSkip() - - const responseIds = new WrappedBuffer() - let numResponseIds = 0 - let endBound = currBound - - this.storage.iterate(lower, upper, (item, index) => { - if (this.exceededFrameSizeLimit(fullOutput.length + responseIds.length)) { - endBound = item - upper = index // shrink upper so that remaining range gets correct fingerprint - return false - } - - responseIds.extend(item.id) - numResponseIds++ - return true - }) - - o.extend(this.encodeBound(endBound)) - o.extend(encodeVarInt(Mode.IdList)) - o.extend(encodeVarInt(numResponseIds)) - o.extend(responseIds) - - fullOutput.extend(o) - o = new WrappedBuffer() - } - } else { - throw Error("unexpected mode") - } - - if (this.exceededFrameSizeLimit(fullOutput.length + o.length)) { - // frameSizeLimit exceeded: Stop range processing and return a fingerprint for the remaining range - const remainingFingerprint = await this.storage.fingerprint(upper, storageSize) - - fullOutput.extend(this.encodeBound(this._bound(Number.MAX_VALUE))) - fullOutput.extend(encodeVarInt(Mode.Fingerprint)) - fullOutput.extend(remainingFingerprint) - break - } else { - fullOutput.extend(o) - } - - prevIndex = upper - prevBound = currBound - } - - return [fullOutput.length === 1 && this.isInitiator ? null : this._renderOutput(fullOutput), haveIds, needIds] - } - - async splitRange(lower, upper, upperBound, o) { - const numElems = upper - lower - const buckets = 16 - - if (numElems < buckets * 2) { - o.extend(this.encodeBound(upperBound)) - o.extend(encodeVarInt(Mode.IdList)) - - o.extend(encodeVarInt(numElems)) - this.storage.iterate(lower, upper, (item) => { - o.extend(item.id) - return true - }) - } else { - const itemsPerBucket = Math.floor(numElems / buckets) - const bucketsWithExtra = numElems % buckets - let curr = lower - - for (let i = 0; i < buckets; i++) { - const bucketSize = itemsPerBucket + (i < bucketsWithExtra ? 1 : 0) - const ourFingerprint = await this.storage.fingerprint(curr, curr + bucketSize) - curr += bucketSize - - let nextBound - - if (curr === upper) { - nextBound = upperBound - } else { - let prevItem, currItem - - this.storage.iterate(curr - 1, curr + 1, (item, index) => { - if (index === curr - 1) prevItem = item - else currItem = item - return true - }) - - nextBound = this.getMinimalBound(prevItem, currItem) - } - - o.extend(this.encodeBound(nextBound)) - o.extend(encodeVarInt(Mode.Fingerprint)) - o.extend(ourFingerprint) - } - } - } - - _renderOutput(o) { - o = o.unwrap() - if (!this.wantUint8ArrayOutput) o = uint8ArrayToHex(o) - return o - } - - exceededFrameSizeLimit(n) { - return this.frameSizeLimit && n > this.frameSizeLimit - 200 - } - - // Decoding - - decodeTimestampIn(encoded) { - let timestamp = decodeVarInt(encoded) - timestamp = timestamp === 0 ? Number.MAX_VALUE : timestamp - 1 - if (this.lastTimestampIn === Number.MAX_VALUE || timestamp === Number.MAX_VALUE) { - this.lastTimestampIn = Number.MAX_VALUE - return Number.MAX_VALUE - } - timestamp += this.lastTimestampIn - this.lastTimestampIn = timestamp - return timestamp - } - - decodeBound(encoded) { - const timestamp = this.decodeTimestampIn(encoded) - const len = decodeVarInt(encoded) - if (len > ID_SIZE) throw Error("bound key too long") - const id = getBytes(encoded, len) - return {timestamp, id} - } - - // Encoding - - encodeTimestampOut(timestamp) { - if (timestamp === Number.MAX_VALUE) { - this.lastTimestampOut = Number.MAX_VALUE - return encodeVarInt(0) - } - - const temp = timestamp - timestamp -= this.lastTimestampOut - this.lastTimestampOut = temp - return encodeVarInt(timestamp + 1) - } - - encodeBound(key) { - const output = new WrappedBuffer() - - output.extend(this.encodeTimestampOut(key.timestamp)) - output.extend(encodeVarInt(key.id.length)) - output.extend(key.id) - - return output - } - - getMinimalBound(prev, curr) { - if (curr.timestamp !== prev.timestamp) { - return this._bound(curr.timestamp) - } else { - let sharedPrefixBytes = 0 - const currKey = curr.id - const prevKey = prev.id - - for (let i = 0; i < ID_SIZE; i++) { - if (currKey[i] !== prevKey[i]) break - sharedPrefixBytes++ - } - - return this._bound(curr.timestamp, curr.id.subarray(0, sharedPrefixBytes + 1)) - } - } -} - -function loadInputBuffer(inp) { - if (typeof(inp) === 'string') inp = hexToUint8Array(inp) - else if (__proto__ !== Uint8Array.prototype) inp = new Uint8Array(inp) // node Buffer? - return inp -} - -function hexToUint8Array(h) { - if (h.startsWith('0x')) h = h.substr(2) - if (h.length % 2 === 1) throw Error("odd length of hex string") - const arr = new Uint8Array(h.length / 2) - for (let i = 0; i < arr.length; i++) arr[i] = parseInt(h.substr(i * 2, 2), 16) - return arr -} - -const uint8ArrayToHexLookupTable = new Array(256) -{ - const hexAlphabet = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'] - for (let i = 0; i < 256; i++) { - uint8ArrayToHexLookupTable[i] = hexAlphabet[(i >>> 4) & 0xF] + hexAlphabet[i & 0xF] - } -} - -function uint8ArrayToHex(arr) { - let out = '' - for (let i = 0, edx = arr.length; i < edx; i++) { - out += uint8ArrayToHexLookupTable[arr[i]] - } - return out -} - - -function compareUint8Array(a, b) { - for (let i = 0; i < a.byteLength; i++) { - if (a[i] < b[i]) return -1 - if (a[i] > b[i]) return 1 - } - - if (a.byteLength > b.byteLength) return 1 - if (a.byteLength < b.byteLength) return -1 - - return 0 -} - -function itemCompare(a, b) { - if (a.timestamp === b.timestamp) { - return compareUint8Array(a.id, b.id) - } - - return a.timestamp - b.timestamp -} - - -export {Negentropy, NegentropyStorageVector,} diff --git a/packages/net2/src/pool.ts b/packages/net2/src/pool.ts deleted file mode 100644 index 190b2de..0000000 --- a/packages/net2/src/pool.ts +++ /dev/null @@ -1,82 +0,0 @@ -import {remove} from "@welshman/lib" -import {normalizeRelayUrl} from "@welshman/util" -import {Socket} from "./socket.js" -import {defaultSocketPolicies} from "./policy.js" - -export const makeSocket = (url: string, policies = defaultSocketPolicies) => { - const socket = new Socket(url) - - for (const applyPolicy of policies) { - applyPolicy(socket) - } - - return socket -} - -export type PoolSubscription = (socket: Socket) => void - -export type PoolOptions = { - makeSocket?: (url: string) => Socket -} - -export class Pool { - _data = new Map() - _subs: PoolSubscription[] = [] - - constructor(readonly options: PoolOptions = {}) {} - - has(url: string) { - return this._data.has(normalizeRelayUrl(url)) - } - - makeSocket(url: string) { - if (this.options.makeSocket) { - return this.options.makeSocket(url) - } - - return makeSocket(url) - } - - get(_url: string): Socket { - const url = normalizeRelayUrl(_url) - const oldSocket = this._data.get(url) - - if (oldSocket) { - return oldSocket - } - - const socket = this.makeSocket(url) - - this._data.set(url, socket) - - for (const cb of this._subs) { - cb(socket) - } - - return socket - } - - subscribe(cb: PoolSubscription) { - this._subs.push(cb) - - return () => { - this._subs = remove(cb, this._subs) - } - } - - remove(url: string) { - const socket = this._data.get(url) - - if (socket) { - socket.cleanup() - - this._data.delete(url) - } - } - - clear() { - for (const url of this._data.keys()) { - this.remove(url) - } - } -} diff --git a/packages/net2/src/publish.ts b/packages/net2/src/publish.ts deleted file mode 100644 index eccf946..0000000 --- a/packages/net2/src/publish.ts +++ /dev/null @@ -1,186 +0,0 @@ -import {EventEmitter} from "events" -import {on, fromPairs, sleep, yieldThread} from "@welshman/lib" -import {SignedEvent} from "@welshman/util" -import {RelayMessage, ClientMessageType, isRelayOk} from "./message.js" -import {AbstractAdapter, AdapterEventType, AdapterContext, getAdapter} from "./adapter.js" -import {TypedEmitter} from "./util.js" - -export enum PublishStatus { - Pending = "publish:status:pending", - Success = "publish:status:success", - Failure = "publish:status:failure", - Timeout = "publish:status:timeout", - Aborted = "publish:status:aborted", -} - -export enum PublishEventType { - Success = "publish:event:success", - Failure = "publish:event:failure", - Timeout = "publish:event:timeout", - Aborted = "publish:event:aborted", - Complete = "publish:event:complete", -} - -// Unicast - -export type UnicastEvents = { - [PublishEventType.Success]: (id: string, detail: string) => void - [PublishEventType.Failure]: (id: string, detail: string) => void - [PublishEventType.Timeout]: () => void - [PublishEventType.Aborted]: () => void - [PublishEventType.Complete]: () => void -} - -export type UnicastOptions = { - event: SignedEvent - relay: string - context: AdapterContext - timeout?: number -} - -export class Unicast extends (EventEmitter as new () => TypedEmitter) { - status = PublishStatus.Pending - - _unsubscriber: () => void - _adapter: AbstractAdapter - - constructor(readonly options: UnicastOptions) { - super() - - // Set up our adapter - this._adapter = getAdapter(this.options.relay, this.options.context) - - // Listen for Unicast result - this._unsubscriber = on( - this._adapter, - AdapterEventType.Receive, - (message: RelayMessage, url: string) => { - if (isRelayOk(message)) { - const [_, id, ok, detail] = message - - - if (id !== this.options.event.id) return - - if (ok) { - this.status = PublishStatus.Success - this.emit(PublishEventType.Success, id, detail) - } else { - this.status = PublishStatus.Failure - this.emit(PublishEventType.Failure, id, detail) - } - - this.cleanup() - } - }, - ) - - // Set timeout - sleep(this.options.timeout || 10_000).then(() => { - if (this.status === PublishStatus.Pending) { - this.status = PublishStatus.Timeout - this.emit(PublishEventType.Timeout) - } - - this.cleanup() - }) - - // Start asynchronously so the caller can set up listeners - yieldThread().then(() => { - this._adapter.send([ClientMessageType.Event, this.options.event]) - }) - } - - abort = () => { - if (this.status === PublishStatus.Pending) { - this.status = PublishStatus.Aborted - this.emit(PublishEventType.Aborted) - this.cleanup() - } - } - - cleanup = () => { - this.emit(PublishEventType.Complete) - this.removeAllListeners() - this._adapter.cleanup() - this._unsubscriber() - } -} - -// Multicast - -export type MulticastEvents = { - [PublishEventType.Success]: (id: string, detail: string, url: string) => void - [PublishEventType.Failure]: (id: string, detail: string, url: string) => void - [PublishEventType.Timeout]: (url: string) => void - [PublishEventType.Aborted]: (url: string) => void - [PublishEventType.Complete]: () => void -} - -export type MulticastOptions = Omit & { - relays: string[] -} - -export class Multicast extends (EventEmitter as new () => TypedEmitter) { - status: Record - - _children: Unicast[] = [] - _completed = new Set() - - 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) diff --git a/packages/net2/src/socket.ts b/packages/net2/src/socket.ts deleted file mode 100644 index 203bb4a..0000000 --- a/packages/net2/src/socket.ts +++ /dev/null @@ -1,130 +0,0 @@ -import WebSocket from "isomorphic-ws" -import EventEmitter from "events" -import {TaskQueue} from "@welshman/lib" -import {RelayMessage, ClientMessage} from "./message.js" -import {TypedEmitter} from "./util.js" - -export enum SocketStatus { - Open = "socket:status:open", - Opening = "socket:status:opening", - Closing = "socket:status:closing", - Closed = "socket:status:closed", - Error = "socket:status:error", - Invalid = "socket:status:invalid", -} - -export enum SocketEventType { - Error = "socket:event:error", - Status = "socket:event:status", - Send = "socket:event:send", - Enqueue = "socket:event:enqueue", - Receive = "socket:event:receive", -} - -export type SocketEvents = { - [SocketEventType.Error]: (error: string, url: string) => void - [SocketEventType.Status]: (status: SocketStatus, url: string) => void - [SocketEventType.Send]: (message: ClientMessage, url: string) => void - [SocketEventType.Enqueue]: (message: ClientMessage, url: string) => void - [SocketEventType.Receive]: (message: RelayMessage, url: string) => void -} - -export class Socket extends (EventEmitter as new () => TypedEmitter) { - readonly status = SocketStatus.Closed - - _ws?: WebSocket - _sendQueue: TaskQueue - _recvQueue: TaskQueue - - constructor(readonly url: string) { - super() - - this._sendQueue = new TaskQueue({ - batchSize: 50, - processItem: (message: ClientMessage) => { - this._ws?.send(JSON.stringify(message)) - this.emit(SocketEventType.Send, message, this.url) - }, - }) - - this._recvQueue = new TaskQueue({ - batchSize: 50, - processItem: (message: RelayMessage) => { - this.emit(SocketEventType.Receive, message, this.url) - }, - }) - - this.on(SocketEventType.Status, (status: SocketStatus) => { - this.status = status - }) - } - - open = () => { - if (this._ws) { - throw new Error("Attempted to open a websocket that has not been closed") - } - - try { - this._ws = new WebSocket(this.url) - this.emit(SocketEventType.Status, SocketStatus.Opening, this.url) - - this._ws.onopen = () => { - this.emit(SocketEventType.Status, SocketStatus.Open, this.url) - this._sendQueue.start() - } - - this._ws.onerror = () => { - this.emit(SocketEventType.Status, SocketStatus.Error, this.url) - this._sendQueue.stop() - this._ws = undefined - } - - this._ws.onclose = () => { - this.emit(SocketEventType.Status, SocketStatus.Closed, this.url) - this._sendQueue.stop() - this._ws = undefined - } - - this._ws.onmessage = (event: any) => { - const data = event.data as string - - try { - const message = JSON.parse(data) - - if (Array.isArray(message)) { - this._recvQueue.push(message as RelayMessage) - } else { - this.emit(SocketEventType.Error, "Invalid message received", this.url) - } - } catch (e) { - this.emit(SocketEventType.Error, "Invalid message received", this.url) - } - } - } catch (e) { - this.emit(SocketEventType.Status, SocketStatus.Invalid, this.url) - } - } - - attemptToOpen = () => { - if (!this._ws) { - this.open() - } - } - - close = () => { - this._ws?.close() - this._ws = undefined - } - - cleanup = () => { - this.close() - this._recvQueue.clear() - this._sendQueue.clear() - this.removeAllListeners() - } - - send = (message: ClientMessage) => { - this._sendQueue.push(message) - this.emit(SocketEventType.Enqueue, message, this.url) - } -} diff --git a/packages/net2/src/tracker.ts b/packages/net2/src/tracker.ts deleted file mode 100644 index 81630fe..0000000 --- a/packages/net2/src/tracker.ts +++ /dev/null @@ -1,85 +0,0 @@ -import {Emitter, addToMapKey} from "@welshman/lib" - -export class Tracker extends Emitter { - relaysById = new Map>() - idsByRelay = new Map>() - - constructor() { - super() - - this.setMaxListeners(100) - } - - getIds = (relay: string) => this.idsByRelay.get(relay) || new Set() - - getRelays = (eventId: string) => this.relaysById.get(eventId) || new Set() - - hasRelay = (eventId: string, relay: string) => this.relaysById.get(eventId)?.has(relay) - - addRelay = (eventId: string, relay: string) => { - let relays = this.relaysById.get(eventId) - let ids = this.idsByRelay.get(relay) - - if (relays?.has(relay) && ids?.has(eventId)) return - - if (!relays) { - relays = new Set() - } - - if (!ids) { - ids = new Set() - } - - relays.add(relay) - ids.add(eventId) - - this.relaysById.set(eventId, relays) - this.idsByRelay.set(relay, ids) - - this.emit("update") - } - - removeRelay = (eventId: string, relay: string) => { - const didDeleteRelay = this.relaysById.get(eventId)?.delete(relay) - const didDeleteId = this.idsByRelay.get(relay)?.delete(eventId) - - if (!didDeleteRelay && !didDeleteId) return - - this.emit("update") - } - - track = (eventId: string, relay: string) => { - const seen = this.relaysById.has(eventId) - - this.addRelay(eventId, relay) - - return seen - } - - copy = (eventId1: string, eventId2: string) => { - for (const relay of this.getRelays(eventId1)) { - this.addRelay(eventId2, relay) - } - } - - load = (relaysById: Tracker["relaysById"]) => { - this.relaysById.clear() - this.idsByRelay.clear() - - for (const [id, relays] of relaysById.entries()) { - for (const relay of relays) { - addToMapKey(this.relaysById, id, relay) - addToMapKey(this.idsByRelay, relay, id) - } - } - - this.emit("update") - } - - clear = () => { - this.relaysById.clear() - this.idsByRelay.clear() - - this.emit("update") - } -} diff --git a/packages/net2/tsconfig.json b/packages/net2/tsconfig.json deleted file mode 100644 index 97e6372..0000000 --- a/packages/net2/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../node_modules/gts/tsconfig-google.json", - "compilerOptions": { - "rootDir": ".", - "outDir": "build", - "module": "nodenext", - "moduleResolution": "nodenext", - "lib": ["esnext", "dom"] - }, - "include": [ - "src/**/*.ts", - "test/**/*.ts" - ] -} diff --git a/packages/net2/typedoc.json b/packages/net2/typedoc.json deleted file mode 100644 index 35fed2c..0000000 --- a/packages/net2/typedoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "entryPoints": ["src/index.ts"] -} diff --git a/vitest.config.ts b/vitest.config.ts index 3c1fc0e..3405bf6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,7 +20,6 @@ export default defineConfig({ "@welshman/feeds": resolve(__dirname, "packages/feeds/src"), "@welshman/lib": resolve(__dirname, "packages/lib/src"), "@welshman/net": resolve(__dirname, "packages/net/src"), - "@welshman/net2": resolve(__dirname, "packages/net2/src"), "@welshman/signer": resolve(__dirname, "packages/signer/src"), "@welshman/store": resolve(__dirname, "packages/store/src"), "@welshman/util": resolve(__dirname, "packages/util/src"),