Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/ensapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"test": "vitest",
"lint": "biome check --write .",
"lint:ci": "biome ci",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"generate:openapi": "tsx scripts/generate-openapi.ts"
Comment on lines +20 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if tsgo is referenced elsewhere in the repo (CI config, root package.json, etc.)
rg -n 'tsgo' --type-add 'config:*.json,*.yaml,*.yml,*.toml' -g '!node_modules'

Repository: namehash/ensnode

Length of output: 830


🏁 Script executed:

#!/bin/bash
# Also check for tsx references and verify both tools in package.json files
echo "=== Checking for tsx references ==="
rg -n 'tsx' --type-add 'config:*.json,*.yaml,*.yml,*.toml' -g '!node_modules'

Repository: namehash/ensnode

Length of output: 50374


🏁 Script executed:

#!/bin/bash
# Check the apps/ensapi/package.json for dependencies
echo "=== Checking apps/ensapi/package.json devDependencies ==="
cat apps/ensapi/package.json | head -50

Repository: namehash/ensnode

Length of output: 1888


🏁 Script executed:

#!/bin/bash
# Check root package.json for these tools
echo "=== Checking root package.json ==="
cat package.json | head -100

Repository: namehash/ensnode

Length of output: 2512


🏁 Script executed:

#!/bin/bash
# Search for CI configuration files
echo "=== Searching for CI config files ==="
find . -type f \( -name "*.yml" -o -name "*.yaml" -o -name ".github" \) -path '*/\.github/*' 2>/dev/null | head -20
git ls-files | grep -E '(\.github|\.gitlab|\.circleci|\.jenkins)'

Repository: namehash/ensnode

Length of output: 1988


🏁 Script executed:

#!/bin/bash
# Search for tsgo in devDependencies across package.json files
rg -A 3 '"tsgo"' --type json

Repository: namehash/ensnode

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Check the main CI workflow file for typecheck command
cat .github/workflows/test_ci.yml | head -100

Repository: namehash/ensnode

Length of output: 3503


🏁 Script executed:

#!/bin/bash
# Check if tsgo is in pnpm-lock.yaml (it must be installed if it's being used)
rg -n 'tsgo' pnpm-lock.yaml | head -20

Repository: namehash/ensnode

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Check for catalog configuration in pnpm-workspace.yaml or similar
cat pnpm-workspace.yaml 2>/dev/null || echo "File not found"

Repository: namehash/ensnode

Length of output: 752


🏁 Script executed:

#!/bin/bash
# Search for tsgo in all files to understand how it's being resolved
find . -type f -name "*.json" -o -name "*.yaml" -o -name "*.yml" | xargs grep -l '"tsgo"' 2>/dev/null | grep -v node_modules | head -20

Repository: namehash/ensnode

Length of output: 42


Typecheck script uses unavailable tsgo tool; generate:openapi script via tsx is correct.

