diff --git a/bun.lockb b/bun.lockb
index e8ab4a7..73b31f2 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 3222860..f036ffd 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,9 @@
},
"dependencies": {
"@cloudflare/workers-types": "^4.20231016.0",
+ "@ensdomains/ensjs": "next",
"@vercel/og": "^0.5.19",
+ "itty-router": "^4.0.23",
"react": "^18.2.0",
"viem": "^1.16.6"
}
diff --git a/scripts/build.ts b/scripts/build.ts
index ac240fd..86ca3a4 100644
--- a/scripts/build.ts
+++ b/scripts/build.ts
@@ -19,6 +19,7 @@ const built = await esbuild.build({
plugins: [wasmPlugin],
alias: {
"viem/*": "./node_modules/viem/_esm/*",
+ "@ensdomains/ensjs/*": "./node_modules/@ensdomains/ensjs/dist/esm/*",
},
metafile: true,
});
diff --git a/src/components/AvatarWithEnsIcon.tsx b/src/components/AvatarWithEnsIcon.tsx
new file mode 100644
index 0000000..015d9f3
--- /dev/null
+++ b/src/components/AvatarWithEnsIcon.tsx
@@ -0,0 +1,37 @@
+type Props = {
+ src: string;
+};
+
+export const AvatarWithEnsIcon = ({ src }: Props) => {
+ return (
+
+
+
+
+ );
+};
diff --git a/src/components/LargeEnsIcon.tsx b/src/components/LargeEnsIcon.tsx
new file mode 100644
index 0000000..768db0d
--- /dev/null
+++ b/src/components/LargeEnsIcon.tsx
@@ -0,0 +1,35 @@
+export const LargeEnsIcon = () => {
+ return (
+
+ );
+};
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
new file mode 100644
index 0000000..8379b2c
--- /dev/null
+++ b/src/components/Layout.tsx
@@ -0,0 +1,73 @@
+import { ReactElement } from "react";
+
+type Props = {
+ imageElement: ReactElement;
+ title: string;
+ subtitle?: string | null;
+ type: "address" | "name";
+};
+
+export const Layout = ({ imageElement, title, subtitle, type }: Props) => {
+ return (
+
+ {imageElement}
+
+
+ {type === "name" ? title.replace(/\./g, ".\u200B") : title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index dae796d..67ecef6 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,244 +1,47 @@
// eslint-disable-next-line import/no-extraneous-dependencies
-import { ImageResponse } from "@vercel/og";
-import type { ReactNode } from "react";
-import { getEnsAddress } from "viem/actions/ens/getEnsAddress";
-import { getEnsAvatar } from "viem/actions/ens/getEnsAvatar";
-import { createClient } from "viem/clients/createClient";
-import { http } from "viem/clients/transports/http";
-import boldFontData from "./fonts/Satoshi-Bold.otf.bin";
-import extraBoldFontData from "./fonts/Satoshi-ExtraBold.otf.bin";
-
-const corsHeaders = {
- /* eslint-disable @typescript-eslint/naming-convention */
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Methods": "GET, PUT, OPTIONS",
- "Access-Control-Allow-Headers": "Content-Type",
- /* eslint-enable @typescript-eslint/naming-convention */
-};
-
-const makeResponse = (
- body?: BodyInit | null,
- status?: number,
- headers?: Record
-) => {
- return new Response(
- typeof body === "string"
- ? JSON.stringify({ message: body })
- : (body as any),
- {
- status,
- headers: {
- ...corsHeaders,
- ...(headers || {}),
- },
- }
- );
-};
-
-const fetchImage = async ({
- name,
- isInvalid,
-}: {
- name: string | null;
- isInvalid: boolean;
-}) => {
- let backgroundGradient: string =
- "linear-gradient(330deg, #44BCF0 4.54%, #7298F8 59.2%, #A099FF 148.85%)";
- let avatarElement: ReactNode;
- let displayName: string;
- let displayEthAddress: string | null = null;
-
- if (!name || isInvalid) {
- backgroundGradient = "linear-gradient(135deg, #EB9E9E 0%, #922 100%)";
- avatarElement = (
-
- );
- displayName = "Invalid name";
+import { error } from "itty-router";
+import { Router } from "itty-router/Router";
+import { createCors } from "itty-router/createCors";
+import { addressHandler } from "./routes/address";
+import { nameHandler } from "./routes/name";
+
+const { corsify } = createCors({
+ origins: ["*"],
+ methods: ["GET", "OPTIONS"],
+ headers: ["Content-Type"],
+ maxAge: 30,
+});
+
+const router = Router();
+router.get("/name/:name", nameHandler);
+router.get("/address/:address", addressHandler);
+
+const main = async (request: Request, ctx: ExecutionContext) => {
+ const start = performance.now();
+
+ const cache = caches.default;
+ const cacheKey = request.url + Date.now();
+
+ let response = await cache.match(cacheKey);
+
+ if (!response) {
+ const result = await router.handle(request);
+ const [body1, body2] = result.body?.tee() || [null, null];
+ result.headers.append("Cache-Control", "s-maxage=30");
+ ctx.waitUntil(cache.put(cacheKey, new Response(body1, result)));
+ response = new Response(body2, result);
} else {
- const decodedName = decodeURIComponent(name);
- let normalisedName: string | null = null;
- const { normalize } = await import("viem/utils/ens/normalize");
- try {
- normalisedName = normalize(decodedName);
- // eslint-disable-next-line no-empty
- } catch {}
- if (!normalisedName) return makeResponse("Invalid name", 400);
-
- const client = createClient({
- transport: http("https://web3.ens.domains/v1/mainnet"),
- });
-
- const universalResolverAddress =
- "0x9380F1974D2B7064eA0c0EC251968D8c69f0Ae31";
-
- const [avatar, ethAddress] = await Promise.all([
- getEnsAvatar(client, {
- name: normalisedName,
- universalResolverAddress,
- }),
- getEnsAddress(client, {
- name: normalisedName,
- universalResolverAddress,
- }),
- ]);
-
- let src = avatar;
-
- if (!src) {
- const { zorbImageDataURI } = await import("./gradient");
- src = zorbImageDataURI(normalisedName, "name", {} as any);
- }
-
- avatarElement = (
- // eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element
-
- );
- displayName = normalisedName;
- displayEthAddress = ethAddress
- ? `${ethAddress.slice(0, 7)}...${ethAddress.slice(-5)}`
- : null;
+ console.log(`Cache hit - ${cacheKey}`);
}
- const element = (
-
-
- {avatarElement}
-
-
-
-
- {displayName}
-
- {displayEthAddress && (
-
- {displayEthAddress}
-
- )}
-
-
- );
+ const end = performance.now();
+
+ console.log(`Returned in ${end - start}ms - ${cacheKey}`);
- return new ImageResponse(element, {
- width: 1200,
- height: 630,
- fonts: [
- {
- name: "Satoshi",
- data: boldFontData,
- weight: 700,
- style: "normal",
- },
- {
- name: "Satoshi",
- data: extraBoldFontData,
- weight: 830 as any,
- style: "normal",
- },
- ],
- });
+ return response;
};
export default {
- async fetch(request: Request, _: unknown, ctx: ExecutionContext) {
- const start = performance.now();
-
- const url = new URL(request.url);
-
- const params = new URLSearchParams(url.search);
- const name = params.get("name");
- const isInvalid = Boolean(params.get("invalid"));
-
- if (!name && !isInvalid) return makeResponse("Missing name parameter", 400);
-
- const cache = caches.default;
- const cacheKey = request.url;
-
- let response = await cache.match(cacheKey);
-
- if (!response) {
- const result = await fetchImage({ name, isInvalid });
- const [body1, body2] = result.body?.tee() || [null, null];
- result.headers.append("Cache-Control", "s-maxage=30");
- ctx.waitUntil(cache.put(cacheKey, new Response(body1, result)));
- response = new Response(body2, result);
- } else {
- console.log("Cache hit - " + cacheKey);
- }
-
- const end = performance.now();
-
- console.log(`Returned in ${end - start}ms for ${name}/${isInvalid}`);
-
- return response;
- },
+ fetch: async (request: Request, _: unknown, ctx: ExecutionContext) =>
+ main(request, ctx).catch(error).then(corsify),
};
diff --git a/src/routes/address.tsx b/src/routes/address.tsx
new file mode 100644
index 0000000..8faf619
--- /dev/null
+++ b/src/routes/address.tsx
@@ -0,0 +1,61 @@
+import type { ClientWithEns } from "@ensdomains/ensjs/contracts";
+import getName from "@ensdomains/ensjs/functions/public/getName";
+import type { IRequest } from "itty-router";
+import type { Address } from "viem";
+import { getEnsAvatar } from "viem/actions/ens/getEnsAvatar";
+import { getAddress } from "viem/utils/address/getAddress";
+import { AvatarWithEnsIcon } from "../components/AvatarWithEnsIcon";
+import { Layout } from "../components/Layout";
+import { client } from "../utils/consts";
+import { generateImage } from "../utils/generateImage";
+import { normaliseAvatar } from "../utils/normaliseAvatar";
+import { shortenAddress } from "../utils/shortenAddress";
+import { tryNormalise } from "../utils/tryNormalise";
+
+export const addressHandler = async ({ params }: IRequest) => {
+ const address = decodeURIComponent(params.address);
+ if (!address) {
+ return new Response("No address provided", { status: 400 });
+ }
+
+ let normalisedAddress: Address;
+ try {
+ normalisedAddress = getAddress(address);
+ } catch {
+ return new Response("Invalid address", { status: 400 });
+ }
+
+ const nameData = await getName(client as ClientWithEns, {
+ address: normalisedAddress,
+ });
+
+ let primaryName = nameData?.match ? nameData.name : null;
+
+ if (primaryName) {
+ const normalisedName = await tryNormalise(primaryName);
+ if (!normalisedName || normalisedName !== primaryName) primaryName = null;
+ }
+
+ const avatar = primaryName
+ ? await getEnsAvatar(client, {
+ name: primaryName,
+ })
+ : null;
+
+ const avatarSrc = await normaliseAvatar({
+ type: "address",
+ avatar,
+ address: normalisedAddress,
+ });
+
+ const element = (
+ }
+ title={shortenAddress(normalisedAddress)}
+ subtitle={primaryName}
+ type="address"
+ />
+ );
+
+ return generateImage(element);
+};
diff --git a/src/routes/name.tsx b/src/routes/name.tsx
new file mode 100644
index 0000000..6079a06
--- /dev/null
+++ b/src/routes/name.tsx
@@ -0,0 +1,72 @@
+import type { ClientWithEns } from "@ensdomains/ensjs/contracts";
+import getExpiry from "@ensdomains/ensjs/functions/public/getExpiry";
+import type { IRequest } from "itty-router";
+import { getEnsAddress } from "viem/actions/ens/getEnsAddress";
+import { getEnsAvatar } from "viem/actions/ens/getEnsAvatar";
+import { AvatarWithEnsIcon } from "../components/AvatarWithEnsIcon";
+import { LargeEnsIcon } from "../components/LargeEnsIcon";
+import { Layout } from "../components/Layout";
+import { client } from "../utils/consts";
+import { generateImage } from "../utils/generateImage";
+import { isSupportedTld } from "../utils/isSupportedTld";
+import { normaliseAvatar } from "../utils/normaliseAvatar";
+import { getRegistrationStatus } from "../utils/registrationStatus";
+import { shortenAddress } from "../utils/shortenAddress";
+import { tryNormalise } from "../utils/tryNormalise";
+
+export const nameHandler = async ({ params }: IRequest) => {
+ const name = decodeURIComponent(params.name);
+ if (!name) {
+ return new Response("No name provided", { status: 400 });
+ }
+
+ const normalisedName = await tryNormalise(name);
+ if (!normalisedName) return new Response("Invalid name", { status: 400 });
+
+ const [avatar, ethAddress, expiryData, supportedTld] = await Promise.all([
+ getEnsAvatar(client, {
+ name: normalisedName,
+ }),
+ getEnsAddress(client, {
+ name: normalisedName,
+ }),
+ getExpiry(client as ClientWithEns, {
+ name: normalisedName,
+ }),
+ isSupportedTld(normalisedName),
+ ]);
+
+ const avatarSrc = await normaliseAvatar({
+ type: "name",
+ name: normalisedName,
+ avatar,
+ });
+
+ const registrationStatus = getRegistrationStatus({
+ name: normalisedName,
+ expiryData,
+ supportedTld,
+ });
+
+ if (registrationStatus === null)
+ return new Response(undefined, { status: 200 });
+
+ const element =
+ registrationStatus === "available" ? (
+ }
+ title={name}
+ subtitle="Available to register"
+ type="name"
+ />
+ ) : (
+ }
+ title={name}
+ subtitle={ethAddress ? shortenAddress(ethAddress) : undefined}
+ type="name"
+ />
+ );
+
+ return generateImage(element);
+};
diff --git a/src/utils/consts.ts b/src/utils/consts.ts
new file mode 100644
index 0000000..ab097cf
--- /dev/null
+++ b/src/utils/consts.ts
@@ -0,0 +1,15 @@
+import { addEnsContracts } from "@ensdomains/ensjs/contracts/addEnsContracts";
+import type { mainnet as mainnetT } from "viem/chains";
+import { mainnet } from "viem/chains/definitions/mainnet";
+import type { Chain } from "viem/chains/index";
+import type { Client } from "viem/clients/createClient";
+import { createClient } from "viem/clients/createClient";
+import type { HttpTransport } from "viem/clients/transports/http";
+import { http } from "viem/clients/transports/http";
+
+export const client = createClient({
+ chain: addEnsContracts(mainnet as typeof mainnetT) as Chain,
+ transport: http("https://web3.ens.domains/v1/mainnet"),
+}) as Client;
+
+export const emptyAddress = "0x0000000000000000000000000000000000000000";
diff --git a/src/utils/generateImage.ts b/src/utils/generateImage.ts
new file mode 100644
index 0000000..e59bc71
--- /dev/null
+++ b/src/utils/generateImage.ts
@@ -0,0 +1,26 @@
+import { ImageResponse } from "@vercel/og";
+import type { ReactElement } from "react";
+import boldFontData from "../fonts/Satoshi-Bold.otf.bin";
+import extraBoldFontData from "../fonts/Satoshi-ExtraBold.otf.bin";
+
+export const generateImage = (element: ReactElement) => {
+ return new ImageResponse(element, {
+ width: 1200,
+ height: 630,
+ fonts: [
+ {
+ name: "Satoshi",
+ data: boldFontData,
+ weight: 700,
+ style: "normal",
+ },
+ {
+ name: "Satoshi",
+ data: extraBoldFontData,
+ weight: 830 as any,
+ style: "normal",
+ },
+ ],
+ emoji: "noto",
+ });
+};
diff --git a/src/gradient.ts b/src/utils/gradient.ts
similarity index 100%
rename from src/gradient.ts
rename to src/utils/gradient.ts
diff --git a/src/utils/isSupportedTld.ts b/src/utils/isSupportedTld.ts
new file mode 100644
index 0000000..d23933c
--- /dev/null
+++ b/src/utils/isSupportedTld.ts
@@ -0,0 +1,46 @@
+const DNS_OVER_HTTP_ENDPOINT = "https://1.1.1.1/dns-query";
+
+interface DNSRecord {
+ name: string;
+ type: number;
+ TTL: number;
+ data: string;
+}
+
+interface DNSQuestion {
+ name: string;
+ type: number;
+}
+
+interface DohResponse {
+ AD: boolean;
+ Answer: DNSRecord[];
+ CD: false;
+ Question: DNSQuestion[];
+ RA: boolean;
+ RD: boolean;
+ Status: number;
+ TC: boolean;
+}
+
+export const isSupportedTld = async (name: string) => {
+ const tld = name.split(".").pop();
+ if (!tld) return false;
+ if (tld === "eth") return true;
+ if (tld === "[root]") return true;
+
+ const response = await fetch(
+ `${DNS_OVER_HTTP_ENDPOINT}?${new URLSearchParams({
+ name: tld,
+ do: "true",
+ })}`,
+ {
+ headers: {
+ accept: "application/dns-json",
+ },
+ }
+ );
+ const result = (await response.json()) as DohResponse;
+ console.log(tld, result);
+ return result?.AD;
+};
diff --git a/src/utils/normaliseAvatar.ts b/src/utils/normaliseAvatar.ts
new file mode 100644
index 0000000..0f2dbec
--- /dev/null
+++ b/src/utils/normaliseAvatar.ts
@@ -0,0 +1,39 @@
+import type { Address } from "viem";
+
+type BaseParams = {
+ type: string;
+ avatar: string | null;
+ name?: string;
+ address?: Address;
+};
+
+type NameParams = {
+ type: "name";
+ name: string;
+ address?: never;
+};
+
+type AddressParams = {
+ type: "address";
+ address: Address;
+ name?: never;
+};
+
+type Params = BaseParams & (NameParams | AddressParams);
+
+export const normaliseAvatar = async ({
+ type,
+ avatar,
+ address,
+ name,
+}: Params) => {
+ let src = avatar;
+
+ if (!src) {
+ const input = type === "address" ? address : name;
+ const { zorbImageDataURI } = await import("./gradient");
+ src = zorbImageDataURI(input, type, {} as any);
+ }
+
+ return src;
+};
diff --git a/src/utils/registrationStatus.ts b/src/utils/registrationStatus.ts
new file mode 100644
index 0000000..b5032a5
--- /dev/null
+++ b/src/utils/registrationStatus.ts
@@ -0,0 +1,33 @@
+import type { GetExpiryReturnType } from "@ensdomains/ensjs/public";
+
+export type RegistrationStatus = "registered" | "available";
+
+export const getRegistrationStatus = ({
+ name,
+ expiryData,
+ supportedTld,
+}: {
+ name: string;
+ expiryData?: GetExpiryReturnType;
+ supportedTld?: boolean | null;
+}): RegistrationStatus | null => {
+ const labels = name.split(".");
+ const isETH = labels[labels.length - 1] === "eth";
+ const is2LD = labels.length === 2;
+ const isShort = labels[0].length < 3;
+
+ if (isETH && is2LD && isShort) {
+ return null;
+ }
+
+ if (!isETH && !supportedTld) {
+ return null;
+ }
+
+ if (isETH && is2LD) {
+ if (!expiryData) return "available";
+ if (expiryData.status === "expired") return "available";
+ }
+
+ return "registered";
+};
diff --git a/src/utils/shortenAddress.ts b/src/utils/shortenAddress.ts
new file mode 100644
index 0000000..9397561
--- /dev/null
+++ b/src/utils/shortenAddress.ts
@@ -0,0 +1,4 @@
+import type { Address } from "viem";
+
+export const shortenAddress = (address: Address) =>
+ `${address.slice(0, 7)}...${address.slice(-5)}`;
diff --git a/src/utils/tryNormalise.ts b/src/utils/tryNormalise.ts
new file mode 100644
index 0000000..d93a77d
--- /dev/null
+++ b/src/utils/tryNormalise.ts
@@ -0,0 +1,9 @@
+export const tryNormalise = async (name: string) => {
+ const { normalize } = await import("viem/utils/ens/normalize");
+ try {
+ return normalize(name);
+ // eslint-disable-next-line no-empty
+ } catch {
+ return null;
+ }
+};
diff --git a/tsconfig.json b/tsconfig.json
index 3a780fa..697b856 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,7 +18,8 @@
"allowJs": true,
"types": ["@cloudflare/workers-types"],
"paths": {
- "viem/*": ["./node_modules/viem/*"]
+ "viem/*": ["./node_modules/viem/*"],
+ "@ensdomains/ensjs/*": ["./node_modules/@ensdomains/ensjs/src/*"]
}
}
}