diff --git a/app.config.ts b/app.config.ts index 09526b8..4c72d68 100644 --- a/app.config.ts +++ b/app.config.ts @@ -18,5 +18,5 @@ export default defineConfig({ projects: ["./tsconfig.json"], }), ], - }, + } as any, }); diff --git a/app/components/DefaultCatchBoundary.tsx b/app/components/DefaultCatchBoundary.tsx index 9bf60a0..c74028d 100644 --- a/app/components/DefaultCatchBoundary.tsx +++ b/app/components/DefaultCatchBoundary.tsx @@ -25,7 +25,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { onClick={() => { router.invalidate(); }} - className="rounded bg-gray-600 px-2 py-1 font-extrabold text-white uppercase dark:bg-gray-700" + className="cursor-pointer rounded bg-gray-600 px-2 py-1 font-extrabold text-white uppercase dark:bg-gray-700" > Try Again diff --git a/app/components/LoadingSpinner.tsx b/app/components/LoadingSpinner.tsx new file mode 100644 index 0000000..7afcda7 --- /dev/null +++ b/app/components/LoadingSpinner.tsx @@ -0,0 +1,7 @@ +export function LoadingSpinner() { + return ( +
+
+
+ ); +} diff --git a/app/hooks/useConnectionState.ts b/app/hooks/useConnectionState.ts index 9160f74..7ce8f1e 100644 --- a/app/hooks/useConnectionState.ts +++ b/app/hooks/useConnectionState.ts @@ -1,23 +1,27 @@ -import { useState } from "react"; -import { useConfig, useWatchBlockNumber } from "wagmi"; +import { useCallback } from "react"; +import { useWatchBlockNumber } from "wagmi"; +import { useConnectionStore } from "#/store/connection"; -export function useConnectionState() { - const [blockNumber, setBlockNumber] = useState(null); - const config = useConfig(); +export function useConnectionState({ rpc }: { rpc: string }) { + const { setState } = useConnectionStore(); - const interval = blockNumber ? 10000 : 1000; + const onBlockNumber = useCallback( + (b: bigint) => { + console.log("onblocknumber", b); + setState({ connected: true, blockNumber: b, rpc }); + }, + [rpc, setState], + ); + + const onError = useCallback(() => { + setState({ connected: false, blockNumber: null, rpc }); + }, [rpc, setState]); useWatchBlockNumber({ emitOnBegin: true, poll: true, - pollingInterval: interval, - onBlockNumber: (b) => setBlockNumber(b), - onError: () => setBlockNumber(null), + pollingInterval: 1000, + onBlockNumber, + onError, }); - - return { - connected: blockNumber !== null, - blockNumber, - rpc: config.chains[0].rpcUrls.default.http, - }; } diff --git a/app/routes/__root.tsx b/app/routes/__root.tsx index 5e4c8c3..6012968 100644 --- a/app/routes/__root.tsx +++ b/app/routes/__root.tsx @@ -1,14 +1,22 @@ +import { Form } from "@ethui/ui/components/form"; +import { Button } from "@ethui/ui/components/shadcn/button"; +import { zodResolver } from "@hookform/resolvers/zod"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { HeadContent, Outlet, Scripts, createRootRouteWithContext, + useNavigate, + useParams, } from "@tanstack/react-router"; import { Suspense, lazy } from "react"; +import { type FieldValues, useForm } from "react-hook-form"; +import { z } from "zod"; import appCss from "#/app.css?url"; import { DefaultCatchBoundary } from "#/components/DefaultCatchBoundary"; import { NotFound } from "#/components/NotFound"; +import { useConnectionStore } from "#/store/connection"; import { seo } from "#/utils/seo"; const TanStackRouterDevtools = @@ -76,6 +84,7 @@ function RootComponent() { return ( + @@ -98,3 +107,57 @@ function RootDocument({ children }: { children: React.ReactNode }) { ); } + +function RpcForm() { + const navigate = useNavigate(); + const { rpc } = useParams({ strict: false }); + const { connected, blockNumber, reset } = useConnectionStore(); + const currentRpc = rpc ? decodeURIComponent(rpc) : "ws://localhost:8545"; + + const schema = z.object({ + url: z.string(), + }); + + const form = useForm({ + mode: "onBlur", + resolver: zodResolver(schema), + defaultValues: { + url: currentRpc, + }, + }); + + const handleSubmit = (data: FieldValues) => { + const newRpc = data.url; + if (newRpc !== currentRpc) { + reset(); + } + navigate({ to: `/rpc/${encodeURIComponent(newRpc)}`, replace: true }); + }; + + return ( + + ); +} diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 3559a8b..ce0ef9f 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -1,16 +1,5 @@ -import { Link, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ - component: Home, + component: () => null, }); - -function Home() { - return ( - - Go - - ); -} diff --git a/app/routes/rpc.$rpc/_l.tsx b/app/routes/rpc.$rpc/_l.tsx index c9d7b49..8a74422 100644 --- a/app/routes/rpc.$rpc/_l.tsx +++ b/app/routes/rpc.$rpc/_l.tsx @@ -1,35 +1,31 @@ -import { Form } from "@ethui/ui/components/form"; -import { Button } from "@ethui/ui/components/shadcn/button"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; -import { type FieldValues, useForm } from "react-hook-form"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; import { http, WagmiProvider, createConfig, webSocket } from "wagmi"; import { foundry } from "wagmi/chains"; -import { z } from "zod"; +import { LoadingSpinner } from "#/components/LoadingSpinner"; import { useConnectionState } from "#/hooks/useConnectionState"; +import { validateRpcConnection } from "#/utils/rpc"; + +const validateRpc = createServerFn({ + method: "GET", +}) + .validator((rpc: string) => rpc) + .handler(async ({ data: rpc }) => validateRpcConnection(rpc)); export const Route = createFileRoute("/rpc/$rpc/_l")({ - loader: ({ params }) => decodeURIComponent(params.rpc), + loader: async ({ params }) => { + const rpc = decodeURIComponent(params.rpc); + return validateRpc({ data: rpc }); + }, component: RouteComponent, + pendingComponent: () => , }); function RouteComponent() { const rpc = Route.useLoaderData(); - const navigate = useNavigate(); - - const schema = z.object({ - url: z.string(), - }); - - const form = useForm({ - mode: "onBlur", - resolver: zodResolver(schema), - defaultValues: { - url: "ws://localhost:8545", - }, - }); const transport = rpc.startsWith("ws://") ? webSocket(rpc) : http(rpc); + const wagmi = createConfig({ chains: [foundry], transports: { @@ -37,28 +33,10 @@ function RouteComponent() { }, }); - const handleSubmit = (data: FieldValues) => { - navigate({ to: `/rpc/${encodeURIComponent(data.url)}` }); - }; - return ( +
-
-
- - - - -
@@ -67,14 +45,7 @@ function RouteComponent() { ); } -function ConnectionState() { - const { connected, blockNumber, rpc } = useConnectionState(); - - return ( - connected && ( -
- Connected to: {rpc} at block {blockNumber} -
- ) - ); +function ConnectionStateUpdater({ rpc }: { rpc: string }) { + useConnectionState({ rpc }); + return null; } diff --git a/app/store/connection.ts b/app/store/connection.ts new file mode 100644 index 0000000..01aa73e --- /dev/null +++ b/app/store/connection.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; + +interface ConnectionState { + connected: boolean | undefined; + blockNumber: bigint | null; + rpc: string | undefined; + setState: (state: Partial) => void; + reset: () => void; +} + +export const useConnectionStore = create((set) => ({ + connected: undefined, + blockNumber: null, + rpc: undefined, + setState: (state) => set(state), + reset: () => set({ connected: undefined, blockNumber: null, rpc: undefined }), +})); diff --git a/app/utils/rpc.ts b/app/utils/rpc.ts new file mode 100644 index 0000000..83e8e39 --- /dev/null +++ b/app/utils/rpc.ts @@ -0,0 +1,41 @@ +import { http, createPublicClient } from "viem"; +import { foundry } from "wagmi/chains"; + +export async function validateRpcConnection(rpc: string): Promise { + if (rpc.startsWith("ws://")) { + const isValid = await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), 5000); + try { + const ws = new WebSocket(rpc); + ws.onopen = () => { + clearTimeout(timeout); + ws.close(); + resolve(true); + }; + ws.onerror = () => { + clearTimeout(timeout); + resolve(false); + }; + } catch { + clearTimeout(timeout); + resolve(false); + } + }); + + if (!isValid) { + throw new Error(`Invalid WebSocket RPC on ${rpc}`); + } + } else { + try { + const client = createPublicClient({ + chain: foundry, + transport: http(rpc), + }); + await client.getChainId(); + } catch (_err) { + throw new Error(`Invalid RPC on ${rpc}`); + } + } + + return rpc; +} diff --git a/package.json b/package.json index 2bfdc03..7e0deda 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "vinxi": "0.5.3", "vite-tsconfig-paths": "^5.1.4", "wagmi": "^2.14.15", - "zod": "^3.24.2" + "zod": "^3.24.2", + "zustand": "^5.0.4" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/yarn.lock b/yarn.lock index 2355e3e..2ef7750 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1077,6 +1077,7 @@ __metadata: vite-tsconfig-paths: "npm:^5.1.4" wagmi: "npm:^2.14.15" zod: "npm:^3.24.2" + zustand: "npm:^5.0.4" languageName: unknown linkType: soft @@ -12691,6 +12692,27 @@ __metadata: languageName: node linkType: hard +"zustand@npm:^5.0.4": + version: 5.0.4 + resolution: "zustand@npm:5.0.4" + peerDependencies: + "@types/react": ">=18.0.0" + immer: ">=9.0.6" + react: ">=18.0.0" + use-sync-external-store: ">=1.2.0" + peerDependenciesMeta: + "@types/react": + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + checksum: 10c0/5b6220f51b315cef3224a6517fcc00fa553df1af604009a9f02f8091727fe52e0499bc093be3efdd64b9fa4ad9238346aff21f34cdf79355207fcad097031596 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4"