The typecheck script references tsgo, but this tool is not found in the project's dependencies, lockfile (pnpm-lock.yaml), or catalog configuration. The generate:openapi script correctly uses tsx (version 4.20.6 installed). Add tsgo as a dev dependency across the workspace or switch to the standard TypeScript compiler (tsc).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensapi/package.json` around lines 20 - 21, The package.json "typecheck"
script references the unavailable tool "tsgo" (scripts: "typecheck") which
causes CI/local failures; either add "tsgo" as a devDependency across the
workspace (pin a compatible version) and update lockfiles, or change the
"typecheck" script to use the standard TypeScript compiler ("tsc") instead
(matching how "generate:openapi" uses "tsx"); update package.json's "typecheck"
entry accordingly and run install to ensure the tool is present.

},
"dependencies": {
"@ensdomains/ensjs": "^4.0.2",
Expand All @@ -27,7 +28,7 @@
"@ensnode/ponder-subgraph": "workspace:*",
"@hono/node-server": "^1.19.5",
"@hono/otel": "^0.2.2",
"@hono/standard-validator": "^0.2.2",
"@hono/zod-openapi": "1.2.1",
"@namehash/ens-referrals": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.0.1",
Expand All @@ -51,7 +52,6 @@
"graphql": "^16.11.0",
"graphql-yoga": "^5.16.0",
"hono": "catalog:",
"hono-openapi": "^1.1.2",
"p-memoize": "^8.0.0",
"p-retry": "^7.1.0",
"pg-connection-string": "catalog:",
Expand Down
37 changes: 37 additions & 0 deletions apps/ensapi/scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Generates the OpenAPI specification JSON for ENSApi.
*
* This script can run without any environment variables or config because it
* imports only from the lightweight `.routes.ts` description files via
* `createRoutesForSpec()`, which have zero config dependencies.
*
* The version is resolved from (in order of precedence):
* 1. OPENAPI_VERSION_OVERRIDE env var
* 2. package.json version
* 3. openapiDocumentation.info.version (placeholder fallback)
*
* Usage:
* pnpm generate:openapi > openapi.json
* pnpm generate:openapi # prints to stdout
* OPENAPI_VERSION_OVERRIDE=1.2.3 pnpm generate:openapi
*/

import packageJson from "@/../package.json" with { type: "json" };

import { openapiDocumentation } from "@/openapi";
import { createRoutesForSpec } from "@/openapi-routes";

const version =
process.env.OPENAPI_VERSION_OVERRIDE || packageJson.version || openapiDocumentation.info.version;

const app = createRoutesForSpec();
const spec = app.getOpenAPI31Document({
...openapiDocumentation,
info: {
...openapiDocumentation.info,
version,
},
});

Comment on lines 26 to 35
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openapiDocumentation.info.version is intentionally a placeholder ("0.0.0"), but this script uses it as-is when generating the OpenAPI document. That means CI/docs-generated specs will have a different version than the runtime /openapi.json (which injects packageJson.version). Consider updating the script to set info.version from apps/ensapi/package.json (or accept a CLI/env override) so generated specs match runtime output.

Suggested change
const app = createRoutesForSpec();
const spec = app.getOpenAPI31Document(openapiDocumentation);
import * as fs from "fs";
import * as path from "path";
const app = createRoutesForSpec();
const packageJsonPath = path.join(__dirname, "..", "package.json");
const packageJsonRaw = fs.readFileSync(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonRaw) as { version?: string };
const resolvedVersion =
process.env.OPENAPI_VERSION_OVERRIDE ||
packageJson.version ||
openapiDocumentation.info?.version;
const openapiDocumentationWithVersion = {
...openapiDocumentation,
info: {
...openapiDocumentation.info,
version: resolvedVersion,
},
};
const spec = app.getOpenAPI31Document(openapiDocumentationWithVersion);

Copilot uses AI. Check for mistakes.
process.stdout.write(JSON.stringify(spec, null, 2));
process.stdout.write("\n");
106 changes: 106 additions & 0 deletions apps/ensapi/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Application setup: creates the Hono app with all middleware and routes.
*
* Separated from index.ts (server startup/shutdown) so the app configuration
* is distinct from the runtime lifecycle concerns.
*/

import packageJson from "@/../package.json" with { type: "json" };
import config from "@/config";

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 { openapiDocumentation } from "@/openapi";

import amIRealtimeApi from "./handlers/amirealtime-api";
import { basePath as amIRealtimeBasePath } from "./handlers/amirealtime-api.routes";
import ensanalyticsApi from "./handlers/ensanalytics-api";
import { basePath as ensanalyticsBasePath } from "./handlers/ensanalytics-api.routes";
import ensanalyticsApiV1 from "./handlers/ensanalytics-api-v1";
import { basePath as ensanalyticsV1BasePath } from "./handlers/ensanalytics-api-v1.routes";
import ensNodeApi from "./handlers/ensnode-api";
import { basePath as ensnodeBasePath } from "./handlers/ensnode-api.routes";
import subgraphApi from "./handlers/subgraph-api";

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

// add ENSIndexer Indexing Status Middleware to all routes for convenience
app.use(indexingStatusMiddleware);
Comment on lines +45 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

indexingStatusMiddleware applied to all routes including /, /health, /openapi.json.

The global app.use(indexingStatusMiddleware) runs on every request, including routes that never inspect c.var.indexingStatus (root page, health check, OpenAPI doc). This adds unnecessary overhead (cache reads, realtime projections) to informational endpoints. Consider scoping it to API paths only, e.g., app.use("/api/*", indexingStatusMiddleware), or to the specific sub-apps that need it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensapi/src/app.ts` around lines 45 - 46, The middleware
indexingStatusMiddleware is currently registered globally via
app.use(indexingStatusMiddleware) causing unnecessary work for endpoints like
"/", "/health", and "/openapi.json"; change the registration to only apply to
API routes (for example register it via app.use("/api",
indexingStatusMiddleware) or attach it to the specific router(s) that need
c.var.indexingStatus) so only requests under the API path run the cache/realtime
projection logic.


// host welcome page
app.get("/", (c) =>
c.html(html`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ENSApi</title>
</head>
<body>
<h1>Hello, World!</h1>
<p>You've reached the root of an ENSApi instance. You might be looking for the <a href="https://ensnode.io/docs/">ENSNode documentation</a>.</p>
</body>
</html>
`),
);

// use ENSNode HTTP API at /api
app.route(ensnodeBasePath, ensNodeApi);

// Subgraph API is a GraphQL middleware handler, not an OpenAPI route,
// so it has no .routes.ts file or basePath export.
app.route("/subgraph", subgraphApi);

// use ENSAnalytics API at /ensanalytics (v0, implicit)
app.route(ensanalyticsBasePath, ensanalyticsApi);

// use ENSAnalytics API v1 at /v1/ensanalytics
app.route(ensanalyticsV1BasePath, ensanalyticsApiV1);

// use Am I Realtime API at /amirealtime
app.route(amIRealtimeBasePath, amIRealtimeApi);

// use OpenAPI Schema
app.doc31("/openapi.json", {
...openapiDocumentation,
info: {
...openapiDocumentation.info,
version: packageJson.version,
},
servers: [
...openapiDocumentation.servers,
{ url: `http://localhost:${config.port}`, description: "Local Development" },
],
});

