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);