From 7034572956789bdacd144318c9319ae11d3ccf43 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 11 Mar 2026 17:47:26 +0300 Subject: [PATCH 01/17] initial rearrange --- .../explore}/name-tokens-api.routes.ts | 0 .../{ => api/explore}/name-tokens-api.ts | 0 .../explore}/registrar-actions-api.routes.ts | 0 .../explore}/registrar-actions-api.ts | 0 .../{ => api/graphql}/ensnode-graphql-api.ts | 0 .../{ => api/meta}/ensnode-api.routes.ts | 0 .../handlers/{ => api/meta}/ensnode-api.ts | 16 ---------------- .../meta/realtime-api.routes.ts} | 4 ++-- .../meta/realtime-api.test.ts} | 4 ++-- .../meta/realtime-api.ts} | 2 +- .../resolution}/resolution-api.routes.ts | 0 .../{ => api/resolution}/resolution-api.ts | 0 apps/ensapi/src/handlers/api/router.ts | 19 +++++++++++++++++++ .../ensanalytics-api-v1.routes.ts | 0 .../ensanalytics-api-v1.test.ts | 9 ++++----- .../{ => ensanalytics}/ensanalytics-api-v1.ts | 0 .../ensanalytics-api.routes.ts | 0 .../ensanalytics-api.test.ts | 5 ++--- .../{ => ensanalytics}/ensanalytics-api.ts | 0 .../handlers/{ => subgraph}/subgraph-api.ts | 0 apps/ensapi/src/index.ts | 14 +++++++------- apps/ensapi/src/openapi-document.ts | 14 +++++++------- 22 files changed, 44 insertions(+), 43 deletions(-) rename apps/ensapi/src/handlers/{ => api/explore}/name-tokens-api.routes.ts (100%) rename apps/ensapi/src/handlers/{ => api/explore}/name-tokens-api.ts (100%) rename apps/ensapi/src/handlers/{ => api/explore}/registrar-actions-api.routes.ts (100%) rename apps/ensapi/src/handlers/{ => api/explore}/registrar-actions-api.ts (100%) rename apps/ensapi/src/handlers/{ => api/graphql}/ensnode-graphql-api.ts (100%) rename apps/ensapi/src/handlers/{ => api/meta}/ensnode-api.routes.ts (100%) rename apps/ensapi/src/handlers/{ => api/meta}/ensnode-api.ts (76%) rename apps/ensapi/src/handlers/{amirealtime-api.routes.ts => api/meta/realtime-api.routes.ts} (95%) rename apps/ensapi/src/handlers/{amirealtime-api.test.ts => api/meta/realtime-api.test.ts} (98%) rename apps/ensapi/src/handlers/{amirealtime-api.ts => api/meta/realtime-api.ts} (96%) rename apps/ensapi/src/handlers/{ => api/resolution}/resolution-api.routes.ts (100%) rename apps/ensapi/src/handlers/{ => api/resolution}/resolution-api.ts (100%) create mode 100644 apps/ensapi/src/handlers/api/router.ts rename apps/ensapi/src/handlers/{ => ensanalytics}/ensanalytics-api-v1.routes.ts (100%) rename apps/ensapi/src/handlers/{ => ensanalytics}/ensanalytics-api-v1.test.ts (99%) rename apps/ensapi/src/handlers/{ => ensanalytics}/ensanalytics-api-v1.ts (100%) rename apps/ensapi/src/handlers/{ => ensanalytics}/ensanalytics-api.routes.ts (100%) rename apps/ensapi/src/handlers/{ => ensanalytics}/ensanalytics-api.test.ts (98%) rename apps/ensapi/src/handlers/{ => ensanalytics}/ensanalytics-api.ts (100%) rename apps/ensapi/src/handlers/{ => subgraph}/subgraph-api.ts (100%) diff --git a/apps/ensapi/src/handlers/name-tokens-api.routes.ts b/apps/ensapi/src/handlers/api/explore/name-tokens-api.routes.ts similarity index 100% rename from apps/ensapi/src/handlers/name-tokens-api.routes.ts rename to apps/ensapi/src/handlers/api/explore/name-tokens-api.routes.ts diff --git a/apps/ensapi/src/handlers/name-tokens-api.ts b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts similarity index 100% rename from apps/ensapi/src/handlers/name-tokens-api.ts rename to apps/ensapi/src/handlers/api/explore/name-tokens-api.ts diff --git a/apps/ensapi/src/handlers/registrar-actions-api.routes.ts b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.routes.ts similarity index 100% rename from apps/ensapi/src/handlers/registrar-actions-api.routes.ts rename to apps/ensapi/src/handlers/api/explore/registrar-actions-api.routes.ts diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts similarity index 100% rename from apps/ensapi/src/handlers/registrar-actions-api.ts rename to apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts diff --git a/apps/ensapi/src/handlers/ensnode-graphql-api.ts b/apps/ensapi/src/handlers/api/graphql/ensnode-graphql-api.ts similarity index 100% rename from apps/ensapi/src/handlers/ensnode-graphql-api.ts rename to apps/ensapi/src/handlers/api/graphql/ensnode-graphql-api.ts diff --git a/apps/ensapi/src/handlers/ensnode-api.routes.ts b/apps/ensapi/src/handlers/api/meta/ensnode-api.routes.ts similarity index 100% rename from apps/ensapi/src/handlers/ensnode-api.routes.ts rename to apps/ensapi/src/handlers/api/meta/ensnode-api.routes.ts diff --git a/apps/ensapi/src/handlers/ensnode-api.ts b/apps/ensapi/src/handlers/api/meta/ensnode-api.ts similarity index 76% rename from apps/ensapi/src/handlers/ensnode-api.ts rename to apps/ensapi/src/handlers/api/meta/ensnode-api.ts index 96e65d0c9..a9d5d3da7 100644 --- a/apps/ensapi/src/handlers/ensnode-api.ts +++ b/apps/ensapi/src/handlers/api/meta/ensnode-api.ts @@ -12,10 +12,6 @@ import { buildEnsApiPublicConfig } from "@/config/config.schema"; import { createApp } from "@/lib/hono-factory"; import { getConfigRoute, getIndexingStatusRoute } from "./ensnode-api.routes"; -import ensnodeGraphQLApi from "./ensnode-graphql-api"; -import nameTokensApi from "./name-tokens-api"; -import registrarActionsApi from "./registrar-actions-api"; -import resolutionApi from "./resolution-api"; const app = createApp(); @@ -49,16 +45,4 @@ app.openapi(getIndexingStatusRoute, async (c) => { ); }); -// Name Tokens API -app.route("/name-tokens", nameTokensApi); - -// Registrar Actions API -app.route("/registrar-actions", registrarActionsApi); - -// Resolution API -app.route("/resolve", resolutionApi); - -// ENSNode GraphQL API -app.route("/graphql", ensnodeGraphQLApi); - export default app; diff --git a/apps/ensapi/src/handlers/amirealtime-api.routes.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.routes.ts similarity index 95% rename from apps/ensapi/src/handlers/amirealtime-api.routes.ts rename to apps/ensapi/src/handlers/api/meta/realtime-api.routes.ts index 328b94167..a04b5884f 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.routes.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.routes.ts @@ -6,7 +6,7 @@ import { makeDurationSchema } from "@ensnode/ensnode-sdk/internal"; import { params } from "@/lib/handlers/params.schema"; -export const basePath = "/amirealtime"; +export const basePath = "/api/realtime"; // Set default `maxWorstCaseDistance` for `GET /amirealtime` endpoint to one minute. export const AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE: Duration = minutesToSeconds(1); @@ -14,7 +14,7 @@ export const AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE: Duration = minutesToSe export const amIRealtimeGetMeta = createRoute({ method: "get", path: "/", - operationId: "isRealtime", + operationId: "getRealtime", tags: ["Meta"], summary: "Check indexing progress", description: diff --git a/apps/ensapi/src/handlers/amirealtime-api.test.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts similarity index 98% rename from apps/ensapi/src/handlers/amirealtime-api.test.ts rename to apps/ensapi/src/handlers/api/meta/realtime-api.test.ts index 8d807c24e..a7f5e6f12 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.test.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts @@ -9,8 +9,8 @@ import { import { createApp } from "@/lib/hono-factory"; import * as middleware from "@/middleware/indexing-status.middleware"; -import amIRealtimeApi from "./amirealtime-api"; -import { AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE } from "./amirealtime-api.routes"; +import amIRealtimeApi from "./realtime-api"; +import { AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE } from "./realtime-api.routes"; vi.mock("@/middleware/indexing-status.middleware", () => ({ indexingStatusMiddleware: vi.fn(), diff --git a/apps/ensapi/src/handlers/amirealtime-api.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.ts similarity index 96% rename from apps/ensapi/src/handlers/amirealtime-api.ts rename to apps/ensapi/src/handlers/api/meta/realtime-api.ts index 7b45d33fb..257ba5f6b 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.ts @@ -1,7 +1,7 @@ import { errorResponse } from "@/lib/handlers/error-response"; import { createApp } from "@/lib/hono-factory"; -import { amIRealtimeGetMeta } from "./amirealtime-api.routes"; +import { amIRealtimeGetMeta } from "./realtime-api.routes"; const app = createApp(); diff --git a/apps/ensapi/src/handlers/resolution-api.routes.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.routes.ts similarity index 100% rename from apps/ensapi/src/handlers/resolution-api.routes.ts rename to apps/ensapi/src/handlers/api/resolution/resolution-api.routes.ts diff --git a/apps/ensapi/src/handlers/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts similarity index 100% rename from apps/ensapi/src/handlers/resolution-api.ts rename to apps/ensapi/src/handlers/api/resolution/resolution-api.ts diff --git a/apps/ensapi/src/handlers/api/router.ts b/apps/ensapi/src/handlers/api/router.ts new file mode 100644 index 000000000..ba7bbd463 --- /dev/null +++ b/apps/ensapi/src/handlers/api/router.ts @@ -0,0 +1,19 @@ +import { createApp } from "@/lib/hono-factory"; + +import nameTokensApi from "./explore/name-tokens-api"; +import registrarActionsApi from "./explore/registrar-actions-api"; +import ensnodeGraphQLApi from "./graphql/ensnode-graphql-api"; +import metaApi from "./meta/ensnode-api"; +import realtimeApi from "./meta/realtime-api"; +import resolutionApi from "./resolution/resolution-api"; + +const app = createApp(); + +app.route("/", metaApi); +app.route("/realtime", realtimeApi); +app.route("/resolve", resolutionApi); +app.route("/name-tokens", nameTokensApi); +app.route("/registrar-actions", registrarActionsApi); +app.route("/graphql", ensnodeGraphQLApi); + +export default app; diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.routes.ts similarity index 100% rename from apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts rename to apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.routes.ts diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.test.ts similarity index 99% rename from apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts rename to apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.test.ts index 916d4ce49..68bede641 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.test.ts @@ -3,9 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import { ENSNamespaceIds } from "@ensnode/datasources"; import type { EnsApiConfig } from "@/config/config.schema"; - -import * as editionsCachesMiddleware from "../middleware/referral-leaderboard-editions-caches.middleware"; -import * as editionSetMiddleware from "../middleware/referral-program-edition-set.middleware"; +import * as editionsCachesMiddleware from "@/middleware/referral-leaderboard-editions-caches.middleware"; +import * as editionSetMiddleware from "@/middleware/referral-program-edition-set.middleware"; vi.mock("@/config", () => ({ get default() { @@ -18,11 +17,11 @@ vi.mock("@/config", () => ({ }, })); -vi.mock("../middleware/referral-program-edition-set.middleware", () => ({ +vi.mock("@/middleware/referral-program-edition-set.middleware", () => ({ referralProgramEditionConfigSetMiddleware: vi.fn(), })); -vi.mock("../middleware/referral-leaderboard-editions-caches.middleware", () => ({ +vi.mock("@/middleware/referral-leaderboard-editions-caches.middleware", () => ({ referralLeaderboardEditionsCachesMiddleware: vi.fn(), })); diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts similarity index 100% rename from apps/ensapi/src/handlers/ensanalytics-api-v1.ts rename to apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts diff --git a/apps/ensapi/src/handlers/ensanalytics-api.routes.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts similarity index 100% rename from apps/ensapi/src/handlers/ensanalytics-api.routes.ts rename to apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.routes.ts diff --git a/apps/ensapi/src/handlers/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts similarity index 98% rename from apps/ensapi/src/handlers/ensanalytics-api.test.ts rename to apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts index 0d0dcefc8..74582d1d7 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts @@ -3,8 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { ENSNamespaceIds } from "@ensnode/datasources"; import type { EnsApiConfig } from "@/config/config.schema"; - -import * as middleware from "../middleware/referrer-leaderboard.middleware"; +import * as middleware from "@/middleware/referrer-leaderboard.middleware"; vi.mock("@/config", () => ({ get default() { @@ -17,7 +16,7 @@ vi.mock("@/config", () => ({ }, })); -vi.mock("../middleware/referrer-leaderboard.middleware", () => ({ +vi.mock("@/middleware/referrer-leaderboard.middleware", () => ({ referrerLeaderboardMiddleware: vi.fn(), })); diff --git a/apps/ensapi/src/handlers/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts similarity index 100% rename from apps/ensapi/src/handlers/ensanalytics-api.ts rename to apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts diff --git a/apps/ensapi/src/handlers/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts similarity index 100% rename from apps/ensapi/src/handlers/subgraph-api.ts rename to apps/ensapi/src/handlers/subgraph/subgraph-api.ts diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 778ab816c..c1638f167 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -18,11 +18,11 @@ import logger from "@/lib/logger"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { generateOpenApi31Document } from "@/openapi-document"; -import amIRealtimeApi from "./handlers/amirealtime-api"; -import ensanalyticsApi from "./handlers/ensanalytics-api"; -import ensanalyticsApiV1 from "./handlers/ensanalytics-api-v1"; -import ensNodeApi from "./handlers/ensnode-api"; -import subgraphApi from "./handlers/subgraph-api"; +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 = factory.createApp(); @@ -60,7 +60,7 @@ app.get("/", (c) => ); // use ENSNode HTTP API at /api -app.route("/api", ensNodeApi); +app.route("/api", apiRouter); // use Subgraph GraphQL API at /subgraph app.route("/subgraph", subgraphApi); @@ -72,7 +72,7 @@ app.route("/ensanalytics", ensanalyticsApi); app.route("/v1/ensanalytics", ensanalyticsApiV1); // use Am I Realtime API at /amirealtime -app.route("/amirealtime", amIRealtimeApi); +app.route("/amirealtime", realtimeApi); // serve pre-generated OpenAPI 3.1 document const openApi31Document = generateOpenApi31Document(); diff --git a/apps/ensapi/src/openapi-document.ts b/apps/ensapi/src/openapi-document.ts index 7a45968e0..f6b6b7781 100644 --- a/apps/ensapi/src/openapi-document.ts +++ b/apps/ensapi/src/openapi-document.ts @@ -2,13 +2,13 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import { openapiMeta } from "@/openapi-meta"; -import * as amIRealtimeRoutes from "./handlers/amirealtime-api.routes"; -import * as ensanalyticsRoutes from "./handlers/ensanalytics-api.routes"; -import * as ensanalyticsV1Routes from "./handlers/ensanalytics-api-v1.routes"; -import * as ensnodeRoutes from "./handlers/ensnode-api.routes"; -import * as nameTokensRoutes from "./handlers/name-tokens-api.routes"; -import * as registrarActionsRoutes from "./handlers/registrar-actions-api.routes"; -import * as resolutionRoutes from "./handlers/resolution-api.routes"; +import * as nameTokensRoutes from "./handlers/api/explore/name-tokens-api.routes"; +import * as registrarActionsRoutes from "./handlers/api/explore/registrar-actions-api.routes"; +import * as ensnodeRoutes from "./handlers/api/meta/ensnode-api.routes"; +import * as amIRealtimeRoutes from "./handlers/api/meta/realtime-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 = [ amIRealtimeRoutes, From 2026b7865df88eb3c90ee3f00017f62eb835897b Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 17 Mar 2026 12:52:19 +0300 Subject: [PATCH 02/17] rename amirealtime to realtime --- apps/ensapi/src/handlers/api/meta/realtime-api.routes.ts | 4 ++-- apps/ensapi/src/handlers/api/meta/realtime-api.ts | 4 ++-- apps/ensapi/src/openapi-document.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ensapi/src/handlers/api/meta/realtime-api.routes.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.routes.ts index a04b5884f..3d171f315 100644 --- a/apps/ensapi/src/handlers/api/meta/realtime-api.routes.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.routes.ts @@ -11,7 +11,7 @@ export const basePath = "/api/realtime"; // Set default `maxWorstCaseDistance` for `GET /amirealtime` endpoint to one minute. export const AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE: Duration = minutesToSeconds(1); -export const amIRealtimeGetMeta = createRoute({ +export const realtimeGetMeta = createRoute({ method: "get", path: "/", operationId: "getRealtime", @@ -47,4 +47,4 @@ export const amIRealtimeGetMeta = createRoute({ }, }); -export const routes = [amIRealtimeGetMeta]; +export const routes = [realtimeGetMeta]; diff --git a/apps/ensapi/src/handlers/api/meta/realtime-api.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.ts index 257ba5f6b..e81f3bf42 100644 --- a/apps/ensapi/src/handlers/api/meta/realtime-api.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.ts @@ -1,13 +1,13 @@ import { errorResponse } from "@/lib/handlers/error-response"; import { createApp } from "@/lib/hono-factory"; -import { amIRealtimeGetMeta } from "./realtime-api.routes"; +import { realtimeGetMeta } from "./realtime-api.routes"; const app = createApp(); // allow performance monitoring clients to read HTTP Status for the provided // `maxWorstCaseDistance` param -app.openapi(amIRealtimeGetMeta, async (c) => { +app.openapi(realtimeGetMeta, async (c) => { // context must be set by the required middleware if (c.var.indexingStatus === undefined) { throw new Error(`Invariant(amirealtime-api): indexingStatusMiddleware required.`); diff --git a/apps/ensapi/src/openapi-document.ts b/apps/ensapi/src/openapi-document.ts index f6b6b7781..0fe9481c5 100644 --- a/apps/ensapi/src/openapi-document.ts +++ b/apps/ensapi/src/openapi-document.ts @@ -5,13 +5,13 @@ 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 ensnodeRoutes from "./handlers/api/meta/ensnode-api.routes"; -import * as amIRealtimeRoutes from "./handlers/api/meta/realtime-api.routes"; +import * as realtimeRoutes from "./handlers/api/meta/realtime-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 = [ - amIRealtimeRoutes, + realtimeRoutes, ensnodeRoutes, ensanalyticsV1Routes, ensanalyticsRoutes, From b9f5a42718ae02823d788a1b03eabd85128f6319 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 18 Mar 2026 16:53:30 +0300 Subject: [PATCH 03/17] specify needed middleware types --- .../handlers/api/explore/name-tokens-api.ts | 19 ++---------- .../api/explore/registrar-actions-api.ts | 8 ++--- .../src/handlers/api/meta/ensnode-api.ts | 9 ++---- .../handlers/api/meta/realtime-api.test.ts | 6 ++-- .../src/handlers/api/meta/realtime-api.ts | 9 ++---- .../handlers/api/resolution/resolution-api.ts | 19 ++---------- apps/ensapi/src/handlers/api/router.ts | 4 +-- .../ensanalytics/ensanalytics-api-v1.ts | 27 +++-------------- .../handlers/ensanalytics/ensanalytics-api.ts | 14 ++------- apps/ensapi/src/lib/hono-factory.ts | 30 +++++++++++++++++-- 10 files changed, 50 insertions(+), 95 deletions(-) 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 888d9b645..8e62c9641 100644 --- a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts @@ -14,14 +14,14 @@ import { serializeNameTokensResponse, } from "@ensnode/ensnode-sdk"; -import { createApp } from "@/lib/hono-factory"; +import { createOpenApiApp } from "@/lib/hono-factory"; import { findRegisteredNameTokensForDomain } from "@/lib/name-tokens/find-name-tokens-for-domain"; import { getIndexedSubregistries } from "@/lib/name-tokens/get-indexed-subregistries"; import { nameTokensApiMiddleware } from "@/middleware/name-tokens.middleware"; import { getNameTokensRoute } from "./name-tokens-api.routes"; -const app = createApp(); +const app = createOpenApiApp<"indexingStatus">(); const indexedSubregistries = getIndexedSubregistries( config.namespace, @@ -48,21 +48,6 @@ const makeNameTokensNotIndexedResponse = ( }); app.openapi(getNameTokensRoute, async (c) => { - // Invariant: context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - return c.json( - serializeNameTokensResponse({ - responseCode: NameTokensResponseCodes.Error, - errorCode: NameTokensResponseErrorCodes.IndexingStatusUnsupported, - error: { - message: "Name Tokens API is not available yet", - details: "Indexing status middleware is required but not initialized.", - }, - }), - 503, - ); - } - // Check if Indexing Status resolution failed. if (c.var.indexingStatus instanceof Error) { return c.json( diff --git a/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts index df6095408..38d6ae140 100644 --- a/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts @@ -9,7 +9,7 @@ import { serializeRegistrarActionsResponse, } from "@ensnode/ensnode-sdk"; -import { createApp } from "@/lib/hono-factory"; +import { createOpenApiApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { findRegistrarActions } from "@/lib/registrar-actions/find-registrar-actions"; import { registrarActionsApiMiddleware } from "@/middleware/registrar-actions.middleware"; @@ -20,7 +20,7 @@ import { type RegistrarActionsQuery, } from "./registrar-actions-api.routes"; -const app = createApp(); +const app = createOpenApiApp<"indexingStatus">(); const logger = makeLogger("registrar-actions-api"); @@ -150,9 +150,7 @@ app.openapi(getRegistrarActionsRoute, async (c) => { */ app.openapi(getRegistrarActionsByParentNodeRoute, async (c) => { try { - // Middleware ensures indexingStatus is available and not an Error - // This check is for TypeScript type safety - if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) { + if (c.var.indexingStatus instanceof Error) { throw new Error("Invariant violation: indexingStatus should be validated by middleware"); } diff --git a/apps/ensapi/src/handlers/api/meta/ensnode-api.ts b/apps/ensapi/src/handlers/api/meta/ensnode-api.ts index a9d5d3da7..17ae7ff89 100644 --- a/apps/ensapi/src/handlers/api/meta/ensnode-api.ts +++ b/apps/ensapi/src/handlers/api/meta/ensnode-api.ts @@ -9,11 +9,11 @@ import { } from "@ensnode/ensnode-sdk"; import { buildEnsApiPublicConfig } from "@/config/config.schema"; -import { createApp } from "@/lib/hono-factory"; +import { createOpenApiApp } from "@/lib/hono-factory"; import { getConfigRoute, getIndexingStatusRoute } from "./ensnode-api.routes"; -const app = createApp(); +const app = createOpenApiApp<"indexingStatus">(); app.openapi(getConfigRoute, async (c) => { const ensApiPublicConfig = buildEnsApiPublicConfig(config); @@ -21,11 +21,6 @@ app.openapi(getConfigRoute, async (c) => { }); app.openapi(getIndexingStatusRoute, async (c) => { - // context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(indexing-status): indexingStatusMiddleware required`); - } - if (c.var.indexingStatus instanceof Error) { return c.json( serializeEnsApiIndexingStatusResponse({ diff --git a/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts index a7f5e6f12..3a373f0cd 100644 --- a/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts @@ -6,7 +6,7 @@ import { type UnixTimestamp, } from "@ensnode/ensnode-sdk"; -import { createApp } from "@/lib/hono-factory"; +import { createOpenApiApp } from "@/lib/hono-factory"; import * as middleware from "@/middleware/indexing-status.middleware"; import amIRealtimeApi from "./realtime-api"; @@ -45,11 +45,11 @@ describe("amirealtime-api", () => { }); }; - let app: ReturnType; + let app: ReturnType; beforeEach(() => { // Create a fresh app instance for each test with middleware registered - app = createApp(); + app = createOpenApiApp(); app.use(middleware.indexingStatusMiddleware); app.route("/amirealtime", amIRealtimeApi); }); diff --git a/apps/ensapi/src/handlers/api/meta/realtime-api.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.ts index e81f3bf42..30ad40894 100644 --- a/apps/ensapi/src/handlers/api/meta/realtime-api.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.ts @@ -1,18 +1,13 @@ import { errorResponse } from "@/lib/handlers/error-response"; -import { createApp } from "@/lib/hono-factory"; +import { createOpenApiApp } from "@/lib/hono-factory"; import { realtimeGetMeta } from "./realtime-api.routes"; -const app = createApp(); +const app = createOpenApiApp<"indexingStatus">(); // allow performance monitoring clients to read HTTP Status for the provided // `maxWorstCaseDistance` param app.openapi(realtimeGetMeta, async (c) => { - // context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(amirealtime-api): indexingStatusMiddleware required.`); - } - // return 503 response error with details on prerequisite being unavailable if (c.var.indexingStatus instanceof Error) { return errorResponse( diff --git a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts index aedb45cb1..1a5c5925c 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts @@ -5,7 +5,7 @@ import type { ResolveRecordsResponse, } from "@ensnode/ensnode-sdk"; -import { createApp } from "@/lib/hono-factory"; +import { createOpenApiApp } from "@/lib/hono-factory"; import { resolveForward } from "@/lib/resolution/forward-resolution"; import { resolvePrimaryNames } from "@/lib/resolution/multichain-primary-name-resolution"; import { resolveReverse } from "@/lib/resolution/reverse-resolution"; @@ -25,7 +25,7 @@ import { */ const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 60; // 1 minute in seconds -const app = createApp(); +const app = createOpenApiApp<"canAccelerate">(); // inject c.var.isRealtime derived from MAX_REALTIME_DISTANCE_TO_ACCELERATE app.use(makeIsRealtimeMiddleware("resolution-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE)); @@ -45,11 +45,6 @@ app.use(canAccelerateMiddleware); * GET /records/example.eth&name=true&addresses=60,0&texts=avatar,com.twitter */ app.openapi(resolveRecordsRoute, async (c) => { - // context must be set by the required middleware - if (c.var.canAccelerate === undefined) { - throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`); - } - const { name } = c.req.valid("param"); const { selection, trace: showTrace, accelerate } = c.req.valid("query"); const canAccelerate = c.var.canAccelerate; @@ -82,11 +77,6 @@ app.openapi(resolveRecordsRoute, async (c) => { * GET /primary-name/0x1234...abcd/0 */ app.openapi(resolvePrimaryNameRoute, async (c) => { - // context must be set by the required middleware - if (c.var.canAccelerate === undefined) { - throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`); - } - const { address, chainId } = c.req.valid("param"); const { trace: showTrace, accelerate } = c.req.valid("query"); const canAccelerate = c.var.canAccelerate; @@ -116,11 +106,6 @@ app.openapi(resolvePrimaryNameRoute, async (c) => { * GET /primary-names/0x1234...abcd?chainIds=1,10,8453 */ app.openapi(resolvePrimaryNamesRoute, async (c) => { - // context must be set by the required middleware - if (c.var.canAccelerate === undefined) { - throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`); - } - const { address } = c.req.valid("param"); const { chainIds, trace: showTrace, accelerate } = c.req.valid("query"); const canAccelerate = c.var.canAccelerate; diff --git a/apps/ensapi/src/handlers/api/router.ts b/apps/ensapi/src/handlers/api/router.ts index ba7bbd463..8dd8e77c6 100644 --- a/apps/ensapi/src/handlers/api/router.ts +++ b/apps/ensapi/src/handlers/api/router.ts @@ -1,4 +1,4 @@ -import { createApp } from "@/lib/hono-factory"; +import { createOpenApiApp } from "@/lib/hono-factory"; import nameTokensApi from "./explore/name-tokens-api"; import registrarActionsApi from "./explore/registrar-actions-api"; @@ -7,7 +7,7 @@ import metaApi from "./meta/ensnode-api"; import realtimeApi from "./meta/realtime-api"; import resolutionApi from "./resolution/resolution-api"; -const app = createApp(); +const app = createOpenApiApp(); app.route("/", metaApi); app.route("/realtime", realtimeApi); diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts index 320a2e68e..5510cd761 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts @@ -15,7 +15,7 @@ import { serializeReferrerMetricsEditionsResponse, } from "@namehash/ens-referrals/v1"; -import { createApp } from "@/lib/hono-factory"; +import { createOpenApiApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { referralLeaderboardEditionsCachesMiddleware } from "@/middleware/referral-leaderboard-editions-caches.middleware"; import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; @@ -28,7 +28,9 @@ import { const logger = makeLogger("ensanalytics-api-v1"); -const app = createApp(); +const app = createOpenApiApp< + "referralLeaderboardEditionsCaches" | "referralProgramEditionConfigSet" +>(); // Apply referral program edition config set middleware app.use(referralProgramEditionConfigSetMiddleware); @@ -38,13 +40,6 @@ app.use(referralLeaderboardEditionsCachesMiddleware); // Get a page from the referrer leaderboard for a specific edition app.openapi(getReferralLeaderboardRoute, async (c) => { - // context must be set by the required middleware - if (c.var.referralLeaderboardEditionsCaches === undefined) { - throw new Error( - `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, - ); - } - try { const { edition, page, recordsPerPage } = c.req.valid("query"); @@ -121,13 +116,6 @@ app.openapi(getReferralLeaderboardRoute, async (c) => { // Get referrer detail for a specific address for requested editions app.openapi(getReferrerDetailRoute, async (c) => { - // context must be set by the required middleware - if (c.var.referralLeaderboardEditionsCaches === undefined) { - throw new Error( - `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, - ); - } - try { const { referrer } = c.req.valid("param"); const { editions } = c.req.valid("query"); @@ -237,13 +225,6 @@ app.openapi(getReferrerDetailRoute, async (c) => { // Get configured edition config set app.openapi(getEditionsRoute, async (c) => { - // context must be set by the required middleware - if (c.var.referralProgramEditionConfigSet === undefined) { - throw new Error( - `Invariant(ensanalytics-api-v1): referralProgramEditionConfigSetMiddleware required`, - ); - } - try { // Check if edition config set failed to load if (c.var.referralProgramEditionConfigSet instanceof Error) { diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts index 35d9f7e5d..7497caabc 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts @@ -9,7 +9,7 @@ import { serializeReferrerLeaderboardPageResponse, } from "@namehash/ens-referrals"; -import { createApp } from "@/lib/hono-factory"; +import { createOpenApiApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { referrerLeaderboardMiddleware } from "@/middleware/referrer-leaderboard.middleware"; @@ -17,18 +17,13 @@ import { getReferrerDetailRoute, getReferrerLeaderboardRoute } from "./ensanalyt const logger = makeLogger("ensanalytics-api"); -const app = createApp(); +const app = createOpenApiApp<"referrerLeaderboard">(); // Apply referrer leaderboard cache middleware to all routes in this handler app.use(referrerLeaderboardMiddleware); // Get a page from the referrer leaderboard app.openapi(getReferrerLeaderboardRoute, async (c) => { - // context must be set by the required middleware - if (c.var.referrerLeaderboard === undefined) { - throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`); - } - try { if (c.var.referrerLeaderboard instanceof Error) { return c.json( @@ -72,11 +67,6 @@ app.openapi(getReferrerLeaderboardRoute, async (c) => { // Get referrer detail for a specific address app.openapi(getReferrerDetailRoute, async (c) => { - // context must be set by the required middleware - if (c.var.referrerLeaderboard === undefined) { - throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`); - } - try { // Check if leaderboard failed to load if (c.var.referrerLeaderboard instanceof Error) { diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index fe3a8743f..1ba9ce82f 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -18,10 +18,36 @@ export type MiddlewareVariables = IndexingStatusMiddlewareVariables & type AppEnv = { Variables: Partial }; +/** + * Produces an env type where the specified keys of MiddlewareVariables are required (non-optional). + * All other middleware variables remain optional. + * + * Use this as the type parameter to `createOpenApiApp` to declare which middleware variables a sub-app + * requires, giving compile-time guarantees in handlers instead of runtime invariant assertions. + */ +type RequireVars = Omit< + Partial, + TRequired +> & + Required>; + export const factory = createFactory(); -export function createApp() { - return new OpenAPIHono({ +/** + * Creates an OpenAPIHono sub-app with typed middleware variable requirements. + * + * Pass a union of `MiddlewareVariables` keys as the type parameter to declare which + * middleware variables handlers in this app can access as non-optional: + * + * ```ts + * // c.var.canAccelerate is `boolean`, not `boolean | undefined` + * const app = createOpenApiApp<"canAccelerate">(); + * ``` + * + * Without a type parameter, all variables remain optional (same as before). + */ +export function createOpenApiApp() { + return new OpenAPIHono<{ Variables: RequireVars }>({ defaultHook: (result, c) => { if (!result.success) { return errorResponse(c, result.error); From 6f9b37c8558d306fe73773eb3888f74cafe20378 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 19 Mar 2026 11:49:22 +0300 Subject: [PATCH 04/17] add runtime check --- .../handlers/api/explore/name-tokens-api.ts | 4 +- .../api/explore/registrar-actions-api.ts | 4 +- .../src/handlers/api/meta/ensnode-api.ts | 4 +- .../handlers/api/meta/realtime-api.test.ts | 6 +-- .../src/handlers/api/meta/realtime-api.ts | 4 +- .../handlers/api/resolution/resolution-api.ts | 4 +- apps/ensapi/src/handlers/api/router.ts | 4 +- .../ensanalytics/ensanalytics-api-v1.ts | 6 +-- .../handlers/ensanalytics/ensanalytics-api.ts | 4 +- apps/ensapi/src/lib/hono-factory.ts | 49 ++++++++++++++----- 10 files changed, 57 insertions(+), 32 deletions(-) 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 8e62c9641..accd2c1f3 100644 --- a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts @@ -14,14 +14,14 @@ import { serializeNameTokensResponse, } from "@ensnode/ensnode-sdk"; -import { createOpenApiApp } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { findRegisteredNameTokensForDomain } from "@/lib/name-tokens/find-name-tokens-for-domain"; import { getIndexedSubregistries } from "@/lib/name-tokens/get-indexed-subregistries"; import { nameTokensApiMiddleware } from "@/middleware/name-tokens.middleware"; import { getNameTokensRoute } from "./name-tokens-api.routes"; -const app = createOpenApiApp<"indexingStatus">(); +const app = createApp("indexingStatus"); const indexedSubregistries = getIndexedSubregistries( config.namespace, diff --git a/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts index 38d6ae140..16ba01ff8 100644 --- a/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts @@ -9,7 +9,7 @@ import { serializeRegistrarActionsResponse, } from "@ensnode/ensnode-sdk"; -import { createOpenApiApp } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { findRegistrarActions } from "@/lib/registrar-actions/find-registrar-actions"; import { registrarActionsApiMiddleware } from "@/middleware/registrar-actions.middleware"; @@ -20,7 +20,7 @@ import { type RegistrarActionsQuery, } from "./registrar-actions-api.routes"; -const app = createOpenApiApp<"indexingStatus">(); +const app = createApp("indexingStatus"); const logger = makeLogger("registrar-actions-api"); diff --git a/apps/ensapi/src/handlers/api/meta/ensnode-api.ts b/apps/ensapi/src/handlers/api/meta/ensnode-api.ts index 17ae7ff89..916870832 100644 --- a/apps/ensapi/src/handlers/api/meta/ensnode-api.ts +++ b/apps/ensapi/src/handlers/api/meta/ensnode-api.ts @@ -9,11 +9,11 @@ import { } from "@ensnode/ensnode-sdk"; import { buildEnsApiPublicConfig } from "@/config/config.schema"; -import { createOpenApiApp } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { getConfigRoute, getIndexingStatusRoute } from "./ensnode-api.routes"; -const app = createOpenApiApp<"indexingStatus">(); +const app = createApp("indexingStatus"); app.openapi(getConfigRoute, async (c) => { const ensApiPublicConfig = buildEnsApiPublicConfig(config); diff --git a/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts index 3a373f0cd..a7f5e6f12 100644 --- a/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.test.ts @@ -6,7 +6,7 @@ import { type UnixTimestamp, } from "@ensnode/ensnode-sdk"; -import { createOpenApiApp } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import * as middleware from "@/middleware/indexing-status.middleware"; import amIRealtimeApi from "./realtime-api"; @@ -45,11 +45,11 @@ describe("amirealtime-api", () => { }); }; - let app: ReturnType; + let app: ReturnType; beforeEach(() => { // Create a fresh app instance for each test with middleware registered - app = createOpenApiApp(); + app = createApp(); app.use(middleware.indexingStatusMiddleware); app.route("/amirealtime", amIRealtimeApi); }); diff --git a/apps/ensapi/src/handlers/api/meta/realtime-api.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.ts index 30ad40894..770c4ed3a 100644 --- a/apps/ensapi/src/handlers/api/meta/realtime-api.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.ts @@ -1,9 +1,9 @@ import { errorResponse } from "@/lib/handlers/error-response"; -import { createOpenApiApp } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { realtimeGetMeta } from "./realtime-api.routes"; -const app = createOpenApiApp<"indexingStatus">(); +const app = createApp("indexingStatus"); // allow performance monitoring clients to read HTTP Status for the provided // `maxWorstCaseDistance` param diff --git a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts index 1a5c5925c..5178e895d 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts @@ -5,7 +5,7 @@ import type { ResolveRecordsResponse, } from "@ensnode/ensnode-sdk"; -import { createOpenApiApp } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { resolveForward } from "@/lib/resolution/forward-resolution"; import { resolvePrimaryNames } from "@/lib/resolution/multichain-primary-name-resolution"; import { resolveReverse } from "@/lib/resolution/reverse-resolution"; @@ -25,7 +25,7 @@ import { */ const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 60; // 1 minute in seconds -const app = createOpenApiApp<"canAccelerate">(); +const app = createApp("canAccelerate"); // inject c.var.isRealtime derived from MAX_REALTIME_DISTANCE_TO_ACCELERATE app.use(makeIsRealtimeMiddleware("resolution-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE)); diff --git a/apps/ensapi/src/handlers/api/router.ts b/apps/ensapi/src/handlers/api/router.ts index 8dd8e77c6..ba7bbd463 100644 --- a/apps/ensapi/src/handlers/api/router.ts +++ b/apps/ensapi/src/handlers/api/router.ts @@ -1,4 +1,4 @@ -import { createOpenApiApp } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import nameTokensApi from "./explore/name-tokens-api"; import registrarActionsApi from "./explore/registrar-actions-api"; @@ -7,7 +7,7 @@ import metaApi from "./meta/ensnode-api"; import realtimeApi from "./meta/realtime-api"; import resolutionApi from "./resolution/resolution-api"; -const app = createOpenApiApp(); +const app = createApp(); app.route("/", metaApi); app.route("/realtime", realtimeApi); diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts index 5510cd761..39748ac7a 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts @@ -15,7 +15,7 @@ import { serializeReferrerMetricsEditionsResponse, } from "@namehash/ens-referrals/v1"; -import { createOpenApiApp } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { referralLeaderboardEditionsCachesMiddleware } from "@/middleware/referral-leaderboard-editions-caches.middleware"; import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; @@ -28,9 +28,7 @@ import { const logger = makeLogger("ensanalytics-api-v1"); -const app = createOpenApiApp< - "referralLeaderboardEditionsCaches" | "referralProgramEditionConfigSet" ->(); +const app = createApp("referralLeaderboardEditionsCaches", "referralProgramEditionConfigSet"); // Apply referral program edition config set middleware app.use(referralProgramEditionConfigSetMiddleware); diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts index 7497caabc..506a5e1f4 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts @@ -9,7 +9,7 @@ import { serializeReferrerLeaderboardPageResponse, } from "@namehash/ens-referrals"; -import { createOpenApiApp } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { referrerLeaderboardMiddleware } from "@/middleware/referrer-leaderboard.middleware"; @@ -17,7 +17,7 @@ import { getReferrerDetailRoute, getReferrerLeaderboardRoute } from "./ensanalyt const logger = makeLogger("ensanalytics-api"); -const app = createOpenApiApp<"referrerLeaderboard">(); +const app = createApp("referrerLeaderboard"); // Apply referrer leaderboard cache middleware to all routes in this handler app.use(referrerLeaderboardMiddleware); diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index 1ba9ce82f..75a24f7a0 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -21,9 +21,6 @@ type AppEnv = { Variables: Partial }; /** * Produces an env type where the specified keys of MiddlewareVariables are required (non-optional). * All other middleware variables remain optional. - * - * Use this as the type parameter to `createOpenApiApp` to declare which middleware variables a sub-app - * requires, giving compile-time guarantees in handlers instead of runtime invariant assertions. */ type RequireVars = Omit< Partial, @@ -34,24 +31,54 @@ type RequireVars = Omit< export const factory = createFactory(); /** - * Creates an OpenAPIHono sub-app with typed middleware variable requirements. + * Creates an OpenAPIHono sub-app that declares which middleware variables its handlers require. + * + * Pass the required variable names as arguments. This gives two guarantees: * - * Pass a union of `MiddlewareVariables` keys as the type parameter to declare which - * middleware variables handlers in this app can access as non-optional: + * 1. **Compile-time**: `c.var.` is typed as non-optional inside handlers. + * 2. **Runtime**: each handler asserts the variables are present before executing, + * producing a clear invariant error if the required middleware was never applied. * * ```ts - * // c.var.canAccelerate is `boolean`, not `boolean | undefined` - * const app = createOpenApiApp<"canAccelerate">(); + * // c.var.canAccelerate is `boolean`, never `boolean | undefined` + * // every handler throws if canAccelerate was not set by middleware + * const app = createApp("canAccelerate"); * ``` * - * Without a type parameter, all variables remain optional (same as before). + * Without arguments, all variables remain optional (same as a plain OpenAPIHono app). */ -export function createOpenApiApp() { - return new OpenAPIHono<{ Variables: RequireVars }>({ +export function createApp( + ...requiredVars: TRequired[] +) { + const app = new OpenAPIHono<{ Variables: RequireVars }>({ defaultHook: (result, c) => { if (!result.success) { return errorResponse(c, result.error); } }, }); + + if (requiredVars.length > 0) { + // Bind openapi as any to avoid fighting overload resolution when wrapping. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const _openapi = app.openapi.bind(app) as (...args: any[]) => any; + + // Override app.openapi to inject a runtime invariant check at the top of every handler body. + // Running the check inside the handler (rather than as a middleware) ensures it fires after + // all middleware — both global (index.ts) and sub-app level — have had a chance to set vars. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (app as any).openapi = (route: any, handler: any, hook?: any) => + _openapi(route, async (c: any) => { + for (const dep of requiredVars) { + if (c.var[dep] === undefined) { + throw new Error( + `Invariant: handler requires "${dep}" but no middleware provided it in c.var`, + ); + } + } + return handler(c); + }, hook); + } + + return app; } From 33124a245454ebe13a79b13ee9115bd79eb93f37 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Thu, 19 Mar 2026 22:02:52 +0300 Subject: [PATCH 05/17] pnpm lint --- apps/ensapi/src/lib/hono-factory.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index 75a24f7a0..5e1ad1fa2 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -68,16 +68,20 @@ export function createApp - _openapi(route, async (c: any) => { - for (const dep of requiredVars) { - if (c.var[dep] === undefined) { - throw new Error( - `Invariant: handler requires "${dep}" but no middleware provided it in c.var`, - ); + _openapi( + route, + async (c: any) => { + for (const dep of requiredVars) { + if (c.var[dep] === undefined) { + throw new Error( + `Invariant: handler requires "${dep}" but no middleware provided it in c.var`, + ); + } } - } - return handler(c); - }, hook); + return handler(c); + }, + hook, + ); } return app; From 3e9a3c82b019d8dc8dc78146c99795af5350f2ab Mon Sep 17 00:00:00 2001 From: sevenzing Date: Fri, 20 Mar 2026 12:27:08 +0300 Subject: [PATCH 06/17] override fast-xml-parser version --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d8f2bfd90..8fc60bde1 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "undici@>=7.0.0 <7.24.0": "^7.24.0", "undici@>=6.0.0 <6.24.0": "^6.24.0", "yauzl@<3.2.1": "^3.2.1", - "fast-xml-parser@>=5.0.0 <=5.5.5": ">=5.5.6", + "fast-xml-parser@>=4.0.0-beta.3 <=5.5.6": ">=5.5.7", "kysely@>=0.26.0 <0.28.12": "^0.28.12", "h3@<1.15.6": "^1.15.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eb2ff248..56eeb50bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,7 +104,7 @@ overrides: undici@>=7.0.0 <7.24.0: ^7.24.0 undici@>=6.0.0 <6.24.0: ^6.24.0 yauzl@<3.2.1: ^3.2.1 - fast-xml-parser@>=5.0.0 <=5.5.5: '>=5.5.6' + fast-xml-parser@>=4.0.0-beta.3 <=5.5.6: '>=5.5.7' kysely@>=0.26.0 <0.28.12: ^0.28.12 h3@<1.15.6: ^1.15.6 @@ -5896,8 +5896,8 @@ packages: fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} - fast-xml-parser@5.5.6: - resolution: {integrity: sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==} + fast-xml-parser@5.5.7: + resolution: {integrity: sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==} hasBin: true fastq@1.19.1: @@ -9827,7 +9827,7 @@ snapshots: '@aws-sdk/xml-builder@3.972.9': dependencies: '@smithy/types': 4.13.0 - fast-xml-parser: 5.5.6 + fast-xml-parser: 5.5.7 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.3': {} @@ -14841,7 +14841,7 @@ snapshots: dependencies: path-expression-matcher: 1.1.3 - fast-xml-parser@5.5.6: + fast-xml-parser@5.5.7: dependencies: fast-xml-builder: 1.1.4 path-expression-matcher: 1.1.3 From 7e0d03238558112cf498532c0e832bf51662d326 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sat, 21 Mar 2026 23:03:58 +0300 Subject: [PATCH 07/17] add ProducingMiddleware --- .../handlers/api/explore/name-tokens-api.ts | 8 +- .../api/explore/registrar-actions-api.ts | 7 +- .../src/handlers/api/meta/realtime-api.ts | 3 +- .../src/handlers/api/meta/status-api.ts | 3 +- .../handlers/api/resolution/resolution-api.ts | 12 +- .../ensanalytics/ensanalytics-api-v1.ts | 11 +- .../handlers/ensanalytics/ensanalytics-api.ts | 5 +- .../src/handlers/subgraph/subgraph-api.ts | 4 + apps/ensapi/src/index.ts | 4 - apps/ensapi/src/lib/hono-factory.ts | 61 ++++++-- .../middleware/can-accelerate.middleware.ts | 131 +++++++++--------- .../middleware/indexing-status.middleware.ts | 31 +++-- .../src/middleware/is-realtime.middleware.ts | 73 +++++----- ...-leaderboard-editions-caches.middleware.ts | 9 +- ...referral-program-edition-set.middleware.ts | 9 +- .../referrer-leaderboard.middleware.ts | 15 +- 16 files changed, 216 insertions(+), 170 deletions(-) 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 accd2c1f3..dc529effb 100644 --- a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts @@ -17,22 +17,18 @@ import { import { createApp } from "@/lib/hono-factory"; 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"; import { nameTokensApiMiddleware } from "@/middleware/name-tokens.middleware"; import { getNameTokensRoute } from "./name-tokens-api.routes"; -const app = createApp("indexingStatus"); +const app = createApp(indexingStatusMiddleware, nameTokensApiMiddleware); const indexedSubregistries = getIndexedSubregistries( config.namespace, config.ensIndexerPublicConfig.plugins as PluginName[], ); -// Middleware managing access to Name Tokens API route. -// It makes the route available if all prerequisites are met, -// and if not returns the appropriate HTTP 503 (Service Unavailable) error. -app.use(nameTokensApiMiddleware); - /** * Factory function for creating a 404 Name Tokens Not Indexed error response */ diff --git a/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts index 16ba01ff8..7cfee17d2 100644 --- a/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts @@ -12,6 +12,7 @@ import { import { createApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { findRegistrarActions } from "@/lib/registrar-actions/find-registrar-actions"; +import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { registrarActionsApiMiddleware } from "@/middleware/registrar-actions.middleware"; import { @@ -20,14 +21,10 @@ import { type RegistrarActionsQuery, } from "./registrar-actions-api.routes"; -const app = createApp("indexingStatus"); +const app = createApp(indexingStatusMiddleware, registrarActionsApiMiddleware); const logger = makeLogger("registrar-actions-api"); -// Middleware managing access to Registrar Actions API routes. -// It makes the routes available if all prerequisites are met. -app.use(registrarActionsApiMiddleware); - // Shared business logic for fetching registrar actions async function fetchRegistrarActions(parentNode: Node | undefined, query: RegistrarActionsQuery) { const { diff --git a/apps/ensapi/src/handlers/api/meta/realtime-api.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.ts index 5ba831294..38dabd343 100644 --- a/apps/ensapi/src/handlers/api/meta/realtime-api.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.ts @@ -1,9 +1,10 @@ import { errorResponse } from "@/lib/handlers/error-response"; import { createApp } from "@/lib/hono-factory"; +import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { realtimeGetMeta } from "./realtime-api.routes"; -const app = createApp("indexingStatus"); +const app = createApp(indexingStatusMiddleware); // allow performance monitoring clients to read HTTP Status for the provided // `maxWorstCaseDistance` param diff --git a/apps/ensapi/src/handlers/api/meta/status-api.ts b/apps/ensapi/src/handlers/api/meta/status-api.ts index 6b1a91635..1e6df57ad 100644 --- a/apps/ensapi/src/handlers/api/meta/status-api.ts +++ b/apps/ensapi/src/handlers/api/meta/status-api.ts @@ -10,10 +10,11 @@ import { import { buildEnsApiPublicConfig } from "@/config/config.schema"; import { createApp } from "@/lib/hono-factory"; +import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { getConfigRoute, getIndexingStatusRoute } from "./status-api.routes"; -const app = createApp("indexingStatus"); +const app = createApp(indexingStatusMiddleware); app.openapi(getConfigRoute, async (c) => { const ensApiPublicConfig = buildEnsApiPublicConfig(config); diff --git a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts index 5178e895d..6448482cb 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts @@ -11,6 +11,7 @@ import { resolvePrimaryNames } from "@/lib/resolution/multichain-primary-name-re import { resolveReverse } from "@/lib/resolution/reverse-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware"; +import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; import { @@ -25,12 +26,11 @@ import { */ const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 60; // 1 minute in seconds -const app = createApp("canAccelerate"); - -// inject c.var.isRealtime derived from MAX_REALTIME_DISTANCE_TO_ACCELERATE -app.use(makeIsRealtimeMiddleware("resolution-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE)); -// inject c.var.canAccelerate derived from that c.var.isRealtime -app.use(canAccelerateMiddleware); +const app = createApp( + indexingStatusMiddleware, + makeIsRealtimeMiddleware("resolution-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE), + canAccelerateMiddleware, +); /** * Example queries for /records: diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts index 2b0e29ef1..23a90aa7c 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts @@ -29,13 +29,10 @@ import { const logger = makeLogger("ensanalytics-api-v1"); -const app = createApp("referralLeaderboardEditionsCaches", "referralProgramEditionConfigSet"); - -// Apply referral program edition config set middleware -app.use(referralProgramEditionConfigSetMiddleware); - -// Apply referrer leaderboard cache middleware (depends on edition config set middleware) -app.use(referralLeaderboardEditionsCachesMiddleware); +const app = createApp( + referralProgramEditionConfigSetMiddleware, + referralLeaderboardEditionsCachesMiddleware, +); // Get a page from the referrer leaderboard for a specific edition app.openapi(getReferralLeaderboardRoute, async (c) => { diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts index 506a5e1f4..53ebd8928 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts @@ -17,10 +17,7 @@ import { getReferrerDetailRoute, getReferrerLeaderboardRoute } from "./ensanalyt const logger = makeLogger("ensanalytics-api"); -const app = createApp("referrerLeaderboard"); - -// Apply referrer leaderboard cache middleware to all routes in this handler -app.use(referrerLeaderboardMiddleware); +const app = createApp(referrerLeaderboardMiddleware); // Get a page from the referrer leaderboard app.openapi(getReferrerLeaderboardRoute, async (c) => { diff --git a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts index fcfb8afdc..dee16ac41 100644 --- a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts @@ -10,6 +10,7 @@ import { factory } from "@/lib/hono-factory"; import { makeSubgraphApiDocumentation } from "@/lib/subgraph/api-documentation"; import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; import { fixContentLengthMiddleware } from "@/middleware/fix-content-length.middleware"; +import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; import { subgraphMetaMiddleware } from "@/middleware/subgraph-meta.middleware"; import { thegraphFallbackMiddleware } from "@/middleware/thegraph-fallback.middleware"; @@ -31,6 +32,9 @@ app.use(async (c, next) => { await next(); }); +// inject c.var.indexingStatus +app.use(indexingStatusMiddleware); + // inject c.var.isRealtime derived from MAX_REALTIME_DISTANCE_TO_RESOLVE app.use(makeIsRealtimeMiddleware("subgraph-api", MAX_REALTIME_DISTANCE_TO_RESOLVE)); diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index a5db69aeb..04fc0c52a 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -15,7 +15,6 @@ import { errorResponse } from "@/lib/handlers/error-response"; import { factory } from "@/lib/hono-factory"; import { sdk } from "@/lib/instrumentation"; import logger from "@/lib/logger"; -import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { generateOpenApi31Document } from "@/openapi-document"; import realtimeApi from "./handlers/api/meta/realtime-api"; @@ -38,9 +37,6 @@ app.use(cors({ origin: "*" })); // include automatic OpenTelemetry instrumentation for incoming requests app.use(otel()); -// add ENSIndexer Indexing Status Middleware to all routes for convenience -app.use(indexingStatusMiddleware); - // host welcome page app.get("/", (c) => c.html(html` diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index 5e1ad1fa2..d6c9190ed 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -1,4 +1,5 @@ import { OpenAPIHono } from "@hono/zod-openapi"; +import type { MiddlewareHandler } from "hono"; import { createFactory } from "hono/factory"; import { errorResponse } from "@/lib/handlers/error-response"; @@ -30,26 +31,61 @@ type RequireVars = Omit< export const factory = createFactory(); +/** A middleware that declares the context variable keys it produces via `__produces`. */ +export type ProducingMiddleware = + MiddlewareHandler & { readonly __produces: readonly K[] }; + +type ExtractProduced = T extends ProducingMiddleware ? K : never; + +/** + * Tags a middleware with the context variable keys it produces. + * Pass the result to `createApp` to get compile-time + runtime guarantees on `c.var`. + * + * ```ts + * export const indexingStatusMiddleware = producing( + * ["indexingStatus"], + * factory.createMiddleware(async (c, next) => { ... }) + * ); + * ``` + */ +export function producing( + keys: readonly K[], + middleware: MiddlewareHandler, +): ProducingMiddleware { + return Object.assign(middleware, { __produces: keys }); +} + /** * Creates an OpenAPIHono sub-app that declares which middleware variables its handlers require. * - * Pass the required variable names as arguments. This gives two guarantees: + * Pass middlewares in execution order. Producing middlewares (created with `producing()`) give + * two additional guarantees beyond plain `app.use()`: * * 1. **Compile-time**: `c.var.` is typed as non-optional inside handlers. * 2. **Runtime**: each handler asserts the variables are present before executing, * producing a clear invariant error if the required middleware was never applied. * + * Plain middlewares (without `__produces`) are applied in order but don't affect typing. + * * ```ts - * // c.var.canAccelerate is `boolean`, never `boolean | undefined` - * // every handler throws if canAccelerate was not set by middleware - * const app = createApp("canAccelerate"); + * const app = createApp( + * indexingStatusMiddleware, // producing — c.var.indexingStatus becomes non-optional + * nameTokensApiMiddleware, // plain gate — applied but doesn't affect types + * ); * ``` * * Without arguments, all variables remain optional (same as a plain OpenAPIHono app). */ -export function createApp( - ...requiredVars: TRequired[] -) { +export function createApp< + const TMiddlewares extends readonly (ProducingMiddleware | MiddlewareHandler)[], +>(...middlewares: TMiddlewares) { + type TRequired = ExtractProduced; + // TODO: how to keep only ProducingMiddleware by type properly? + const requiredVars = middlewares + .filter((m) => "__produces" in m) + .map((m) => m as ProducingMiddleware) + .flatMap((m) => [...m.__produces]) as TRequired[]; + const app = new OpenAPIHono<{ Variables: RequireVars }>({ defaultHook: (result, c) => { if (!result.success) { @@ -58,6 +94,12 @@ export function createApp 0) { // Bind openapi as any to avoid fighting overload resolution when wrapping. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -65,7 +107,7 @@ export function createApp _openapi( @@ -74,7 +116,8 @@ export function createApp { - // context must be set by the required middleware - if (c.var.isRealtime === undefined) { - throw new Error(`Invariant(canAccelerateMiddleware): isRealtime middleware required`); - } - - //////////////////////////// - /// Temporary ENSv2 Bailout - //////////////////////////// - // TODO: re-enable acceleration for ensv2 once implemented - if (config.ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { - if (!didWarnCannotAccelerateENSv2) { +export const canAccelerateMiddleware = producing( + ["canAccelerate"], + factory.createMiddleware(async (c, next) => { + // context must be set by the required middleware + if (c.var.isRealtime === undefined) { + throw new Error(`Invariant(canAccelerateMiddleware): isRealtime middleware required`); + } + + //////////////////////////// + /// Temporary ENSv2 Bailout + //////////////////////////// + // TODO: re-enable acceleration for ensv2 once implemented + if (config.ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { + if (!didWarnCannotAccelerateENSv2) { + logger.warn( + `ENSApi is currently unable to accelerate Resolution API requests while indexing ENSv2. Protocol Acceleration is DISABLED.`, + ); + + didWarnCannotAccelerateENSv2 = true; + } + + c.set("canAccelerate", false); + return await next(); + } + + ////////////////////////////////////////////// + /// Protocol Acceleration Plugin Availability + ////////////////////////////////////////////// + + const hasProtocolAccelerationPlugin = config.ensIndexerPublicConfig.plugins.includes( + PluginName.ProtocolAcceleration, + ); + + // log one warning to the console if !hasProtocolAccelerationPlugin + if (!didWarnNoProtocolAccelerationPlugin && !hasProtocolAccelerationPlugin) { logger.warn( - `ENSApi is currently unable to accelerate Resolution API requests while indexing ENSv2. Protocol Acceleration is DISABLED.`, + `ENSApi is connected to an ENSIndexer that does NOT include the ${PluginName.ProtocolAcceleration} plugin: ENSApi will NOT be able to accelerate Resolution API requests, even if ?accelerate=true. Resolution requests will abide by the full Forward/Reverse Resolution specification, including RPC calls and CCIP-Read requests to external CCIP-Read Gateways.`, ); - didWarnCannotAccelerateENSv2 = true; + didWarnNoProtocolAccelerationPlugin = true; } - c.set("canAccelerate", false); - return await next(); - } - - ////////////////////////////////////////////// - /// Protocol Acceleration Plugin Availability - ////////////////////////////////////////////// + ////////////////////////////// + /// Can Accelerate Derivation + ////////////////////////////// + + // the Resolution API can accelerate requests if + // a) ENSIndexer reports that it is within MAX_REALTIME_DISTANCE_TO_ACCELERATE of realtime, and + // b) ENSIndexer reports that it has the ProtocolAcceleration plugin enabled. + const canAccelerate = hasProtocolAccelerationPlugin && c.var.isRealtime; + + // log notice when acceleration begins + if ( + (!didInitialCanAccelerate && canAccelerate) || // first time + (didInitialCanAccelerate && !prevCanAccelerate && canAccelerate) // future change in status + ) { + logger.info(`Protocol Acceleration is now ENABLED.`); + } - const hasProtocolAccelerationPlugin = config.ensIndexerPublicConfig.plugins.includes( - PluginName.ProtocolAcceleration, - ); + // log notice when acceleration ends + if ( + (!didInitialCanAccelerate && !canAccelerate) || // first time + (didInitialCanAccelerate && prevCanAccelerate && !canAccelerate) // future change in status + ) { + logger.info(`Protocol Acceleration is DISABLED.`); + } - // log one warning to the console if !hasProtocolAccelerationPlugin - if (!didWarnNoProtocolAccelerationPlugin && !hasProtocolAccelerationPlugin) { - logger.warn( - `ENSApi is connected to an ENSIndexer that does NOT include the ${PluginName.ProtocolAcceleration} plugin: ENSApi will NOT be able to accelerate Resolution API requests, even if ?accelerate=true. Resolution requests will abide by the full Forward/Reverse Resolution specification, including RPC calls and CCIP-Read requests to external CCIP-Read Gateways.`, - ); + prevCanAccelerate = canAccelerate; + didInitialCanAccelerate = true; - didWarnNoProtocolAccelerationPlugin = true; - } - - ////////////////////////////// - /// Can Accelerate Derivation - ////////////////////////////// - - // the Resolution API can accelerate requests if - // a) ENSIndexer reports that it is within MAX_REALTIME_DISTANCE_TO_ACCELERATE of realtime, and - // b) ENSIndexer reports that it has the ProtocolAcceleration plugin enabled. - const canAccelerate = hasProtocolAccelerationPlugin && c.var.isRealtime; - - // log notice when acceleration begins - if ( - (!didInitialCanAccelerate && canAccelerate) || // first time - (didInitialCanAccelerate && !prevCanAccelerate && canAccelerate) // future change in status - ) { - logger.info(`Protocol Acceleration is now ENABLED.`); - } - - // log notice when acceleration ends - if ( - (!didInitialCanAccelerate && !canAccelerate) || // first time - (didInitialCanAccelerate && prevCanAccelerate && !canAccelerate) // future change in status - ) { - logger.info(`Protocol Acceleration is DISABLED.`); - } - - prevCanAccelerate = canAccelerate; - didInitialCanAccelerate = true; - - c.set("canAccelerate", canAccelerate); - await next(); -}); + c.set("canAccelerate", canAccelerate); + await next(); + }), +); diff --git a/apps/ensapi/src/middleware/indexing-status.middleware.ts b/apps/ensapi/src/middleware/indexing-status.middleware.ts index 372c7d631..90d986f1d 100644 --- a/apps/ensapi/src/middleware/indexing-status.middleware.ts +++ b/apps/ensapi/src/middleware/indexing-status.middleware.ts @@ -7,7 +7,7 @@ import { } from "@ensnode/ensnode-sdk"; import { indexingStatusCache } from "@/cache/indexing-status.cache"; -import { factory } from "@/lib/hono-factory"; +import { factory, producing } from "@/lib/hono-factory"; /** * Type definition for the indexing status middleware context passed to downstream middleware and handlers. @@ -39,18 +39,21 @@ export type IndexingStatusMiddlewareVariables = { * continue generating new {@link RealtimeIndexingStatusProjection} containing updated worst-case distances * to downstream middleware and handlers. */ -export const indexingStatusMiddleware = factory.createMiddleware(async (c, next) => { - const indexingStatus = await indexingStatusCache.read(); +export const indexingStatusMiddleware = producing( + ["indexingStatus"], + factory.createMiddleware(async (c, next) => { + const indexingStatus = await indexingStatusCache.read(); - if (indexingStatus instanceof Error) { - // if indexingStatus was never fetched (and cached), propagate error to consumers - c.set("indexingStatus", indexingStatus); - } else { - // otherwise, build realtime indexing status projection - const now = getUnixTime(new Date()); - const realtimeProjection = createRealtimeIndexingStatusProjection(indexingStatus, now); - c.set("indexingStatus", realtimeProjection); - } + if (indexingStatus instanceof Error) { + // if indexingStatus was never fetched (and cached), propagate error to consumers + c.set("indexingStatus", indexingStatus); + } else { + // otherwise, build realtime indexing status projection + const now = getUnixTime(new Date()); + const realtimeProjection = createRealtimeIndexingStatusProjection(indexingStatus, now); + c.set("indexingStatus", realtimeProjection); + } - await next(); -}); + await next(); + }), +); diff --git a/apps/ensapi/src/middleware/is-realtime.middleware.ts b/apps/ensapi/src/middleware/is-realtime.middleware.ts index 89c753132..7490799db 100644 --- a/apps/ensapi/src/middleware/is-realtime.middleware.ts +++ b/apps/ensapi/src/middleware/is-realtime.middleware.ts @@ -1,6 +1,6 @@ import type { Duration } from "@ensnode/ensnode-sdk"; -import { factory } from "@/lib/hono-factory"; +import { factory, producing } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; /** @@ -14,44 +14,47 @@ export const makeIsRealtimeMiddleware = (scope: string, maxWorstCaseDistance: Du let hasLoggedIndexingStatusError = false; let lastLoggedIsRealtime: boolean | null = null; - return factory.createMiddleware(async function isRealtimeMiddleware(c, next) { - // context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(isRealtimeMiddleware): indexingStatusMiddleware required`); - } + return producing( + ["isRealtime"], + factory.createMiddleware(async function isRealtimeMiddleware(c, next) { + // context must be set by the required middleware + if (c.var.indexingStatus === undefined) { + throw new Error(`Invariant(isRealtimeMiddleware): indexingStatusMiddleware required`); + } - if (c.var.indexingStatus instanceof Error) { - // no indexing status available in context - if (!hasLoggedIndexingStatusError) { - logger.warn( - `ENSIndexer is NOT guaranteed to be within ${maxWorstCaseDistance} seconds of realtime. Current indexing status has not been successfully fetched by this ENSApi instance yet and is therefore unknown to this ENSApi instance because: ${c.var.indexingStatus.message}.`, - ); + if (c.var.indexingStatus instanceof Error) { + // no indexing status available in context + if (!hasLoggedIndexingStatusError) { + logger.warn( + `ENSIndexer is NOT guaranteed to be within ${maxWorstCaseDistance} seconds of realtime. Current indexing status has not been successfully fetched by this ENSApi instance yet and is therefore unknown to this ENSApi instance because: ${c.var.indexingStatus.message}.`, + ); - hasLoggedIndexingStatusError = true; - } + hasLoggedIndexingStatusError = true; + } - c.set("isRealtime", false); - return await next(); - } - - // determine if we're within the max worst-case distance to qualify as "realtime". - const isRealtime = c.var.indexingStatus.worstCaseDistance <= maxWorstCaseDistance; - - if (lastLoggedIsRealtime !== isRealtime) { - if (isRealtime) { - logger.info( - `ENSIndexer is guaranteed to be within ${maxWorstCaseDistance} seconds of realtime`, - ); - } else { - logger.warn( - `ENSIndexer is NOT guaranteed to be within ${maxWorstCaseDistance} seconds of realtime. (Worst Case distance: ${c.var.indexingStatus.worstCaseDistance} seconds > ${maxWorstCaseDistance} seconds).`, - ); + c.set("isRealtime", false); + return await next(); } - lastLoggedIsRealtime = isRealtime; - } + // determine if we're within the max worst-case distance to qualify as "realtime". + const isRealtime = c.var.indexingStatus.worstCaseDistance <= maxWorstCaseDistance; + + if (lastLoggedIsRealtime !== isRealtime) { + if (isRealtime) { + logger.info( + `ENSIndexer is guaranteed to be within ${maxWorstCaseDistance} seconds of realtime`, + ); + } else { + logger.warn( + `ENSIndexer is NOT guaranteed to be within ${maxWorstCaseDistance} seconds of realtime. (Worst Case distance: ${c.var.indexingStatus.worstCaseDistance} seconds > ${maxWorstCaseDistance} seconds).`, + ); + } + + lastLoggedIsRealtime = isRealtime; + } - c.set("isRealtime", isRealtime); - return await next(); - }); + c.set("isRealtime", isRealtime); + return await next(); + }), + ); }; diff --git a/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts b/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts index 1ac93a1bb..791995ddb 100644 --- a/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts +++ b/apps/ensapi/src/middleware/referral-leaderboard-editions-caches.middleware.ts @@ -2,7 +2,7 @@ import { initializeReferralLeaderboardEditionsCaches, type ReferralLeaderboardEditionsCacheMap, } from "@/cache/referral-leaderboard-editions.cache"; -import { factory } from "@/lib/hono-factory"; +import { factory, producing } from "@/lib/hono-factory"; import type { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; /** @@ -40,8 +40,9 @@ export type ReferralLeaderboardEditionsCachesMiddlewareVariables = { * Each cache's builder function handles immutability internally - when an edition becomes immutably * closed (past the safety window), the builder returns previously cached data without re-fetching. */ -export const referralLeaderboardEditionsCachesMiddleware = factory.createMiddleware( - async (c, next) => { +export const referralLeaderboardEditionsCachesMiddleware = producing( + ["referralLeaderboardEditionsCaches"], + factory.createMiddleware(async (c, next) => { const editionConfigSet = c.get("referralProgramEditionConfigSet"); // Invariant: referralProgramEditionConfigSetMiddleware must be applied before this middleware @@ -62,5 +63,5 @@ export const referralLeaderboardEditionsCachesMiddleware = factory.createMiddlew const caches = initializeReferralLeaderboardEditionsCaches(editionConfigSet); c.set("referralLeaderboardEditionsCaches", caches); await next(); - }, + }), ); diff --git a/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts b/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts index 58410d476..b5d38856b 100644 --- a/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts +++ b/apps/ensapi/src/middleware/referral-program-edition-set.middleware.ts @@ -1,7 +1,7 @@ import type { ReferralProgramEditionConfigSet } from "@namehash/ens-referrals/v1"; import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache"; -import { factory } from "@/lib/hono-factory"; +import { factory, producing } from "@/lib/hono-factory"; /** * Type definition for the referral program edition config set middleware context. @@ -26,10 +26,11 @@ export type ReferralProgramEditionConfigSetMiddlewareVariables = { * * If the cache fails to load, the JSON fetching will be retried on subsequent requests. */ -export const referralProgramEditionConfigSetMiddleware = factory.createMiddleware( - async (c, next) => { +export const referralProgramEditionConfigSetMiddleware = producing( + ["referralProgramEditionConfigSet"], + factory.createMiddleware(async (c, next) => { const editionConfigSet = await referralProgramEditionConfigSetCache.read(); c.set("referralProgramEditionConfigSet", editionConfigSet); await next(); - }, + }), ); diff --git a/apps/ensapi/src/middleware/referrer-leaderboard.middleware.ts b/apps/ensapi/src/middleware/referrer-leaderboard.middleware.ts index f3c402348..52830e176 100644 --- a/apps/ensapi/src/middleware/referrer-leaderboard.middleware.ts +++ b/apps/ensapi/src/middleware/referrer-leaderboard.middleware.ts @@ -1,7 +1,7 @@ import type { ReferrerLeaderboard } from "@namehash/ens-referrals"; import { referrerLeaderboardCache } from "@/cache/referrer-leaderboard.cache"; -import { factory } from "@/lib/hono-factory"; +import { factory, producing } from "@/lib/hono-factory"; /** * Type definition for the referrer leaderboard middleware context passed to downstream middleware and handlers. @@ -25,9 +25,12 @@ export type ReferrerLeaderboardMiddlewareVariables = { * Middleware that provides {@link ReferrerLeaderboardMiddlewareVariables} * to downstream middleware and handlers. */ -export const referrerLeaderboardMiddleware = factory.createMiddleware(async (c, next) => { - const leaderboard = await referrerLeaderboardCache.read(); +export const referrerLeaderboardMiddleware = producing( + ["referrerLeaderboard"], + factory.createMiddleware(async (c, next) => { + const leaderboard = await referrerLeaderboardCache.read(); - c.set("referrerLeaderboard", leaderboard); - await next(); -}); + c.set("referrerLeaderboard", leaderboard); + await next(); + }), +); From 00a235ef06da3ac069f9285b70ec9a72bed3852f Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sat, 21 Mar 2026 23:13:45 +0300 Subject: [PATCH 08/17] make middleware to be keyvalue arg --- .../handlers/api/explore/name-tokens-api.ts | 2 +- .../api/explore/registrar-actions-api.ts | 2 +- .../src/handlers/api/meta/realtime-api.ts | 2 +- .../src/handlers/api/meta/status-api.ts | 2 +- .../handlers/api/resolution/resolution-api.ts | 12 ++++++---- .../ensanalytics/ensanalytics-api-v1.ts | 10 ++++---- .../handlers/ensanalytics/ensanalytics-api.ts | 2 +- apps/ensapi/src/lib/hono-factory.ts | 23 ++++++++++--------- 8 files changed, 30 insertions(+), 25 deletions(-) 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 dc529effb..00910fc66 100644 --- a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts @@ -22,7 +22,7 @@ import { nameTokensApiMiddleware } from "@/middleware/name-tokens.middleware"; import { getNameTokensRoute } from "./name-tokens-api.routes"; -const app = createApp(indexingStatusMiddleware, nameTokensApiMiddleware); +const app = createApp({ middlewares: [indexingStatusMiddleware, nameTokensApiMiddleware] }); const indexedSubregistries = getIndexedSubregistries( config.namespace, diff --git a/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts index 7cfee17d2..6f5c07131 100644 --- a/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/api/explore/registrar-actions-api.ts @@ -21,7 +21,7 @@ import { type RegistrarActionsQuery, } from "./registrar-actions-api.routes"; -const app = createApp(indexingStatusMiddleware, registrarActionsApiMiddleware); +const app = createApp({ middlewares: [indexingStatusMiddleware, registrarActionsApiMiddleware] }); const logger = makeLogger("registrar-actions-api"); diff --git a/apps/ensapi/src/handlers/api/meta/realtime-api.ts b/apps/ensapi/src/handlers/api/meta/realtime-api.ts index 38dabd343..9b57f0bb7 100644 --- a/apps/ensapi/src/handlers/api/meta/realtime-api.ts +++ b/apps/ensapi/src/handlers/api/meta/realtime-api.ts @@ -4,7 +4,7 @@ import { indexingStatusMiddleware } from "@/middleware/indexing-status.middlewar import { realtimeGetMeta } from "./realtime-api.routes"; -const app = createApp(indexingStatusMiddleware); +const app = createApp({ middlewares: [indexingStatusMiddleware] }); // allow performance monitoring clients to read HTTP Status for the provided // `maxWorstCaseDistance` param diff --git a/apps/ensapi/src/handlers/api/meta/status-api.ts b/apps/ensapi/src/handlers/api/meta/status-api.ts index 1e6df57ad..86a7c0669 100644 --- a/apps/ensapi/src/handlers/api/meta/status-api.ts +++ b/apps/ensapi/src/handlers/api/meta/status-api.ts @@ -14,7 +14,7 @@ import { indexingStatusMiddleware } from "@/middleware/indexing-status.middlewar import { getConfigRoute, getIndexingStatusRoute } from "./status-api.routes"; -const app = createApp(indexingStatusMiddleware); +const app = createApp({ middlewares: [indexingStatusMiddleware] }); app.openapi(getConfigRoute, async (c) => { const ensApiPublicConfig = buildEnsApiPublicConfig(config); diff --git a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts index 6448482cb..9aabc9bd8 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolution-api.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolution-api.ts @@ -26,11 +26,13 @@ import { */ const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 60; // 1 minute in seconds -const app = createApp( - indexingStatusMiddleware, - makeIsRealtimeMiddleware("resolution-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE), - canAccelerateMiddleware, -); +const app = createApp({ + middlewares: [ + indexingStatusMiddleware, + makeIsRealtimeMiddleware("resolution-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE), + canAccelerateMiddleware, + ], +}); /** * Example queries for /records: diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts index 23a90aa7c..2bf4e9a30 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api-v1.ts @@ -29,10 +29,12 @@ import { const logger = makeLogger("ensanalytics-api-v1"); -const app = createApp( - referralProgramEditionConfigSetMiddleware, - referralLeaderboardEditionsCachesMiddleware, -); +const app = createApp({ + middlewares: [ + referralProgramEditionConfigSetMiddleware, + referralLeaderboardEditionsCachesMiddleware, + ], +}); // Get a page from the referrer leaderboard for a specific edition app.openapi(getReferralLeaderboardRoute, async (c) => { diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts index 53ebd8928..875f49bfa 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.ts @@ -17,7 +17,7 @@ import { getReferrerDetailRoute, getReferrerLeaderboardRoute } from "./ensanalyt const logger = makeLogger("ensanalytics-api"); -const app = createApp(referrerLeaderboardMiddleware); +const app = createApp({ middlewares: [referrerLeaderboardMiddleware] }); // Get a page from the referrer leaderboard app.openapi(getReferrerLeaderboardRoute, async (c) => { diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index d6c9190ed..cfae1519a 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -68,22 +68,23 @@ export function producing( * Plain middlewares (without `__produces`) are applied in order but don't affect typing. * * ```ts - * const app = createApp( - * indexingStatusMiddleware, // producing — c.var.indexingStatus becomes non-optional - * nameTokensApiMiddleware, // plain gate — applied but doesn't affect types - * ); + * const app = createApp({ + * middlewares: [ + * indexingStatusMiddleware, // producing — c.var.indexingStatus becomes non-optional + * nameTokensApiMiddleware, // plain gate — applied but doesn't affect types + * ], + * }); * ``` * * Without arguments, all variables remain optional (same as a plain OpenAPIHono app). */ export function createApp< - const TMiddlewares extends readonly (ProducingMiddleware | MiddlewareHandler)[], ->(...middlewares: TMiddlewares) { + const TMiddlewares extends readonly (ProducingMiddleware | MiddlewareHandler)[] = [], +>({ middlewares }: { middlewares?: TMiddlewares } = {}) { type TRequired = ExtractProduced; - // TODO: how to keep only ProducingMiddleware by type properly? - const requiredVars = middlewares - .filter((m) => "__produces" in m) - .map((m) => m as ProducingMiddleware) + const mws: readonly (ProducingMiddleware | MiddlewareHandler)[] = middlewares ?? []; + const requiredVars = mws + .filter((m): m is ProducingMiddleware => "__produces" in m) .flatMap((m) => [...m.__produces]) as TRequired[]; const app = new OpenAPIHono<{ Variables: RequireVars }>({ @@ -95,7 +96,7 @@ export function createApp< }); // Apply the middlewares in order so callers don't need separate app.use() calls. - for (const middleware of middlewares) { + for (const middleware of mws) { // eslint-disable-next-line @typescript-eslint/no-explicit-any app.use(middleware as any); } From 56ac789385ecc8ffb37304e657889ba2cc483c51 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Fri, 20 Mar 2026 19:22:29 +0300 Subject: [PATCH 09/17] generate openapi spec from existing app --- apps/ensapi/src/app.ts | 85 +++ .../ensapi/src/cache/indexing-status.cache.ts | 90 ++-- .../referral-program-edition-set.cache.ts | 34 +- .../src/cache/referrer-leaderboard.cache.ts | 104 ++-- apps/ensapi/src/config/index.ts | 19 +- .../find-domains/canonical-registries-cte.ts | 7 +- .../src/graphql-api/lib/get-canonical-path.ts | 9 +- .../lib/get-domain-by-interpreted-name.ts | 5 +- .../handlers/api/explore/name-tokens-api.ts | 8 +- .../src/handlers/subgraph/subgraph-api.ts | 4 +- apps/ensapi/src/index.ts | 80 +-- apps/ensapi/src/lib/db.ts | 27 +- apps/ensapi/src/lib/lazy.ts | 7 + .../protocol-acceleration/find-resolver.ts | 11 +- .../multichain-primary-name-resolution.ts | 7 +- .../resolve-with-universal-resolver.ts | 15 +- apps/ensapi/src/openapi-document.ts | 46 +- docs/docs.ensnode.io/ensapi-openapi.json | 485 ++++++++++-------- docs/docs.ensnode.io/package.json | 2 +- ...openapi.ts => generate-ensapi-openapi.mts} | 8 +- 20 files changed, 582 insertions(+), 471 deletions(-) create mode 100644 apps/ensapi/src/app.ts create mode 100644 apps/ensapi/src/lib/lazy.ts rename scripts/{generate-ensapi-openapi.ts => generate-ensapi-openapi.mts} (84%) diff --git a/apps/ensapi/src/app.ts b/apps/ensapi/src/app.ts new file mode 100644 index 000000000..cdecd3582 --- /dev/null +++ b/apps/ensapi/src/app.ts @@ -0,0 +1,85 @@ +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 { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; +import { openapiMeta } from "@/openapi-meta"; + +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 { generateOpenApi31Document } from "@/openapi-document"; + +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"); +}); + +export default app; diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts index 4a4364e11..6eeab399a 100644 --- a/apps/ensapi/src/cache/indexing-status.cache.ts +++ b/apps/ensapi/src/cache/indexing-status.cache.ts @@ -7,42 +7,62 @@ import { SWRCache, } from "@ensnode/ensnode-sdk"; +import { lazy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; const logger = makeLogger("indexing-status.cache"); -const client = new ENSNodeClient({ url: config.ensIndexerUrl }); - -export const indexingStatusCache = new SWRCache({ - fn: async (_cachedResult) => - client - .indexingStatus() // fetch a new indexing status snapshot - .then((response) => { - if (response.responseCode !== IndexingStatusResponseCodes.Ok) { - // An indexing status response was successfully fetched, but the response code contained within the response was not 'ok'. - // Therefore, throw an error to trigger the subsequent `.catch` handler. - throw new Error("Received Indexing Status response with responseCode other than 'ok'."); - } - - logger.info("Fetched Indexing Status to be cached"); - - // 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 response.realtimeProjection.snapshot; - }) - .catch((error) => { - // Either the indexing status snapshot fetch failed, or the indexing status response was not 'ok'. - // 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 fetching a new indexing status snapshot. The cached indexing status snapshot (if any) will not be updated.", - ); - throw error; - }), - ttl: 5, // 5 seconds - proactiveRevalidationInterval: 10, // 10 seconds - proactivelyInitialize: true, + +const getClient = lazy(() => new ENSNodeClient({ url: config.ensIndexerUrl })); + +type IndexingStatusCache = SWRCache; + +const _getCache = lazy( + () => + new SWRCache({ + fn: async (_cachedResult) => + getClient() + .indexingStatus() // fetch a new indexing status snapshot + .then((response) => { + if (response.responseCode !== IndexingStatusResponseCodes.Ok) { + // An indexing status response was successfully fetched, but the response code contained within the response was not 'ok'. + // Therefore, throw an error to trigger the subsequent `.catch` handler. + throw new Error( + "Received Indexing Status response with responseCode other than 'ok'.", + ); + } + + logger.info("Fetched Indexing Status to be cached"); + + // 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 response.realtimeProjection.snapshot; + }) + .catch((error) => { + // Either the indexing status snapshot fetch failed, or the indexing status response was not 'ok'. + // 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 fetching a new indexing status snapshot. The cached indexing status snapshot (if any) will not be updated.", + ); + throw error; + }), + ttl: 5, // 5 seconds + proactiveRevalidationInterval: 10, // 10 seconds + proactivelyInitialize: true, + }), +); + +export const indexingStatusCache = new Proxy({} as IndexingStatusCache, { + get(_, prop) { + const cache = _getCache(); + const value = Reflect.get(cache, prop as string, cache); + if (typeof value === "function") { + return (value as (...args: unknown[]) => unknown).bind(cache); + } + return value; + }, }); 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..11dc82b39 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 { lazy } 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,27 @@ 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, -}); +const _getCache = lazy( + () => + new SWRCache({ + fn: loadReferralProgramEditionConfigSet, + ttl: Number.POSITIVE_INFINITY, + errorTtl: minutesToSeconds(1), + proactiveRevalidationInterval: undefined, + proactivelyInitialize: true, + }), +); + +export const referralProgramEditionConfigSetCache = new Proxy( + {} as ReferralProgramEditionConfigSetCache, + { + get(_, prop) { + const cache = _getCache(); + const value = Reflect.get(cache, prop as string, cache); + if (typeof value === "function") { + return (value as (...args: unknown[]) => unknown).bind(cache); + } + return value; + }, + }, +); diff --git a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts index 98594a32b..160a83ef0 100644 --- a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts +++ b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts @@ -19,18 +19,21 @@ import { } from "@ensnode/ensnode-sdk"; import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard"; +import { lazy } 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), +const getRules = lazy(() => + 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 +48,63 @@ 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; - 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 _getCache = lazy( + () => + 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 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 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 rules = getRules(); + 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; +export const referrerLeaderboardCache = new Proxy({} as ReferrerLeaderboardCache, { + get(_, prop) { + const cache = _getCache(); + const value = Reflect.get(cache, prop as string, cache); + if (typeof value === "function") { + return (value as (...args: unknown[]) => unknown).bind(cache); } + return value; }, - ttl: minutesToSeconds(1), - proactiveRevalidationInterval: minutesToSeconds(2), - proactivelyInitialize: true, }); diff --git a/apps/ensapi/src/config/index.ts b/apps/ensapi/src/config/index.ts index e658e9601..efea7baff 100644 --- a/apps/ensapi/src/config/index.ts +++ b/apps/ensapi/src/config/index.ts @@ -1,3 +1,20 @@ +import type { EnsApiConfig } from "@/config/config.schema"; import { buildConfigFromEnvironment } from "@/config/config.schema"; -export default await buildConfigFromEnvironment(process.env); +let _config: EnsApiConfig | null = null; + +export async function initConfig(env: NodeJS.ProcessEnv): Promise { + _config = await buildConfigFromEnvironment(env); +} + +export default new Proxy({} as EnsApiConfig, { + get(_, prop: string | symbol) { + if (_config === null) { + 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.`, + ); + } + return _config[prop as keyof EnsApiConfig]; + }, +}); 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 d6088a8a4..ef6ac02a8 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 @@ -6,6 +6,7 @@ import * as schema from "@ensnode/ensdb-sdk"; import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; import { db } from "@/lib/db"; +import { lazy } from "@/lib/lazy"; /** * The maximum depth to traverse the ENSv2 namegraph in order to construct the set of Canonical @@ -22,7 +23,7 @@ import { db } from "@/lib/db"; */ const CANONICAL_REGISTRIES_MAX_DEPTH = 16; -const ENSV2_ROOT_REGISTRY_ID = maybeGetENSv2RootRegistryId(config.namespace); +const getENSV2RootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); /** * Builds a recursive CTE that traverses from the ENSv2 Root Registry to construct a set of all @@ -33,7 +34,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 db .select({ id: sql`registry_id`.as("id") }) .from(sql`(SELECT NULL::text AS registry_id WHERE FALSE) AS canonical_registries_cte`) @@ -51,7 +52,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 ${schema.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 c42c170fc..66e51ced3 100644 --- a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts @@ -14,9 +14,10 @@ import { } from "@ensnode/ensnode-sdk"; import { db } from "@/lib/db"; +import { lazy } from "@/lib/lazy"; const MAX_DEPTH = 16; -const ENSv2_ROOT_REGISTRY_ID = maybeGetENSv2RootRegistryId(config.namespace); +const getENSv2RootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); /** * Provide the canonical parents for an ENSv1 Domain. @@ -75,7 +76,7 @@ export async function getV1CanonicalPath(domainId: ENSv1DomainId): Promise { // if the ENSv2 Root Registry is not defined, null - if (!ENSv2_ROOT_REGISTRY_ID) return null; + if (!getENSv2RootRegistryId()) return null; const result = await db.execute(sql` WITH RECURSIVE upward AS ( @@ -101,7 +102,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"); @@ -65,7 +66,7 @@ export async function getDomainIdByInterpretedName( 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, + getRootRegistryId() ? v2_getDomainIdByInterpretedName(getRootRegistryId()!, 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..72c3d2802 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 { lazy } 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,8 @@ import { getNameTokensRoute } from "./name-tokens-api.routes"; const app = createApp({ middlewares: [indexingStatusMiddleware, nameTokensApiMiddleware] }); -const indexedSubregistries = getIndexedSubregistries( - config.namespace, - config.ensIndexerPublicConfig.plugins as PluginName[], +const getIndexedSubregistriesOnce = lazy(() => + getIndexedSubregistries(config.namespace, config.ensIndexerPublicConfig.plugins as PluginName[]), ); /** @@ -79,7 +79,7 @@ app.openapi(getNameTokensRoute, async (c) => { } const parentNode = namehash(getParentNameFQDN(name)); - const subregistry = indexedSubregistries.find((subregistry) => subregistry.node === parentNode); + const subregistry = getIndexedSubregistriesOnce().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 dee16ac41..a608b2138 100644 --- a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts @@ -7,6 +7,7 @@ import { type Duration, hasSubgraphApiConfigSupport } from "@ensnode/ensnode-sdk import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; import { factory } 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"; @@ -51,7 +52,7 @@ app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/ app.use(subgraphMetaMiddleware); // use subgraph middleware -app.use( +const getSubgraphMiddleware = lazy(() => subgraphGraphQLMiddleware({ databaseUrl: config.databaseUrl, databaseSchema: config.databaseSchemaName, @@ -96,5 +97,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 04fc0c52a..35d63e94a 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, { initConfig } 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 { factory } 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 = factory.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 initConfig(process.env); // start ENSNode API OpenTelemetry SDK sdk.start(); diff --git a/apps/ensapi/src/lib/db.ts b/apps/ensapi/src/lib/db.ts index a5c04ee38..5692d2907 100644 --- a/apps/ensapi/src/lib/db.ts +++ b/apps/ensapi/src/lib/db.ts @@ -3,9 +3,28 @@ import config from "@/config"; import * as schema from "@ensnode/ensdb-sdk"; import { makeDrizzle } from "@/lib/handlers/drizzle"; +import { lazy } from "@/lib/lazy"; -export const db = makeDrizzle({ - databaseUrl: config.databaseUrl, - databaseSchema: config.databaseSchemaName, - schema, +type Db = ReturnType>; + +const _getDb = lazy(() => + makeDrizzle({ + databaseUrl: config.databaseUrl, + databaseSchema: config.databaseSchemaName, + schema, + }), +); + +export const db = new Proxy({} as Db, { + get(_, prop) { + const realDb = _getDb(); + const value = Reflect.get(realDb, prop as string, realDb); + if (typeof value === "function") { + return (value as (...args: unknown[]) => unknown).bind(realDb); + } + return value; + }, + has(_, prop) { + return Reflect.has(_getDb(), prop); + }, }); diff --git a/apps/ensapi/src/lib/lazy.ts b/apps/ensapi/src/lib/lazy.ts new file mode 100644 index 000000000..059c41817 --- /dev/null +++ b/apps/ensapi/src/lib/lazy.ts @@ -0,0 +1,7 @@ +/** + * Creates a lazy singleton — the factory is called at most once, on first invocation. + */ +export function lazy(factory: () => T): () => T { + let cached: T | undefined; + return () => (cached ??= factory()); +} diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index b07233950..0a41e67cc 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 { db } from "@/lib/db"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; +import { lazy } from "@/lib/lazy"; type FindResolverResult = | { @@ -44,10 +45,8 @@ const NULL_RESULT: FindResolverResult = { const tracer = trace.getTracer("find-resolver"); -const ENSv1RegistryOld = getDatasourceContract( - config.namespace, - DatasourceNames.ENSRoot, - "ENSv1RegistryOld", +const getENSv1RegistryOld = lazy(() => + getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "ENSv1RegistryOld"), ); /** @@ -221,8 +220,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, getENSv1RegistryOld().chainId), + eq(t.address, getENSv1RegistryOld().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..d5c3c21c0 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,18 @@ import { type ResolverRecordsSelection, } from "@ensnode/ensnode-sdk"; +import { lazy } from "@/lib/lazy"; import type { ResolveCalls, ResolveCallsAndRawResults, } from "@/lib/resolution/resolve-calls-and-results"; -const UniversalResolver = getDatasourceContract( - config.namespace, - DatasourceNames.ENSRoot, - "UniversalResolver", +const getUniversalResolver = lazy(() => + 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 +58,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 ?? getUniversalResolver().address, functionName: "resolve", args: [encodedName, encodedMethod], }); diff --git a/apps/ensapi/src/openapi-document.ts b/apps/ensapi/src/openapi-document.ts index ae6805800..19c44b05d 100644 --- a/apps/ensapi/src/openapi-document.ts +++ b/apps/ensapi/src/openapi-document.ts @@ -1,50 +1,12 @@ -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. - */ -function createStubRoutesForSpec() { - const app = new OpenAPIHono(); - - 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({}), - ); - } - } - - return app; -} +import app from "./app"; /** - * Generates an OpenAPI 3.1 document from stub route definitions. + * Generates an OpenAPI 3.1 document from the real registered routes. */ export function generateOpenApi31Document(): ReturnType { - return createStubRoutesForSpec().getOpenAPI31Document(openapiMeta); + return app.getOpenAPI31Document(openapiMeta); } diff --git a/docs/docs.ensnode.io/ensapi-openapi.json b/docs/docs.ensnode.io/ensapi-openapi.json index d69de2e6f..fcdcbacab 100644 --- a/docs/docs.ensnode.io/ensapi-openapi.json +++ b/docs/docs.ensnode.io/ensapi-openapi.json @@ -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,149 +1934,179 @@ } } }, - "/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 }, + "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 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" } + } + } + }, + "/amirealtime": { + "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, - "name": "accelerate", + "description": "Maximum acceptable worst-case indexing distance in seconds", + "name": "maxWorstCaseDistance", "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"] - } - } - } + "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" } } } diff --git a/docs/docs.ensnode.io/package.json b/docs/docs.ensnode.io/package.json index 9eb08bf96..47b186b6f 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 84% rename from scripts/generate-ensapi-openapi.ts rename to scripts/generate-ensapi-openapi.mts index 597653e17..da4e3ee20 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. No config initialization is required. */ import { execFileSync } from "node:child_process"; @@ -51,3 +51,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); From 5e036d4642e50a1cbae5870c7c750eed0888bc74 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sun, 22 Mar 2026 13:02:42 +0300 Subject: [PATCH 10/17] fix error during merge conflict --- apps/ensapi/src/app.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/ensapi/src/app.ts b/apps/ensapi/src/app.ts index cdecd3582..2ef92cd5b 100644 --- a/apps/ensapi/src/app.ts +++ b/apps/ensapi/src/app.ts @@ -15,8 +15,6 @@ 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 { generateOpenApi31Document } from "@/openapi-document"; - const app = createApp(); // set the X-ENSNode-Version header to the current version @@ -66,9 +64,8 @@ app.route("/v1/ensanalytics", ensanalyticsApiV1); app.route("/amirealtime", realtimeApi); // serve pre-generated OpenAPI 3.1 document -const openApi31Document = generateOpenApi31Document(); app.get("/openapi.json", (c) => { - return c.json(openApi31Document); + return c.json(app.getOpenAPI31Document(openapiMeta)); }); // will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware From c046ee17883305ac3ee8bf97d91e489c2f3c6dbf Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sun, 22 Mar 2026 13:53:46 +0300 Subject: [PATCH 11/17] fix lint issues --- apps/ensapi/src/app.ts | 2 +- .../src/graphql-api/lib/get-domain-by-interpreted-name.ts | 3 ++- apps/ensapi/src/lib/lazy.ts | 7 ++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/ensapi/src/app.ts b/apps/ensapi/src/app.ts index 2ef92cd5b..1159c3997 100644 --- a/apps/ensapi/src/app.ts +++ b/apps/ensapi/src/app.ts @@ -7,7 +7,6 @@ import { html } from "hono/html"; import { errorResponse } from "@/lib/handlers/error-response"; import { createApp } from "@/lib/hono-factory"; import logger from "@/lib/logger"; -import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { openapiMeta } from "@/openapi-meta"; import realtimeApi from "./handlers/api/meta/realtime-api"; @@ -15,6 +14,7 @@ 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 header to the current version diff --git a/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts index e7d4716f3..2cf9ed7ec 100644 --- a/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts +++ b/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts @@ -63,10 +63,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 - getRootRegistryId() ? v2_getDomainIdByInterpretedName(getRootRegistryId()!, name) : null, + rootRegistryId ? v2_getDomainIdByInterpretedName(rootRegistryId, name) : null, ]); logger.debug({ v1DomainId, v2DomainId }); diff --git a/apps/ensapi/src/lib/lazy.ts b/apps/ensapi/src/lib/lazy.ts index 059c41817..3016dc787 100644 --- a/apps/ensapi/src/lib/lazy.ts +++ b/apps/ensapi/src/lib/lazy.ts @@ -3,5 +3,10 @@ */ export function lazy(factory: () => T): () => T { let cached: T | undefined; - return () => (cached ??= factory()); + return () => { + if (cached === undefined) { + cached = factory(); + } + return cached; + }; } From 75dc815777af3ca6966571ed1db3ea5e9b794e1b Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sun, 22 Mar 2026 22:01:59 +0300 Subject: [PATCH 12/17] fix lazy --- apps/ensapi/src/lib/lazy.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/ensapi/src/lib/lazy.ts b/apps/ensapi/src/lib/lazy.ts index 3016dc787..98fe8fa13 100644 --- a/apps/ensapi/src/lib/lazy.ts +++ b/apps/ensapi/src/lib/lazy.ts @@ -1,12 +1,13 @@ +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`. */ export function lazy(factory: () => T): () => T { - let cached: T | undefined; + let cached: T | typeof UNSET = UNSET; return () => { - if (cached === undefined) { - cached = factory(); - } - return cached; + if (cached === UNSET) cached = factory(); + return cached as T; }; } From e1111c6e1725a370d46eef8e61c8d1ac4a055350 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 24 Mar 2026 19:18:07 +0300 Subject: [PATCH 13/17] fix PR comments --- apps/ensapi/src/app.ts | 9 ++--- .../ensapi/src/cache/indexing-status.cache.ts | 11 ++++++ .../src/cache/referrer-leaderboard.cache.ts | 2 + apps/ensapi/src/config/index.ts | 39 ++++++++++++++++--- .../find-domains/canonical-registries-cte.ts | 2 + .../src/graphql-api/lib/get-canonical-path.ts | 2 + .../lib/get-domain-by-interpreted-name.ts | 2 + .../handlers/api/explore/name-tokens-api.ts | 2 + .../src/handlers/subgraph/subgraph-api.ts | 3 +- apps/ensapi/src/index.ts | 12 ++++-- .../protocol-acceleration/find-resolver.ts | 2 + .../resolve-with-universal-resolver.ts | 2 + apps/ensapi/src/openapi-document.ts | 27 +++++++++++-- docs/docs.ensnode.io/ensapi-openapi.json | 31 +-------------- scripts/generate-ensapi-openapi.mts | 5 ++- 15 files changed, 100 insertions(+), 51 deletions(-) diff --git a/apps/ensapi/src/app.ts b/apps/ensapi/src/app.ts index 1159c3997..1ac987157 100644 --- a/apps/ensapi/src/app.ts +++ b/apps/ensapi/src/app.ts @@ -7,7 +7,7 @@ import { html } from "hono/html"; import { errorResponse } from "@/lib/handlers/error-response"; import { createApp } from "@/lib/hono-factory"; import logger from "@/lib/logger"; -import { openapiMeta } from "@/openapi-meta"; +import { generateOpenApi31Document } from "@/openapi-document"; import realtimeApi from "./handlers/api/meta/realtime-api"; import apiRouter from "./handlers/api/router"; @@ -17,7 +17,7 @@ import subgraphApi from "./handlers/subgraph/subgraph-api"; const app = createApp(); -// set the X-ENSNode-Version header to the current version +// 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(); @@ -63,12 +63,11 @@ app.route("/v1/ensanalytics", ensanalyticsApiV1); // 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 +// generate and return OpenAPI 3.1 document app.get("/openapi.json", (c) => { - return c.json(app.getOpenAPI31Document(openapiMeta)); + return c.json(generateOpenApi31Document(app)); }); -// will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware app.get("/health", async (c) => { return c.json({ message: "fallback ok" }); }); diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts index 6eeab399a..05f381136 100644 --- a/apps/ensapi/src/cache/indexing-status.cache.ts +++ b/apps/ensapi/src/cache/indexing-status.cache.ts @@ -12,10 +12,14 @@ import { makeLogger } from "@/lib/logger"; const logger = makeLogger("indexing-status.cache"); +// lazy() defers construction until first use so that this module can be +// imported without env vars being present (e.g. during OpenAPI generation). const getClient = lazy(() => new ENSNodeClient({ url: config.ensIndexerUrl })); type IndexingStatusCache = SWRCache; +// lazy() defers construction until first use so that this module can be +// imported without env vars being present (e.g. during OpenAPI generation). const _getCache = lazy( () => new SWRCache({ @@ -56,6 +60,13 @@ const _getCache = lazy( }), ); +// Wraps the lazily-initialized cache in a Proxy so that consumers can import +// `indexingStatusCache` as a stable object reference and call methods on it +// directly (e.g. `indexingStatusCache.read()`), without needing to call a +// getter function. +// +// So the Proxy gives a stable reference that looks and feels like a plain object +// to the rest of the codebase. export const indexingStatusCache = new Proxy({} as IndexingStatusCache, { get(_, prop) { const cache = _getCache(); diff --git a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts index 160a83ef0..5c156b044 100644 --- a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts +++ b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts @@ -26,6 +26,8 @@ import { indexingStatusCache } from "./indexing-status.cache"; const logger = makeLogger("referrer-leaderboard-cache.cache"); +// lazy() defers construction until first use so that this module can be +// imported without env vars being present (e.g. during OpenAPI generation). const getRules = lazy(() => buildReferralProgramRules( ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE, diff --git a/apps/ensapi/src/config/index.ts b/apps/ensapi/src/config/index.ts index efea7baff..c4b7d44b4 100644 --- a/apps/ensapi/src/config/index.ts +++ b/apps/ensapi/src/config/index.ts @@ -3,18 +3,47 @@ import { buildConfigFromEnvironment } from "@/config/config.schema"; let _config: EnsApiConfig | null = null; -export async function initConfig(env: NodeJS.ProcessEnv): Promise { +/** + * 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 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.`, - ); + 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 ef6ac02a8..ebad1b16e 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 @@ -23,6 +23,8 @@ import { lazy } from "@/lib/lazy"; */ const CANONICAL_REGISTRIES_MAX_DEPTH = 16; +// 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)); /** 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 66e51ced3..cc3b92a84 100644 --- a/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/graphql-api/lib/get-canonical-path.ts @@ -17,6 +17,8 @@ import { db } from "@/lib/db"; import { lazy } from "@/lib/lazy"; const MAX_DEPTH = 16; +// 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)); /** diff --git a/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts index 2cf9ed7ec..dd43ae4a2 100644 --- a/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts +++ b/apps/ensapi/src/graphql-api/lib/get-domain-by-interpreted-name.ts @@ -20,6 +20,8 @@ import { db } from "@/lib/db"; import { lazy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; +// lazy() defers construction until first use so that this module can be +// imported without env vars being present (e.g. during OpenAPI generation). const getRootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace)); const logger = makeLogger("get-domain-by-interpreted-name"); 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 72c3d2802..c1e84f0a9 100644 --- a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts @@ -25,6 +25,8 @@ import { getNameTokensRoute } from "./name-tokens-api.routes"; const app = createApp({ middlewares: [indexingStatusMiddleware, nameTokensApiMiddleware] }); +// lazy() defers construction until first use so that this module can be +// imported without env vars being present (e.g. during OpenAPI generation). const getIndexedSubregistriesOnce = lazy(() => getIndexedSubregistries(config.namespace, config.ensIndexerPublicConfig.plugins as PluginName[]), ); diff --git a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts index 7fd975d9b..1c1386cd4 100644 --- a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts @@ -51,7 +51,8 @@ app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/ // inject _meta into the hono (and yoga) context for the subgraph middleware app.use(subgraphMetaMiddleware); -// use subgraph middleware +// 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, diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 35d63e94a..456716a36 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -1,4 +1,4 @@ -import config, { initConfig } from "@/config"; +import config, { initEnvConfig } from "@/config"; import { serve } from "@hono/node-server"; @@ -12,7 +12,7 @@ import logger from "@/lib/logger"; import app from "./app"; -await initConfig(process.env); +await initEnvConfig(process.env); // start ENSNode API OpenTelemetry SDK sdk.start(); @@ -26,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/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index 0a41e67cc..3c1f7b360 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -45,6 +45,8 @@ const NULL_RESULT: FindResolverResult = { const tracer = trace.getTracer("find-resolver"); +// lazy() defers construction until first use so that this module can be +// imported without env vars being present (e.g. during OpenAPI generation). const getENSv1RegistryOld = lazy(() => getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "ENSv1RegistryOld"), ); 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 d5c3c21c0..509750abf 100644 --- a/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts +++ b/apps/ensapi/src/lib/resolution/resolve-with-universal-resolver.ts @@ -25,6 +25,8 @@ import type { ResolveCallsAndRawResults, } from "@/lib/resolution/resolve-calls-and-results"; +// lazy() defers construction until first use so that this module can be +// imported without env vars being present (e.g. during OpenAPI generation). const getUniversalResolver = lazy(() => getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolver"), ); diff --git a/apps/ensapi/src/openapi-document.ts b/apps/ensapi/src/openapi-document.ts index 19c44b05d..86d4189f5 100644 --- a/apps/ensapi/src/openapi-document.ts +++ b/apps/ensapi/src/openapi-document.ts @@ -2,11 +2,30 @@ import type { OpenAPIHono } from "@hono/zod-openapi"; import { openapiMeta } from "@/openapi-meta"; -import app from "./app"; +/** + * Endpoints to exclude from the generated OpenAPI document. + * TODO: remove /amirealtime once the legacy endpoint is deleted. + */ +const HIDE_OPENAPI_ENDPOINTS: string[] = ["/amirealtime"]; + +type OpenApiDocument = ReturnType; + +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 the real registered routes. + * 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 app.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 ca21edb79..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": [ @@ -2081,35 +2081,6 @@ "503": { "description": "Service unavailable" } } } - }, - "/amirealtime": { - "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" - } - } - } } }, "webhooks": {} diff --git a/scripts/generate-ensapi-openapi.mts b/scripts/generate-ensapi-openapi.mts index da4e3ee20..c1546a354 100644 --- a/scripts/generate-ensapi-openapi.mts +++ b/scripts/generate-ensapi-openapi.mts @@ -6,7 +6,7 @@ * Output: docs/docs.ensnode.io/ensapi-openapi.json * * This script calls generateOpenApi31Document() which uses the real app routes - * and static metadata. No config initialization is required. + * 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 }); From 00c68001f089da8c0f90f7a539136f87413cba32 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 24 Mar 2026 19:28:18 +0300 Subject: [PATCH 14/17] add lazyProxy --- .../ensapi/src/cache/indexing-status.cache.ts | 24 ++---------- .../referral-program-edition-set.cache.ts | 39 +++++++------------ .../src/cache/referrer-leaderboard.cache.ts | 17 ++------ apps/ensapi/src/lib/db.ts | 20 ++-------- apps/ensapi/src/lib/lazy.ts | 34 ++++++++++++++++ 5 files changed, 59 insertions(+), 75 deletions(-) diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts index 05f381136..debcab3cd 100644 --- a/apps/ensapi/src/cache/indexing-status.cache.ts +++ b/apps/ensapi/src/cache/indexing-status.cache.ts @@ -7,7 +7,7 @@ import { SWRCache, } from "@ensnode/ensnode-sdk"; -import { lazy } from "@/lib/lazy"; +import { lazy, lazyProxy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; const logger = makeLogger("indexing-status.cache"); @@ -18,9 +18,9 @@ const getClient = lazy(() => new ENSNodeClient({ url: config.ensIndexerUrl })); type IndexingStatusCache = SWRCache; -// lazy() defers construction until first use so that this module can be +// lazyProxy defers construction until first use so that this module can be // imported without env vars being present (e.g. during OpenAPI generation). -const _getCache = lazy( +export const indexingStatusCache = lazyProxy( () => new SWRCache({ fn: async (_cachedResult) => @@ -59,21 +59,3 @@ const _getCache = lazy( proactivelyInitialize: true, }), ); - -// Wraps the lazily-initialized cache in a Proxy so that consumers can import -// `indexingStatusCache` as a stable object reference and call methods on it -// directly (e.g. `indexingStatusCache.read()`), without needing to call a -// getter function. -// -// So the Proxy gives a stable reference that looks and feels like a plain object -// to the rest of the codebase. -export const indexingStatusCache = new Proxy({} as IndexingStatusCache, { - get(_, prop) { - const cache = _getCache(); - const value = Reflect.get(cache, prop as string, cache); - if (typeof value === "function") { - return (value as (...args: unknown[]) => unknown).bind(cache); - } - return value; - }, -}); 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 11dc82b39..44d7b5fba 100644 --- a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -10,7 +10,7 @@ import { minutesToSeconds } from "date-fns"; import { type CachedResult, SWRCache } from "@ensnode/ensnode-sdk"; -import { lazy } from "@/lib/lazy"; +import { lazyProxy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; const logger = makeLogger("referral-program-edition-set-cache"); @@ -77,27 +77,16 @@ type ReferralProgramEditionConfigSetCache = SWRCache( - () => - new SWRCache({ - fn: loadReferralProgramEditionConfigSet, - ttl: Number.POSITIVE_INFINITY, - errorTtl: minutesToSeconds(1), - proactiveRevalidationInterval: undefined, - proactivelyInitialize: true, - }), -); - -export const referralProgramEditionConfigSetCache = new Proxy( - {} as ReferralProgramEditionConfigSetCache, - { - get(_, prop) { - const cache = _getCache(); - const value = Reflect.get(cache, prop as string, cache); - if (typeof value === "function") { - return (value as (...args: unknown[]) => unknown).bind(cache); - } - return value; - }, - }, -); +// 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 5c156b044..6684b8d96 100644 --- a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts +++ b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts @@ -19,7 +19,7 @@ import { } from "@ensnode/ensnode-sdk"; import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard"; -import { lazy } from "@/lib/lazy"; +import { lazy, lazyProxy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; import { indexingStatusCache } from "./indexing-status.cache"; @@ -52,7 +52,9 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [ type ReferrerLeaderboardCache = SWRCache; -const _getCache = lazy( +// 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) => { @@ -99,14 +101,3 @@ const _getCache = lazy( proactivelyInitialize: true, }), ); - -export const referrerLeaderboardCache = new Proxy({} as ReferrerLeaderboardCache, { - get(_, prop) { - const cache = _getCache(); - const value = Reflect.get(cache, prop as string, cache); - if (typeof value === "function") { - return (value as (...args: unknown[]) => unknown).bind(cache); - } - return value; - }, -}); diff --git a/apps/ensapi/src/lib/db.ts b/apps/ensapi/src/lib/db.ts index 5692d2907..bf63cc320 100644 --- a/apps/ensapi/src/lib/db.ts +++ b/apps/ensapi/src/lib/db.ts @@ -3,28 +3,16 @@ import config from "@/config"; import * as schema from "@ensnode/ensdb-sdk"; import { makeDrizzle } from "@/lib/handlers/drizzle"; -import { lazy } from "@/lib/lazy"; +import { lazyProxy } from "@/lib/lazy"; type Db = ReturnType>; -const _getDb = lazy(() => +// 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 db = lazyProxy(() => makeDrizzle({ databaseUrl: config.databaseUrl, databaseSchema: config.databaseSchemaName, schema, }), ); - -export const db = new Proxy({} as Db, { - get(_, prop) { - const realDb = _getDb(); - const value = Reflect.get(realDb, prop as string, realDb); - if (typeof value === "function") { - return (value as (...args: unknown[]) => unknown).bind(realDb); - } - return value; - }, - has(_, prop) { - return Reflect.has(_getDb(), prop); - }, -}); diff --git a/apps/ensapi/src/lib/lazy.ts b/apps/ensapi/src/lib/lazy.ts index 98fe8fa13..615d1ab1e 100644 --- a/apps/ensapi/src/lib/lazy.ts +++ b/apps/ensapi/src/lib/lazy.ts @@ -3,6 +3,9 @@ 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; @@ -11,3 +14,34 @@ export function lazy(factory: () => T): () => T { 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); + }, + }); +} From df11f6d6ea229ddb9ff3ab44b7bb3e7adc1950dc Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 24 Mar 2026 20:01:25 +0300 Subject: [PATCH 15/17] remove trailing slash --- apps/ensapi/src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensapi/src/app.ts b/apps/ensapi/src/app.ts index 1ac987157..0bd7d13d1 100644 --- a/apps/ensapi/src/app.ts +++ b/apps/ensapi/src/app.ts @@ -41,7 +41,7 @@ app.get("/", (c) =>

