Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/short-parts-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lightsparkdev/lightspark-sdk": patch
---

- Resolve circular dependencies
5 changes: 5 additions & 0 deletions .changeset/shy-bugs-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lightsparkdev/ui": patch
---

- Icon, component updates
6 changes: 6 additions & 0 deletions .changeset/tame-sites-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@lightsparkdev/core": patch
---

- Resolve circular dependencies
- React Native compatible entrypoint
14 changes: 14 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -39,6 +40,7 @@
"src/*",
"dist/*",
"dist/utils/*",
"dist/react-native/*",
"CHANGELOG.md"
],
"scripts": {
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -89,6 +93,16 @@
"tsup": "^8.2.4",
"typescript": "^5.6.2"
},
"madge": {
"detectiveOptions": {
"ts": {
"skipTypeImports": true
},
"tsx": {
"skipTypeImports": true
}
}
},
"engines": {
"node": ">=18"
}
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/Logger.ts
Original file line number Diff line number Diff line change
@@ -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> | boolean) | undefined;

Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/crypto/SigningKey.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
14 changes: 3 additions & 11 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
2 changes: 2 additions & 0 deletions packages/core/src/react-native/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "../requester/index.js";
export * from "../shared.js";
46 changes: 46 additions & 0 deletions packages/core/src/requester/DefaultRequester.ts
Original file line number Diff line number Diff line change
@@ -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;
55 changes: 11 additions & 44 deletions packages/core/src/requester/Requester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -31,14 +31,14 @@ type BodyData = {
};

class Requester {
private wsClient: Promise<WsClient | null>;
private resolveWsClient: ((value: WsClient | null) => void) | null = null;
protected wsClient: Promise<WsClient | null>;
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,
Expand All @@ -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<WsClient | null> {
return Promise.resolve(null);
}

public async executeQuery<T>(query: Query<T>): Promise<T | null> {
Expand Down Expand Up @@ -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, "");
}

Expand Down
129 changes: 129 additions & 0 deletions packages/core/src/requester/tests/DefaultRequester.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) => ({
...headers,
"X-Test": "1",
})),
isAuthorized: jest.fn(async () => true),
addWsConnectionParams: jest.fn(
async (params: Record<string, unknown>) => ({
...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<void>((resolve) => {
observable.subscribe({
next: (data: { data: { foo: string } }) => {
results.push(data.data);
},
complete: () => {
expect(results).toEqual([{ foo: "bar" }]);
resolve();
},
});
});

expect(wsClient.subscribe).toHaveBeenCalled();
});
});
});
Loading