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
3 changes: 2 additions & 1 deletion apps/ensrainbow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"progress": "^2.0.3",
"protobufjs": "^7.4.0",
"viem": "catalog:",
"yargs": "^17.7.2"
"yargs": "^17.7.2",
"zod": "catalog:"
},
"devDependencies": {
"@ensnode/shared-configs": "workspace:*",
Expand Down
21 changes: 14 additions & 7 deletions apps/ensrainbow/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { DEFAULT_PORT, getEnvPort } from "@/lib/env";
import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults";
import { getEnvPort } from "@/lib/env";

import { createCLI, validatePortConfiguration } from "./cli";

Expand Down Expand Up @@ -38,8 +39,8 @@ describe("CLI", () => {
});

describe("getEnvPort", () => {
it("should return DEFAULT_PORT when PORT is not set", () => {
expect(getEnvPort()).toBe(DEFAULT_PORT);
it("should return ENSRAINBOW_DEFAULT_PORT when PORT is not set", () => {
expect(getEnvPort()).toBe(ENSRAINBOW_DEFAULT_PORT);
});

it("should return port from environment variable", () => {
Expand All @@ -50,14 +51,20 @@ describe("CLI", () => {

it("should throw error for invalid port number", () => {
process.env.PORT = "invalid";
expect(() => getEnvPort()).toThrow(
'Invalid PORT value "invalid": must be a non-negative integer',
);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit called");
}) as never);
expect(() => getEnvPort()).toThrow();
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("should throw error for negative port number", () => {
process.env.PORT = "-1";
expect(() => getEnvPort()).toThrow('Invalid PORT value "-1": must be a non-negative integer');
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit called");
}) as never);
expect(() => getEnvPort()).toThrow();
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

Expand Down
13 changes: 7 additions & 6 deletions apps/ensrainbow/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { ingestProtobufCommand } from "@/commands/ingest-protobuf-command";
import { purgeCommand } from "@/commands/purge-command";
import { serverCommand } from "@/commands/server-command";
import { validateCommand } from "@/commands/validate-command";
import { getDefaultDataSubDir, getEnvPort } from "@/lib/env";
import { getDefaultDataDir } from "@/config/defaults";
import { getEnvPort } from "@/lib/env";

export function validatePortConfiguration(cliPort: number): void {
const envPort = process.env.PORT;
Expand Down Expand Up @@ -85,7 +86,7 @@ export function createCLI(options: CLIOptions = {}) {
// .option("data-dir", {
// type: "string",
// description: "Directory to store LevelDB data",
// default: getDefaultDataSubDir(),
// default: getDefaultDataDir(),
// });
// },
// async (argv: ArgumentsCamelCase<IngestArgs>) => {
Expand All @@ -108,7 +109,7 @@ export function createCLI(options: CLIOptions = {}) {
.option("data-dir", {
type: "string",
description: "Directory to store LevelDB data",
default: getDefaultDataSubDir(),
default: getDefaultDataDir(),
});
},
async (argv: ArgumentsCamelCase<IngestProtobufArgs>) => {
Expand All @@ -131,7 +132,7 @@ export function createCLI(options: CLIOptions = {}) {
.option("data-dir", {
type: "string",
description: "Directory containing LevelDB data",
default: getDefaultDataSubDir(),
default: getDefaultDataDir(),
});
},
async (argv: ArgumentsCamelCase<ServeArgs>) => {
Expand All @@ -150,7 +151,7 @@ export function createCLI(options: CLIOptions = {}) {
.option("data-dir", {
type: "string",
description: "Directory containing LevelDB data",
default: getDefaultDataSubDir(),
default: getDefaultDataDir(),
})
.option("lite", {
type: "boolean",
Expand All @@ -173,7 +174,7 @@ export function createCLI(options: CLIOptions = {}) {
return yargs.option("data-dir", {
type: "string",
description: "Directory containing LevelDB data",
default: getDefaultDataSubDir(),
default: getDefaultDataDir(),
});
},
async (argv: ArgumentsCamelCase<PurgeArgs>) => {
Expand Down
84 changes: 84 additions & 0 deletions apps/ensrainbow/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { join } from "node:path";

import { prettifyError, ZodError, z } from "zod/v4";

import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal";

import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults";
import type { ENSRainbowEnvironment } from "@/config/environment";
import { invariant_dbSchemaVersionMatch } from "@/config/validations";
import { logger } from "@/utils/logger";

const DataDirSchema = z
.string()
.trim()
.min(1, {
error: "DATA_DIR must be a non-empty string.",
})
.transform((path: string) => {
// Resolve relative paths to absolute paths
if (path.startsWith("/")) {
return path;
}
return join(process.cwd(), path);
});

const DbSchemaVersionSchema = z.coerce
.number({ error: "DB_SCHEMA_VERSION must be a number." })
.int({ error: "DB_SCHEMA_VERSION must be an integer." })
.optional();

const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET");

const ENSRainbowConfigSchema = z
.object({
port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT),
dataDir: DataDirSchema.default(getDefaultDataDir()),
dbSchemaVersion: DbSchemaVersionSchema,
labelSet: LabelSetSchema.optional(),
})
/**
* Invariant enforcement
*
* We enforce invariants across multiple values parsed with `ENSRainbowConfigSchema`
* by calling `.check()` function with relevant invariant-enforcing logic.
* Each such function has access to config values that were already parsed.
*/
.check(invariant_dbSchemaVersionMatch);

export type ENSRainbowConfig = z.infer<typeof ENSRainbowConfigSchema>;

/**
* Builds the ENSRainbow configuration object from an ENSRainbowEnvironment object.
*
* Validates and parses the complete environment configuration using ENSRainbowConfigSchema.
*
* @returns A validated ENSRainbowConfig object
* @throws Error with formatted validation messages if environment parsing fails
*/
export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig {
try {
return ENSRainbowConfigSchema.parse({
port: env.PORT,
dataDir: env.DATA_DIR,
dbSchemaVersion: env.DB_SCHEMA_VERSION,
labelSet:
env.LABEL_SET_ID || env.LABEL_SET_VERSION
? {
labelSetId: env.LABEL_SET_ID,
labelSetVersion: env.LABEL_SET_VERSION,
}
: undefined,
});
} catch (error) {
if (error instanceof ZodError) {
logger.error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`);
} else if (error instanceof Error) {
logger.error(error, `Failed to build ENSRainbowConfig`);
} else {
logger.error(`Unknown Error`);
}

process.exit(1);
}
}
5 changes: 5 additions & 0 deletions apps/ensrainbow/src/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { join } from "node:path";

export const ENSRAINBOW_DEFAULT_PORT = 3223;

export const getDefaultDataDir = () => join(process.cwd(), "data");
31 changes: 31 additions & 0 deletions apps/ensrainbow/src/config/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { LogLevelEnvironment, PortEnvironment } from "@ensnode/ensnode-sdk/internal";

/**
* Represents the raw, unvalidated environment variables for the ENSRainbow application.
*
* Keys correspond to the environment variable names, and all values are optional strings, reflecting
* their state in `process.env`. This interface is intended to be the source type which then gets
* mapped/parsed into a structured configuration object like `ENSRainbowConfig`.
*/
export type ENSRainbowEnvironment = PortEnvironment &
LogLevelEnvironment & {
/**
* Directory path where the LevelDB database is stored.
*/
DATA_DIR?: string;

/**
* Expected Database Schema Version.
*/
DB_SCHEMA_VERSION?: string;

/**
* Expected Label Set ID.
*/
LABEL_SET_ID?: string;

/**
* Expected Label Set Version.
*/
LABEL_SET_VERSION?: string;
};
4 changes: 4 additions & 0 deletions apps/ensrainbow/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type { ENSRainbowConfig } from "./config.schema";
export { buildConfigFromEnvironment } from "./config.schema";
export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults";
export type { ENSRainbowEnvironment } from "./environment";
1 change: 1 addition & 0 deletions apps/ensrainbow/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { ENSRainbowConfig } from "./config.schema";
25 changes: 25 additions & 0 deletions apps/ensrainbow/src/config/validations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { z } from "zod/v4";

import { DB_SCHEMA_VERSION } from "@/lib/database";

import type { ENSRainbowConfig } from "./config.schema";

/**
* Zod `.check()` function input.
*/
type ZodCheckFnInput<T> = z.core.ParsePayload<T>;

/**
* Invariant: dbSchemaVersion must match the version expected by the code.
*/
export function invariant_dbSchemaVersionMatch(
ctx: ZodCheckFnInput<Pick<ENSRainbowConfig, "dbSchemaVersion">>,
): void {
const { value: config } = ctx;

if (config.dbSchemaVersion !== undefined && config.dbSchemaVersion !== DB_SCHEMA_VERSION) {
throw new Error(
`DB_SCHEMA_VERSION mismatch! Expected version ${DB_SCHEMA_VERSION} from code, but found ${config.dbSchemaVersion} in environment variables.`,
);
}
}
28 changes: 7 additions & 21 deletions apps/ensrainbow/src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,10 @@
import { join } from "node:path";
import { buildConfigFromEnvironment } from "@/config/config.schema";
import type { ENSRainbowEnvironment } from "@/config/environment";

import { parseNonNegativeInteger } from "@ensnode/ensnode-sdk";

import { logger } from "@/utils/logger";

export const getDefaultDataSubDir = () => join(process.cwd(), "data");

export const DEFAULT_PORT = 3223;
/**
* Gets the port from environment variables.
*/
export function getEnvPort(): number {
const envPort = process.env.PORT;
if (!envPort) {
return DEFAULT_PORT;
}

try {
const port = parseNonNegativeInteger(envPort);
return port;
} catch (_error: unknown) {
const errorMessage = `Invalid PORT value "${envPort}": must be a non-negative integer`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
const config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment);
return config.port;
}
2 changes: 2 additions & 0 deletions packages/ensnode-sdk/src/shared/config/zod-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, {

/**
* Parses a numeric value as a port number.
* Ensures the value is an integer (not a float) within the valid port range.
*/
export const PortSchema = z.coerce
.number({ error: "PORT must be a number." })
.int({ error: "PORT must be an integer." })
.min(1, { error: "PORT must be greater than 1." })
.max(65535, { error: "PORT must be less than 65535" })
.optional();
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.