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/*"] } } }