diff --git a/packages/ensnode-react/example/LICENSE b/packages/ensnode-react/example/LICENSE new file mode 100644 index 0000000000..0d70998c14 --- /dev/null +++ b/packages/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/packages/ensnode-react/example/README.md b/packages/ensnode-react/example/README.md new file mode 100644 index 0000000000..dc7f86b633 --- /dev/null +++ b/packages/ensnode-react/example/README.md @@ -0,0 +1,48 @@ +# `ensnode-react` Example + +> [!IMPORTANT] > **For React integrations with ENSNode, use [`enskit`](../../enskit), not `ensnode-react`.** +> See the [`enskit` React example](../../../examples/enskit-react-example). +> +> This app is internaldocumentation for refining `ensnode-react` before its functionality +> is folded into `enskit`. + +## What it demonstrates + +A bulletproof reference for managing a frontend's connection to an ENSNode instance. +Resolving a Mainnet Primary Name via `usePrimaryName` is just the payoff. The +interesting part is everything that gates it: + +1. **Connection negotiation.** Wait for a healthy `useIndexingStatus` response +2. **Disambiguated error handling.** Connection failures are classified as `network` + (fetch / DNS / CORS), `application` (bad response, or `responseCode === "error"`), + or `unsupported-namespace`. See + [`classify-connection-error.ts`](./src/lib/classify-connection-error.ts) and + [`RequireActiveConnection.tsx`](./src/components/RequireActiveConnection.tsx). +3. **Explicit namespace verification.** The app hardcodes an expected ENS namespace + (defaulting to `mainnet`, overridable via `VITE_ENS_NAMESPACE`) and refuses + connection if the ENSNode is indexing something else. +4. **Live indexing-status projection.** An + [`IndexingStatusBadge`](./src/components/IndexingStatusBadge.tsx) shows how far + behind realtime the connected ENSNode is, modeled on ENSAdmin's `ProjectionInfo`. + +`PrimaryNameView` is intentionally thin; the connection scaffolding is the part worth copying. + +## Configuration + +| Env var | Default | Purpose | +| -------------------- | ------------------------------ | ---------------------------------------------------------------------- | +| `VITE_ENSNODE_URL` | `https://api.alpha.ensnode.io` | URL of the ENSNode instance to connect to. | +| `VITE_ENS_NAMESPACE` | `mainnet` | Expected ENS namespace. Connection is refused if the server disagrees. | + +## Usage + +```bash +pnpm install +pnpm -F @ensnode/ensnode-react-example dev +``` + +Point at a different ENSNode and/or namespace: + +```bash +VITE_ENSNODE_URL=http://localhost:4334 VITE_ENS_NAMESPACE=sepolia pnpm -F @ensnode/ensnode-react-example dev +``` diff --git a/packages/ensnode-react/example/index.html b/packages/ensnode-react/example/index.html new file mode 100644 index 0000000000..2db51df750 --- /dev/null +++ b/packages/ensnode-react/example/index.html @@ -0,0 +1,12 @@ + + + + + + ensnode-react Example + + +
+ + + diff --git a/packages/ensnode-react/example/package.json b/packages/ensnode-react/example/package.json new file mode 100644 index 0000000000..819fa962a7 --- /dev/null +++ b/packages/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/datasources": "workspace:*", + "@ensnode/ensnode-react": "workspace:*", + "@ensnode/ensnode-sdk": "workspace:*", + "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/packages/ensnode-react/example/src/App.tsx b/packages/ensnode-react/example/src/App.tsx new file mode 100644 index 0000000000..2da97deb14 --- /dev/null +++ b/packages/ensnode-react/example/src/App.tsx @@ -0,0 +1,36 @@ +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 options = createEnsNodeProviderOptions({ url: ENSNODE_URL }); + +export function App() { + return ( + + +
+
+

+ ensnode-react Example App +

+

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

+ +
+ + + + +
+
+
+ ); +} diff --git a/packages/ensnode-react/example/src/PrimaryNameView.tsx b/packages/ensnode-react/example/src/PrimaryNameView.tsx new file mode 100644 index 0000000000..eeff9e90a7 --- /dev/null +++ b/packages/ensnode-react/example/src/PrimaryNameView.tsx @@ -0,0 +1,147 @@ +import { + type ChainId, + 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"; + +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`. + */ +// 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[] = [ + { 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 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, + 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 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. +

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

Loading…

} + {error &&

Error: {error.message}

} + {data && ( +

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

+ )} +
+ ); +} diff --git a/packages/ensnode-react/example/src/components/IndexingStatusBadge.tsx b/packages/ensnode-react/example/src/components/IndexingStatusBadge.tsx new file mode 100644 index 0000000000..e1cfff9148 --- /dev/null +++ b/packages/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/packages/ensnode-react/example/src/components/RequireActiveConnection.tsx b/packages/ensnode-react/example/src/components/RequireActiveConnection.tsx new file mode 100644 index 0000000000..ed34b36096 --- /dev/null +++ b/packages/ensnode-react/example/src/components/RequireActiveConnection.tsx @@ -0,0 +1,92 @@ +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`. + */ +// TODO: candidate for promotion to `enskit` as it duplicates `apps/ensadmin/src/components/connections/require-active-connection.tsx` +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/packages/ensnode-react/example/src/config.ts b/packages/ensnode-react/example/src/config.ts new file mode 100644 index 0000000000..4a09a09af4 --- /dev/null +++ b/packages/ensnode-react/example/src/config.ts @@ -0,0 +1,13 @@ +import { type ENSNamespaceId, ENSNamespaceIds } from "@ensnode/datasources"; +import { ENSNamespaceSchema, makeUrlSchema } from "@ensnode/ensnode-sdk/internal"; + +// TODO: potential internal to be made public +const DEFAULT_ENSNODE_URL = "https://api.alpha.green.ensnode.io"; + +export const ENSNODE_URL: URL = makeUrlSchema("VITE_ENSNODE_URL").parse( + import.meta.env.VITE_ENSNODE_URL ?? DEFAULT_ENSNODE_URL, +); + +export const EXPECTED_NAMESPACE: ENSNamespaceId = ENSNamespaceSchema.parse( + import.meta.env.VITE_ENS_NAMESPACE ?? ENSNamespaceIds.Mainnet, +); diff --git a/packages/ensnode-react/example/src/lib/classify-connection-error.ts b/packages/ensnode-react/example/src/lib/classify-connection-error.ts new file mode 100644 index 0000000000..0d7fad70c6 --- /dev/null +++ b/packages/ensnode-react/example/src/lib/classify-connection-error.ts @@ -0,0 +1,27 @@ +export type ConnectionFailureKind = "network" | "application" | "unsupported-namespace"; + +export interface ConnectionFailure { + kind: ConnectionFailureKind; + message: string; +} + +// TODO: abstract out +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/packages/ensnode-react/example/src/main.tsx b/packages/ensnode-react/example/src/main.tsx new file mode 100644 index 0000000000..4a1253371e --- /dev/null +++ b/packages/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/packages/ensnode-react/example/src/vite-env.d.ts b/packages/ensnode-react/example/src/vite-env.d.ts new file mode 100644 index 0000000000..c04205cedd --- /dev/null +++ b/packages/ensnode-react/example/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_ENSNODE_URL?: string; + readonly VITE_ENS_NAMESPACE?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/packages/ensnode-react/example/tsconfig.json b/packages/ensnode-react/example/tsconfig.json new file mode 100644 index 0000000000..0192fe334b --- /dev/null +++ b/packages/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/packages/ensnode-react/example/vite.config.ts b/packages/ensnode-react/example/vite.config.ts new file mode 100644 index 0000000000..58676f788a --- /dev/null +++ b/packages/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 469f7f3d7e..631915dac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1026,6 +1026,43 @@ importers: specifier: 'catalog:' version: 4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.12))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) + packages/ensnode-react/example: + dependencies: + '@ensnode/datasources': + specifier: workspace:* + version: link:../../datasources + '@ensnode/ensnode-react': + specifier: workspace:* + version: link:.. + '@ensnode/ensnode-sdk': + specifier: workspace:* + version: link:../../ensnode-sdk + enssdk: + specifier: workspace:* + version: link:../../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/ensnode-sdk: dependencies: '@ensdomains/address-encoder': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 57cbd0d031..e6dd7f889b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,8 @@ packages: - docs/* - examples/* - packages/* + # TODO: remove once we can move into `examples` folder enskit-example + - packages/ensnode-react/example catalog: "@adraffy/ens-normalize": 1.11.1