From feba95ec421aa4afdd59016fe2a19e94126ee8fb Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 27 Apr 2026 07:37:32 +0100 Subject: [PATCH 1/9] docs(example): ensnode-react --- examples/ensnode-react-example/LICENSE | 21 ++++++++ examples/ensnode-react-example/README.md | 20 ++++++++ examples/ensnode-react-example/index.html | 12 +++++ examples/ensnode-react-example/package.json | 28 ++++++++++ examples/ensnode-react-example/src/App.tsx | 25 +++++++++ .../src/PrimaryNameView.tsx | 51 +++++++++++++++++++ examples/ensnode-react-example/src/main.tsx | 6 +++ .../ensnode-react-example/src/vite-env.d.ts | 1 + examples/ensnode-react-example/tsconfig.json | 13 +++++ examples/ensnode-react-example/vite.config.ts | 6 +++ pnpm-lock.yaml | 37 ++++++++++++++ 11 files changed, 220 insertions(+) create mode 100644 examples/ensnode-react-example/LICENSE create mode 100644 examples/ensnode-react-example/README.md create mode 100644 examples/ensnode-react-example/index.html create mode 100644 examples/ensnode-react-example/package.json create mode 100644 examples/ensnode-react-example/src/App.tsx create mode 100644 examples/ensnode-react-example/src/PrimaryNameView.tsx create mode 100644 examples/ensnode-react-example/src/main.tsx create mode 100644 examples/ensnode-react-example/src/vite-env.d.ts create mode 100644 examples/ensnode-react-example/tsconfig.json create mode 100644 examples/ensnode-react-example/vite.config.ts diff --git a/examples/ensnode-react-example/LICENSE b/examples/ensnode-react-example/LICENSE new file mode 100644 index 0000000000..0d70998c14 --- /dev/null +++ b/examples/ensnode-react-example/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NameHash + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/ensnode-react-example/README.md b/examples/ensnode-react-example/README.md new file mode 100644 index 0000000000..acb2eb3531 --- /dev/null +++ b/examples/ensnode-react-example/README.md @@ -0,0 +1,20 @@ +# ensnode-react Example + +A minimal React app demonstrating how to use `@ensnode/ensnode-react` to resolve +an address' Mainnet Primary Name (via `usePrimaryName`). + +By default it connects to the NameHash-hosted alpha ENSNode at +`https://api.alpha.ensnode.io`. + +## Usage + +```bash +pnpm install +pnpm -F @ensnode/ensnode-react-example dev +``` + +To point at a different ENSNode, set `VITE_ENSNODE_URL`: + +```bash +VITE_ENSNODE_URL=http://localhost:4334 pnpm -F @ensnode/ensnode-react-example dev +``` diff --git a/examples/ensnode-react-example/index.html b/examples/ensnode-react-example/index.html new file mode 100644 index 0000000000..2db51df750 --- /dev/null +++ b/examples/ensnode-react-example/index.html @@ -0,0 +1,12 @@ + + + + + + ensnode-react Example + + +
+ + + diff --git a/examples/ensnode-react-example/package.json b/examples/ensnode-react-example/package.json new file mode 100644 index 0000000000..a85203056b --- /dev/null +++ b/examples/ensnode-react-example/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ensnode/ensnode-react-example", + "private": true, + "version": "0.0.1", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@ensnode/ensnode-react": "workspace:*", + "@ensnode/ensnode-sdk": "workspace:*", + "@tanstack/react-query": "^5.62.14", + "enssdk": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/examples/ensnode-react-example/src/App.tsx b/examples/ensnode-react-example/src/App.tsx new file mode 100644 index 0000000000..28795237f4 --- /dev/null +++ b/examples/ensnode-react-example/src/App.tsx @@ -0,0 +1,25 @@ +import { StrictMode } from "react"; + +import { createEnsNodeProviderOptions, EnsNodeProvider } from "@ensnode/ensnode-react"; + +import { PrimaryNameView } from "./PrimaryNameView"; + +const ENSNODE_URL = import.meta.env.VITE_ENSNODE_URL ?? "https://api.alpha.ensnode.io"; + +const options = createEnsNodeProviderOptions({ url: ENSNODE_URL }); + +export function App() { + return ( + + +

+ ensnode-react Example App +

+

+ Connected to {ENSNODE_URL} +

+ +
+
+ ); +} diff --git a/examples/ensnode-react-example/src/PrimaryNameView.tsx b/examples/ensnode-react-example/src/PrimaryNameView.tsx new file mode 100644 index 0000000000..9367afc5a3 --- /dev/null +++ b/examples/ensnode-react-example/src/PrimaryNameView.tsx @@ -0,0 +1,51 @@ +import type { Address, ChainId } from "enssdk"; +import { useState } from "react"; + +import { usePrimaryName } from "@ensnode/ensnode-react"; + +const DEFAULT_ADDRESS: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // vitalik.eth +const MAINNET: ChainId = 1; + +export function PrimaryNameView() { + const [address, setAddress] = useState
(DEFAULT_ADDRESS); + const [input, setInput] = useState(DEFAULT_ADDRESS); + + const { data, isLoading, error } = usePrimaryName({ + address, + chainId: MAINNET, + accelerate: true, + }); + + return ( +
+

Primary Name

+

+ Resolves the ENSIP-19 Mainnet Primary Name for an address using usePrimaryName. +

+ +
{ + event.preventDefault(); + setAddress(input as Address); + }} + > + setInput(event.target.value)} + placeholder="0x..." + style={{ width: "28rem" }} + /> + +
+ + {isLoading &&

Loading...

} + {error &&

Error: {error.message}

} + {data && ( +

+ Primary Name: {data.name ?? "(none)"} +

+ )} +
+ ); +} diff --git a/examples/ensnode-react-example/src/main.tsx b/examples/ensnode-react-example/src/main.tsx new file mode 100644 index 0000000000..4a1253371e --- /dev/null +++ b/examples/ensnode-react-example/src/main.tsx @@ -0,0 +1,6 @@ +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; + +// biome-ignore lint/style/noNonNullAssertion: the #root element definitely exists (see index.html) +createRoot(document.getElementById("root")!).render(); diff --git a/examples/ensnode-react-example/src/vite-env.d.ts b/examples/ensnode-react-example/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/ensnode-react-example/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/ensnode-react-example/tsconfig.json b/examples/ensnode-react-example/tsconfig.json new file mode 100644 index 0000000000..0192fe334b --- /dev/null +++ b/examples/ensnode-react-example/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/examples/ensnode-react-example/vite.config.ts b/examples/ensnode-react-example/vite.config.ts new file mode 100644 index 0000000000..58676f788a --- /dev/null +++ b/examples/ensnode-react-example/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6ed891c9a..f303d48e5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -839,6 +839,43 @@ importers: specifier: 'catalog:' version: 7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) + examples/ensnode-react-example: + dependencies: + '@ensnode/ensnode-react': + specifier: workspace:* + version: link:../../packages/ensnode-react + '@ensnode/ensnode-sdk': + specifier: workspace:* + version: link:../../packages/ensnode-sdk + '@tanstack/react-query': + specifier: ^5.62.14 + version: 5.90.5(react@19.2.1) + enssdk: + specifier: workspace:* + version: link:../../packages/enssdk + react: + specifier: 'catalog:' + version: 19.2.1 + react-dom: + specifier: 'catalog:' + version: 19.2.1(react@19.2.1) + devDependencies: + '@types/react': + specifier: 'catalog:' + version: 19.2.7 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^4.5.2 + version: 4.7.0(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3)) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 'catalog:' + version: 7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) + packages/datasources: dependencies: '@ponder/utils': From bf92c54eb87c70e0c0d48a20725efbc88d67cf6f Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 11 May 2026 08:46:05 +0100 Subject: [PATCH 2/9] apply code suggestions --- examples/ensnode-react-example/package.json | 2 +- examples/ensnode-react-example/src/App.tsx | 29 ++-- .../src/PrimaryNameView.tsx | 142 +++++++++++++++--- .../src/components/IndexingStatusBadge.tsx | 54 +++++++ .../components/RequireActiveConnection.tsx | 91 +++++++++++ examples/ensnode-react-example/src/config.ts | 27 ++++ .../src/lib/classify-connection-error.ts | 26 ++++ .../ensnode-react-example/src/vite-env.d.ts | 9 ++ pnpm-lock.yaml | 6 +- 9 files changed, 349 insertions(+), 37 deletions(-) create mode 100644 examples/ensnode-react-example/src/components/IndexingStatusBadge.tsx create mode 100644 examples/ensnode-react-example/src/components/RequireActiveConnection.tsx create mode 100644 examples/ensnode-react-example/src/config.ts create mode 100644 examples/ensnode-react-example/src/lib/classify-connection-error.ts diff --git a/examples/ensnode-react-example/package.json b/examples/ensnode-react-example/package.json index a85203056b..819fa962a7 100644 --- a/examples/ensnode-react-example/package.json +++ b/examples/ensnode-react-example/package.json @@ -11,9 +11,9 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@ensnode/datasources": "workspace:*", "@ensnode/ensnode-react": "workspace:*", "@ensnode/ensnode-sdk": "workspace:*", - "@tanstack/react-query": "^5.62.14", "enssdk": "workspace:*", "react": "catalog:", "react-dom": "catalog:" diff --git a/examples/ensnode-react-example/src/App.tsx b/examples/ensnode-react-example/src/App.tsx index 28795237f4..2da97deb14 100644 --- a/examples/ensnode-react-example/src/App.tsx +++ b/examples/ensnode-react-example/src/App.tsx @@ -2,23 +2,34 @@ import { StrictMode } from "react"; import { createEnsNodeProviderOptions, EnsNodeProvider } from "@ensnode/ensnode-react"; +import { IndexingStatusBadge } from "./components/IndexingStatusBadge"; +import { RequireActiveConnection } from "./components/RequireActiveConnection"; +import { ENSNODE_URL, EXPECTED_NAMESPACE } from "./config"; import { PrimaryNameView } from "./PrimaryNameView"; -const ENSNODE_URL = import.meta.env.VITE_ENSNODE_URL ?? "https://api.alpha.ensnode.io"; - const options = createEnsNodeProviderOptions({ url: ENSNODE_URL }); export function App() { return ( -