Hello, World!

-

You've reached the root of an ENSApi instance. You might be looking for the ENSNode documentation.

+

You've reached the root of an ENSApi instance. You might be looking for the ENSNode documentation.

`), From bddb22a85f4aaae95d3aadbbba0d72b4ae6ec5f4 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 24 Mar 2026 20:24:01 +0300 Subject: [PATCH 16/17] use lazyCache instead of lazy --- .../ensapi/src/cache/indexing-status.cache.ts | 8 +++---- .../referral-program-edition-set.cache.ts | 21 +++++++++---------- .../src/cache/referrer-leaderboard.cache.ts | 7 +++---- apps/ensapi/src/config/index.ts | 3 +-- .../handlers/api/explore/name-tokens-api.ts | 8 +++---- .../src/handlers/subgraph/subgraph-api.ts | 2 +- .../protocol-acceleration/find-resolver.ts | 10 ++++----- .../resolve-with-universal-resolver.ts | 8 +++---- apps/ensapi/src/openapi-document.ts | 6 ++---- 9 files changed, 34 insertions(+), 39 deletions(-) diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts index debcab3cd..b43e52584 100644 --- a/apps/ensapi/src/cache/indexing-status.cache.ts +++ b/apps/ensapi/src/cache/indexing-status.cache.ts @@ -7,14 +7,14 @@ import { SWRCache, } from "@ensnode/ensnode-sdk"; -import { lazy, lazyProxy } from "@/lib/lazy"; +import { lazyProxy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; const logger = makeLogger("indexing-status.cache"); -// lazy() defers construction until first use so that this module can be +// lazyProxy defers construction until first use so that this module can be // imported without env vars being present (e.g. during OpenAPI generation). -const getClient = lazy(() => new ENSNodeClient({ url: config.ensIndexerUrl })); +const client = lazyProxy(() => new ENSNodeClient({ url: config.ensIndexerUrl })); type IndexingStatusCache = SWRCache; @@ -24,7 +24,7 @@ export const indexingStatusCache = lazyProxy( () => new SWRCache({ fn: async (_cachedResult) => - getClient() + client .indexingStatus() // fetch a new indexing status snapshot .then((response) => { if (response.responseCode !== IndexingStatusResponseCodes.Ok) { 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 44d7b5fba..580099370 100644 --- a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -79,14 +79,13 @@ type ReferralProgramEditionConfigSetCache = SWRCache( - () => - new SWRCache({ - fn: loadReferralProgramEditionConfigSet, - ttl: Number.POSITIVE_INFINITY, - errorTtl: minutesToSeconds(1), - proactiveRevalidationInterval: undefined, - proactivelyInitialize: true, - }), - ); +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 6684b8d96..7219d7016 100644 --- a/apps/ensapi/src/cache/referrer-leaderboard.cache.ts +++ b/apps/ensapi/src/cache/referrer-leaderboard.cache.ts @@ -19,16 +19,16 @@ import { } from "@ensnode/ensnode-sdk"; import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard"; -import { lazy, lazyProxy } from "@/lib/lazy"; +import { lazyProxy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; import { indexingStatusCache } from "./indexing-status.cache"; const logger = makeLogger("referrer-leaderboard-cache.cache"); -// lazy() defers construction until first use so that this module can be +// lazyProxy defers construction until first use so that this module can be // imported without env vars being present (e.g. during OpenAPI generation). -const getRules = lazy(() => +const rules = lazyProxy(() => buildReferralProgramRules( ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE, ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS, @@ -72,7 +72,6 @@ export const referrerLeaderboardCache = lazyProxy( ); } - const rules = getRules(); const latestIndexedBlockRef = getLatestIndexedBlockRef( indexingStatus, rules.subregistryId.chainId, diff --git a/apps/ensapi/src/config/index.ts b/apps/ensapi/src/config/index.ts index c4b7d44b4..f8eab6ac2 100644 --- a/apps/ensapi/src/config/index.ts +++ b/apps/ensapi/src/config/index.ts @@ -40,10 +40,9 @@ export default new Proxy({} as EnsApiConfig, { }, }); - 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/handlers/api/explore/name-tokens-api.ts b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts index c1e84f0a9..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,7 +15,7 @@ import { } from "@ensnode/ensnode-sdk"; import { createApp } from "@/lib/hono-factory"; -import { lazy } from "@/lib/lazy"; +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"; @@ -25,9 +25,9 @@ import { getNameTokensRoute } from "./name-tokens-api.routes"; const app = createApp({ middlewares: [indexingStatusMiddleware, nameTokensApiMiddleware] }); -// lazy() defers construction until first use so that this module can be +// lazyProxy defers construction until first use so that this module can be // imported without env vars being present (e.g. during OpenAPI generation). -const getIndexedSubregistriesOnce = lazy(() => +const indexedSubregistries = lazyProxy(() => getIndexedSubregistries(config.namespace, config.ensIndexerPublicConfig.plugins as PluginName[]), ); @@ -81,7 +81,7 @@ app.openapi(getNameTokensRoute, async (c) => { } const parentNode = namehash(getParentNameFQDN(name)); - const subregistry = getIndexedSubregistriesOnce().find((s) => s.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 1c1386cd4..0be19714f 100644 --- a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts @@ -6,8 +6,8 @@ import * as schema from "@ensnode/ensdb-sdk"; import { type Duration, hasSubgraphApiConfigSupport } from "@ensnode/ensnode-sdk"; import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; -import { lazy } from "@/lib/lazy"; 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"; diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index 3c1f7b360..83673001b 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -27,7 +27,7 @@ import { import { db } from "@/lib/db"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; -import { lazy } from "@/lib/lazy"; +import { lazyProxy } from "@/lib/lazy"; type FindResolverResult = | { @@ -45,9 +45,9 @@ const NULL_RESULT: FindResolverResult = { const tracer = trace.getTracer("find-resolver"); -// lazy() defers construction until first use so that this module can be +// lazyProxy defers construction until first use so that this module can be // imported without env vars being present (e.g. during OpenAPI generation). -const getENSv1RegistryOld = lazy(() => +const ensv1RegistryOld = lazyProxy(() => getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "ENSv1RegistryOld"), ); @@ -222,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, getENSv1RegistryOld().chainId), - eq(t.address, getENSv1RegistryOld().address), + eq(t.chainId, ensv1RegistryOld.chainId), + eq(t.address, ensv1RegistryOld.address), ) : undefined, ), 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 509750abf..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,15 +19,15 @@ import { type ResolverRecordsSelection, } from "@ensnode/ensnode-sdk"; -import { lazy } from "@/lib/lazy"; +import { lazy, lazyProxy } from "@/lib/lazy"; import type { ResolveCalls, ResolveCallsAndRawResults, } from "@/lib/resolution/resolve-calls-and-results"; -// lazy() defers construction until first use so that this module can be +// lazyProxy defers construction until first use so that this module can be // imported without env vars being present (e.g. during OpenAPI generation). -const getUniversalResolver = lazy(() => +const universalResolver = lazyProxy(() => getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolver"), ); @@ -60,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: getUniversalResolverV2()?.address ?? getUniversalResolver().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 86d4189f5..c722e9748 100644 --- a/apps/ensapi/src/openapi-document.ts +++ b/apps/ensapi/src/openapi-document.ts @@ -19,13 +19,11 @@ function removeHiddenEndpoints(doc: OpenApiDocument): OpenApiDocument { /** * 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( - app: OpenAPIHono, -): OpenApiDocument { +export function generateOpenApi31Document(app: OpenAPIHono): OpenApiDocument { const doc = app.getOpenAPI31Document(openapiMeta); return removeHiddenEndpoints(doc); } From 3e010c03d753d0006e5ded86e442a9ebfc6fc667 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 25 Mar 2026 12:18:33 +0300 Subject: [PATCH 17/17] fix tests and lint --- apps/ensapi/src/config/config.singleton.test.ts | 15 +++++++++++---- apps/ensapi/src/lib/ensdb/singleton.ts | 4 +++- 2 files changed, 14 insertions(+), 5 deletions(-) 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/lib/ensdb/singleton.ts b/apps/ensapi/src/lib/ensdb/singleton.ts index 384468912..b2d4ee481 100644 --- a/apps/ensapi/src/lib/ensdb/singleton.ts +++ b/apps/ensapi/src/lib/ensdb/singleton.ts @@ -24,4 +24,6 @@ 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 = lazyProxy(() => ensDbClient.ensIndexerSchema); +export const ensIndexerSchema = lazyProxy( + () => ensDbClient.ensIndexerSchema, +);