// will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware
app.get("/health", async (c) => {
return c.json({ message: "fallback ok" });
});
Comment on lines +95 to +98
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading comment on /health endpoint.

The comment on Line 95 says the endpoint "will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware," but no such middleware is applied in this file. The indexingStatusMiddleware (Line 46) sets a context variable but doesn't block or return 503. As written, /health always returns 200 with { message: "fallback ok" }.

Either remove the stale comment or wire the middleware that would actually produce 503 responses on this endpoint.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensapi/src/app.ts` around lines 95 - 98, The comment above the /health
route is stale: either remove it or actually attach the middleware that can
return 503; update app.get("/health", ...) to include
ensIndexerPublicConfigMiddleware (or the middleware that implements the 503
behavior) before the handler, or delete/replace the comment to reflect current
behavior since indexingStatusMiddleware only sets context and does not block;
reference the route handler for "/health", the indexingStatusMiddleware, and
ensIndexerPublicConfigMiddleware when making the change.


// log hono errors to console
app.onError((error, ctx) => {
logger.error(error);
return errorResponse(ctx, "Internal Server Error");
});

export default app;
43 changes: 43 additions & 0 deletions apps/ensapi/src/handlers/amirealtime-api.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createRoute } from "@hono/zod-openapi";
import { minutesToSeconds } from "date-fns";
import { z } from "zod/v4";

import type { Duration } from "@ensnode/ensnode-sdk";
import { makeDurationSchema } from "@ensnode/ensnode-sdk/internal";

import { params } from "@/lib/handlers/params.schema";

export const basePath = "/amirealtime";

// 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({
method: "get",
path: "/",
tags: ["Meta"],
summary: "Check indexing progress",
description:
"Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime",
request: {
query: z.object({
maxWorstCaseDistance: params.queryParam
.optional()
.default(AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE)
.pipe(makeDurationSchema("maxWorstCaseDistance query param"))
.describe("Maximum acceptable worst-case indexing distance in seconds"),
}),
},
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",
},
},
Comment on lines +31 to +40
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

200 and 503 response bodies are undocumented in the spec.

Both responses declare only description with no content / schema. However the handler (amirealtime-api.ts) always returns JSON:

  • 200: { maxWorstCaseDistance, slowestChainIndexingCursor, worstCaseDistance }
  • 503: { message: string } (via errorResponse)

The OpenAPI spec will incorrectly indicate body-less responses for both status codes.

✏️ Proposed fix (add response schemas)
+import { z } from "zod/v4";

 export const getAmIRealtimeRoute = createRoute({
   ...
   responses: {
       description:
         "Indexing progress is guaranteed to be within the requested distance of realtime",
+      content: {
+        "application/json": {
+          schema: z.object({
+            maxWorstCaseDistance: z.number(),
+            slowestChainIndexingCursor: z.number().nullable(),
+            worstCaseDistance: z.number(),
+          }),
+        },
+      },
     },
       description:
         "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable",
+      content: {
+        "application/json": {
+          schema: z.object({ message: z.string() }),
+        },
+      },
     },
   },
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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",
},
},
responses: {
200: {
description:
"Indexing progress is guaranteed to be within the requested distance of realtime",
content: {
"application/json": {
schema: z.object({
maxWorstCaseDistance: z.number(),
slowestChainIndexingCursor: z.number().nullable(),
worstCaseDistance: z.number(),
}),
},
},
},
503: {
description:
"Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable",
content: {
"application/json": {
schema: z.object({ message: z.string() }),
},
},
},
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensapi/src/handlers/amirealtime-api.routes.ts` around lines 31 - 40, The
OpenAPI responses in amirealtime-api.routes.ts lack response bodies: update the
responses object for status 200 to include content: application/json with a
schema describing the success payload (properties: maxWorstCaseDistance,
slowestChainIndexingCursor, worstCaseDistance with appropriate types) and for
503 add content: application/json with a schema matching the errorResponse shape
(message: string); ensure these schemas align with the handler
amirealtime-api.ts return values so the spec accurately documents the JSON
payloads.

});