- ensnode-react Example App -

-

- Connected to {ENSNODE_URL} -

- +
+
+

+ ensnode-react Example App +

+

+ Configured ENSNode: {ENSNODE_URL.href} +
+ Expected ENS namespace: {EXPECTED_NAMESPACE} +

+ +
+ + + + +
); diff --git a/examples/ensnode-react-example/src/PrimaryNameView.tsx b/examples/ensnode-react-example/src/PrimaryNameView.tsx index 9367afc5a3..f566420d85 100644 --- a/examples/ensnode-react-example/src/PrimaryNameView.tsx +++ b/examples/ensnode-react-example/src/PrimaryNameView.tsx @@ -1,51 +1,145 @@ -import type { Address, ChainId } from "enssdk"; -import { useState } from "react"; +import { + DEFAULT_EVM_CHAIN_ID, + type DefaultableChainId, + type NormalizedAddress, + toNormalizedAddress, +} from "enssdk"; +import { useId, useMemo, useState } from "react"; +import { + DatasourceNames, + type ENSNamespaceId, + getENSRootChain, + maybeGetDatasource, +} from "@ensnode/datasources"; import { usePrimaryName } from "@ensnode/ensnode-react"; -const DEFAULT_ADDRESS: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // vitalik.eth -const MAINNET: ChainId = 1; +import { EXPECTED_NAMESPACE } from "./config"; + +const DEFAULT_INPUT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // vitalik.eth +const DEFAULT_ADDRESS: NormalizedAddress = toNormalizedAddress(DEFAULT_INPUT); + +const REVERSE_RESOLVER_DATASOURCES = [ + DatasourceNames.ReverseResolverBase, + DatasourceNames.ReverseResolverLinea, + DatasourceNames.ReverseResolverOptimism, + DatasourceNames.ReverseResolverArbitrum, + DatasourceNames.ReverseResolverScroll, +] as const; + +interface ChainOption { + id: DefaultableChainId; + label: string; +} + +/** + * Builds the ENSIP-19 chain options for the picker: + * default EVM chain (0), the ENS root chain, then any reverse-resolver chains + * exposed by the active namespace. Matches the composition in + * `apps/ensadmin/src/app/inspect/primary-name/page.tsx`. + */ +function getENSIP19ChainOptions(namespace: ENSNamespaceId): ChainOption[] { + const root = getENSRootChain(namespace); + const options: ChainOption[] = [ + { id: DEFAULT_EVM_CHAIN_ID, label: "Default EVM Chain Address (ENSIP-19)" }, + { id: root.id, label: `${root.name} — ENS Root` }, + ]; + + const seen = new Set([DEFAULT_EVM_CHAIN_ID, root.id]); + for (const name of REVERSE_RESOLVER_DATASOURCES) { + const ds = maybeGetDatasource(namespace, name); + if (!ds || seen.has(ds.chain.id)) continue; + seen.add(ds.chain.id); + options.push({ id: ds.chain.id, label: ds.chain.name }); + } + + return options; +} export function PrimaryNameView() { - const [address, setAddress] = useState
(DEFAULT_ADDRESS); - const [input, setInput] = useState(DEFAULT_ADDRESS); + const addressInputId = useId(); + const chainSelectId = useId(); + + const chainOptions = useMemo(() => getENSIP19ChainOptions(EXPECTED_NAMESPACE), []); + + const [address, setAddress] = useState(DEFAULT_ADDRESS); + const [chainId, setChainId] = useState( + getENSRootChain(EXPECTED_NAMESPACE).id, + ); + const [input, setInput] = useState(DEFAULT_INPUT); + const [inputError, setInputError] = useState(null); const { data, isLoading, error } = usePrimaryName({ address, - chainId: MAINNET, + chainId, accelerate: true, }); + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + try { + setAddress(toNormalizedAddress(input.trim())); + setInputError(null); + } catch (err) { + setInputError(err instanceof Error ? err.message : "Invalid EVM address."); + } + }; + return ( -
+

Primary Name

- Resolves the ENSIP-19 Mainnet Primary Name for an address using usePrimaryName. + Resolves the ENSIP-19 Primary Name for an address on a selected chain using{" "} + usePrimaryName. Because ENSIP-19 is multichain, pick which chain's primary name + you want to read.

-
{ - event.preventDefault(); - setAddress(input as Address); - }} - > - setInput(event.target.value)} - placeholder="0x..." - style={{ width: "28rem" }} - /> + +
+ + setInput(event.target.value)} + placeholder="0x…" + aria-invalid={inputError !== null} + aria-describedby={inputError ? `${addressInputId}-error` : undefined} + style={{ width: "28rem" }} + /> +
+ +
+ + +
+ + + {inputError && ( + + )}
- {isLoading &&

Loading...

} + {isLoading &&

Loading…

} {error &&

Error: {error.message}

} {data && (

Primary Name: {data.name ?? "(none)"}

)} -
+ ); } diff --git a/examples/ensnode-react-example/src/components/IndexingStatusBadge.tsx b/examples/ensnode-react-example/src/components/IndexingStatusBadge.tsx new file mode 100644 index 0000000000..e1cfff9148 --- /dev/null +++ b/examples/ensnode-react-example/src/components/IndexingStatusBadge.tsx @@ -0,0 +1,54 @@ +import type { Duration, UnixTimestamp } from "enssdk"; + +import { useIndexingStatus } from "@ensnode/ensnode-react"; +import { EnsApiIndexingStatusResponseCodes } from "@ensnode/ensnode-sdk"; + +function formatWorstCaseDistance(distance: Duration): string { + if (distance <= 60) return `${distance}s behind`; + if (distance <= 60 * 60) return `${Math.round(distance / 60)}m behind`; + if (distance <= 60 * 60 * 24) return `${Math.round(distance / (60 * 60))}h behind`; + return `${Math.round(distance / (60 * 60 * 24))}d behind`; +} + +function formatSnapshotAge(snapshotTime: UnixTimestamp, now: UnixTimestamp): string { + const age = Math.max(0, now - snapshotTime); + if (age < 60) return `${age}s ago`; + if (age < 60 * 60) return `${Math.round(age / 60)}m ago`; + return `${Math.round(age / (60 * 60))}h ago`; +} + +/** + * Compact indexing-status indicator inspired by the ENSAdmin `ProjectionInfo` info-icon. + * + * Polls the connected ENSNode's `/api/indexing-status` endpoint (via + * `useIndexingStatus`) and renders the worst-case projection distance plus + * snapshot freshness so consumers can see at a glance how far behind realtime + * the connected ENSNode is. + */ +export function IndexingStatusBadge() { + const { data, isLoading, error } = useIndexingStatus(); + + if (isLoading) { + return Indexing status: loading…; + } + + if (error) { + return Indexing status: unavailable; + } + + if (!data || data.responseCode !== EnsApiIndexingStatusResponseCodes.Ok) { + return Indexing status: unavailable; + } + + const { projectedAt, worstCaseDistance, snapshot } = data.realtimeProjection; + + return ( + + Indexing status: {formatWorstCaseDistance(worstCaseDistance)} (snapshot{" "} + {formatSnapshotAge(snapshot.snapshotTime, projectedAt)}) + + ); +} diff --git a/examples/ensnode-react-example/src/components/RequireActiveConnection.tsx b/examples/ensnode-react-example/src/components/RequireActiveConnection.tsx new file mode 100644 index 0000000000..2efceaf0c7 --- /dev/null +++ b/examples/ensnode-react-example/src/components/RequireActiveConnection.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; + +import { useIndexingStatus } from "@ensnode/ensnode-react"; +import { EnsApiIndexingStatusResponseCodes } from "@ensnode/ensnode-sdk"; + +import { ENSNODE_URL, EXPECTED_NAMESPACE } from "../config"; +import { classifyConnectionError } from "../lib/classify-connection-error"; + +interface RequireActiveConnectionProps { + children: ReactNode; +} + +/** + * Protects child rendering on a healthy, namespace-matching ENSNode connection. + * + * Modeled after ENSAdmin's `RequireActiveConnection` + * (`apps/ensadmin/src/components/connections/require-active-connection.tsx`), + * with the additional namespace-mismatch check required by this example app. + * + * Failure modes are intentionally disambiguated so the UI can tell apart a + * network-level problem from an application-level problem: + * + * 1. `useIndexingStatus` throws (fetch failed, server returned an error response, + * or the response failed deserialization) `classifyConnectionError` decides + * whether to call this a `network` or `application` failure. + * 2. The server answered with `responseCode === "error"` surfaced as an + * application-level "ENSNode reported its indexing status is unavailable" state. + * 3. The response is OK but `stackInfo.ensIndexer.namespace` does not match + * `EXPECTED_NAMESPACE` surfaced as an `unsupported-namespace` mismatch and + * the connection is refused. + * + * Only once we've confirmed (1) we can reach ENSNode, (2) it returned a usable + * config, and (3) the namespace matches, do we render `children`. + */ +export function RequireActiveConnection({ children }: RequireActiveConnectionProps) { + const { data, isLoading, error } = useIndexingStatus(); + + if (isLoading) { + return ( +
+

Connecting to ENSNode at {ENSNODE_URL.href}…

+
+ ); + } + + if (error) { + const failure = classifyConnectionError(error); + return ( +
+

Connection failed ({failure.kind})

+

{failure.message}

+

+ Configured ENSNode: {ENSNODE_URL.href} +

+
+ ); + } + + if (!data || data.responseCode === EnsApiIndexingStatusResponseCodes.Error) { + return ( +
+

Connection failed (application)

+

+ ENSNode answered, but reported that its indexing status is currently unavailable. The + instance may still be starting up or its dependencies (ENSDb, ENSIndexer) are not yet + healthy. +

+
+ ); + } + + const actualNamespace = data.stackInfo.ensIndexer.namespace; + if (actualNamespace !== EXPECTED_NAMESPACE) { + return ( +
+

Connection refused (unsupported-namespace)

+

+ This example app was built for ENS namespace {EXPECTED_NAMESPACE}, but the + ENSNode at {ENSNODE_URL.href} is indexing namespace{" "} + {actualNamespace}. +

+

+ Re-run with VITE_ENS_NAMESPACE={actualNamespace} or point{" "} + VITE_ENSNODE_URL at an instance indexing {EXPECTED_NAMESPACE}. +

+
+ ); + } + + return <>{children}; +} diff --git a/examples/ensnode-react-example/src/config.ts b/examples/ensnode-react-example/src/config.ts new file mode 100644 index 0000000000..11466cf039 --- /dev/null +++ b/examples/ensnode-react-example/src/config.ts @@ -0,0 +1,27 @@ +import { type ENSNamespaceId, ENSNamespaceIds } from "@ensnode/datasources"; + +const DEFAULT_ENSNODE_URL = "https://api.alpha.ensnode.io"; + +function parseEnsNodeUrl(value: string): URL { + try { + return new URL(value); + } catch { + throw new Error(`VITE_ENSNODE_ULR must be a valid URL. Got: '${value}'.`); + } +} + +function parseExpectedNamespace(value: string): ENSNamespaceId { + const validIds = Object.values(ENSNamespaceIds) as readonly string[]; + if (!validIds.includes(value)) { + throw new Error(`VITE_ENS_NAMESPACE must be one of: ${validIds.join(", ")}. Got: '${value}'.`); + } + return value as ENSNamespaceId; +} + +export const ENSNODE_URL: URL = parseEnsNodeUrl( + import.meta.env.VITE_ENSNODE_URL ?? DEFAULT_ENSNODE_URL, +); + +export const EXPECTED_NAMESPACE: ENSNamespaceId = parseExpectedNamespace( + import.meta.env.VITE_ENS_NAMESPACE ?? ENSNamespaceIds.Mainnet, +); diff --git a/examples/ensnode-react-example/src/lib/classify-connection-error.ts b/examples/ensnode-react-example/src/lib/classify-connection-error.ts new file mode 100644 index 0000000000..264e242689 --- /dev/null +++ b/examples/ensnode-react-example/src/lib/classify-connection-error.ts @@ -0,0 +1,26 @@ +export type ConnectionFailureKind = "network" | "application" | "unsupported-namespace"; + +export interface ConnectionFailure { + kind: ConnectionFailureKind; + message: string; +} + +export function classifyConnectionError(error: unknown): ConnectionFailure { + if (error instanceof TypeError) { + return { + kind: "network", + message: + "Could not reach the ENSNode instance. " + + "Check the URL, your network, and that the server allows CORS from this origin.", + }; + } + + if (error instanceof Error) { + return { kind: "application", message: error.message }; + } + + return { + kind: "application", + message: "Unknown error connecting to ENSNode.", + }; +} diff --git a/examples/ensnode-react-example/src/vite-env.d.ts b/examples/ensnode-react-example/src/vite-env.d.ts index 11f02fe2a0..c04205cedd 100644 --- a/examples/ensnode-react-example/src/vite-env.d.ts +++ b/examples/ensnode-react-example/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly VITE_ENSNODE_URL?: string; + readonly VITE_ENS_NAMESPACE?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f303d48e5b..cc08630972 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -841,15 +841,15 @@ importers: examples/ensnode-react-example: dependencies: + '@ensnode/datasources': + specifier: workspace:* + version: link:../../packages/datasources '@ensnode/ensnode-react': specifier: workspace:* version: link:../../packages/ensnode-react '@ensnode/ensnode-sdk': specifier: workspace:* version: link:../../packages/ensnode-sdk - '@tanstack/react-query': - specifier: ^5.62.14 - version: 5.90.5(react@19.2.1) enssdk: specifier: workspace:* version: link:../../packages/enssdk From 8a23e61cbbc433be485a71aaee3559bd6bfb7a57 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 11 May 2026 09:02:08 +0100 Subject: [PATCH 3/9] mark next steps --- .../src/PrimaryNameView.tsx | 26 +++++++++++++------ .../components/RequireActiveConnection.tsx | 19 +++++++++----- .../src/lib/classify-connection-error.ts | 6 ++++- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/examples/ensnode-react-example/src/PrimaryNameView.tsx b/examples/ensnode-react-example/src/PrimaryNameView.tsx index f566420d85..9cb11b9c28 100644 --- a/examples/ensnode-react-example/src/PrimaryNameView.tsx +++ b/examples/ensnode-react-example/src/PrimaryNameView.tsx @@ -38,6 +38,7 @@ interface ChainOption { * exposed by the active namespace. Matches the composition in * `apps/ensadmin/src/app/inspect/primary-name/page.tsx`. */ +// TODO: candidate for promotion to `@ensnode/datasources` (no React) duplicates the chain composition in `apps/ensadmin/src/app/inspect/primary-name/page.tsx` + `apps/ensadmin/src/lib/get-ensip19-supported-chain-ids.ts`. function getENSIP19ChainOptions(namespace: ENSNamespaceId): ChainOption[] { const root = getENSRootChain(namespace); const options: ChainOption[] = [ @@ -60,11 +61,14 @@ export function PrimaryNameView() { const addressInputId = useId(); const chainSelectId = useId(); - const chainOptions = useMemo(() => getENSIP19ChainOptions(EXPECTED_NAMESPACE), []); + const chainOptions = useMemo( + () => getENSIP19ChainOptions(EXPECTED_NAMESPACE), + [] + ); const [address, setAddress] = useState(DEFAULT_ADDRESS); const [chainId, setChainId] = useState( - getENSRootChain(EXPECTED_NAMESPACE).id, + getENSRootChain(EXPECTED_NAMESPACE).id ); const [input, setInput] = useState(DEFAULT_INPUT); const [inputError, setInputError] = useState(null); @@ -81,7 +85,9 @@ export function PrimaryNameView() { setAddress(toNormalizedAddress(input.trim())); setInputError(null); } catch (err) { - setInputError(err instanceof Error ? err.message : "Invalid EVM address."); + setInputError( + err instanceof Error ? err.message : "Invalid EVM address." + ); } }; @@ -89,9 +95,9 @@ export function PrimaryNameView() {

Primary Name

- Resolves the ENSIP-19 Primary Name for an address on a selected chain using{" "} - usePrimaryName. Because ENSIP-19 is multichain, pick which chain's primary name - you want to read. + Resolves the ENSIP-19 Primary Name for an address on a selected chain + using usePrimaryName. Because ENSIP-19 is multichain, pick + which chain's primary name you want to read.

@@ -104,7 +110,9 @@ export function PrimaryNameView() { onChange={(event) => setInput(event.target.value)} placeholder="0x…" aria-invalid={inputError !== null} - aria-describedby={inputError ? `${addressInputId}-error` : undefined} + aria-describedby={ + inputError ? `${addressInputId}-error` : undefined + } style={{ width: "28rem" }} /> @@ -114,7 +122,9 @@ export function PrimaryNameView() { - setChainId(Number(event.target.value) as DefaultableChainId) - } + onChange={(event) => setChainId(Number(event.target.value))} > {chainOptions.map((option) => (