diff --git a/examples/manager/src/main.tsx b/examples/manager/src/main.tsx index 5e16705a..7fae903d 100644 --- a/examples/manager/src/main.tsx +++ b/examples/manager/src/main.tsx @@ -4,9 +4,9 @@ import { App } from './App.js'; import { AppConfig, ConfigProvider, - NodeProvider, WalletProvider, ContractsProvider, + NodeProvider, } from '@windingtree/sdk-react/providers'; import { hardhat, polygonZkEvmTestnet } from 'viem/chains'; import { contractsConfig } from 'wtmp-examples-shared-files/dist/index.js'; diff --git a/examples/node/src/index.ts b/examples/node/src/index.ts index 418dcafd..d228348d 100644 --- a/examples/node/src/index.ts +++ b/examples/node/src/index.ts @@ -289,6 +289,7 @@ const main = async (): Promise => { secret: 'secret', ownerAccount: entityOwnerAddress, protocolContracts: contractsManager, + cors: ['http://localhost:5173'], }); apiServer.start(appRouter); diff --git a/packages/node-api/src/server.ts b/packages/node-api/src/server.ts index 8705f4d7..14504924 100644 --- a/packages/node-api/src/server.ts +++ b/packages/node-api/src/server.ts @@ -55,6 +55,11 @@ export interface NodeApiServerOptions { * If not provided, some default value or policy might be used. */ expire?: string | number; + + /** + * CORS origins + */ + cors: string[]; } /** @@ -218,6 +223,8 @@ export class NodeApiServer { ownerAccount?: Address; /** The duration (as a string or number) after which the access token will expire */ expire: string | number; + /** CORS origins */ + cors: string[]; /** * Creates an instance of NodeApiServerOptions. @@ -234,6 +241,7 @@ export class NodeApiServer { secret, ownerAccount, expire, + cors, } = options; // TODO Validate NodeApiServerOptions @@ -243,6 +251,7 @@ export class NodeApiServer { this.secret = secret; this.ownerAccount = ownerAccount; this.expire = expire ?? '1h'; + this.cors = cors || ['*']; // All origins are allowed by default /** Initialize the UsersDb instance with the provided options */ this.users = new UsersDb({ storage: storage['users'], prefix }); @@ -390,7 +399,7 @@ export class NodeApiServer { // Create a http server for handling of HTTP requests // TODO Implement origin configuration via .env this.server = createServer((req, res) => { - res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5174'); + res.setHeader('Access-Control-Allow-Origin', this.cors.join(', ')); res.setHeader('Access-Control-Request-Method', 'GET'); res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET'); res.setHeader( diff --git a/packages/node-api/test/api.nodeApiServer.spec.ts b/packages/node-api/test/api.nodeApiServer.spec.ts index ae15e3ef..5af2a15b 100644 --- a/packages/node-api/test/api.nodeApiServer.spec.ts +++ b/packages/node-api/test/api.nodeApiServer.spec.ts @@ -90,6 +90,7 @@ describe('NodeApiServer', () => { secret: 'secret', ownerAccount: owner.address, protocolContracts: contractsManager, + cors: ['*'], }; server = new NodeApiServer(options); diff --git a/packages/react/package.json b/packages/react/package.json index 7950bcf8..116e666e 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -32,6 +32,16 @@ "default": "./dist/providers.cjs" } }, + "./hooks": { + "import": { + "types": "./dist/hooks/index.d.ts", + "default": "./dist/hooks.es.js" + }, + "require": { + "types": "./dist/hooks/index.d.ts", + "default": "./dist/hooks.cjs" + } + }, "./utils": { "import": { "types": "./dist/utils/index.d.ts", @@ -45,6 +55,7 @@ }, "devDependencies": { "@trpc/client": "^10.44.1", + "@trpc/server": "^10.44.1", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@vitejs/plugin-react": "^4.0.3", @@ -53,6 +64,7 @@ "@windingtree/sdk-node-api": "workspace:*", "@windingtree/sdk-storage": "workspace:*", "@windingtree/sdk-types": "workspace:*", + "@windingtree/sdk-logger": "workspace:*", "eslint": "^8.45.0", "eslint-config-react-app": "^7.0.1", "react": "^18.2.0", diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts new file mode 100644 index 00000000..cf0e0120 --- /dev/null +++ b/packages/react/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './usePoller.js'; diff --git a/packages/react/src/hooks/usePoller.ts b/packages/react/src/hooks/usePoller.ts new file mode 100644 index 00000000..da2d3e7e --- /dev/null +++ b/packages/react/src/hooks/usePoller.ts @@ -0,0 +1,70 @@ +import { useEffect } from 'react'; +import { createLogger } from '@windingtree/sdk-logger'; + +// Initialize a logger for the hook +const logger = createLogger('usePoller'); + +/** + * Custom React hook for running a function at regular intervals. + * + * @param fn - The function to be executed periodically. + * @param delay - The delay (in milliseconds) between each execution. + * @param enabled - Boolean to enable or disable the polling. + * @param name - Name of the poller for logging purposes. + * @param maxFailures - Maximum number of allowed consecutive failures. + */ +export const usePoller = ( + fn: () => void, + delay: number | null, + enabled = true, + name = ' ', + maxFailures = 100, +): void => { + useEffect(() => { + let failures = 0; + let timeoutId: ReturnType | undefined; + + // Schedules the next execution of fnRunner + const scheduleNextRun = () => { + if (enabled && delay !== null) { + timeoutId = setTimeout(fnRunner, delay); + } + }; + + // Function to be executed at each interval + const fnRunner = async (): Promise => { + if (failures >= maxFailures) { + // Stop polling after reaching maximum failures + logger.error(`Poller ${name} stopped after reaching max failures`); + return; + } + + try { + // Execute the provided function + await Promise.resolve(fn()); + // Schedule the next run after successful execution + scheduleNextRun(); + } catch (error) { + failures++; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`Poller ${name} error: ${errorMessage}`); + // Schedule the next run even if an error occurred + scheduleNextRun(); + } + }; + + if (enabled) { + // Start the initial run + scheduleNextRun(); + } + + // Cleanup function for useEffect + return () => { + if (timeoutId) { + // Clear the timeout when the component is unmounted or dependencies change + clearTimeout(timeoutId); + } + logger.trace(`Poller ${name} stopped`); + }; + }, [fn, delay, name, enabled, maxFailures]); +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 66e37891..acdc7635 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,2 +1,3 @@ export * as providers from './providers/index.js'; export * as utils from './utils/index.js'; +export * as hooks from './hooks/index.js'; diff --git a/packages/react/src/providers/NodeProvider/NodeProviderContext.ts b/packages/react/src/providers/NodeProvider/NodeProviderContext.ts index f6f09f95..2f23555b 100644 --- a/packages/react/src/providers/NodeProvider/NodeProviderContext.ts +++ b/packages/react/src/providers/NodeProvider/NodeProviderContext.ts @@ -1,9 +1,10 @@ -import { createContext, useContext } from 'react'; +import { createContext, useContext, Context } from 'react'; +import type { AnyRouter } from '@trpc/server'; import { createTRPCProxyClient } from '@trpc/client'; import type { AppRouter } from '@windingtree/sdk-node-api/router'; -export interface NodeContextData { - node?: ReturnType> | undefined; +export interface NodeContextData { + node?: ReturnType> | undefined; nodeConnected: boolean; nodeError?: string; } @@ -12,8 +13,10 @@ export const NodeContext = createContext( {} as NodeContextData, ); -export const useNode = () => { - const context = useContext(NodeContext); +export const useNode = () => { + const context = useContext>( + NodeContext as Context>, + ); if (context === undefined) { throw new Error('useNode must be used within a "NodeContext"'); diff --git a/packages/react/src/providers/NodeProvider/index.tsx b/packages/react/src/providers/NodeProvider/index.tsx index 4a0950d9..2cfaca72 100644 --- a/packages/react/src/providers/NodeProvider/index.tsx +++ b/packages/react/src/providers/NodeProvider/index.tsx @@ -1,44 +1,64 @@ -import { - CreateTRPCClientOptions, - createTRPCProxyClient, - httpBatchLink, -} from '@trpc/client'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import superjson from 'superjson'; -import { PropsWithChildren, useState, useEffect } from 'react'; +import { + type PropsWithChildren, + useState, + useEffect, + useCallback, +} from 'react'; import { NodeContext } from './NodeProviderContext.js'; +import { AppRouter } from '@windingtree/sdk-node-api/router'; import { unauthorizedLink } from '@windingtree/sdk-node-api/client'; -import type { AppRouter } from '@windingtree/sdk-node-api/router'; import { useConfig } from '../ConfigProvider/ConfigProviderContext.js'; +import { usePoller } from '../../hooks/usePoller.js'; +import { createLogger } from '@windingtree/sdk-logger'; + +// Initialize logger +const logger = createLogger('NodeProvider'); export const NodeProvider = ({ children }: PropsWithChildren) => { - const { nodeHost, setAuth, resetAuth } = useConfig(); + const { nodeHost, resetAuth } = useConfig(); const [node, setNode] = useState< ReturnType> | undefined >(); const [error, setError] = useState(); + const [isConnected, setIsConnected] = useState(false); - const stopClient = () => { - try { - setError(() => undefined); - setNode(() => undefined); - } catch (error) { - setError((error as Error).message || 'Unknown node provider error'); - } - }; + // Function to stop and reset the client + const stopClient = useCallback(() => { + setError(undefined); + setNode(undefined); + }, []); - useEffect(() => { - if (!nodeHost) { - stopClient(); + // Function to check the connection + const checkConnection = useCallback(async () => { + setError(undefined); + + if (!node) { + setIsConnected(false); return; } + try { + const { message } = await node.service.ping.query(); + setIsConnected(message === 'pong'); + } catch (err) { + setIsConnected(false); + setError('Unable to connect the Node'); + logger.error(err); + } + }, [node]); + + // Initialize and clean up the client + useEffect(() => { const startClient = async () => { - try { - setError(undefined); + if (!nodeHost) { + return; + } + try { const tRpcNode = createTRPCProxyClient({ - transformer: - superjson as unknown as CreateTRPCClientOptions['transformer'], + transformer: superjson, links: [ unauthorizedLink(resetAuth), httpBatchLink({ @@ -46,7 +66,6 @@ export const NodeProvider = ({ children }: PropsWithChildren) => { fetch(url, options) { return fetch(url, { ...options, - // allows to send cookies to the server credentials: 'include', }); }, @@ -54,21 +73,10 @@ export const NodeProvider = ({ children }: PropsWithChildren) => { ], }); - const { message } = await tRpcNode.service.ping.query(); - - if (message === 'pong') { - setNode(() => tRpcNode); - } - } catch (error) { - console.log(error); - setNode(() => undefined); - let errMessage = (error as Error).message; - - if (errMessage === 'Failed to fetch') { - errMessage = 'Node connection failed'; - } - - setError(() => errMessage || 'Unknown node provider error'); + setNode(() => tRpcNode); + } catch (err) { + setError((err as Error).message || 'Unknown node provider error'); + logger.error(err); } }; @@ -77,13 +85,16 @@ export const NodeProvider = ({ children }: PropsWithChildren) => { return () => { stopClient(); }; - }, [nodeHost, setAuth, resetAuth]); + }, [stopClient, resetAuth, nodeHost]); + + // Polling for connection check + usePoller(checkConnection, 5000, true, 'NodeConnection'); return ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd210b9d..e751ce2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -700,6 +700,9 @@ importers: '@trpc/client': specifier: ^10.44.1 version: 10.44.1(@trpc/server@10.44.1) + '@trpc/server': + specifier: ^10.44.1 + version: 10.44.1 '@types/react': specifier: ^18.2.15 version: 18.2.43 @@ -715,6 +718,9 @@ importers: '@windingtree/sdk-contracts-manager': specifier: workspace:* version: link:../contracts-manger + '@windingtree/sdk-logger': + specifier: workspace:* + version: link:../logger '@windingtree/sdk-node-api': specifier: workspace:* version: link:../node-api