export const routes = [amIRealtimeGetMeta];
29 changes: 21 additions & 8 deletions apps/ensapi/src/handlers/amirealtime-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import {
type UnixTimestamp,
} from "@ensnode/ensnode-sdk";

import { factory } from "@/lib/hono-factory";
import { createApp } from "@/lib/hono-factory";
import * as middleware from "@/middleware/indexing-status.middleware";

import amIRealtimeApi, { AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE } from "./amirealtime-api"; // adjust import path as needed
import amIRealtimeApi from "./amirealtime-api";
import { AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE } from "./amirealtime-api.routes";

vi.mock("@/middleware/indexing-status.middleware", () => ({
indexingStatusMiddleware: vi.fn(),
Expand Down Expand Up @@ -44,11 +45,11 @@ describe("amirealtime-api", () => {
});
};

let app: ReturnType<typeof factory.createApp>;
let app: ReturnType<typeof createApp>;

beforeEach(() => {
// Create a fresh app instance for each test with middleware registered
app = factory.createApp();
app = createApp();
app.use(middleware.indexingStatusMiddleware);
app.route("/amirealtime", amIRealtimeApi);
});
Expand Down Expand Up @@ -76,7 +77,10 @@ describe("amirealtime-api", () => {

it("should accept valid maxWorstCaseDistance query param (set to `0`)", async () => {
// Arrange: set `indexingStatus` context var
arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now });
arrangeMockedIndexingStatusMiddleware({
now,
slowestChainIndexingCursor: now,
});

// Act
const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=0");
Expand Down Expand Up @@ -153,7 +157,10 @@ describe("amirealtime-api", () => {
describe("response", () => {
it("should return 200 when worstCaseDistance is below maxWorstCaseDistance", async () => {
// Arrange: set `indexingStatus` context var
arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 9 });
arrangeMockedIndexingStatusMiddleware({
now,
slowestChainIndexingCursor: now - 9,
});

// Act
const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=10");
Expand All @@ -170,7 +177,10 @@ describe("amirealtime-api", () => {

it("should return 200 when worstCaseDistance equals maxWorstCaseDistance", async () => {
// Arrange: set `indexingStatus` context var
arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 10 });
arrangeMockedIndexingStatusMiddleware({
now,
slowestChainIndexingCursor: now - 10,
});

// Act
const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=10");
Expand All @@ -187,7 +197,10 @@ describe("amirealtime-api", () => {

it("should return 503 when worstCaseDistance exceeds maxWorstCaseDistance", async () => {
// Arrange: set `indexingStatus` context var
arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 11 });
arrangeMockedIndexingStatusMiddleware({
now,
slowestChainIndexingCursor: now - 11,
});

// Act
const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=10");
Expand Down
Loading