diff --git a/apps/ensapi/src/app.ts b/apps/ensapi/src/app.ts
new file mode 100644
index 000000000..0bd7d13d1
--- /dev/null
+++ b/apps/ensapi/src/app.ts
@@ -0,0 +1,81 @@
+import packageJson from "@/../package.json" with { type: "json" };
+
+import { otel } from "@hono/otel";
+import { cors } from "hono/cors";
+import { html } from "hono/html";
+
+import { errorResponse } from "@/lib/handlers/error-response";
+import { createApp } from "@/lib/hono-factory";
+import logger from "@/lib/logger";
+import { generateOpenApi31Document } from "@/openapi-document";
+
+import realtimeApi from "./handlers/api/meta/realtime-api";
+import apiRouter from "./handlers/api/router";
+import ensanalyticsApi from "./handlers/ensanalytics/ensanalytics-api";
+import ensanalyticsApiV1 from "./handlers/ensanalytics/ensanalytics-api-v1";
+import subgraphApi from "./handlers/subgraph/subgraph-api";
+
+const app = createApp();
+
+// set the X-ENSNode-Version response header to the current version
+app.use(async (ctx, next) => {
+ ctx.header("x-ensnode-version", packageJson.version);
+ return next();
+});
+
+// use CORS middleware
+app.use(cors({ origin: "*" }));
+
+// include automatic OpenTelemetry instrumentation for incoming requests
+app.use(otel());
+
+// host welcome page
+app.get("/", (c) =>
+ c.html(html`
+
+
+
+
+
+ ENSApi
+
+
+ Hello, World!
+ You've reached the root of an ENSApi instance. You might be looking for the ENSNode documentation.
+
+
+`),
+);
+
+// use ENSNode HTTP API at /api
+app.route("/api", apiRouter);
+
+// use Subgraph GraphQL API at /subgraph
+app.route("/subgraph", subgraphApi);
+
+// use ENSAnalytics API at /ensanalytics (v0, implicit)
+app.route("/ensanalytics", ensanalyticsApi);
+
+// use ENSAnalytics API v1 at /v1/ensanalytics
+app.route("/v1/ensanalytics", ensanalyticsApiV1);
+
+// use Am I Realtime API at /amirealtime
+// NOTE: this is legacy endpoint and will be deleted in future. one should use /api/realtime instead
+app.route("/amirealtime", realtimeApi);
+
+// generate and return OpenAPI 3.1 document
+app.get("/openapi.json", (c) => {
+ return c.json(generateOpenApi31Document(app));
+});
+
+app.get("/health", async (c) => {
+ return c.json({ message: "fallback ok" });
+});
+
+// log hono errors to console
+app.onError((error, ctx) => {
+ logger.error(error);
+ return errorResponse(ctx, "Internal Server Error");
+});
+
+export default app;
diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts
index 627ed9c3a..8ae9562f2 100644
--- a/apps/ensapi/src/cache/indexing-status.cache.ts
+++ b/apps/ensapi/src/cache/indexing-status.cache.ts
@@ -2,45 +2,53 @@ import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk";
import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk";
import { ensDbClient } from "@/lib/ensdb/singleton";
+import { lazyProxy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";
const logger = makeLogger("indexing-status.cache");
-export const indexingStatusCache = new SWRCache({
- fn: async (_cachedResult) =>
- ensDbClient
- .getIndexingStatusSnapshot() // get the latest indexing status snapshot
- .then((snapshot) => {
- if (snapshot === undefined) {
- // An indexing status snapshot has not been found in ENSDb yet.
- // This might happen during application startup, i.e. when ENSDb
- // has not yet been populated with the first snapshot.
- // Therefore, throw an error to trigger the subsequent `.catch` handler.
- throw new Error("Indexing Status snapshot not found in ENSDb yet.");
- }
+// lazyProxy defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+// SWRCache with proactivelyInitialize:true starts background polling immediately
+// on construction, which would trigger ensDbClient before env vars are available.
+export const indexingStatusCache = lazyProxy>(
+ () =>
+ new SWRCache({
+ fn: async (_cachedResult) =>
+ ensDbClient
+ .getIndexingStatusSnapshot() // get the latest indexing status snapshot
+ .then((snapshot) => {
+ if (snapshot === undefined) {
+ // An indexing status snapshot has not been found in ENSDb yet.
+ // This might happen during application startup, i.e. when ENSDb
+ // has not yet been populated with the first snapshot.
+ // Therefore, throw an error to trigger the subsequent `.catch` handler.
+ throw new Error("Indexing Status snapshot not found in ENSDb yet.");
+ }
- // The indexing status snapshot has been fetched and successfully validated for caching.
- // Therefore, return it so that this current invocation of `readCache` will:
- // - Replace the currently cached value (if any) with this new value.
- // - Return this non-null value.
- return snapshot;
- })
- .catch((error) => {
- // Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
- // Therefore, throw an error so that this current invocation of `readCache` will:
- // - Reject the newly fetched response (if any) such that it won't be cached.
- // - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
- logger.error(
- error,
- `Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` +
- `Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` +
- `The cached indexing status snapshot (if any) will not be updated.`,
- );
- throw error;
- }),
- // We need to refresh the indexing status cache very frequently.
- // ENSDb won't have issues handling this frequency of queries.
- ttl: 1, // 1 second
- proactiveRevalidationInterval: 1, // 1 second
- proactivelyInitialize: true,
-});
+ // The indexing status snapshot has been fetched and successfully validated for caching.
+ // Therefore, return it so that this current invocation of `readCache` will:
+ // - Replace the currently cached value (if any) with this new value.
+ // - Return this non-null value.
+ return snapshot;
+ })
+ .catch((error) => {
+ // Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
+ // Therefore, throw an error so that this current invocation of `readCache` will:
+ // - Reject the newly fetched response (if any) such that it won't be cached.
+ // - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
+ logger.error(
+ error,
+ `Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` +
+ `Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` +
+ `The cached indexing status snapshot (if any) will not be updated.`,
+ );
+ throw error;
+ }),
+ // We need to refresh the indexing status cache very frequently.
+ // ENSDb won't have issues handling this frequency of queries.
+ ttl: 1, // 1 second
+ proactiveRevalidationInterval: 1, // 1 second
+ proactivelyInitialize: true,
+ }),
+);
diff --git a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts
index 5ca2ae3e7..580099370 100644
--- a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts
+++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts
@@ -10,6 +10,7 @@ import { minutesToSeconds } from "date-fns";
import { type CachedResult, SWRCache } from "@ensnode/ensnode-sdk";
+import { lazyProxy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";
const logger = makeLogger("referral-program-edition-set-cache");
@@ -63,6 +64,8 @@ async function loadReferralProgramEditionConfigSet(
return editionConfigSet;
}
+type ReferralProgramEditionConfigSetCache = SWRCache;
+
/**
* SWR Cache for the referral program edition config set.
*
@@ -74,10 +77,15 @@ async function loadReferralProgramEditionConfigSet(
* - proactiveRevalidationInterval: undefined - No proactive revalidation
* - proactivelyInitialize: true - Load immediately on startup
*/
-export const referralProgramEditionConfigSetCache = new SWRCache({
- fn: loadReferralProgramEditionConfigSet,
- ttl: Number.POSITIVE_INFINITY,
- errorTtl: minutesToSeconds(1),
- proactiveRevalidationInterval: undefined,
- proactivelyInitialize: true,
-});
+// lazyProxy defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+export const referralProgramEditionConfigSetCache = lazyProxy(
+ () =>
+ new SWRCache({
+ fn: loadReferralProgramEditionConfigSet,
+ ttl: Number.POSITIVE_INFINITY,
+ errorTtl: minutesToSeconds(1),
+ proactiveRevalidationInterval: undefined,
+ proactivelyInitialize: true,
+ }),
+);
diff --git a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts
index 98594a32b..7219d7016 100644
--- a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts
+++ b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts
@@ -19,18 +19,23 @@ import {
} from "@ensnode/ensnode-sdk";
import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard";
+import { lazyProxy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";
import { indexingStatusCache } from "./indexing-status.cache";
const logger = makeLogger("referrer-leaderboard-cache.cache");
-const rules = buildReferralProgramRules(
- ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE,
- ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS,
- ENS_HOLIDAY_AWARDS_START_DATE,
- ENS_HOLIDAY_AWARDS_END_DATE,
- getEthnamesSubregistryId(config.namespace),
+// lazyProxy defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+const rules = lazyProxy(() =>
+ buildReferralProgramRules(
+ ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE,
+ ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS,
+ ENS_HOLIDAY_AWARDS_START_DATE,
+ ENS_HOLIDAY_AWARDS_END_DATE,
+ getEthnamesSubregistryId(config.namespace),
+ ),
);
/**
@@ -45,46 +50,53 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [
OmnichainIndexingStatusIds.Completed,
];
-export const referrerLeaderboardCache = new SWRCache({
- fn: async (_cachedResult) => {
- const indexingStatus = await indexingStatusCache.read();
- if (indexingStatus instanceof Error) {
- throw new Error(
- "Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.",
- );
- }
+type ReferrerLeaderboardCache = SWRCache;
+
+// lazyProxy defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+export const referrerLeaderboardCache = lazyProxy(
+ () =>
+ new SWRCache({
+ fn: async (_cachedResult) => {
+ const indexingStatus = await indexingStatusCache.read();
+ if (indexingStatus instanceof Error) {
+ throw new Error(
+ "Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.",
+ );
+ }
- const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus;
- if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) {
- throw new Error(
- `Unable to generate referrer leaderboard. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")} to generate a referrer leaderboard.`,
- );
- }
+ const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus;
+ if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) {
+ throw new Error(
+ `Unable to generate referrer leaderboard. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")} to generate a referrer leaderboard.`,
+ );
+ }
- const latestIndexedBlockRef = getLatestIndexedBlockRef(
- indexingStatus,
- rules.subregistryId.chainId,
- );
- if (latestIndexedBlockRef === null) {
- throw new Error(
- `Unable to generate referrer leaderboard. Latest indexed block ref for chain ${rules.subregistryId.chainId} is null.`,
- );
- }
+ const latestIndexedBlockRef = getLatestIndexedBlockRef(
+ indexingStatus,
+ rules.subregistryId.chainId,
+ );
+ if (latestIndexedBlockRef === null) {
+ throw new Error(
+ `Unable to generate referrer leaderboard. Latest indexed block ref for chain ${rules.subregistryId.chainId} is null.`,
+ );
+ }
- logger.info(`Building referrer leaderboard with rules:\n${JSON.stringify(rules, null, 2)}`);
+ logger.info(`Building referrer leaderboard with rules:\n${JSON.stringify(rules, null, 2)}`);
- try {
- const result = await getReferrerLeaderboard(rules, latestIndexedBlockRef.timestamp);
- logger.info(
- `Successfully built referrer leaderboard with ${result.referrers.size} referrers from indexed data`,
- );
- return result;
- } catch (error) {
- logger.error({ error }, "Failed to build referrer leaderboard");
- throw error;
- }
- },
- ttl: minutesToSeconds(1),
- proactiveRevalidationInterval: minutesToSeconds(2),
- proactivelyInitialize: true,
-});
+ try {
+ const result = await getReferrerLeaderboard(rules, latestIndexedBlockRef.timestamp);
+ logger.info(
+ `Successfully built referrer leaderboard with ${result.referrers.size} referrers from indexed data`,
+ );
+ return result;
+ } catch (error) {
+ logger.error({ error }, "Failed to build referrer leaderboard");
+ throw error;
+ }
+ },
+ ttl: minutesToSeconds(1),
+ proactiveRevalidationInterval: minutesToSeconds(2),
+ proactivelyInitialize: true,
+ }),
+);
diff --git a/apps/ensapi/src/config/config.singleton.test.ts b/apps/ensapi/src/config/config.singleton.test.ts
index ef4402851..29f52cd49 100644
--- a/apps/ensapi/src/config/config.singleton.test.ts
+++ b/apps/ensapi/src/config/config.singleton.test.ts
@@ -22,10 +22,11 @@ describe("ensdb singleton bootstrap", () => {
});
it("constructs EnsDbReader from real env wiring without errors", async () => {
- const { EnsDbReader } = await import("@ensnode/ensdb-sdk");
const { ensDbClient, ensDb, ensIndexerSchema } = await import("@/lib/ensdb/singleton");
- expect(ensDbClient).toBeInstanceOf(EnsDbReader);
+ // ensDbClient is a lazyProxy — construction is deferred until first property access.
+ // Accessing a property triggers EnsDbReader construction; verify it succeeds.
+ expect(ensDbClient.ensIndexerSchemaName).toBe(VALID_SCHEMA_NAME);
expect(ensDb).toBeDefined();
expect(ensIndexerSchema).toBeDefined();
});
@@ -37,7 +38,10 @@ describe("ensdb singleton bootstrap", () => {
const { default: logger } = await import("@/lib/logger");
vi.stubEnv("DATABASE_URL", "");
- await expect(import("@/lib/ensdb/singleton")).rejects.toThrow("process.exit");
+ // ensDbClient is a lazyProxy — import succeeds but first property access triggers construction,
+ // which calls buildEnsDbConfigFromEnvironment and exits on invalid config.
+ const { ensDbClient } = await import("@/lib/ensdb/singleton");
+ expect(() => ensDbClient.ensDb).toThrow("process.exit");
expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
@@ -51,7 +55,10 @@ describe("ensdb singleton bootstrap", () => {
const { default: logger } = await import("@/lib/logger");
vi.stubEnv("ENSINDEXER_SCHEMA_NAME", "");
- await expect(import("@/lib/ensdb/singleton")).rejects.toThrow("process.exit");
+ // ensDbClient is a lazyProxy — import succeeds but first property access triggers construction,
+ // which calls buildEnsDbConfigFromEnvironment and exits on invalid config.
+ const { ensDbClient } = await import("@/lib/ensdb/singleton");
+ expect(() => ensDbClient.ensDb).toThrow("process.exit");
expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
diff --git a/apps/ensapi/src/config/index.ts b/apps/ensapi/src/config/index.ts
index e658e9601..f8eab6ac2 100644
--- a/apps/ensapi/src/config/index.ts
+++ b/apps/ensapi/src/config/index.ts
@@ -1,3 +1,48 @@
+import type { EnsApiConfig } from "@/config/config.schema";
import { buildConfigFromEnvironment } from "@/config/config.schema";
-export default await buildConfigFromEnvironment(process.env);
+let _config: EnsApiConfig | null = null;
+
+/**
+ * Initializes the global config from environment variables.
+ * Must be called before any config property is accessed (i.e. as the first
+ * statement in index.ts, before the server starts).
+ */
+export async function initEnvConfig(env: NodeJS.ProcessEnv): Promise {
+ _config = await buildConfigFromEnvironment(env);
+}
+
+/**
+ * Lazy config proxy — defers access to the underlying config object until it
+ * has been initialized via initConfig().
+ *
+ * This allows app.ts (and all route/handler modules it imports) to be loaded
+ * at module evaluation time without requiring env vars to be present. That
+ * property is essential for the OpenAPI generation script, which imports app.ts
+ * to introspect routes but never starts the server or calls initConfig().
+ *
+ * Any attempt to read a config property before initConfig() is called will
+ * throw a descriptive error. Use @/lib/lazy to defer config-dependent
+ * initialization in modules that are evaluated at import time.
+ */
+export default new Proxy({} as EnsApiConfig, {
+ get(_, prop: string | symbol) {
+ if (_config === null) {
+ throw err(prop);
+ }
+ return _config[prop as keyof EnsApiConfig];
+ },
+ has(_, prop: string | symbol) {
+ if (_config === null) {
+ throw err(prop);
+ }
+ return prop in _config;
+ },
+});
+
+const err = (prop: string | symbol) => {
+ throw new Error(
+ `Config not initialized — call initConfig() before accessing config.${String(prop)}
+ Probably you access config in top level of the module. Use @/lib/lazy for lazy loading dependencies.`,
+ );
+};
diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/canonical-registries-cte.ts b/apps/ensapi/src/graphql-api/lib/find-domains/canonical-registries-cte.ts
index 83e0f3cfe..c14e8a0e2 100644
--- a/apps/ensapi/src/graphql-api/lib/find-domains/canonical-registries-cte.ts
+++ b/apps/ensapi/src/graphql-api/lib/find-domains/canonical-registries-cte.ts
@@ -5,6 +5,7 @@ import { sql } from "drizzle-orm";
import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk";
import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
+import { lazy } from "@/lib/lazy";
/**
* The maximum depth to traverse the ENSv2 namegraph in order to construct the set of Canonical
@@ -21,7 +22,9 @@ import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
*/
const CANONICAL_REGISTRIES_MAX_DEPTH = 16;
-const ENSV2_ROOT_REGISTRY_ID = maybeGetENSv2RootRegistryId(config.namespace);
+// lazy() defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+const getENSV2RootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace));
/**
* Builds a recursive CTE that traverses from the ENSv2 Root Registry to construct a set of all
@@ -32,7 +35,7 @@ const ENSV2_ROOT_REGISTRY_ID = maybeGetENSv2RootRegistryId(config.namespace);
*/
export const getCanonicalRegistriesCTE = () => {
// if ENSv2 is not defined, return an empty set with identical structure to below
- if (!ENSV2_ROOT_REGISTRY_ID) {
+ if (!getENSV2RootRegistryId()) {
return ensDb
.select({ id: sql`registry_id`.as("id") })
.from(sql`(SELECT NULL::text AS registry_id WHERE FALSE) AS canonical_registries_cte`)
@@ -50,7 +53,7 @@ export const getCanonicalRegistriesCTE = () => {
sql`
(
WITH RECURSIVE canonical_registries AS (
- SELECT ${ENSV2_ROOT_REGISTRY_ID}::text AS registry_id, 0 AS depth
+ SELECT ${getENSV2RootRegistryId()}::text AS registry_id, 0 AS depth
UNION ALL
SELECT rcd.registry_id, cr.depth + 1
FROM ${ensIndexerSchema.registryCanonicalDomain} rcd
diff --git a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts
index a2fe1ec04..f9df7c14c 100644
--- a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts
+++ b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts
@@ -13,9 +13,12 @@ import {
} from "@ensnode/ensnode-sdk";
import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
+import { lazy } from "@/lib/lazy";
const MAX_DEPTH = 16;
-const ENSv2_ROOT_REGISTRY_ID = maybeGetENSv2RootRegistryId(config.namespace);
+// lazy() defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+const getENSv2RootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace));
/**
* Provide the canonical parents for an ENSv1 Domain.
@@ -73,8 +76,10 @@ export async function getV1CanonicalPath(domainId: ENSv1DomainId): Promise {
+ const rootRegistryId = getENSv2RootRegistryId();
+
// if the ENSv2 Root Registry is not defined, null
- if (!ENSv2_ROOT_REGISTRY_ID) return null;
+ if (!rootRegistryId) return null;
const result = await ensDb.execute(sql`
WITH RECURSIVE upward AS (
@@ -100,7 +105,7 @@ export async function getV2CanonicalPath(domainId: ENSv2DomainId): Promise maybeGetENSv2RootRegistryId(config.namespace));
const logger = makeLogger("get-domain-by-interpreted-name");
const v1Logger = makeLogger("get-domain-by-interpreted-name:v1");
@@ -61,10 +64,11 @@ export async function getDomainIdByInterpretedName(
name: InterpretedName,
): Promise {
// Domains addressable in v2 are preferred, but v1 lookups are cheap, so just do them both ahead of time
+ const rootRegistryId = getRootRegistryId();
const [v1DomainId, v2DomainId] = await Promise.all([
v1_getDomainIdByInterpretedName(name),
// only resolve v2Domain if ENSv2 Root Registry is defined
- ROOT_REGISTRY_ID ? v2_getDomainIdByInterpretedName(ROOT_REGISTRY_ID, name) : null,
+ rootRegistryId ? v2_getDomainIdByInterpretedName(rootRegistryId, name) : null,
]);
logger.debug({ v1DomainId, v2DomainId });
diff --git a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts
index 00910fc66..3bcec10a7 100644
--- a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts
+++ b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts
@@ -15,6 +15,7 @@ import {
} from "@ensnode/ensnode-sdk";
import { createApp } from "@/lib/hono-factory";
+import { lazyProxy } from "@/lib/lazy";
import { findRegisteredNameTokensForDomain } from "@/lib/name-tokens/find-name-tokens-for-domain";
import { getIndexedSubregistries } from "@/lib/name-tokens/get-indexed-subregistries";
import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware";
@@ -24,9 +25,10 @@ import { getNameTokensRoute } from "./name-tokens-api.routes";
const app = createApp({ middlewares: [indexingStatusMiddleware, nameTokensApiMiddleware] });
-const indexedSubregistries = getIndexedSubregistries(
- config.namespace,
- config.ensIndexerPublicConfig.plugins as PluginName[],
+// lazyProxy defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+const indexedSubregistries = lazyProxy(() =>
+ getIndexedSubregistries(config.namespace, config.ensIndexerPublicConfig.plugins as PluginName[]),
);
/**
@@ -79,7 +81,7 @@ app.openapi(getNameTokensRoute, async (c) => {
}
const parentNode = namehash(getParentNameFQDN(name));
- const subregistry = indexedSubregistries.find((subregistry) => subregistry.node === parentNode);
+ const subregistry = indexedSubregistries.find((s) => s.node === parentNode);
// Return 404 response with error code for Name Tokens Not Indexed when
// the parent name of the requested name does not match any of the
diff --git a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts
index 2da79e151..a3e8a71dc 100644
--- a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts
+++ b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts
@@ -7,6 +7,7 @@ import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph";
import { ensIndexerSchema } from "@/lib/ensdb/singleton";
import { createApp } from "@/lib/hono-factory";
+import { lazy } from "@/lib/lazy";
import { makeSubgraphApiDocumentation } from "@/lib/subgraph/api-documentation";
import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix";
import { fixContentLengthMiddleware } from "@/middleware/fix-content-length.middleware";
@@ -50,8 +51,9 @@ app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/
// inject _meta into the hono (and yoga) context for the subgraph middleware
app.use(subgraphMetaMiddleware);
-// use subgraph middleware
-app.use(
+// lazy() defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+const getSubgraphMiddleware = lazy(() =>
subgraphGraphQLMiddleware({
databaseUrl: config.databaseUrl,
databaseSchema: config.ensIndexerSchemaName,
@@ -96,5 +98,6 @@ app.use(
},
}),
);
+app.use((c, next) => getSubgraphMiddleware()(c, next));
export default app;
diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts
index 4431c39a0..456716a36 100644
--- a/apps/ensapi/src/index.ts
+++ b/apps/ensapi/src/index.ts
@@ -1,92 +1,18 @@
-import packageJson from "@/../package.json" with { type: "json" };
-import config from "@/config";
+import config, { initEnvConfig } from "@/config";
import { serve } from "@hono/node-server";
-import { otel } from "@hono/otel";
-import { cors } from "hono/cors";
-import { html } from "hono/html";
import { indexingStatusCache } from "@/cache/indexing-status.cache";
import { getReferralLeaderboardEditionsCaches } from "@/cache/referral-leaderboard-editions.cache";
import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache";
import { referrerLeaderboardCache } from "@/cache/referrer-leaderboard.cache";
import { redactEnsApiConfig } from "@/config/redact";
-import { errorResponse } from "@/lib/handlers/error-response";
-import { createApp } from "@/lib/hono-factory";
import { sdk } from "@/lib/instrumentation";
import logger from "@/lib/logger";
-import { generateOpenApi31Document } from "@/openapi-document";
-import realtimeApi from "./handlers/api/meta/realtime-api";
-import apiRouter from "./handlers/api/router";
-import ensanalyticsApi from "./handlers/ensanalytics/ensanalytics-api";
-import ensanalyticsApiV1 from "./handlers/ensanalytics/ensanalytics-api-v1";
-import subgraphApi from "./handlers/subgraph/subgraph-api";
+import app from "./app";
-const app = createApp();
-
-// set the X-ENSNode-Version header to the current version
-app.use(async (ctx, next) => {
- ctx.header("x-ensnode-version", packageJson.version);
- return next();
-});
-
-// use CORS middleware
-app.use(cors({ origin: "*" }));
-
-// include automatic OpenTelemetry instrumentation for incoming requests
-app.use(otel());
-
-// host welcome page
-app.get("/", (c) =>
- c.html(html`
-
-
-
-
-
- ENSApi
-
-
- Hello, World!
- You've reached the root of an ENSApi instance. You might be looking for the ENSNode documentation.
-
-
-`),
-);
-
-// use ENSNode HTTP API at /api
-app.route("/api", apiRouter);
-
-// use Subgraph GraphQL API at /subgraph
-app.route("/subgraph", subgraphApi);
-
-// use ENSAnalytics API at /ensanalytics (v0, implicit)
-app.route("/ensanalytics", ensanalyticsApi);
-
-// use ENSAnalytics API v1 at /v1/ensanalytics
-app.route("/v1/ensanalytics", ensanalyticsApiV1);
-
-// use Am I Realtime API at /amirealtime
-// NOTE: this is legacy endpoint and will be deleted in future. one should use /api/realtime instead
-app.route("/amirealtime", realtimeApi);
-
-// serve pre-generated OpenAPI 3.1 document
-const openApi31Document = generateOpenApi31Document();
-app.get("/openapi.json", (c) => {
- return c.json(openApi31Document);
-});
-
-// will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware
-app.get("/health", async (c) => {
- return c.json({ message: "fallback ok" });
-});
-
-// log hono errors to console
-app.onError((error, ctx) => {
- logger.error(error);
- return errorResponse(ctx, "Internal Server Error");
-});
+await initEnvConfig(process.env);
// start ENSNode API OpenTelemetry SDK
sdk.start();
@@ -100,8 +26,12 @@ const server = serve(
async (info) => {
logger.info({ config: redactEnsApiConfig(config) }, `ENSApi listening on port ${info.port}`);
- // self-healthcheck to connect to ENSIndexer & warm Indexing Status cache
- await app.request("/health");
+ // Trigger proactive initialization of the indexing status cache at startup.
+ // SWRCache with proactivelyInitialize: true starts fetching immediately upon
+ // construction, but construction is deferred via the lazy proxy until first
+ // access — so we access it explicitly here rather than waiting for the first
+ // user request.
+ indexingStatusCache.read();
},
);
diff --git a/apps/ensapi/src/lib/ensdb/singleton.ts b/apps/ensapi/src/lib/ensdb/singleton.ts
index 73746c820..b2d4ee481 100644
--- a/apps/ensapi/src/lib/ensdb/singleton.ts
+++ b/apps/ensapi/src/lib/ensdb/singleton.ts
@@ -1,22 +1,29 @@
import { EnsDbReader } from "@ensnode/ensdb-sdk";
-import ensDbConfig from "@/config/ensdb-config";
+import { buildEnsDbConfigFromEnvironment } from "@/config/ensdb-config.schema";
+import { lazyProxy } from "@/lib/lazy";
-const { databaseUrl: ensDbUrl, ensIndexerSchemaName } = ensDbConfig;
+// lazyProxy defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
/**
* Singleton instance of ENSDbReader for the ENSApi application.
*/
-export const ensDbClient = new EnsDbReader(ensDbUrl, ensIndexerSchemaName);
+export const ensDbClient = lazyProxy(() => {
+ const { databaseUrl, ensIndexerSchemaName } = buildEnsDbConfigFromEnvironment(process.env);
+ return new EnsDbReader(databaseUrl, ensIndexerSchemaName);
+});
/**
* Convenience alias for {@link ensDbClient.ensDb} to be used for building
* custom ENSDb queries throughout the ENSApi codebase.
*/
-export const ensDb = ensDbClient.ensDb;
+export const ensDb = lazyProxy(() => ensDbClient.ensDb);
/**
* Convenience alias for {@link ensDbClient.ensIndexerSchema} to be used for building
* custom ENSDb queries throughout the ENSApi codebase.
*/
-export const ensIndexerSchema = ensDbClient.ensIndexerSchema;
+export const ensIndexerSchema = lazyProxy(
+ () => ensDbClient.ensIndexerSchema,
+);
diff --git a/apps/ensapi/src/lib/lazy.ts b/apps/ensapi/src/lib/lazy.ts
new file mode 100644
index 000000000..615d1ab1e
--- /dev/null
+++ b/apps/ensapi/src/lib/lazy.ts
@@ -0,0 +1,47 @@
+const UNSET = Symbol("UNSET");
+
+/**
+ * Creates a lazy singleton — the factory is called at most once, on first invocation.
+ * Correctly handles factories that return `null` or `undefined`.
+ *
+ * Returns a getter function. Use this for primitive or nullable values where a Proxy
+ * is not suitable (e.g. `bigint | null`). For objects, prefer `lazyProxy`.
+ */
+export function lazy(factory: () => T): () => T {
+ let cached: T | typeof UNSET = UNSET;
+ return () => {
+ if (cached === UNSET) cached = factory();
+ return cached as T;
+ };
+}
+
+/**
+ * Creates a lazy singleton object exposed as a stable Proxy reference.
+ * The factory is called at most once, on first property access.
+ *
+ * Unlike `lazy()`, this returns the object itself (not a getter function), so
+ * consumers can import and use it directly without calling a getter:
+ *
+ * ```ts
+ * export const myCache = lazyProxy(() => new SWRCache(...));
+ * // usage: myCache.read() ← no getter call needed
+ * ```
+ *
+ * Not suitable for primitives or nullable values — use `lazy()` for those.
+ */
+export function lazyProxy(factory: () => T): T {
+ const getInstance = lazy(factory);
+ return new Proxy({} as T, {
+ get(_, prop) {
+ const instance = getInstance();
+ const value = Reflect.get(instance, prop as string, instance);
+ if (typeof value === "function") {
+ return (value as (...args: unknown[]) => unknown).bind(instance);
+ }
+ return value;
+ },
+ has(_, prop) {
+ return Reflect.has(getInstance(), prop);
+ },
+ });
+}
diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts
index 53cf2452d..5aa88be23 100644
--- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts
+++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts
@@ -27,6 +27,7 @@ import {
import { ensDb } from "@/lib/ensdb/singleton";
import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span";
+import { lazyProxy } from "@/lib/lazy";
type FindResolverResult =
| {
@@ -44,10 +45,10 @@ const NULL_RESULT: FindResolverResult = {
const tracer = trace.getTracer("find-resolver");
-const ENSv1RegistryOld = getDatasourceContract(
- config.namespace,
- DatasourceNames.ENSRoot,
- "ENSv1RegistryOld",
+// lazyProxy defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+const ensv1RegistryOld = lazyProxy(() =>
+ getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "ENSv1RegistryOld"),
);
/**
@@ -221,8 +222,8 @@ async function findResolverWithIndex(
// OR, if the registry is the ENS Root Registry, also include records from RegistryOld
isENSv1Registry(config.namespace, registry)
? and(
- eq(t.chainId, ENSv1RegistryOld.chainId),
- eq(t.address, ENSv1RegistryOld.address),
+ eq(t.chainId, ensv1RegistryOld.chainId),
+ eq(t.address, ensv1RegistryOld.address),
)
: undefined,
),
diff --git a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts
index b1b9b2611..05b0af3f4 100644
--- a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts
+++ b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts
@@ -12,11 +12,12 @@ import {
} from "@ensnode/ensnode-sdk";
import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span";
+import { lazy } from "@/lib/lazy";
import { resolveReverse } from "@/lib/resolution/reverse-resolution";
const tracer = trace.getTracer("multichain-primary-name-resolution");
-const ENSIP19_SUPPORTED_CHAIN_IDS: ChainId[] = [
+const getENSIP19SupportedChainIds = lazy(() => [
// always include Mainnet, because its chainId corresponds to the ENS Root Chain's coinType,
// regardless of the current namespace
mainnet.id,
@@ -34,7 +35,7 @@ const ENSIP19_SUPPORTED_CHAIN_IDS: ChainId[] = [
.filter((ds) => ds !== undefined)
.map((ds) => ds.chain.id),
),
-];
+]);
/**
* Implements batch resolution of an address' Primary Name across the provided `chainIds`.
@@ -50,7 +51,7 @@ const ENSIP19_SUPPORTED_CHAIN_IDS: ChainId[] = [
*/
export async function resolvePrimaryNames(
address: MultichainPrimaryNameResolutionArgs["address"],
- chainIds: MultichainPrimaryNameResolutionArgs["chainIds"] = ENSIP19_SUPPORTED_CHAIN_IDS,
+ chainIds: MultichainPrimaryNameResolutionArgs["chainIds"] = getENSIP19SupportedChainIds(),
options: Parameters[2],
): Promise {
// parallel reverseResolve
diff --git a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts b/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts
index f82d8168f..5e041141d 100644
--- a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts
+++ b/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts
@@ -19,21 +19,20 @@ import {
type ResolverRecordsSelection,
} from "@ensnode/ensnode-sdk";
+import { lazy, lazyProxy } from "@/lib/lazy";
import type {
ResolveCalls,
ResolveCallsAndRawResults,
} from "@/lib/resolution/resolve-calls-and-results";
-const UniversalResolver = getDatasourceContract(
- config.namespace,
- DatasourceNames.ENSRoot,
- "UniversalResolver",
+// lazyProxy defers construction until first use so that this module can be
+// imported without env vars being present (e.g. during OpenAPI generation).
+const universalResolver = lazyProxy(() =>
+ getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolver"),
);
-const UniversalResolverV2 = maybeGetDatasourceContract(
- config.namespace,
- DatasourceNames.ENSRoot,
- "UniversalResolverV2",
+const getUniversalResolverV2 = lazy(() =>
+ maybeGetDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolverV2"),
);
/**
@@ -61,7 +60,7 @@ export async function executeResolveCallsWithUniversalResolver<
abi: UniversalResolverABI,
// NOTE(ensv2-transition): if UniversalResolverV2 is defined, prefer it over UniversalResolver
// TODO(ensv2-transition): confirm this is correct
- address: UniversalResolverV2?.address ?? UniversalResolver.address,
+ address: getUniversalResolverV2()?.address ?? universalResolver.address,
functionName: "resolve",
args: [encodedName, encodedMethod],
});
diff --git a/apps/ensapi/src/openapi-document.ts b/apps/ensapi/src/openapi-document.ts
index ae6805800..c722e9748 100644
--- a/apps/ensapi/src/openapi-document.ts
+++ b/apps/ensapi/src/openapi-document.ts
@@ -1,50 +1,29 @@
-import { OpenAPIHono } from "@hono/zod-openapi";
+import type { OpenAPIHono } from "@hono/zod-openapi";
import { openapiMeta } from "@/openapi-meta";
-import * as nameTokensRoutes from "./handlers/api/explore/name-tokens-api.routes";
-import * as registrarActionsRoutes from "./handlers/api/explore/registrar-actions-api.routes";
-import * as realtimeRoutes from "./handlers/api/meta/realtime-api.routes";
-import * as statusRoutes from "./handlers/api/meta/status-api.routes";
-import * as resolutionRoutes from "./handlers/api/resolution/resolution-api.routes";
-import * as ensanalyticsRoutes from "./handlers/ensanalytics/ensanalytics-api.routes";
-import * as ensanalyticsV1Routes from "./handlers/ensanalytics/ensanalytics-api-v1.routes";
-
-const routeGroups = [
- realtimeRoutes,
- statusRoutes,
- ensanalyticsV1Routes,
- ensanalyticsRoutes,
- nameTokensRoutes,
- registrarActionsRoutes,
- resolutionRoutes,
-];
-
/**
- * Creates an OpenAPIHono app with all route definitions registered using stub
- * handlers. This allows generating the OpenAPI spec without importing any
- * handler code that depends on config/env vars.
+ * Endpoints to exclude from the generated OpenAPI document.
+ * TODO: remove /amirealtime once the legacy endpoint is deleted.
*/
-function createStubRoutesForSpec() {
- const app = new OpenAPIHono();
+const HIDE_OPENAPI_ENDPOINTS: string[] = ["/amirealtime"];
- for (const group of routeGroups) {
- for (const route of group.routes) {
- const path = route.path === "/" ? group.basePath : `${group.basePath}${route.path}`;
- app.openapi(
- { ...route, path },
- // stub handler — never called, only needed for route registration
- (c) => c.json({}),
- );
- }
- }
+type OpenApiDocument = ReturnType;
- return app;
+function removeHiddenEndpoints(doc: OpenApiDocument): OpenApiDocument {
+ for (const path of HIDE_OPENAPI_ENDPOINTS) {
+ delete doc.paths?.[path];
+ }
+ return doc;
}
/**
- * Generates an OpenAPI 3.1 document from stub route definitions.
+ * Generates an OpenAPI 3.1 document from the registered routes.
+ *
+ * Generation script and the runtime endpoint share the same function so that
+ * the generated OpenAPI document is always in sync with the actual API.
*/
-export function generateOpenApi31Document(): ReturnType {
- return createStubRoutesForSpec().getOpenAPI31Document(openapiMeta);
+export function generateOpenApi31Document(app: OpenAPIHono): OpenApiDocument {
+ const doc = app.getOpenAPI31Document(openapiMeta);
+ return removeHiddenEndpoints(doc);
}
diff --git a/docs/docs.ensnode.io/ensapi-openapi.json b/docs/docs.ensnode.io/ensapi-openapi.json
index a501614dd..75f86ad1a 100644
--- a/docs/docs.ensnode.io/ensapi-openapi.json
+++ b/docs/docs.ensnode.io/ensapi-openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "ENSApi APIs",
- "version": "1.8.0",
+ "version": "1.8.1",
"description": "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode"
},
"servers": [
@@ -29,35 +29,6 @@
],
"components": { "schemas": {}, "parameters": {} },
"paths": {
- "/api/realtime": {
- "get": {
- "operationId": "getRealtime",
- "tags": ["Meta"],
- "summary": "Check indexing progress",
- "description": "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime",
- "parameters": [
- {
- "schema": {
- "type": "integer",
- "minimum": 0,
- "description": "Maximum acceptable worst-case indexing distance in seconds"
- },
- "required": false,
- "description": "Maximum acceptable worst-case indexing distance in seconds",
- "name": "maxWorstCaseDistance",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "Indexing progress is guaranteed to be within the requested distance of realtime"
- },
- "503": {
- "description": "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable"
- }
- }
- }
- },
"/api/config": {
"get": {
"operationId": "getConfig",
@@ -956,151 +927,179 @@
}
}
},
- "/v1/ensanalytics/referral-leaderboard": {
+ "/api/realtime": {
"get": {
- "operationId": "getReferralLeaderboard_v1",
- "tags": ["ENSAwards"],
- "summary": "Get Referrer Leaderboard (v1)",
- "description": "Returns a paginated page from the referrer leaderboard for a specific edition",
+ "operationId": "getRealtime",
+ "tags": ["Meta"],
+ "summary": "Check indexing progress",
+ "description": "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime",
"parameters": [
- {
- "schema": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
- "required": true,
- "name": "edition",
- "in": "query"
- },
- {
- "schema": {
- "type": "integer",
- "minimum": 1,
- "description": "Page number for pagination"
- },
- "required": false,
- "description": "Page number for pagination",
- "name": "page",
- "in": "query"
- },
{
"schema": {
"type": "integer",
- "minimum": 1,
- "maximum": 100,
- "description": "Number of referrers per page"
+ "minimum": 0,
+ "description": "Maximum acceptable worst-case indexing distance in seconds"
},
"required": false,
- "description": "Number of referrers per page",
- "name": "recordsPerPage",
+ "description": "Maximum acceptable worst-case indexing distance in seconds",
+ "name": "maxWorstCaseDistance",
"in": "query"
}
],
"responses": {
- "200": { "description": "Successfully retrieved referrer leaderboard page" },
- "404": { "description": "Unknown edition slug" },
- "500": { "description": "Internal server error" },
- "503": { "description": "Service unavailable" }
+ "200": {
+ "description": "Indexing progress is guaranteed to be within the requested distance of realtime"
+ },
+ "503": {
+ "description": "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable"
+ }
}
}
},
- "/v1/ensanalytics/referrer/{referrer}": {
+ "/api/resolve/records/{name}": {
"get": {
- "operationId": "getReferrerDetail_v1",
- "tags": ["ENSAwards"],
- "summary": "Get Referrer Detail for Editions (v1)",
- "description": "Returns detailed information for a specific referrer for the requested editions. Requires 1-20 distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.",
+ "operationId": "resolveRecords",
+ "tags": ["Resolution"],
+ "summary": "Resolve ENS Records",
+ "description": "Resolves ENS records for a given name",
"parameters": [
+ { "schema": { "type": "string" }, "required": true, "name": "name", "in": "path" },
+ { "schema": { "type": "string" }, "required": false, "name": "name", "in": "query" },
+ { "schema": { "type": "string" }, "required": false, "name": "addresses", "in": "query" },
+ { "schema": { "type": "string" }, "required": false, "name": "texts", "in": "query" },
{
- "schema": { "type": "string", "description": "Referrer Ethereum address" },
- "required": true,
- "description": "Referrer Ethereum address",
- "name": "referrer",
- "in": "path"
+ "schema": { "type": "boolean", "default": false },
+ "required": false,
+ "name": "trace",
+ "in": "query"
},
{
- "schema": { "type": "string", "description": "Comma-separated list of edition slugs" },
- "required": true,
- "description": "Comma-separated list of edition slugs",
- "name": "editions",
+ "schema": { "type": "boolean", "default": false },
+ "required": false,
+ "name": "accelerate",
"in": "query"
}
],
"responses": {
- "200": { "description": "Successfully retrieved referrer detail for requested editions" },
- "400": { "description": "Invalid request" },
- "404": { "description": "Unknown edition slug" },
- "500": { "description": "Internal server error" },
- "503": { "description": "Service unavailable" }
- }
- }
- },
- "/v1/ensanalytics/editions": {
- "get": {
- "operationId": "getEditions_v1",
- "tags": ["ENSAwards"],
- "summary": "Get Edition Summaries (v1)",
- "description": "Returns a summary for each configured referral program edition, including its current status and award-model-specific runtime data. Editions are sorted in descending order by start timestamp (most recent first).",
- "responses": {
- "200": { "description": "Successfully retrieved edition summaries." },
- "500": { "description": "Internal server error" },
- "503": { "description": "Service unavailable" }
+ "200": {
+ "description": "Successfully resolved records",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "records": {
+ "type": "object",
+ "properties": {
+ "name": { "type": ["string", "null"] },
+ "addresses": {
+ "type": "object",
+ "additionalProperties": { "type": ["string", "null"] }
+ },
+ "texts": {
+ "type": "object",
+ "additionalProperties": { "type": ["string", "null"] }
+ }
+ }
+ },
+ "accelerationRequested": { "type": "boolean" },
+ "accelerationAttempted": { "type": "boolean" },
+ "trace": { "type": "array", "items": {} }
+ },
+ "required": ["records", "accelerationRequested", "accelerationAttempted"]
+ }
+ }
+ }
+ }
}
}
},
- "/ensanalytics/referrers": {
+ "/api/resolve/primary-name/{address}/{chainId}": {
"get": {
- "operationId": "getReferrerLeaderboard",
- "tags": ["ENSAwards"],
- "summary": "Get Referrer Leaderboard",
- "description": "Returns a paginated page from the referrer leaderboard",
+ "operationId": "resolvePrimaryName",
+ "tags": ["Resolution"],
+ "summary": "Resolve Primary Name",
+ "description": "Resolves a primary name for a given `address` and `chainId`",
"parameters": [
+ { "schema": { "type": "string" }, "required": true, "name": "address", "in": "path" },
+ { "schema": { "type": "string" }, "required": true, "name": "chainId", "in": "path" },
{
- "schema": {
- "type": "integer",
- "minimum": 1,
- "description": "Page number for pagination"
- },
+ "schema": { "type": "boolean", "default": false },
"required": false,
- "description": "Page number for pagination",
- "name": "page",
+ "name": "trace",
"in": "query"
},
{
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 100,
- "description": "Number of referrers per page"
- },
+ "schema": { "type": "boolean", "default": false },
"required": false,
- "description": "Number of referrers per page",
- "name": "recordsPerPage",
+ "name": "accelerate",
"in": "query"
}
],
"responses": {
- "200": { "description": "Successfully retrieved referrer leaderboard page" },
- "500": { "description": "Internal server error" }
+ "200": {
+ "description": "Successfully resolved name",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": { "type": ["string", "null"] },
+ "accelerationRequested": { "type": "boolean" },
+ "accelerationAttempted": { "type": "boolean" },
+ "trace": { "type": "array", "items": {} }
+ },
+ "required": ["name", "accelerationRequested", "accelerationAttempted"]
+ }
+ }
+ }
+ }
}
}
},
- "/ensanalytics/referrers/{referrer}": {
+ "/api/resolve/primary-names/{address}": {
"get": {
- "operationId": "getReferrerDetail",
- "tags": ["ENSAwards"],
- "summary": "Get Referrer Detail",
- "description": "Returns detailed information for a specific referrer by address",
+ "operationId": "resolvePrimaryNames",
+ "tags": ["Resolution"],
+ "summary": "Resolve Primary Names",
+ "description": "Resolves all primary names for a given address across multiple chains",
"parameters": [
+ { "schema": { "type": "string" }, "required": true, "name": "address", "in": "path" },
+ { "schema": { "type": "string" }, "required": false, "name": "chainIds", "in": "query" },
{
- "schema": { "type": "string", "description": "Referrer Ethereum address" },
- "required": true,
- "description": "Referrer Ethereum address",
- "name": "referrer",
- "in": "path"
+ "schema": { "type": "boolean", "default": false },
+ "required": false,
+ "name": "trace",
+ "in": "query"
+ },
+ {
+ "schema": { "type": "boolean", "default": false },
+ "required": false,
+ "name": "accelerate",
+ "in": "query"
}
],
"responses": {
- "200": { "description": "Successfully retrieved referrer detail" },
- "500": { "description": "Internal server error" },
- "503": { "description": "Service unavailable - referrer leaderboard data not yet cached" }
+ "200": {
+ "description": "Successfully resolved records",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "names": {
+ "type": "object",
+ "additionalProperties": { "type": ["string", "null"] }
+ },
+ "accelerationRequested": { "type": "boolean" },
+ "accelerationAttempted": { "type": "boolean" },
+ "trace": { "type": "array", "items": {} }
+ },
+ "required": ["names", "accelerationRequested", "accelerationAttempted"]
+ }
+ }
+ }
+ }
}
}
},
@@ -1935,150 +1934,151 @@
}
}
},
- "/api/resolve/records/{name}": {
+ "/ensanalytics/referrers": {
"get": {
- "operationId": "resolveRecords",
- "tags": ["Resolution"],
- "summary": "Resolve ENS Records",
- "description": "Resolves ENS records for a given name",
+ "operationId": "getReferrerLeaderboard",
+ "tags": ["ENSAwards"],
+ "summary": "Get Referrer Leaderboard",
+ "description": "Returns a paginated page from the referrer leaderboard",
"parameters": [
- { "schema": { "type": "string" }, "required": true, "name": "name", "in": "path" },
- { "schema": { "type": "string" }, "required": false, "name": "name", "in": "query" },
- { "schema": { "type": "string" }, "required": false, "name": "addresses", "in": "query" },
- { "schema": { "type": "string" }, "required": false, "name": "texts", "in": "query" },
{
- "schema": { "type": "boolean", "default": false },
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number for pagination"
+ },
"required": false,
- "name": "trace",
+ "description": "Page number for pagination",
+ "name": "page",
"in": "query"
},
{
- "schema": { "type": "boolean", "default": false },
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "description": "Number of referrers per page"
+ },
"required": false,
- "name": "accelerate",
+ "description": "Number of referrers per page",
+ "name": "recordsPerPage",
"in": "query"
}
],
"responses": {
- "200": {
- "description": "Successfully resolved records",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "records": {
- "type": "object",
- "properties": {
- "name": { "type": ["string", "null"] },
- "addresses": {
- "type": "object",
- "additionalProperties": { "type": ["string", "null"] }
- },
- "texts": {
- "type": "object",
- "additionalProperties": { "type": ["string", "null"] }
- }
- }
- },
- "accelerationRequested": { "type": "boolean" },
- "accelerationAttempted": { "type": "boolean" },
- "trace": { "type": "array", "items": {} }
- },
- "required": ["records", "accelerationRequested", "accelerationAttempted"]
- }
- }
- }
+ "200": { "description": "Successfully retrieved referrer leaderboard page" },
+ "500": { "description": "Internal server error" }
+ }
+ }
+ },
+ "/ensanalytics/referrers/{referrer}": {
+ "get": {
+ "operationId": "getReferrerDetail",
+ "tags": ["ENSAwards"],
+ "summary": "Get Referrer Detail",
+ "description": "Returns detailed information for a specific referrer by address",
+ "parameters": [
+ {
+ "schema": { "type": "string", "description": "Referrer Ethereum address" },
+ "required": true,
+ "description": "Referrer Ethereum address",
+ "name": "referrer",
+ "in": "path"
}
+ ],
+ "responses": {
+ "200": { "description": "Successfully retrieved referrer detail" },
+ "500": { "description": "Internal server error" },
+ "503": { "description": "Service unavailable - referrer leaderboard data not yet cached" }
}
}
},
- "/api/resolve/primary-name/{address}/{chainId}": {
+ "/v1/ensanalytics/referral-leaderboard": {
"get": {
- "operationId": "resolvePrimaryName",
- "tags": ["Resolution"],
- "summary": "Resolve Primary Name",
- "description": "Resolves a primary name for a given `address` and `chainId`",
+ "operationId": "getReferralLeaderboard_v1",
+ "tags": ["ENSAwards"],
+ "summary": "Get Referrer Leaderboard (v1)",
+ "description": "Returns a paginated page from the referrer leaderboard for a specific edition",
"parameters": [
- { "schema": { "type": "string" }, "required": true, "name": "address", "in": "path" },
- { "schema": { "type": "string" }, "required": true, "name": "chainId", "in": "path" },
{
- "schema": { "type": "boolean", "default": false },
+ "schema": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
+ "required": true,
+ "name": "edition",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number for pagination"
+ },
"required": false,
- "name": "trace",
+ "description": "Page number for pagination",
+ "name": "page",
"in": "query"
},
{
- "schema": { "type": "boolean", "default": false },
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "description": "Number of referrers per page"
+ },
"required": false,
- "name": "accelerate",
+ "description": "Number of referrers per page",
+ "name": "recordsPerPage",
"in": "query"
}
],
"responses": {
- "200": {
- "description": "Successfully resolved name",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "name": { "type": ["string", "null"] },
- "accelerationRequested": { "type": "boolean" },
- "accelerationAttempted": { "type": "boolean" },
- "trace": { "type": "array", "items": {} }
- },
- "required": ["name", "accelerationRequested", "accelerationAttempted"]
- }
- }
- }
- }
+ "200": { "description": "Successfully retrieved referrer leaderboard page" },
+ "404": { "description": "Unknown edition slug" },
+ "500": { "description": "Internal server error" },
+ "503": { "description": "Service unavailable" }
}
}
},
- "/api/resolve/primary-names/{address}": {
+ "/v1/ensanalytics/referrer/{referrer}": {
"get": {
- "operationId": "resolvePrimaryNames",
- "tags": ["Resolution"],
- "summary": "Resolve Primary Names",
- "description": "Resolves all primary names for a given address across multiple chains",
+ "operationId": "getReferrerDetail_v1",
+ "tags": ["ENSAwards"],
+ "summary": "Get Referrer Detail for Editions (v1)",
+ "description": "Returns detailed information for a specific referrer for the requested editions. Requires 1-20 distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.",
"parameters": [
- { "schema": { "type": "string" }, "required": true, "name": "address", "in": "path" },
- { "schema": { "type": "string" }, "required": false, "name": "chainIds", "in": "query" },
{
- "schema": { "type": "boolean", "default": false },
- "required": false,
- "name": "trace",
- "in": "query"
+ "schema": { "type": "string", "description": "Referrer Ethereum address" },
+ "required": true,
+ "description": "Referrer Ethereum address",
+ "name": "referrer",
+ "in": "path"
},
{
- "schema": { "type": "boolean", "default": false },
- "required": false,
- "name": "accelerate",
+ "schema": { "type": "string", "description": "Comma-separated list of edition slugs" },
+ "required": true,
+ "description": "Comma-separated list of edition slugs",
+ "name": "editions",
"in": "query"
}
],
"responses": {
- "200": {
- "description": "Successfully resolved records",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "names": {
- "type": "object",
- "additionalProperties": { "type": ["string", "null"] }
- },
- "accelerationRequested": { "type": "boolean" },
- "accelerationAttempted": { "type": "boolean" },
- "trace": { "type": "array", "items": {} }
- },
- "required": ["names", "accelerationRequested", "accelerationAttempted"]
- }
- }
- }
- }
+ "200": { "description": "Successfully retrieved referrer detail for requested editions" },
+ "400": { "description": "Invalid request" },
+ "404": { "description": "Unknown edition slug" },
+ "500": { "description": "Internal server error" },
+ "503": { "description": "Service unavailable" }
+ }
+ }
+ },
+ "/v1/ensanalytics/editions": {
+ "get": {
+ "operationId": "getEditions_v1",
+ "tags": ["ENSAwards"],
+ "summary": "Get Edition Summaries (v1)",
+ "description": "Returns a summary for each configured referral program edition, including its current status and award-model-specific runtime data. Editions are sorted in descending order by start timestamp (most recent first).",
+ "responses": {
+ "200": { "description": "Successfully retrieved edition summaries." },
+ "500": { "description": "Internal server error" },
+ "503": { "description": "Service unavailable" }
}
}
}
diff --git a/docs/docs.ensnode.io/package.json b/docs/docs.ensnode.io/package.json
index f2275b3f2..d00578d28 100644
--- a/docs/docs.ensnode.io/package.json
+++ b/docs/docs.ensnode.io/package.json
@@ -13,7 +13,7 @@
"homepage": "https://github.com/namehash/ensnode/tree/main/docs/docs.ensnode.io",
"scripts": {
"mint": "pnpm dlx mint@^4.1.0",
- "generate:openapi": "pnpm --filter ensapi exec tsx --tsconfig tsconfig.json ../../scripts/generate-ensapi-openapi.ts"
+ "generate:openapi": "pnpm --filter ensapi exec tsx --tsconfig tsconfig.json ../../scripts/generate-ensapi-openapi.mts"
},
"packageManager": "pnpm@10.28.0"
}
diff --git a/scripts/generate-ensapi-openapi.ts b/scripts/generate-ensapi-openapi.mts
similarity index 79%
rename from scripts/generate-ensapi-openapi.ts
rename to scripts/generate-ensapi-openapi.mts
index 597653e17..c1546a354 100644
--- a/scripts/generate-ensapi-openapi.ts
+++ b/scripts/generate-ensapi-openapi.mts
@@ -5,8 +5,8 @@
*
* Output: docs/docs.ensnode.io/ensapi-openapi.json
*
- * This script has no runtime dependencies — it calls generateOpenApi31Document()
- * which uses only stub route handlers and static metadata.
+ * This script calls generateOpenApi31Document() which uses the real app routes
+ * and static metadata. Lazy initialization enables this script to run without config initialization.
*/
import { execFileSync } from "node:child_process";
@@ -14,13 +14,14 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
+import app from "@/app";
import { generateOpenApi31Document } from "@/openapi-document";
const __dirname = dirname(fileURLToPath(import.meta.url));
const outputPath = resolve(__dirname, "..", "docs", "docs.ensnode.io", "ensapi-openapi.json");
// Generate the document (no additional servers for the static spec)
-const document = generateOpenApi31Document();
+const document = generateOpenApi31Document(app);
// Write JSON (Biome handles formatting)
mkdirSync(dirname(outputPath), { recursive: true });
@@ -51,3 +52,7 @@ try {
}
process.exit(1);
}
+
+// Explicitly exit to prevent lazy-initialized SWR caches (imported transitively via app.ts)
+// from keeping the process alive with their background timers.
+process.exit(0);