diff --git a/package.json b/package.json index b7ec6df..9d58d51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codex-data/sdk", - "version": "2.3.81", + "version": "2.4.0", "engines": { "node": ">=17.5.0" }, @@ -13,7 +13,12 @@ "import": "./dist/index.mjs", "require": "./dist/index.js" }, - "./schema.graphql": "./schema.graphql" + "./schema.graphql": "./schema.graphql", + "./edge": { + "types": "./dist/edge.d.ts", + "import": "./dist/edge.mjs", + "require": "./dist/edge.js" + } }, "sideEffects": false, "scripts": { @@ -44,7 +49,10 @@ "dist/index.d.ts", "dist/sdk/", "schema.graphql", - "README.md" + "README.md", + "dist/edge.js", + "dist/edge.mjs", + "dist/edge.d.ts" ], "keywords": [ "crypto", diff --git a/src/edge.ts b/src/edge.ts new file mode 100644 index 0000000..9d524db --- /dev/null +++ b/src/edge.ts @@ -0,0 +1,11 @@ +export { + createClient, + createEdgeClient, + DEFAULT_EDGE_URL, + withEdge, +} from "./sdk/edge"; +export type { + ClientWithEdge, + EdgeClientOptions, + SubscriptionOptions, +} from "./sdk/edge"; diff --git a/src/index.ts b/src/index.ts index abb5672..c0dc84f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ // Main SDK exports export { Codex } from "./sdk"; export type { ApiConfig, CleanupFunction } from "./sdk"; +export type { SubscriptionOptions } from "./sdk/edge"; // Export GraphQL types for use in applications export * from "./sdk/generated/graphql"; diff --git a/src/scripts/buildSdk.ts b/src/scripts/buildSdk.ts index 7a4313c..c7bfaba 100644 --- a/src/scripts/buildSdk.ts +++ b/src/scripts/buildSdk.ts @@ -43,7 +43,7 @@ class Subscription { this.subscriptionName }SubscriptionVariables, sink: Sink>) => this.sdk.subscribe(\`${graphqlString}\`, vars, sink);`; + }Subscription>>, options?: SubscriptionOptions) => this.sdk.subscribe(\`${graphqlString}\`, vars, sink, options);`; } } @@ -112,6 +112,7 @@ import { ${this.subscriptions.map((q) => " " + q.pre()).join(",\n")} } from "./generated/graphql"; import { Codex } from "./index"; +import type { SubscriptionOptions } from "./edge"; import { ExecutionResult, Sink } from "graphql-ws"; export class Subscribe { diff --git a/src/sdk/Subscribe.ts b/src/sdk/Subscribe.ts index 2cc3932..b118f6f 100644 --- a/src/sdk/Subscribe.ts +++ b/src/sdk/Subscribe.ts @@ -1,6 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED, DO NOT EDIT IT DIRECTLY import { ExecutionResult, Sink } from "graphql-ws"; +import type { SubscriptionOptions } from "./edge"; import { OnBalanceUpdatedSubscription, OnBalanceUpdatedSubscriptionVariables, @@ -68,6 +69,7 @@ export class Subscribe { onBalanceUpdated = ( vars: OnBalanceUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnBalanceUpdated($walletAddress: String!) { @@ -77,10 +79,12 @@ export class Subscribe { }`, vars, sink, + options, ); onBarsUpdated = ( vars: OnBarsUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnBarsUpdated($commitmentLevel: [BarCommitmentLevel!], $pairId: String, $quoteToken: QuoteToken) { @@ -90,12 +94,14 @@ export class Subscribe { }`, vars, sink, + options, ); onDetailedPredictionEventStatsUpdated = ( vars: OnDetailedPredictionEventStatsUpdatedSubscriptionVariables, sink: Sink< ExecutionResult >, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnDetailedPredictionEventStatsUpdated($eventId: String!) { @@ -105,12 +111,14 @@ export class Subscribe { }`, vars, sink, + options, ); onDetailedPredictionMarketStatsUpdated = ( vars: OnDetailedPredictionMarketStatsUpdatedSubscriptionVariables, sink: Sink< ExecutionResult >, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnDetailedPredictionMarketStatsUpdated($marketId: String!) { @@ -120,10 +128,12 @@ export class Subscribe { }`, vars, sink, + options, ); onDetailedStatsUpdated = ( vars: OnDetailedStatsUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnDetailedStatsUpdated($pairId: String, $tokenOfInterest: TokenOfInterest) { @@ -133,10 +143,12 @@ export class Subscribe { }`, vars, sink, + options, ); onDetailedTokenStatsUpdated = ( vars: OnDetailedTokenStatsUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnDetailedTokenStatsUpdated($tokenId: String) { @@ -146,10 +158,12 @@ export class Subscribe { }`, vars, sink, + options, ); onEventLabelCreated = ( vars: OnEventLabelCreatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnEventLabelCreated($id: String) { @@ -177,10 +191,12 @@ export class Subscribe { `, vars, sink, + options, ); onEventsCreated = ( vars: OnEventsCreatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnEventsCreated($address: String, $commitmentLevel: [EventCommitmentLevel!], $id: String, $networkId: Int, $quoteToken: QuoteToken) { @@ -190,10 +206,12 @@ export class Subscribe { }`, vars, sink, + options, ); onEventsCreatedByMaker = ( vars: OnEventsCreatedByMakerSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnEventsCreatedByMaker($commitmentLevel: [EventCommitmentLevel!], $input: OnEventsCreatedByMakerInput!) { @@ -203,10 +221,12 @@ export class Subscribe { }`, vars, sink, + options, ); onFilterTokensUpdated = ( vars: OnFilterTokensUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnFilterTokensUpdated($excludeTokens: [String], $filters: TokenFilters, $limit: Int, $offset: Int, $phrase: String, $rankings: [TokenRanking], $statsType: TokenPairStatisticsType, $tokens: [String], $updatePeriod: Int, $useAggregatedStats: Boolean) { @@ -216,10 +236,12 @@ export class Subscribe { }`, vars, sink, + options, ); onHoldersUpdated = ( vars: OnHoldersUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnHoldersUpdated($tokenId: String!) { @@ -229,10 +251,12 @@ export class Subscribe { }`, vars, sink, + options, ); onLatestTokens = ( vars: OnLatestTokensSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnLatestTokens($id: String, $networkId: Int, $tokenAddress: String) { @@ -242,10 +266,12 @@ export class Subscribe { }`, vars, sink, + options, ); onLaunchpadTokenEvent = ( vars: OnLaunchpadTokenEventSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnLaunchpadTokenEvent($input: OnLaunchpadTokenEventInput) { @@ -255,10 +281,12 @@ export class Subscribe { }`, vars, sink, + options, ); onLaunchpadTokenEventBatch = ( vars: OnLaunchpadTokenEventBatchSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnLaunchpadTokenEventBatch($input: OnLaunchpadTokenEventBatchInput) { @@ -268,10 +296,12 @@ export class Subscribe { }`, vars, sink, + options, ); onNftAssetsCreated = ( vars: OnNftAssetsCreatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnNftAssetsCreated($address: String, $networkId: Int, $tokenId: String) { @@ -281,10 +311,12 @@ export class Subscribe { }`, vars, sink, + options, ); onNftEventsCreated = ( vars: OnNftEventsCreatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnNftEventsCreated($address: String, $networkId: Int) { @@ -294,10 +326,12 @@ export class Subscribe { }`, vars, sink, + options, ); onNftPoolEventsCreated = ( vars: OnNftPoolEventsCreatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnNftPoolEventsCreated($collectionAddress: String, $exchangeAddress: String, $networkId: Int, $poolAddress: String) { @@ -307,10 +341,12 @@ export class Subscribe { }`, vars, sink, + options, ); onPairMetadataUpdated = ( vars: OnPairMetadataUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnPairMetadataUpdated($id: String, $quoteToken: QuoteToken, $useNonLiquidityTokenAsQuoteToken: Boolean) { @@ -320,10 +356,12 @@ export class Subscribe { }`, vars, sink, + options, ); onPredictionEventBarsUpdated = ( vars: OnPredictionEventBarsUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnPredictionEventBarsUpdated($eventId: String!) { @@ -333,10 +371,12 @@ export class Subscribe { }`, vars, sink, + options, ); onPredictionMarketBarsUpdated = ( vars: OnPredictionMarketBarsUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnPredictionMarketBarsUpdated($marketId: String!) { @@ -346,10 +386,12 @@ export class Subscribe { }`, vars, sink, + options, ); onPredictionTradesCreated = ( vars: OnPredictionTradesCreatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnPredictionTradesCreated($input: OnPredictionTradesCreatedInput) { @@ -359,10 +401,12 @@ export class Subscribe { }`, vars, sink, + options, ); onPriceUpdated = ( vars: OnPriceUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnPriceUpdated($address: String, $networkId: Int, $sourcePairAddress: String, $useWeightedPrices: Boolean) { @@ -372,10 +416,12 @@ export class Subscribe { }`, vars, sink, + options, ); onPricesUpdated = ( vars: OnPricesUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnPricesUpdated($input: [OnPricesUpdatedInput!]!, $useWeightedPrices: Boolean) { @@ -385,10 +431,12 @@ export class Subscribe { }`, vars, sink, + options, ); onTokenBarsUpdated = ( vars: OnTokenBarsUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnTokenBarsUpdated($commitmentLevel: [BarCommitmentLevel!], $networkId: Int, $tokenId: String) { @@ -398,10 +446,12 @@ export class Subscribe { }`, vars, sink, + options, ); onTokenEventsCreated = ( vars: OnTokenEventsCreatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnTokenEventsCreated($commitmentLevel: [EventCommitmentLevel!], $input: OnTokenEventsCreatedInput!) { @@ -411,10 +461,12 @@ export class Subscribe { }`, vars, sink, + options, ); onTokenLifecycleEventsCreated = ( vars: OnTokenLifecycleEventsCreatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnTokenLifecycleEventsCreated($address: String, $networkId: Int) { @@ -424,10 +476,12 @@ export class Subscribe { }`, vars, sink, + options, ); onUnconfirmedBarsUpdated = ( vars: OnUnconfirmedBarsUpdatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnUnconfirmedBarsUpdated($pairId: String, $quoteToken: QuoteToken) { @@ -437,10 +491,12 @@ export class Subscribe { }`, vars, sink, + options, ); onUnconfirmedEventsCreated = ( vars: OnUnconfirmedEventsCreatedSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnUnconfirmedEventsCreated($address: String, $id: String, $quoteToken: QuoteToken) { @@ -450,10 +506,12 @@ export class Subscribe { }`, vars, sink, + options, ); onUnconfirmedEventsCreatedByMaker = ( vars: OnUnconfirmedEventsCreatedByMakerSubscriptionVariables, sink: Sink>, + options?: SubscriptionOptions, ) => this.sdk.subscribe( `subscription OnUnconfirmedEventsCreatedByMaker($input: OnUnconfirmedEventsCreatedByMakerInput!) { @@ -463,5 +521,6 @@ export class Subscribe { }`, vars, sink, + options, ); } diff --git a/src/sdk/edge.integration.test.ts b/src/sdk/edge.integration.test.ts new file mode 100644 index 0000000..bd473eb --- /dev/null +++ b/src/sdk/edge.integration.test.ts @@ -0,0 +1,295 @@ +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; + +import { type ClientOptions, createClient, parseMessage } from "graphql-ws"; +import WebSocket from "isomorphic-ws"; + +import { withEdge } from "./edge"; +import { Codex } from "./index"; + +function createClientWithEdge(options: ClientOptions & { edgeUrl?: string }) { + return withEdge(createClient(options), options); +} + +it("delivers edge and normal results over separate authenticated sockets", async () => { + const requests: Array<{ source: string; type: string; payload: unknown }> = + []; + const servers = ["main", "edge"].map((source) => { + const server = new WebSocket.Server({ host: "127.0.0.1", port: 0 }); + server.on("connection", (socket) => { + socket.on("message", (data) => { + const message = JSON.parse(data.toString()); + requests.push({ source, type: message.type, payload: message.payload }); + if (message.type === "connection_init") { + socket.send(JSON.stringify({ type: "connection_ack" })); + } else if (message.type === "subscribe") { + socket.send( + JSON.stringify({ + id: message.id, + type: "next", + payload: { data: { source } }, + }), + ); + socket.send(JSON.stringify({ id: message.id, type: "complete" })); + } + }); + }); + return server; + }); + await Promise.all( + servers.map( + (server) => + new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); + }), + ), + ); + const urls = servers.map( + (server) => `ws://127.0.0.1:${(server.address() as AddressInfo).port}`, + ); + const client = createClientWithEdge({ + url: urls[0], + edgeUrl: urls[1], + webSocketImpl: WebSocket, + connectionParams: { Authorization: "test-customer" }, + }); + const query = `subscription { onEventsCreated(id: "pair:1399811149", commitmentLevel: [Processed, Confirmed]) { events { transactionHash } } }`; + try { + expect(() => + client.subscribe( + { + query: `subscription { onPriceUpdated(networkId: 1) { priceUsd } }`, + }, + { next: () => {}, error: () => {}, complete: () => {} }, + { useEdge: true }, + ), + ).toThrow(); + expect(requests).toEqual([]); + const receive = (useEdge: boolean) => + new Promise((resolve, reject) => { + client.subscribe<{ source: string }>( + { query }, + { + next: (result) => resolve(result.data!.source), + error: reject, + complete: () => {}, + }, + { useEdge }, + ); + }); + await expect(Promise.all([receive(false), receive(true)])).resolves.toEqual( + ["main", "edge"], + ); + expect( + requests + .filter(({ type }) => type === "connection_init") + .map(({ payload }) => payload), + ).toEqual([ + { Authorization: "test-customer" }, + { Authorization: "test-customer" }, + ]); + expect( + requests + .filter(({ type }) => type === "subscribe") + .map(({ payload }) => payload), + ).toEqual([{ query }, { query }]); + } finally { + await client.dispose(); + await Promise.all( + servers.map( + (server) => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + ), + ); + } +}); + +async function subscriptionServer() { + const server = new WebSocket.Server({ host: "127.0.0.1", port: 0 }); + server.on("connection", (socket) => { + socket.on("message", (data) => { + const message = parseMessage(data.toString()); + if (message.type === "connection_init") { + server.emit("init", message.payload); + socket.send(JSON.stringify({ type: "connection_ack" })); + } else if (message.type === "subscribe") { + server.emit("subscription", message.payload, socket); + } else if (message.type === "pong") { + server.emit("pong", message.payload); + } + }); + }); + await once(server, "listening"); + return { + server, + url: `ws://127.0.0.1:${(server.address() as AddressInfo).port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +const edgeQuery = `subscription Events($input: OnTokenEventsCreatedInput!, $levels: [EventCommitmentLevel!]) { + onTokenEventsCreated(input: $input, commitmentLevel: $levels) { events { transactionHash } } +}`; + +it("keeps SDK subscriptions on main until a valid edge opt-in and disposes both sockets", async () => { + const main = await subscriptionServer(); + const edge = await subscriptionServer(); + const sdk = new Codex("customer", { + apiRealtimeUrl: main.url, + apiEdgeUrl: edge.url, + }); + const variables = { + input: { networkId: 1399811149 }, + levels: ["Processed", "Confirmed"], + }; + const expected = JSON.parse(JSON.stringify({ query: edgeQuery, variables })); + const sink = { next: () => {}, error: () => {}, complete: () => {} }; + try { + const mainInit = once(main.server, "init"); + const mainRequest = once(main.server, "subscription"); + sdk.subscribe(edgeQuery, variables, sink); + expect((await mainInit)[0]).toEqual({ Authorization: "customer" }); + expect((await mainRequest)[0]).toEqual(expected); + expect(edge.server.clients.size).toBe(0); + expect(() => + sdk.subscribe(edgeQuery, { ...variables, levels: ["Confirmed"] }, sink, { + useEdge: true, + }), + ).toThrow("useEdge requires"); + expect(edge.server.clients.size).toBe(0); + const edgeInit = once(edge.server, "init"); + const edgeRequest = once(edge.server, "subscription"); + sdk.subscribe(edgeQuery, variables, sink, { useEdge: true }); + variables.input.networkId = 1; + variables.levels[0] = "Confirmed"; + expect((await edgeInit)[0]).toEqual({ Authorization: "customer" }); + expect((await edgeRequest)[0]).toEqual(expected); + } finally { + await sdk.dispose(); + await Promise.all([main.close(), edge.close()]); + } +}); + +it("preserves the validated payload through delayed authentication and retries", async () => { + const edge = await subscriptionServer(); + let authenticate!: () => void; + const authentication = new Promise((resolve) => { + authenticate = resolve; + }); + let serializedLevels = 0; + const client = createClientWithEdge({ + url: "ws://main.example/graphql", + edgeUrl: edge.url, + webSocketImpl: WebSocket, + connectionParams: async () => { + await authentication; + return { Authorization: "customer", omit: "remove" }; + }, + jsonMessageReplacer: (key, value) => { + if (key === "levels") { + serializedLevels++; + return serializedLevels === 1 ? value : ["Confirmed"]; + } + return key === "omit" ? undefined : value; + }, + retryWait: async () => {}, + }); + const variables = { + input: { networkId: 1399811149, tokenAddress: "original" }, + levels: ["Preprocessed", "Processed", "Confirmed"], + }; + const payload = { query: edgeQuery, variables }; + const expected = JSON.parse(JSON.stringify(payload)); + try { + const init = once(edge.server, "init"); + const first = once(edge.server, "subscription"); + client.subscribe( + payload, + { next: () => {}, error: () => {}, complete: () => {} }, + { useEdge: true }, + ); + variables.levels.splice(0, variables.levels.length, "Confirmed"); + variables.input.networkId = 1; + variables.input.tokenAddress = "changed"; + payload.query = "subscription { onPriceUpdated { priceUsd } }"; + authenticate(); + expect((await init)[0]).toEqual({ Authorization: "customer" }); + expect((await first)[0]).toEqual(expected); + const retry = once(edge.server, "subscription"); + client.terminate(); + expect((await retry)[0]).toEqual(expected); + expect(serializedLevels).toBe(1); + } finally { + authenticate(); + await client.dispose(); + await edge.close(); + } +}); + +it("forwards edge events, supports custom pong handling, and terminates both sockets", async () => { + const main = await subscriptionServer(); + const edge = await subscriptionServer(); + const configuredConnected = jest.fn(); + let activeSocket: WebSocket; + const client = createClientWithEdge({ + url: main.url, + edgeUrl: edge.url, + webSocketImpl: WebSocket, + disablePong: true, + retryWait: async () => {}, + on: { + connected: configuredConnected, + ping: (received) => { + if (received) activeSocket.send(JSON.stringify({ type: "pong" })); + }, + }, + }); + const connected = jest.fn(); + const mainConnected = jest.fn(); + const edgeConnected = jest.fn((socket) => { + activeSocket = socket as WebSocket; + }); + const closed = jest.fn(); + client.on("connected", connected); + client.on("connected", mainConnected, { useEdge: false }); + const removeEdge = client.on("connected", edgeConnected, { useEdge: true }); + client.on("closed", closed); + const payload = { + query: edgeQuery, + variables: { input: { networkId: 1399811149 }, levels: ["Processed"] }, + }; + const sink = { next: () => {}, error: () => {}, complete: () => {} }; + try { + const mainFirst = once(main.server, "subscription"); + const edgeFirst = once(edge.server, "subscription"); + client.subscribe(payload, sink); + client.subscribe(payload, sink, { useEdge: true }); + await mainFirst; + const [, edgeSocket] = await edgeFirst; + expect(configuredConnected).toHaveBeenCalledTimes(2); + expect(connected).toHaveBeenCalledTimes(2); + expect(mainConnected).toHaveBeenCalledTimes(1); + expect(edgeConnected).toHaveBeenCalledTimes(1); + const pong = once(edge.server, "pong"); + edgeSocket.send(JSON.stringify({ type: "ping" })); + await pong; + removeEdge(); + const mainRetry = once(main.server, "subscription"); + const edgeRetry = once(edge.server, "subscription"); + client.terminate(); + await Promise.all([mainRetry, edgeRetry]); + expect(closed).toHaveBeenCalledTimes(2); + expect(connected).toHaveBeenCalledTimes(4); + expect(mainConnected).toHaveBeenCalledTimes(2); + expect(edgeConnected).toHaveBeenCalledTimes(1); + } finally { + await client.dispose(); + await Promise.all([main.close(), edge.close()]); + } +}); diff --git a/src/sdk/edge.test.ts b/src/sdk/edge.test.ts new file mode 100644 index 0000000..5ef6bfd --- /dev/null +++ b/src/sdk/edge.test.ts @@ -0,0 +1,300 @@ +import { type ClientOptions, createClient } from "graphql-ws"; + +import { createEdgeClient, DEFAULT_EDGE_URL, withEdge } from "./edge"; +import * as edgeModule from "./edge"; +import { assertEdgeSubscription } from "./edgeSubscription"; +import { EventCommitmentLevel } from "./generated/graphql"; +import { Codex } from "./index"; + +jest.mock("graphql-ws", () => ({ createClient: jest.fn() })); + +function createClientWithEdge(options: ClientOptions & { edgeUrl?: string }) { + return withEdge(createClient(options), options); +} + +const transports: Array<{ + options: ClientOptions; + subscribe: jest.Mock; + dispose: jest.Mock; +}> = []; +const sink = { next: jest.fn(), error: jest.fn(), complete: jest.fn() }; +const events = `subscription Events($input: OnTokenEventsCreatedInput!, $levels: [EventCommitmentLevel!]) { + onTokenEventsCreated(input: $input, commitmentLevel: $levels) { events { transactionHash } } +}`; +const variables = { + input: { networkId: 1399811149, tokenAddress: "token" }, + levels: ["Processed"], +}; + +beforeEach(() => { + transports.length = 0; + jest.mocked(createClient).mockImplementation((options) => { + const transport = { + options, + subscribe: jest.fn(() => jest.fn()), + dispose: jest.fn(async () => {}), + on: jest.fn(() => () => {}), + terminate: jest.fn(), + iterate: async function* () {}, + }; + transports.push(transport); + return transport; + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it("creates a standalone edge transport without creating a main transport", () => { + createEdgeClient({ connectionParams: { Authorization: "customer" } }); + expect(transports).toHaveLength(1); + expect(transports[0].options.url).toBe(DEFAULT_EDGE_URL); +}); + +it("creates the SDK edge client only for a valid opt-in and reuses it", async () => { + const createEdge = jest.spyOn(edgeModule, "createEdgeClient"); + const sdk = new Codex("customer", { + apiEdgeUrl: "wss://edge.staging.example/graphql", + }); + sdk.subscribe(events, variables, sink); + expect(createEdge).not.toHaveBeenCalled(); + expect(() => + sdk.subscribe(events, { ...variables, levels: ["Confirmed"] }, sink, { + useEdge: true, + }), + ).toThrow("useEdge requires"); + expect(createEdge).not.toHaveBeenCalled(); + sdk.subscribe(events, variables, sink, { useEdge: true }); + sdk.subscribe(events, variables, sink, { useEdge: true }); + expect(createEdge).toHaveBeenCalledTimes(1); + expect(transports[1].options.url).toBe("wss://edge.staging.example/graphql"); + await sdk.dispose(); + expect(() => + sdk.subscribe(events, variables, sink, { useEdge: true }), + ).toThrow("disposed"); + expect(createEdge).toHaveBeenCalledTimes(1); +}); + +it("reuses one edge transport while keeping ordinary subscriptions on the main transport", () => { + const client = createClientWithEdge({ + url: "wss://main.example/graphql", + connectionParams: { Authorization: "customer" }, + }); + expect(transports).toHaveLength(1); + const payload = { query: events, variables }; + client.subscribe(payload, sink); + const unsubscribe = client.subscribe(payload, sink, { useEdge: true }); + client.subscribe(payload, sink, { useEdge: true }); + client.subscribe(payload, sink, { useEdge: false }); + expect(transports).toHaveLength(2); + expect(transports[0].subscribe).toHaveBeenCalledTimes(2); + expect(transports[1].subscribe).toHaveBeenCalledTimes(2); + expect(transports[1].options.url).toBe(DEFAULT_EDGE_URL); + expect(transports[1].options.connectionParams).toEqual({ + Authorization: "customer", + }); + expect(transports[1].subscribe).toHaveBeenCalledWith(payload, sink); + unsubscribe(); + expect(transports[1].subscribe.mock.results[0].value).toHaveBeenCalledTimes( + 1, + ); +}); + +it("uses current connection credentials and infrastructure options on both transports", async () => { + let token = "first"; + const connectionParams = async () => ({ Authorization: token }); + const client = createClientWithEdge({ + url: "wss://main.example/graphql", + edgeUrl: "wss://edge.staging.example/graphql", + connectionParams, + keepAlive: 10000, + lazyCloseTimeout: 5000, + }); + client.subscribe({ query: events, variables }, sink, { useEdge: true }); + token = "refreshed"; + for (const transport of transports) { + expect(transport.options.connectionParams).toBe(connectionParams); + await expect(connectionParams()).resolves.toEqual({ + Authorization: "refreshed", + }); + expect(transport.options.keepAlive).toBe(10000); + expect(transport.options.lazyCloseTimeout).toBe(5000); + } + expect(transports[1].options.url).toBe("wss://edge.staging.example/graphql"); +}); + +it.each([ + `subscription { onEventsCreated(id: "pair:1399811149", commitmentLevel: [Processed]) { events { transactionHash } } }`, + `subscription { onEventsCreated(address: "pair", networkId: 1399811149, commitmentLevel: Processed) { events { transactionHash } } }`, + `subscription { alias: onTokenEventsCreated(input: {networkId: 1399811149}, commitmentLevel: Processed) { events { transactionHash } } }`, + `subscription { onBarsUpdated(pairId: "pair:1399811149", quoteToken: token0, commitmentLevel: [Processed]) { timestamp } }`, + `subscription { onTokenBarsUpdated(tokenId: "token:1399811149", commitmentLevel: [Processed]) { timestamp } }`, + `subscription { onTokenBarsUpdated(networkId: 1399811149, commitmentLevel: [Processed]) { timestamp } }`, + `subscription($network: Int = 1399811149, $levels: [EventCommitmentLevel!] = [Processed]) { onEventsCreated(networkId: $network, commitmentLevel: $levels) { events { transactionHash } } }`, + `subscription { onEventsCreated(id: "pair:1399811149", commitmentLevel: [Confirmed, Processed]) { events { transactionHash } } }`, + `subscription { onTokenEventsCreated(input: {networkId: 1399811149}, commitmentLevel: [Preprocessed, Processed, Confirmed]) { events { transactionHash } } }`, + `subscription { onBarsUpdated(pairId: "pair:1399811149", quoteToken: token0, commitmentLevel: [Processed, Confirmed]) { timestamp } }`, + `subscription { onTokenBarsUpdated(tokenId: "token:1399811149", commitmentLevel: [Preprocessed, Processed]) { timestamp } }`, + `subscription($network: Int = 1399811149, $levels: [EventCommitmentLevel!] = [Confirmed, Processed]) { onEventsCreated(networkId: $network, commitmentLevel: $levels) { events { transactionHash } } }`, +])( + "accepts eligible literal and default-variable subscriptions: %s", + (query) => { + const client = createClientWithEdge({ url: "wss://main.example/graphql" }); + client.subscribe({ query }, sink, { useEdge: true }); + expect(transports[1].subscribe).toHaveBeenCalledTimes(1); + }, +); + +it.each([ + {}, + { levels: undefined }, + { levels: null }, + { levels: [] }, + { levels: new Array(1) }, + { levels: Object.assign(new Array(2), { 0: "Processed" }) }, + { levels: ["Confirmed"] }, + { levels: ["Preprocessed"] }, + { levels: ["Preprocessed", "Confirmed"] }, + { levels: ["Processed", "Finalized"] }, + { levels: ["Processed", null] }, + { levels: ["Processed", 1] }, + { input: { networkId: 8453 } }, + { input: { networkId: "1399811149" } }, + { input: { tokenAddress: "token" } }, +])( + "rejects ineligible variables without sending an operation: %j", + (override) => { + const client = createClientWithEdge({ url: "wss://main.example/graphql" }); + // An empty override exercises missing variables rather than the valid base. + const values = Object.keys(override).length + ? { ...variables, ...override } + : {}; + expect(() => + client.subscribe({ query: events, variables: values }, sink, { + useEdge: true, + }), + ).toThrow("useEdge requires"); + expect(transports).toHaveLength(1); + expect(transports[0].subscribe).not.toHaveBeenCalled(); + }, +); + +it.each([ + `query { onEventsCreated(networkId: 1399811149, commitmentLevel: [Processed]) { events { transactionHash } } }`, + `subscription { onPriceUpdated(networkId: 1399811149, commitmentLevel: [Processed]) { priceUsd } }`, + `subscription { onEventsCreatedByMaker(input: {networkId: 1399811149}, commitmentLevel: [Processed]) { events { transactionHash } } }`, + `subscription { onEventsCreated(id: "pair:8453", commitmentLevel: [Processed]) { events { transactionHash } } }`, + `subscription { onEventsCreated(id: "pair:1399811149", networkId: 1, commitmentLevel: [Processed]) { events { transactionHash } } }`, + `subscription { onEventsCreated(commitmentLevel: [Processed]) { events { transactionHash } } }`, + `subscription { onTokenBarsUpdated(tokenId: "token", commitmentLevel: [Processed]) { timestamp } }`, + `subscription { a: onEventsCreated(networkId: 1399811149, commitmentLevel: [Processed]) { id } b: onEventsCreated(networkId: 1399811149, commitmentLevel: [Processed]) { id } }`, + `subscription { onEventsCreated(networkId: 1399811149, commitmentLevel: [Processed]) @skip(if: true) { id } }`, + `subscription { ...Root } fragment Root on Subscription { onEventsCreated(networkId: 1399811149, commitmentLevel: [Processed]) { id } }`, +])("rejects ineligible documents: %s", (query) => { + const client = createClientWithEdge({ url: "wss://main.example/graphql" }); + expect(() => client.subscribe({ query }, sink, { useEdge: true })).toThrow( + "useEdge requires", + ); + expect(transports).toHaveLength(1); +}); + +it("rejects implicit pair quote selection instead of letting edge relay it through Oregon", () => { + const client = createClientWithEdge({ url: "wss://main.example/graphql" }); + expect(() => + client.subscribe( + { + query: `subscription { onBarsUpdated(pairId: "pair:1399811149", commitmentLevel: [Processed]) { timestamp } }`, + }, + sink, + { useEdge: true }, + ), + ).toThrow("explicit quoteToken"); + expect(transports).toHaveLength(1); +}); + +it("validates the selected operation in a multi-operation document", () => { + const client = createClientWithEdge({ url: "wss://main.example/graphql" }); + const query = `${events}\nsubscription Other { onPriceUpdated(networkId: 1) { priceUsd } }`; + client.subscribe({ query, variables, operationName: "Events" }, sink, { + useEdge: true, + }); + expect(() => + client.subscribe({ query, variables }, sink, { useEdge: true }), + ).toThrow(); +}); + +it("disposes both transports and prevents subscriptions from recreating them", async () => { + const client = createClientWithEdge({ url: "wss://main.example/graphql" }); + client.subscribe({ query: events, variables }, sink, { useEdge: true }); + await client.dispose(); + expect(transports.map(({ dispose }) => dispose.mock.calls.length)).toEqual([ + 1, 1, + ]); + expect(() => + client.subscribe({ query: events, variables }, sink, { useEdge: true }), + ).toThrow("disposed"); +}); + +it("routes raw and generated mixed SDK subscriptions through useEdge without dropping commitments", async () => { + const sdk = new Codex("customer"); + const levels = [ + EventCommitmentLevel.Preprocessed, + EventCommitmentLevel.Processed, + EventCommitmentLevel.Confirmed, + ]; + const mixedVariables = { ...variables, levels }; + sdk.subscribe(events, mixedVariables, sink, { useEdge: true }); + sdk.subscriptions.onTokenEventsCreated( + { + input: variables.input, + commitmentLevel: levels, + }, + sink, + { useEdge: true }, + ); + expect(transports).toHaveLength(2); + expect(transports[1].subscribe).toHaveBeenCalledTimes(2); + expect(transports[1].subscribe.mock.calls[0][0].variables).toEqual( + mixedVariables, + ); + expect(transports[1].subscribe.mock.calls[1][0].variables).toEqual({ + input: variables.input, + commitmentLevel: levels, + }); + sdk.updateConfig({ wsHeaders: { Authorization: "new-customer" } }); + sdk.subscribe(events, variables, sink, { useEdge: true }); + expect(transports[3].options.connectionParams).toEqual({ + Authorization: "new-customer", + }); + expect(transports[1].dispose).toHaveBeenCalledTimes(1); + await sdk.dispose(); + expect(transports[3].dispose).toHaveBeenCalledTimes(1); +}); + +it("rejects sparse commitment arrays even before wire serialization", () => { + expect(() => + assertEdgeSubscription({ + query: events, + variables: { ...variables, levels: new Array(1) }, + }), + ).toThrow("useEdge requires"); +}); + +it("validates serialized variables rather than a misleading toJSON value", () => { + const client = createClientWithEdge({ url: "wss://main.example/graphql" }); + expect(() => + client.subscribe( + { + query: events, + variables: { + ...variables, + toJSON: () => ({ ...variables, levels: ["Confirmed"] }), + }, + }, + sink, + { useEdge: true }, + ), + ).toThrow("useEdge requires"); + expect(transports).toHaveLength(1); +}); diff --git a/src/sdk/edge.ts b/src/sdk/edge.ts new file mode 100644 index 0000000..8c83831 --- /dev/null +++ b/src/sdk/edge.ts @@ -0,0 +1,145 @@ +import { + type Client, + type ClientOptions, + createClient, + type Event, + type EventListener, + type ExecutionResult, + type Sink, + type SubscribePayload, +} from "graphql-ws"; + +import { assertEdgeSubscription } from "./edgeSubscription"; + +export { createClient }; + +export const DEFAULT_EDGE_URL = "wss://fra.edge.codex.io/graphql"; + +export interface SubscriptionOptions { + /** Experimental: use edge for supported Solana streams whose commitments include Processed. */ + useEdge?: boolean; +} + +export interface ClientWithEdge extends Client { + /** Observe both transports, or select one with useEdge. */ + on( + event: E, + listener: EventListener, + options?: SubscriptionOptions, + ): () => void; + subscribe, Extensions = unknown>( + payload: SubscribePayload, + sink: Sink>, + options?: SubscriptionOptions, + ): () => void; +} + +export interface EdgeClientOptions extends Omit { + /** Defaults to the regional edge endpoint. */ + url?: ClientOptions["url"]; +} + +/** Create a transport that connects only to edge. */ +export function createEdgeClient(options: EdgeClientOptions): Client { + return createClient({ ...options, url: options.url ?? DEFAULT_EDGE_URL }); +} + +/** Snapshot and validate the arguments that will actually be sent to edge. */ +export function prepareEdgeSubscription( + payload: SubscribePayload, + replacer?: ClientOptions["jsonMessageReplacer"], +): SubscribePayload { + const snapshot: SubscribePayload = JSON.parse( + JSON.stringify(payload, replacer), + ); + assertEdgeSubscription(snapshot); + return snapshot; +} + +/** Add per-subscription edge routing to an existing GraphQL client. */ +export function withEdge( + main: Client, + options: ClientOptions & { edgeUrl?: string }, +): ClientWithEdge { + const { edgeUrl = DEFAULT_EDGE_URL, ...wsOptions } = options; + type EventHandlers = NonNullable; + const mainHandlers = new Set(); + const edgeHandlers = new Set( + wsOptions.on ? [wsOptions.on] : [], + ); + const handlersFor = (useEdge: boolean): Required => { + const handlers = () => Array.from(useEdge ? edgeHandlers : mainHandlers); + // Install before createClient so even non-lazy connection events are observed. + return { + connecting: (...args) => + handlers().forEach((on) => on?.connecting?.(...args)), + opened: (...args) => handlers().forEach((on) => on?.opened?.(...args)), + connected: (...args) => + handlers().forEach((on) => on?.connected?.(...args)), + ping: (...args) => handlers().forEach((on) => on?.ping?.(...args)), + pong: (...args) => handlers().forEach((on) => on?.pong?.(...args)), + message: (...args) => handlers().forEach((on) => on?.message?.(...args)), + closed: (...args) => handlers().forEach((on) => on?.closed?.(...args)), + error: (...args) => handlers().forEach((on) => on?.error?.(...args)), + }; + }; + const mainEvents = handlersFor(false); + main.on("connecting", mainEvents.connecting); + main.on("opened", mainEvents.opened); + main.on("connected", mainEvents.connected); + main.on("ping", mainEvents.ping); + main.on("pong", mainEvents.pong); + main.on("message", mainEvents.message); + main.on("closed", mainEvents.closed); + main.on("error", mainEvents.error); + let edge: Client | undefined; + let disposed = false; + return { + ...main, + on: (event, listener, subscriptionOptions = {}) => { + const handlers = { [event]: listener }; + if (subscriptionOptions.useEdge !== true) mainHandlers.add(handlers); + if (subscriptionOptions.useEdge !== false) edgeHandlers.add(handlers); + return () => { + mainHandlers.delete(handlers); + edgeHandlers.delete(handlers); + }; + }, + subscribe: (payload, sink, subscriptionOptions = {}) => { + if (disposed) throw new Error("Edge client has been disposed"); + if (!subscriptionOptions.useEdge) return main.subscribe(payload, sink); + // Reject before creating an edge client or opening any upstream operation. + const snapshot = prepareEdgeSubscription( + payload, + wsOptions.jsonMessageReplacer, + ); + if (!edge) { + // Edge payloads are serialized once, before validation. Preserve custom + // serialization for handshakes without transforming subscriptions again. + let subscriptionMessage = false; + edge = createEdgeClient({ + ...wsOptions, + url: edgeUrl, + on: handlersFor(true), + jsonMessageReplacer: function (key, value) { + if (key === "") subscriptionMessage = value?.type === "subscribe"; + return subscriptionMessage + ? value + : wsOptions.jsonMessageReplacer + ? wsOptions.jsonMessageReplacer.call(this, key, value) + : value; + }, + }); + } + return edge.subscribe(snapshot, sink); + }, + terminate: () => { + main.terminate(); + edge?.terminate(); + }, + dispose: async () => { + disposed = true; + await Promise.all([main.dispose(), edge?.dispose()]); + }, + }; +} diff --git a/src/sdk/edgeSubscription.ts b/src/sdk/edgeSubscription.ts new file mode 100644 index 0000000..f545542 --- /dev/null +++ b/src/sdk/edgeSubscription.ts @@ -0,0 +1,83 @@ +import { getOperationAST, Kind, parse, valueFromASTUntyped } from "graphql"; +import type { SubscribePayload } from "graphql-ws"; + +const SOLANA_NETWORK_ID = 1399811149; +const SUPPORTED_OPERATIONS = new Set([ + "onEventsCreated", + "onTokenEventsCreated", + "onBarsUpdated", + "onTokenBarsUpdated", +]); + +/** Validate the actual GraphQL arguments, including literals and variable defaults. */ +export function assertEdgeSubscription(payload: SubscribePayload): void { + function fail(): never { + throw new Error( + "useEdge requires a supported Solana subscription with commitmentLevel including Processed", + ); + } + const document = parse(payload.query); + const operation = getOperationAST(document, payload.operationName); + if (!operation || operation.operation !== "subscription") fail(); + // Edge executes a single root field; root fragments and conditional roots + // are not supported by its current customer transport. + const selections = operation.selectionSet.selections; + if (selections.length !== 1) fail(); + const field = selections[0]; + if (field.kind !== Kind.FIELD || !SUPPORTED_OPERATIONS.has(field.name.value)) + fail(); + if (field.directives?.length) fail(); + + const variables = { ...payload.variables }; + for (const definition of operation.variableDefinitions ?? []) { + const name = definition.variable.name.value; + if ( + variables[name] === undefined && + definition.defaultValue !== undefined + ) { + variables[name] = valueFromASTUntyped(definition.defaultValue); + } + } + const args: Record = Object.fromEntries( + (field.arguments ?? []).map((argument) => [ + argument.name.value, + valueFromASTUntyped(argument.value, variables), + ]), + ); + const levels = Array.isArray(args.commitmentLevel) + ? args.commitmentLevel + : [args.commitmentLevel]; + if ( + !levels.includes("Processed") || + [...levels].some( + (level) => + level !== "Processed" && + level !== "Confirmed" && + level !== "Preprocessed", + ) + ) + fail(); + + const networks: unknown[] = []; + const collect = (value: unknown): void => { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + for (const [name, item] of Object.entries(value)) { + if (name === "networkId" && item != null) networks.push(item); + if (["id", "pairId", "tokenId"].includes(name) && item != null) { + if (typeof item !== "string" || !/^[^:]+:\d+$/.test(item)) fail(); + networks.push(Number(item.slice(item.lastIndexOf(":") + 1))); + } + if (name === "input") collect(item); + } + }; + collect(args); + if ( + !networks.length || + networks.some((network) => network !== SOLANA_NETWORK_ID) + ) + fail(); + // Without an explicit quote, edge proxies this request back to Oregon. + if (field.name.value === "onBarsUpdated" && args.quoteToken == null) { + throw new Error("useEdge onBarsUpdated requires an explicit quoteToken"); + } +} diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 1ce1631..cfa1757 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -8,6 +8,11 @@ import { } from "graphql-ws"; import WebSocket from "isomorphic-ws"; +import { + createEdgeClient, + prepareEdgeSubscription, + SubscriptionOptions, +} from "./edge"; import { invariant } from "./invariant"; import { Mutation } from "./Mutation"; import { Query } from "./Query"; @@ -22,6 +27,9 @@ export type ApiConfig = { // wss://graph.codex.io/graphql apiRealtimeUrl: string; + // Optional deployment override for the edge transport. + apiEdgeUrl?: string; + // true if you want to connect a websocket, sometimes useful to turn this off in certain environments ws?: boolean; @@ -47,6 +55,8 @@ const defaultConfig: ApiConfig = { export class Codex { private client: GraphQLClient; private wsClient: GraphQLWsClient | undefined; + private edgeWsClient: GraphQLWsClient | undefined; + private disposed = false; public queries: Query; public mutations: Mutation; public subscriptions: Subscribe; @@ -90,18 +100,20 @@ export class Codex { }); } + private websocketOptions(config: ApiConfig) { + return { + webSocketImpl: WebSocket, + keepAlive: 10_000, // ping server every 10 seconds + url: config.apiRealtimeUrl, + connectionParams: { + Authorization: this.apiKey, + ...config.wsHeaders, + }, + }; + } + private createWebsocketClient(config: ApiConfig) { - return config.ws - ? createClient({ - webSocketImpl: WebSocket, - keepAlive: 10_000, // ping server every 10 seconds - url: config.apiRealtimeUrl, - connectionParams: { - Authorization: this.apiKey, - ...config.wsHeaders, - }, - }) - : undefined; + return config.ws ? createClient(this.websocketOptions(config)) : undefined; } private async getRequestHeaders() { @@ -122,7 +134,10 @@ export class Codex { */ public updateConfig(incomingConfig: Partial) { // If we have an existing websocket connection, dispose of it. - if (this.wsClient) this.dispose(); + if (this.wsClient || this.edgeWsClient) void this.dispose(); + this.edgeWsClient = undefined; + this.disposed = false; + this.apiConfig = { ...this.apiConfig, ...incomingConfig }; // Merge the incoming config with the existing config. const config = this.parseConfig( @@ -151,7 +166,8 @@ export class Codex { * It's useful for when you need to modify some of the configuration, or when you're done with the class. */ public async dispose() { - this.wsClient?.dispose(); + this.disposed = true; + await Promise.all([this.wsClient?.dispose(), this.edgeWsClient?.dispose()]); } /** @@ -222,18 +238,29 @@ export class Codex { doc: string, args: TVars, sink: Sink>, + options?: SubscriptionOptions, ): CleanupFunction { if (!this.wsClient) { throw new Error("Websocket is not enabled in the config"); } - const cleanup = this.wsClient.subscribe( - { - query: doc, - variables: args, - }, - sink, - ); - return cleanup; + if (this.disposed) throw new Error("SDK has been disposed"); + const payload = { query: doc, variables: args }; + if (!options?.useEdge) + return this.wsClient.subscribe(payload, sink); + + // Validate before creating the edge transport or starting an operation. + const snapshot = prepareEdgeSubscription(payload); + if (!this.edgeWsClient) { + const config = this.parseConfig(this.apiConfig ?? {}); + this.edgeWsClient = createEdgeClient({ + ...this.websocketOptions(config), + url: config.apiEdgeUrl, + }); + this.edgeWsClient.on("error", (error) => { + console.error("Websocket error: ", error); + }); + } + return this.edgeWsClient.subscribe(snapshot, sink); } } diff --git a/tsconfig.json b/tsconfig.json index d205ffb..7bdc9c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,10 @@ "**/*.spec.ts", "examples" ], - "include": ["src/sdk/**/*.ts", "src/index.ts", "!src/sdk/**/*.test.ts"] + "include": [ + "src/sdk/**/*.ts", + "src/index.ts", + "src/edge.ts", + "!src/sdk/**/*.test.ts" + ] }