Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: replace vercel-kv with ioredis #2049

Merged
merged 14 commits into from
Mar 27, 2025
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
"@types/react-calendar-heatmap": "^1.6.7",
"@types/three": "0.153.0",
"@vercel/blob": "^0.23.4",
"@vercel/kv": "^1.0.1",
"@vercel/og": "^0.6.2",
"@vercel/postgres-kysely": "^0.8.0",
"base-ui": "0.1.1",
Expand All @@ -51,6 +50,7 @@
"ethers": "5.7.2",
"framer-motion": "^11.9.0",
"hls.js": "^1.5.14",
"ioredis": "^5.6.0",
"ipaddr.js": "^2.2.0",
"is-ipfs": "^8.0.4",
"jose": "^5.4.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/pages/api/checkNftProof/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { kv } from '@vercel/kv';
import { kv } from 'apps/web/src/utils/kv';
import { logger } from 'apps/web/src/utils/logger';

type RequestBody = {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/pages/api/registry/entries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { db } from 'apps/web/src/utils/ocsRegistry';
import { kv } from '@vercel/kv';
import { kv } from 'apps/web/src/utils/kv';
import { logger } from 'apps/web/src/utils/logger';
import { withTimeout } from 'apps/web/pages/api/decorators';

Expand Down
2 changes: 1 addition & 1 deletion apps/web/pages/api/registry/featured.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { db } from 'apps/web/src/utils/ocsRegistry';
import { kv } from '@vercel/kv';
import { kv } from 'apps/web/src/utils/kv';
import { logger } from 'apps/web/src/utils/logger';
import { withTimeout } from 'apps/web/pages/api/decorators';

Expand Down
166 changes: 166 additions & 0 deletions apps/web/src/utils/kv/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import Redis, { type Redis as RedisType } from 'ioredis';
import { isDevelopment } from 'apps/web/src/constants';
import { logger } from '../logger';

type KvConstructorParam =
| { url: string; tls?: boolean }
| { host: string; port: number; username?: string; password?: string; tls?: boolean };

/**
* Provides a limited, type-safe interface to Redis operations.
* Intentionally restricts access to dangerous commands and raw client operations.
*/
export class KVManager {
private client: RedisType | null = null;

private readonly connectionArg: KvConstructorParam;

private readonly connectionTls: boolean;

constructor(connectionParam: KvConstructorParam) {
if (!connectionParam || (!('url' in connectionParam) && !('host' in connectionParam))) {
throw new Error('No URL or options provided to KVManager');
}
this.connectionArg = connectionParam;
this.connectionTls = connectionParam.tls ?? false;
}

private async getClient(): Promise<RedisType> {
if (!this.client) {
if (!this.connectionArg) {
throw new Error('No URL or options provided to KVManager');
}

if ('url' in this.connectionArg) {
this.client = new Redis(this.connectionArg.url, this.connectionTls ? { tls: {} } : {});
} else {
this.client = new Redis({
...this.connectionArg,
tls: this.connectionTls ? {} : undefined,
});
}
}

try {
await this.client.ping();
} catch (err) {
if (!isDevelopment) {
logger.error('KV connection failed', err);
}
throw new Error(`Failed to connect to KV: ${err}`);
}

return this.client;
}

async ping() {
if (this.client) {
try {
return await this.client.ping();
} catch (err) {
if (!isDevelopment) {
logger.error('Failed to scan keys', err);
}
throw new Error(`Failed to ping: ${err}`);
}
}
}

async close() {
if (this.client) {
try {
await this.client.quit();
this.client = null;
} catch (err) {
if (!isDevelopment) {
logger.error('Failed to close client', err);
}
throw new Error(`Failed to close client: ${err}`);
}
}
}

async scan(cursor: number | string = '0', batchSize: number | string = 10) {
try {
const client = await this.getClient();
const [newCursor, elements] = batchSize
? await client.scan(cursor, 'COUNT', batchSize)
: await client.scan(cursor);

return { cursor: newCursor, elements };
} catch (err) {
if (!isDevelopment) {
logger.error('Failed to scan keys', err);
}
throw new Error(`Failed to scan keys: ${err}`);
}
}

async get<T>(key: string): Promise<T | null> {
try {
const client = await this.getClient();
const value = await client.get(key);
return value ? (JSON.parse(value) as T) : null;
} catch (err) {
if (!isDevelopment) {
logger.error('Failed to get key', err);
}
throw new Error(`Failed to get key: ${err}`);
}
}

async set<T>(
key: string,
value: T,
options?: {
ex?: number;
nx?: boolean;
},
) {
try {
const client = await this.getClient();
const stringifiedValue = JSON.stringify(value);

if (!options) {
return await client.set(key, stringifiedValue);
}
if (options.ex && options.nx) {
return await client.set(key, stringifiedValue, 'EX', options.ex, 'NX');
}
if (options.nx) {
return await client.set(key, stringifiedValue, 'NX');
}
if (options.ex) {
return await client.set(key, stringifiedValue, 'EX', options.ex);
}
} catch (err) {
if (!isDevelopment) {
logger.error('Failed to set key', err);
}
throw new Error(`Failed to set key: ${err}`);
}
}

async incr(key: string) {
try {
const client = await this.getClient();
return await client.incr(key);
} catch (err) {
if (!isDevelopment) {
logger.error('Failed to increment key', err);
}
throw new Error(`Failed to increment key: ${err}`);
}
}
}

function createDefaultKVManager() {
const url = process.env.KV_URL_DEVELOPMENT_VERCEL;
if (!url) {
throw new Error('No KV URL provided');
}
return new KVManager({ url, tls: true });
}

// Exports an instance of KVManager with the default KV URL
export const kv = createDefaultKVManager();
4 changes: 2 additions & 2 deletions apps/web/src/utils/proofs/sybil_resistance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getAttestations } from '@coinbase/onchainkit/identity';
import { kv } from '@vercel/kv';
import { kv } from 'apps/web/src/utils/kv';
import { CoinbaseProofResponse } from 'apps/web/pages/api/proofs/coinbase';
import RegistrarControllerABI from 'apps/web/src/abis/RegistrarControllerABI';
import {
Expand Down Expand Up @@ -223,7 +223,7 @@ export async function sybilResistantUsernameSigning(
const claim: PreviousClaim = { address, signedMessage };
previousClaims[discountType] = claim;

await kv.set(kvKey, previousClaims, { nx: true, ex: parseInt(EXPIRY) });
await kv.set<PreviousClaims>(kvKey, previousClaims, { nx: true, ex: parseInt(EXPIRY) });

return {
signedMessage: claim.signedMessage,
Expand Down
Loading