Skip to content

Commit

Permalink
Merge pull request #79 from windingtree/feat/new-nodeprovider
Browse files Browse the repository at this point in the history
feat: 🎸 Updated NodeProvider in the react package
  • Loading branch information
kostysh authored Dec 24, 2023
2 parents 0580026 + 8eae00e commit 0d09e55
Show file tree
Hide file tree
Showing 11 changed files with 163 additions and 48 deletions.
2 changes: 1 addition & 1 deletion examples/manager/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions examples/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ const main = async (): Promise<void> => {
secret: 'secret',
ownerAccount: entityOwnerAddress,
protocolContracts: contractsManager,
cors: ['http://localhost:5173'],
});

apiServer.start(appRouter);
Expand Down
11 changes: 10 additions & 1 deletion packages/node-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -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.
Expand All @@ -234,6 +241,7 @@ export class NodeApiServer {
secret,
ownerAccount,
expire,
cors,
} = options;

// TODO Validate NodeApiServerOptions
Expand All @@ -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 });
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/node-api/test/api.nodeApiServer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ describe('NodeApiServer', () => {
secret: 'secret',
ownerAccount: owner.address,
protocolContracts: contractsManager,
cors: ['*'],
};
server = new NodeApiServer(options);

Expand Down
12 changes: 12 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './usePoller.js';
70 changes: 70 additions & 0 deletions packages/react/src/hooks/usePoller.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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<void> => {
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]);
};
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
13 changes: 8 additions & 5 deletions packages/react/src/providers/NodeProvider/NodeProviderContext.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createTRPCProxyClient<AppRouter>> | undefined;
export interface NodeContextData<TRouter extends AnyRouter = AppRouter> {
node?: ReturnType<typeof createTRPCProxyClient<TRouter>> | undefined;
nodeConnected: boolean;
nodeError?: string;
}
Expand All @@ -12,8 +13,10 @@ export const NodeContext = createContext<NodeContextData>(
{} as NodeContextData,
);

export const useNode = () => {
const context = useContext(NodeContext);
export const useNode = <TRouter extends AnyRouter = AppRouter>() => {
const context = useContext<NodeContextData<TRouter>>(
NodeContext as Context<NodeContextData<TRouter>>,
);

if (context === undefined) {
throw new Error('useNode must be used within a "NodeContext"');
Expand Down
93 changes: 52 additions & 41 deletions packages/react/src/providers/NodeProvider/index.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,82 @@
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<typeof createTRPCProxyClient<AppRouter>> | undefined
>();
const [error, setError] = useState<string | undefined>();
const [isConnected, setIsConnected] = useState<boolean>(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<AppRouter>({
transformer:
superjson as unknown as CreateTRPCClientOptions<AppRouter>['transformer'],
transformer: superjson,
links: [
unauthorizedLink(resetAuth),
httpBatchLink({
url: nodeHost,
fetch(url, options) {
return fetch(url, {
...options,
// allows to send cookies to the server
credentials: 'include',
});
},
}),
],
});

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);
}
};

Expand All @@ -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 (
<NodeContext.Provider
value={{
node,
nodeConnected: Boolean(node),
nodeConnected: isConnected,
nodeError: error,
}}
>
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 0d09e55

Please sign in to comment.