diff --git a/.changeset/short-parts-march.md b/.changeset/short-parts-march.md new file mode 100644 index 000000000..ce34ed1aa --- /dev/null +++ b/.changeset/short-parts-march.md @@ -0,0 +1,5 @@ +--- +"@lightsparkdev/lightspark-sdk": patch +--- + +- Resolve circular dependencies diff --git a/.changeset/shy-bugs-march.md b/.changeset/shy-bugs-march.md new file mode 100644 index 000000000..26aa45269 --- /dev/null +++ b/.changeset/shy-bugs-march.md @@ -0,0 +1,5 @@ +--- +"@lightsparkdev/ui": patch +--- + +- Icon, component updates diff --git a/.changeset/tame-sites-mate.md b/.changeset/tame-sites-mate.md new file mode 100644 index 000000000..a874f0bfe --- /dev/null +++ b/.changeset/tame-sites-mate.md @@ -0,0 +1,6 @@ +--- +"@lightsparkdev/core": patch +--- + +- Resolve circular dependencies +- React Native compatible entrypoint diff --git a/packages/core/package.json b/packages/core/package.json index eb7ae5275..10a1110fb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,7 @@ "types": "./dist/index.d.ts", "exports": { ".": { + "react-native": "./dist/react-native/index.js", "import": "./dist/index.js", "require": "./dist/index.cjs" }, @@ -39,6 +40,7 @@ "src/*", "dist/*", "dist/utils/*", + "dist/react-native/*", "CHANGELOG.md" ], "scripts": { @@ -51,6 +53,7 @@ "lint:fix": "eslint --fix .", "lint:watch": "esw ./src -w --ext .ts,.tsx,.js --color", "lint": "eslint .", + "circular-deps": "madge --circular --extensions ts,tsx src", "package:checks": "yarn publint && yarn attw --pack .", "postversion": "yarn build", "test-cmd": "node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --bail", @@ -81,6 +84,7 @@ "eslint-watch": "^8.0.0", "jest": "^29.6.2", "lodash-es": "^4.17.21", + "madge": "^6.1.0", "prettier": "3.0.3", "prettier-plugin-organize-imports": "^3.2.4", "publint": "^0.3.9", @@ -89,6 +93,16 @@ "tsup": "^8.2.4", "typescript": "^5.6.2" }, + "madge": { + "detectiveOptions": { + "ts": { + "skipTypeImports": true + }, + "tsx": { + "skipTypeImports": true + } + } + }, "engines": { "node": ">=18" } diff --git a/packages/core/src/Logger.ts b/packages/core/src/Logger.ts index c27a05164..f5694fdf9 100644 --- a/packages/core/src/Logger.ts +++ b/packages/core/src/Logger.ts @@ -1,5 +1,6 @@ -import { ConfigKeys, getLocalStorageConfigItem } from "./index.js"; +import { ConfigKeys } from "./constants/index.js"; import { isBrowser, isTest } from "./utils/environment.js"; +import { getLocalStorageConfigItem } from "./utils/localStorage.js"; type GetLoggingEnabled = (() => Promise | boolean) | undefined; diff --git a/packages/core/src/crypto/SigningKey.ts b/packages/core/src/crypto/SigningKey.ts index a14cc28e8..8626e2e98 100644 --- a/packages/core/src/crypto/SigningKey.ts +++ b/packages/core/src/crypto/SigningKey.ts @@ -1,6 +1,8 @@ import secp256k1 from "secp256k1"; -import { SigningKeyType, type CryptoInterface } from "../index.js"; -import { createSha256Hash, hexToBytes } from "../utils/index.js"; +import { createSha256Hash } from "../utils/createHash.js"; +import { hexToBytes } from "../utils/hex.js"; +import type { CryptoInterface } from "./crypto.js"; +import { SigningKeyType } from "./types.js"; interface Alias { alias: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9988a8870..3dfd0c9e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,13 +1,5 @@ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved -export * from "./auth/index.js"; -export * from "./constants/index.js"; -export * from "./crypto/index.js"; -export { default as LightsparkException } from "./LightsparkException.js"; -export { Logger, LoggingLevel, logger } from "./Logger.js"; -export * from "./requester/index.js"; -export { - default as ServerEnvironment, - apiDomainForEnvironment, -} from "./ServerEnvironment.js"; -export * from "./utils/index.js"; +export { DefaultRequester as Requester } from "./requester/DefaultRequester.js"; +export { default as Query } from "./requester/Query.js"; +export * from "./shared.js"; diff --git a/packages/core/src/react-native/index.ts b/packages/core/src/react-native/index.ts new file mode 100644 index 000000000..aa8747e75 --- /dev/null +++ b/packages/core/src/react-native/index.ts @@ -0,0 +1,2 @@ +export * from "../requester/index.js"; +export * from "../shared.js"; diff --git a/packages/core/src/requester/DefaultRequester.ts b/packages/core/src/requester/DefaultRequester.ts new file mode 100644 index 000000000..a3e677e51 --- /dev/null +++ b/packages/core/src/requester/DefaultRequester.ts @@ -0,0 +1,46 @@ +import type AuthProvider from "../auth/AuthProvider.js"; +import { isBare, isNode } from "../utils/environment.js"; +import Requester from "./Requester.js"; + +export class DefaultRequester extends Requester { + protected async initWsClient(baseUrl: string, authProvider: AuthProvider) { + if (!this.resolveWsClient) { + /* If resolveWsClient is null assume already initialized: */ + return this.wsClient; + } + + if (isBare) { + /* graphql-ws library is currently not supported in Bare environment, see LIG-7942 */ + return null; + } + + let websocketImpl; + if (isNode && typeof WebSocket === "undefined") { + const wsModule = await import("ws"); + websocketImpl = wsModule.default; + } + let websocketProtocol = "wss"; + if (baseUrl.startsWith("http://")) { + websocketProtocol = "ws"; + } + + const graphqlWsModule = await import("graphql-ws"); + const { createClient } = graphqlWsModule; + + const wsClient = createClient({ + url: `${websocketProtocol}://${this.stripProtocol(this.baseUrl)}/${ + this.schemaEndpoint + }`, + connectionParams: () => authProvider.addWsConnectionParams({}), + webSocketImpl: websocketImpl, + }); + + if (this.resolveWsClient) { + this.resolveWsClient(wsClient); + this.resolveWsClient = null; + } + + return wsClient; + } +} +export default DefaultRequester; diff --git a/packages/core/src/requester/Requester.ts b/packages/core/src/requester/Requester.ts index 8a82c3965..f37b08d3f 100644 --- a/packages/core/src/requester/Requester.ts +++ b/packages/core/src/requester/Requester.ts @@ -17,7 +17,7 @@ import type { SigningKey } from "../crypto/SigningKey.js"; import LightsparkException from "../LightsparkException.js"; import { logger } from "../Logger.js"; import { b64encode } from "../utils/base64.js"; -import { isBare, isNode } from "../utils/environment.js"; +import { isNode } from "../utils/environment.js"; const DEFAULT_BASE_URL = "api.lightspark.com"; dayjs.extend(utc); @@ -31,14 +31,14 @@ type BodyData = { }; class Requester { - private wsClient: Promise; - private resolveWsClient: ((value: WsClient | null) => void) | null = null; + protected wsClient: Promise; + protected resolveWsClient: ((value: WsClient | null) => void) | null = null; constructor( private readonly nodeKeyCache: NodeKeyCache, - private readonly schemaEndpoint: string, + protected readonly schemaEndpoint: string, private readonly sdkUserAgent: string, private readonly authProvider: AuthProvider = new StubAuthProvider(), - private readonly baseUrl: string = DEFAULT_BASE_URL, + protected readonly baseUrl: string = DEFAULT_BASE_URL, private readonly cryptoImpl: CryptoInterface = DefaultCrypto, private readonly signingKey?: SigningKey, private readonly fetchImpl: typeof fetch = fetch, @@ -50,44 +50,11 @@ class Requester { autoBind(this); } - private async initWsClient(baseUrl: string, authProvider: AuthProvider) { - if (!this.resolveWsClient) { - /* If resolveWsClient is null assume already initialized: */ - return this.wsClient; - } - - if (isBare) { - /* graphql-ws library is currently not supported in Bare environment, see LIG-7942 */ - return null; - } - - let websocketImpl; - if (isNode && typeof WebSocket === "undefined") { - const wsModule = await import("ws"); - websocketImpl = wsModule.default; - } - let websocketProtocol = "wss"; - if (baseUrl.startsWith("http://")) { - websocketProtocol = "ws"; - } - - const graphqlWsModule = await import("graphql-ws"); - const { createClient } = graphqlWsModule; - - const wsClient = createClient({ - url: `${websocketProtocol}://${this.stripProtocol(this.baseUrl)}/${ - this.schemaEndpoint - }`, - connectionParams: () => authProvider.addWsConnectionParams({}), - webSocketImpl: websocketImpl, - }); - - if (this.resolveWsClient) { - this.resolveWsClient(wsClient); - this.resolveWsClient = null; - } - - return wsClient; + protected initWsClient( + baseUrl: string, + authProvider: AuthProvider, + ): Promise { + return Promise.resolve(null); } public async executeQuery(query: Query): Promise { @@ -293,7 +260,7 @@ class Requester { return `${this.sdkUserAgent} ${platform}/${platformVersion}`; } - private stripProtocol(url: string): string { + protected stripProtocol(url: string): string { return url.replace(/.*?:\/\//g, ""); } diff --git a/packages/core/src/requester/tests/DefaultRequester.test.ts b/packages/core/src/requester/tests/DefaultRequester.test.ts new file mode 100644 index 000000000..b584ba00d --- /dev/null +++ b/packages/core/src/requester/tests/DefaultRequester.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, jest } from "@jest/globals"; + +import type { Client as WsClient } from "graphql-ws"; +import type AuthProvider from "../../auth/AuthProvider.js"; +import type { CryptoInterface } from "../../crypto/crypto.js"; +import type NodeKeyCache from "../../crypto/NodeKeyCache.js"; +import type { SigningKey } from "../../crypto/SigningKey.js"; +import { SigningKeyType } from "../../crypto/types.js"; + +/* Mocking ESM modules (when running node with --experimental-vm-modules) + requires unstable_mockModule, see https://bit.ly/433nRV1 */ +await jest.unstable_mockModule("graphql-ws", () => ({ + __esModule: true, + createClient: jest.fn(), +})); +/* Since Requester uses graphql-ws we need a dynamic import after the above mock */ +const { DefaultRequester } = await import("../DefaultRequester.js"); + +describe("DefaultRequester", () => { + const schemaEndpoint = "graphql"; + const sdkUserAgent = "test-agent"; + const baseUrl = "https://api.example.com"; + + let nodeKeyCache: NodeKeyCache; + let authProvider: AuthProvider; + let signingKey: SigningKey; + let cryptoImpl: CryptoInterface; + let fetchImpl: typeof fetch; + + beforeEach(() => { + nodeKeyCache = { + getKey: jest.fn(), + hasKey: jest.fn(), + } as unknown as NodeKeyCache; + + authProvider = { + addAuthHeaders: jest.fn(async (headers: Record) => ({ + ...headers, + "X-Test": "1", + })), + isAuthorized: jest.fn(async () => true), + addWsConnectionParams: jest.fn( + async (params: Record) => ({ + ...params, + ws: true, + }), + ), + } satisfies AuthProvider; + + signingKey = { + type: SigningKeyType.RSASigningKey, + sign: jest.fn(async (data: Uint8Array) => new Uint8Array([1, 2, 3])), + } satisfies SigningKey; + + cryptoImpl = { + decryptSecretWithNodePassword: jest.fn(async () => new ArrayBuffer(0)), + generateSigningKeyPair: jest.fn(async () => ({ + publicKey: "", + privateKey: "", + })), + serializeSigningKey: jest.fn(async () => new ArrayBuffer(0)), + getNonce: jest.fn(async () => 123), + sign: jest.fn(async () => new ArrayBuffer(0)), + importPrivateSigningKey: jest.fn(async () => ""), + } satisfies CryptoInterface; + + fetchImpl = jest.fn( + async () => + ({ + ok: true, + json: async () => ({ data: { foo: "bar" }, errors: undefined }), + statusText: "OK", + }) as Response, + ); + }); + + describe("subscribe", () => { + it("returns an Observable for a valid subscription", async () => { + // Mock wsClient and its subscribe method + const wsClient = { + subscribe: jest.fn( + ( + _body, + handlers: { next?: (data: unknown) => void; complete?: () => void }, + ) => { + setTimeout(() => { + handlers.next?.({ data: { foo: "bar" } }); + handlers.complete?.(); + }, 10); + return jest.fn(); + }, + ), + } as unknown as WsClient; + + const { createClient } = await import("graphql-ws"); + (createClient as jest.Mock).mockReturnValue(wsClient); + + const requester = new DefaultRequester( + nodeKeyCache, + schemaEndpoint, + sdkUserAgent, + authProvider, + baseUrl, + cryptoImpl, + signingKey, + fetchImpl, + ); + + const observable = requester.subscribe<{ foo: string }>( + "subscription TestSub { foo }", + ); + + const results: { foo: string }[] = []; + await new Promise((resolve) => { + observable.subscribe({ + next: (data: { data: { foo: string } }) => { + results.push(data.data); + }, + complete: () => { + expect(results).toEqual([{ foo: "bar" }]); + resolve(); + }, + }); + }); + + expect(wsClient.subscribe).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/requester/tests/Requester.test.ts b/packages/core/src/requester/tests/Requester.test.ts index cc64bb593..9bdf2303d 100644 --- a/packages/core/src/requester/tests/Requester.test.ts +++ b/packages/core/src/requester/tests/Requester.test.ts @@ -1,6 +1,5 @@ import { beforeEach, jest } from "@jest/globals"; -import type { Client as WsClient } from "graphql-ws"; import type AuthProvider from "../../auth/AuthProvider.js"; import type { CryptoInterface } from "../../crypto/crypto.js"; import type NodeKeyCache from "../../crypto/NodeKeyCache.js"; @@ -16,7 +15,7 @@ await jest.unstable_mockModule("graphql-ws", () => ({ createClient: jest.fn(), })); /* Since Requester uses graphql-ws we need a dynamic import after the above mock */ -const { Requester } = await import("../index.js"); +const { default: Requester } = await import("../Requester.js"); describe("Requester", () => { const schemaEndpoint = "graphql"; @@ -257,26 +256,7 @@ describe("Requester", () => { expect(() => requester.subscribe("invalid")).toThrow(LightsparkException); }); - it("returns an Observable for a valid subscription", async () => { - // Mock wsClient and its subscribe method - const wsClient = { - subscribe: jest.fn( - ( - _body, - handlers: { next?: (data: unknown) => void; complete?: () => void }, - ) => { - setTimeout(() => { - handlers.next?.({ data: { foo: "bar" } }); - handlers.complete?.(); - }, 10); - return jest.fn(); - }, - ), - } as unknown as WsClient; - - const { createClient } = await import("graphql-ws"); - (createClient as jest.Mock).mockReturnValue(wsClient); - + it("emits error when wsClient is not initialized", async () => { const requester = new Requester( nodeKeyCache, schemaEndpoint, @@ -287,25 +267,36 @@ describe("Requester", () => { signingKey, fetchImpl, ); + // Resolve internal wsClient promise to null so the observable emits an error. + ( + requester as unknown as { + resolveWsClient: ((v: unknown) => void) | null; + } + ).resolveWsClient?.(null); - const observable = requester.subscribe<{ foo: string }>( - "subscription TestSub { foo }", - ); + const observable = requester.subscribe("subscription TestSub { foo }"); - const results: { foo: string }[] = []; await new Promise((resolve) => { observable.subscribe({ - next: (data) => { - results.push(data.data); + next: () => { + throw new Error( + "Should not emit next when wsClient is uninitialized", + ); }, - complete: () => { - expect(results).toEqual([{ foo: "bar" }]); + error: (err) => { + expect(err).toBeInstanceOf(LightsparkException); + expect(String((err as Error).message)).toMatch( + /WebSocket client is not initialized/, + ); resolve(); }, + complete: () => { + throw new Error( + "Should not complete when wsClient is uninitialized", + ); + }, }); }); - - expect(wsClient.subscribe).toHaveBeenCalled(); }); }); diff --git a/packages/core/src/shared.ts b/packages/core/src/shared.ts new file mode 100644 index 000000000..5540e90bd --- /dev/null +++ b/packages/core/src/shared.ts @@ -0,0 +1,10 @@ +export * from "./auth/index.js"; +export * from "./constants/index.js"; +export * from "./crypto/index.js"; +export { default as LightsparkException } from "./LightsparkException.js"; +export { Logger, LoggingLevel, logger } from "./Logger.js"; +export { + default as ServerEnvironment, + apiDomainForEnvironment, +} from "./ServerEnvironment.js"; +export * from "./utils/index.js"; diff --git a/packages/core/src/utils/environment.ts b/packages/core/src/utils/environment.ts index e98579969..a8f03eb23 100644 --- a/packages/core/src/utils/environment.ts +++ b/packages/core/src/utils/environment.ts @@ -14,3 +14,6 @@ export const isTest = isNode && process.env.NODE_ENV === "test"; /* https://github.com/holepunchto/which-runtime/blob/main/index.js */ export const isBare = typeof Bare !== "undefined"; + +export const isReactNative = + typeof navigator !== "undefined" && navigator.product === "ReactNative"; diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 1728ea9fb..56b2ca467 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/utils/index.ts"], + entry: ["src/index.ts", "src/react-native/index.ts", "src/utils/index.ts"], format: ["cjs", "esm"], dts: true, clean: true, diff --git a/packages/lightspark-sdk/package.json b/packages/lightspark-sdk/package.json index ae0b0bbb3..9e530d111 100644 --- a/packages/lightspark-sdk/package.json +++ b/packages/lightspark-sdk/package.json @@ -59,6 +59,7 @@ "lint:fix:continue": "eslint --fix . || exit 0", "lint:watch": "esw ./src -w --ext .ts,.tsx,.js --color", "lint": "eslint .", + "circular-deps": "madge --circular --extensions ts,tsx src", "package:checks": "yarn publint && yarn attw --pack .", "postversion": "yarn build", "test-cmd": "node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --bail", @@ -90,6 +91,7 @@ "eslint": "^8.3.0", "eslint-watch": "^8.0.0", "jest": "^29.6.2", + "madge": "^6.1.0", "prettier": "3.0.3", "prettier-plugin-organize-imports": "^3.2.4", "publint": "^0.3.9", @@ -99,6 +101,16 @@ "typedoc": "^0.24.7", "typescript": "^5.6.2" }, + "madge": { + "detectiveOptions": { + "ts": { + "skipTypeImports": true + }, + "tsx": { + "skipTypeImports": true + } + } + }, "engines": { "node": ">=18" } diff --git a/packages/lightspark-sdk/src/SigningKeyLoader.ts b/packages/lightspark-sdk/src/SigningKeyLoader.ts index bd639664b..87b6d2bce 100644 --- a/packages/lightspark-sdk/src/SigningKeyLoader.ts +++ b/packages/lightspark-sdk/src/SigningKeyLoader.ts @@ -8,7 +8,7 @@ import { type Requester, } from "@lightsparkdev/core"; import { RecoverNodeSigningKey } from "./graphql/RecoverNodeSigningKey.js"; -import { BitcoinNetwork } from "./index.js"; +import { BitcoinNetwork } from "./objects/BitcoinNetwork.js"; const SIGNING_KEY_PATH = "m/5"; diff --git a/packages/lightspark-sdk/src/client.ts b/packages/lightspark-sdk/src/client.ts index db4222c51..48f0dae5c 100644 --- a/packages/lightspark-sdk/src/client.ts +++ b/packages/lightspark-sdk/src/client.ts @@ -68,7 +68,6 @@ import { SingleNodeDashboard as SingleNodeDashboardQuery } from "./graphql/Singl import { TransactionSubscription } from "./graphql/TransactionSubscription.js"; import { TransactionsForNode } from "./graphql/TransactionsForNode.js"; import { WithdrawalFeeEstimate } from "./graphql/WithdrawalFeeEstimate.js"; -import { RiskRating, TransactionStatus } from "./index.js"; import { logger } from "./logger.js"; import Account from "./objects/Account.js"; import { ApiTokenFromJson } from "./objects/ApiToken.js"; @@ -92,12 +91,14 @@ import type PaymentDirection from "./objects/PaymentDirection.js"; import { PaymentRequestFromJson } from "./objects/PaymentRequest.js"; import Permission from "./objects/Permission.js"; import type RegionCode from "./objects/RegionCode.js"; +import RiskRating from "./objects/RiskRating.js"; import type SingleNodeDashboard from "./objects/SingleNodeDashboard.js"; import type Transaction from "./objects/Transaction.js"; import { TransactionFromJson, getTransactionQuery, } from "./objects/Transaction.js"; +import TransactionStatus from "./objects/TransactionStatus.js"; import type TransactionUpdate from "./objects/TransactionUpdate.js"; import { TransactionUpdateFromJson } from "./objects/TransactionUpdate.js"; import type UmaInvitation from "./objects/UmaInvitation.js"; diff --git a/packages/ui/src/components/Loading.tsx b/packages/ui/src/components/Loading.tsx index 5ffa38416..0de30e2d0 100644 --- a/packages/ui/src/components/Loading.tsx +++ b/packages/ui/src/components/Loading.tsx @@ -1,6 +1,7 @@ import { useTheme, type Theme } from "@emotion/react"; import styled from "@emotion/styled"; import { type LoadingThemeKey } from "../styles/themeDefaults/loading.js"; +import { type FontColorKey } from "../styles/themes.js"; import { Icon } from "./Icon/Icon.js"; export const loadingKinds = ["primary", "secondary"] as const; @@ -12,6 +13,7 @@ type Props = { ml?: number; mt?: number; kind?: LoadingKind; + color?: FontColorKey | undefined; }; export function Loading({ @@ -20,6 +22,7 @@ export function Loading({ ml = 0, mt = 0, kind = "primary", + color = undefined, }: Props) { const theme = useTheme(); const iconName = resolveLoadingProp(null, kind, "defaultIconName", theme); @@ -27,7 +30,7 @@ export function Loading({ return ( - + ); diff --git a/packages/ui/src/icons/UmaPaymentLoadingSpinner.tsx b/packages/ui/src/icons/UmaPaymentLoadingSpinner.tsx index b223ec466..ee410fa9e 100644 --- a/packages/ui/src/icons/UmaPaymentLoadingSpinner.tsx +++ b/packages/ui/src/icons/UmaPaymentLoadingSpinner.tsx @@ -5,13 +5,7 @@ export function UmaPaymentLoadingSpinner({ strokeLinecap = "round", }: PathProps) { return ( - + + + + + ); +} diff --git a/packages/ui/src/icons/central/People.tsx b/packages/ui/src/icons/central/People.tsx index dcb8869c9..5cdc2720f 100644 --- a/packages/ui/src/icons/central/People.tsx +++ b/packages/ui/src/icons/central/People.tsx @@ -6,13 +6,7 @@ export function People({ strokeLinejoin = "round", }: PathProps) { return ( - +