From d9a2dd69db271ff44ec9dd8ce859d10929e70bb7 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Wed, 30 Apr 2025 13:00:59 +0100 Subject: [PATCH 1/6] add error on invalid rpc --- app/components/DefaultCatchBoundary.tsx | 2 +- app/components/LoadingSpinner.tsx | 7 ++ app/routes/__root.tsx | 54 ++++++++++++++ app/routes/index.tsx | 20 ++--- app/routes/rpc.$rpc/_l.tsx | 99 ++++++++++++------------- 5 files changed, 115 insertions(+), 67 deletions(-) create mode 100644 app/components/LoadingSpinner.tsx 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/routes/__root.tsx b/app/routes/__root.tsx index 5e4c8c3..90e506a 100644 --- a/app/routes/__root.tsx +++ b/app/routes/__root.tsx @@ -1,13 +1,21 @@ +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 { LoadingSpinner } from "#/components/LoadingSpinner"; import { NotFound } from "#/components/NotFound"; import { seo } from "#/utils/seo"; @@ -67,6 +75,11 @@ export const Route = createRootRouteWithContext()({ ); }, notFoundComponent: () => , + pendingComponent: () => ( + + + + ), component: RootComponent, }); @@ -76,6 +89,7 @@ function RootComponent() { return ( + @@ -98,3 +112,43 @@ function RootDocument({ children }: { children: React.ReactNode }) { ); } + +function RpcForm() { + const navigate = useNavigate(); + const params = useParams({ from: "/rpc/$rpc" }); + const currentRpc = params?.rpc + ? decodeURIComponent(params.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) => { + navigate({ to: `/rpc/${encodeURIComponent(data.url)}` }); + }; + + return ( + + ); +} diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 3559a8b..b82d181 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -1,16 +1,10 @@ -import { Link, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ - component: Home, + loader: () => { + throw redirect({ + to: "/rpc/$rpc", + params: { rpc: encodeURIComponent("ws://localhost:8545") }, + }); + }, }); - -function Home() { - return ( - - Go - - ); -} diff --git a/app/routes/rpc.$rpc/_l.tsx b/app/routes/rpc.$rpc/_l.tsx index c9d7b49..94967d5 100644 --- a/app/routes/rpc.$rpc/_l.tsx +++ b/app/routes/rpc.$rpc/_l.tsx @@ -1,33 +1,57 @@ -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, useLoaderData } from "@tanstack/react-router"; +import { createPublicClient } from "viem"; import { http, WagmiProvider, createConfig, webSocket } from "wagmi"; import { foundry } from "wagmi/chains"; -import { z } from "zod"; -import { useConnectionState } from "#/hooks/useConnectionState"; +import { LoadingSpinner } from "#/components/LoadingSpinner"; export const Route = createFileRoute("/rpc/$rpc/_l")({ - loader: ({ params }) => decodeURIComponent(params.rpc), + loader: async ({ params }) => { + const rpc = decodeURIComponent(params.rpc); + if (rpc.startsWith("http")) { + try { + const client = createPublicClient({ + chain: foundry, + transport: http(rpc), + }); + await client.getChainId(); + } catch (_err) { + throw new Error(`Invalid HTTP RPC on ${rpc}`); + } + } + + 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}`); + } + } + + return 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 rpc = useLoaderData({ from: Route.id }); const transport = rpc.startsWith("ws://") ? webSocket(rpc) : http(rpc); const wagmi = createConfig({ @@ -37,28 +61,9 @@ function RouteComponent() { }, }); - const handleSubmit = (data: FieldValues) => { - navigate({ to: `/rpc/${encodeURIComponent(data.url)}` }); - }; - return (
-
-
- - - - -
@@ -66,15 +71,3 @@ function RouteComponent() { ); } - -function ConnectionState() { - const { connected, blockNumber, rpc } = useConnectionState(); - - return ( - connected && ( -
- Connected to: {rpc} at block {blockNumber} -
- ) - ); -} From 195aa8799daffdc7681f8b35a91ded90da3325cc Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Wed, 30 Apr 2025 13:08:51 +0100 Subject: [PATCH 2/6] fix lint --- app.config.ts | 1 - app/routes/__root.tsx | 5 ----- 2 files changed, 6 deletions(-) diff --git a/app.config.ts b/app.config.ts index 09526b8..d6500d7 100644 --- a/app.config.ts +++ b/app.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ esbuild: { options: { supported: { "top-level-await": true } } }, }, vite: { - server: { allowedHosts: ["lvh.me"] }, plugins: [ tailwindcss(), tsConfigPaths({ diff --git a/app/routes/__root.tsx b/app/routes/__root.tsx index 90e506a..27e28c9 100644 --- a/app/routes/__root.tsx +++ b/app/routes/__root.tsx @@ -75,11 +75,6 @@ export const Route = createRootRouteWithContext()({ ); }, notFoundComponent: () => , - pendingComponent: () => ( - - - - ), component: RootComponent, }); From 6f44e000d9b6ead3e707e1ae76fd7057605994bd Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Wed, 30 Apr 2025 13:10:24 +0100 Subject: [PATCH 3/6] fix lint --- app/routes/__root.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/routes/__root.tsx b/app/routes/__root.tsx index 27e28c9..4c192c5 100644 --- a/app/routes/__root.tsx +++ b/app/routes/__root.tsx @@ -15,7 +15,6 @@ import { type FieldValues, useForm } from "react-hook-form"; import { z } from "zod"; import appCss from "#/app.css?url"; import { DefaultCatchBoundary } from "#/components/DefaultCatchBoundary"; -import { LoadingSpinner } from "#/components/LoadingSpinner"; import { NotFound } from "#/components/NotFound"; import { seo } from "#/utils/seo"; From 3502eca38d95504ec22d64a768919bb4503bdf1f Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Wed, 30 Apr 2025 13:17:24 +0100 Subject: [PATCH 4/6] fix --- app/routes/rpc.$rpc/_l.tsx | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/app/routes/rpc.$rpc/_l.tsx b/app/routes/rpc.$rpc/_l.tsx index 94967d5..6ab6620 100644 --- a/app/routes/rpc.$rpc/_l.tsx +++ b/app/routes/rpc.$rpc/_l.tsx @@ -7,17 +7,6 @@ import { LoadingSpinner } from "#/components/LoadingSpinner"; export const Route = createFileRoute("/rpc/$rpc/_l")({ loader: async ({ params }) => { const rpc = decodeURIComponent(params.rpc); - if (rpc.startsWith("http")) { - try { - const client = createPublicClient({ - chain: foundry, - transport: http(rpc), - }); - await client.getChainId(); - } catch (_err) { - throw new Error(`Invalid HTTP RPC on ${rpc}`); - } - } if (rpc.startsWith("ws")) { const isValid = await new Promise((resolve) => { @@ -42,6 +31,16 @@ export const Route = createFileRoute("/rpc/$rpc/_l")({ 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; From 8d0c16e27039a7fc1c84c7e2c6e2e38ee7038627 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 2 May 2025 14:53:01 +0100 Subject: [PATCH 5/6] added error handling and rpc validation --- app.config.ts | 3 +- app/hooks/useConnectionState.ts | 34 ++++++++++--------- app/routes/__root.tsx | 30 ++++++++++++++--- app/routes/index.tsx | 9 ++--- app/routes/rpc.$rpc/_l.tsx | 59 +++++++++++---------------------- app/store/connection.ts | 17 ++++++++++ app/utils/rpc.ts | 41 +++++++++++++++++++++++ package.json | 3 +- yarn.lock | 22 ++++++++++++ 9 files changed, 149 insertions(+), 69 deletions(-) create mode 100644 app/store/connection.ts create mode 100644 app/utils/rpc.ts diff --git a/app.config.ts b/app.config.ts index d6500d7..4c72d68 100644 --- a/app.config.ts +++ b/app.config.ts @@ -11,11 +11,12 @@ export default defineConfig({ esbuild: { options: { supported: { "top-level-await": true } } }, }, vite: { + server: { allowedHosts: ["lvh.me"] }, plugins: [ tailwindcss(), tsConfigPaths({ projects: ["./tsconfig.json"], }), ], - }, + } as any, }); 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 4c192c5..9003622 100644 --- a/app/routes/__root.tsx +++ b/app/routes/__root.tsx @@ -16,6 +16,7 @@ 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 = @@ -32,6 +33,11 @@ const TanStackRouterDevtools = export interface RouteContext { breadcrumb?: string; + connectionState?: { + connected: boolean; + blockNumber: bigint | null; + rpc: string; + }; } export const Route = createRootRouteWithContext()({ @@ -109,10 +115,9 @@ function RootDocument({ children }: { children: React.ReactNode }) { function RpcForm() { const navigate = useNavigate(); - const params = useParams({ from: "/rpc/$rpc" }); - const currentRpc = params?.rpc - ? decodeURIComponent(params.rpc) - : "ws://localhost:8545"; + 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(), @@ -127,7 +132,11 @@ function RpcForm() { }); const handleSubmit = (data: FieldValues) => { - navigate({ to: `/rpc/${encodeURIComponent(data.url)}` }); + const newRpc = data.url; + if (newRpc !== currentRpc) { + reset(); + } + navigate({ to: `/rpc/${encodeURIComponent(newRpc)}`, replace: true }); }; return ( @@ -142,6 +151,17 @@ function RpcForm() { /> +
+ {connected === undefined ? ( + No connection + ) : connected ? ( + + Connected to {currentRpc} (Block: {blockNumber?.toString()}) + + ) : ( + Disconnected + )} +
); diff --git a/app/routes/index.tsx b/app/routes/index.tsx index b82d181..ce0ef9f 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -1,10 +1,5 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ - loader: () => { - throw redirect({ - to: "/rpc/$rpc", - params: { rpc: encodeURIComponent("ws://localhost:8545") }, - }); - }, + component: () => null, }); diff --git a/app/routes/rpc.$rpc/_l.tsx b/app/routes/rpc.$rpc/_l.tsx index 6ab6620..8a74422 100644 --- a/app/routes/rpc.$rpc/_l.tsx +++ b/app/routes/rpc.$rpc/_l.tsx @@ -1,58 +1,31 @@ -import { Outlet, createFileRoute, useLoaderData } from "@tanstack/react-router"; -import { createPublicClient } from "viem"; +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 { 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: async ({ params }) => { const rpc = decodeURIComponent(params.rpc); - - 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; + return validateRpc({ data: rpc }); }, component: RouteComponent, pendingComponent: () => , }); function RouteComponent() { - const rpc = useLoaderData({ from: Route.id }); + const rpc = Route.useLoaderData(); const transport = rpc.startsWith("ws://") ? webSocket(rpc) : http(rpc); + const wagmi = createConfig({ chains: [foundry], transports: { @@ -62,6 +35,7 @@ function RouteComponent() { return ( +
@@ -70,3 +44,8 @@ function RouteComponent() { ); } + +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" From 8f9d631d57d49f8d540db02e1c289577e3d54200 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 2 May 2025 14:58:55 +0100 Subject: [PATCH 6/6] remove unused context --- app/routes/__root.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/routes/__root.tsx b/app/routes/__root.tsx index 9003622..6012968 100644 --- a/app/routes/__root.tsx +++ b/app/routes/__root.tsx @@ -33,11 +33,6 @@ const TanStackRouterDevtools = export interface RouteContext { breadcrumb?: string; - connectionState?: { - connected: boolean; - blockNumber: bigint | null; - rpc: string; - }; } export const Route = createRootRouteWithContext()({