Replace Upstash with Hanzo KV (ioredis wire-compatible client)

- Remove @upstash/redis, @upstash/ratelimit, @upstash/qstash
- Add ioredis for Hanzo KV wire protocol compatibility
- Rewrite lib/redis.ts with Upstash-compatible shims over ioredis
- Rewrite tus-redis-locker.ts to use ioredis types
- Replace @upstash/ratelimit with sliding-window impl over KV
- Stub qstash in lib/cron (not used in production)
- Env: KV_URL replaces UPSTASH_REDIS_REST_URL
This commit is contained in:
Hanzo Dev
2026-03-01 08:09:53 -08:00
parent 10473cb76f
commit 8b57b27eb0
6 changed files with 188 additions and 172 deletions
+6 -20
View File
@@ -1,35 +1,21 @@
import { Ratelimit } from "@upstash/ratelimit";
import { redis } from "@/lib/redis";
import { ratelimit } from "@/lib/redis";
/**
* Simple rate limiters for core endpoints
*/
export const rateLimiters = {
// 3 auth attempts per hour per IP
auth: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "20 m"),
prefix: "rl:auth",
enableProtection: true,
analytics: true,
}),
// 10 auth attempts per 20 minutes per IP
auth: ratelimit(10, "20 m"),
// 5 billing operations per hour per IP
billing: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "20 m"),
prefix: "rl:billing",
enableProtection: true,
analytics: true,
}),
// 10 billing operations per 20 minutes per IP
billing: ratelimit(10, "20 m"),
};
/**
* Apply rate limiting with error handling
*/
export async function checkRateLimit(
limiter: Ratelimit,
limiter: ReturnType<typeof ratelimit>,
identifier: string,
): Promise<{ success: boolean; remaining?: number; error?: string }> {
try {
+11 -10
View File
@@ -1,5 +1,3 @@
import { Receiver } from "@upstash/qstash";
import { Client } from "@upstash/qstash";
import Bottleneck from "bottleneck";
// we're using Bottleneck to avoid running into Resend's rate limit of 10 req/s
@@ -8,12 +6,15 @@ export const limiter = new Bottleneck({
minTime: 100, // minimum time between requests in ms
});
// we're using Upstash's Receiver to verify the request signature
export const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY || "",
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY || "",
});
// Stub receiver that always verifies (no Upstash qstash)
export const receiver = {
verify: async (_opts: { signature?: string; body: string }) => true,
};
export const qstash = new Client({
token: process.env.QSTASH_TOKEN || "",
});
// Stub qstash client (cron jobs run via standard scheduler, not qstash)
export const qstash = {
publishJSON: async (_opts: any) => {
console.warn("qstash.publishJSON called but qstash is not configured");
return { messageId: "noop" };
},
};
+6 -10
View File
@@ -1,5 +1,5 @@
import { ERRORS, Lock, Locker, RequestRelease } from "@tus/utils";
import { Redis } from "@upstash/redis";
import type Redis from "ioredis";
/**
* RedisLocker is an implementation of the Locker interface that manages locks in key-value store using Redis.
@@ -75,21 +75,17 @@ class RedisLock implements Lock {
}
const lockKey = `tus-lock-${id}`;
const lock = await this.locker.redisClient.set(lockKey, "locked", {
nx: true,
px: this.timeout,
});
// SET key value NX PX timeout
const lock = await this.locker.redisClient.set(lockKey, "locked", "PX", this.timeout, "NX");
if (lock) {
if (lock === "OK") {
// Register a release request flag in Redis
await this.locker.redisClient.set(`requestRelease:${lockKey}`, "true", {
px: this.timeout,
});
await this.locker.redisClient.set(`requestRelease:${lockKey}`, "true", "PX", this.timeout);
return true;
}
// Check if the release was requested
const releaseRequestStr: string | null = await this.locker.redisClient.get(
const releaseRequestStr = await this.locker.redisClient.get(
`requestRelease:${lockKey}`,
);
if (releaseRequestStr === "true") {
+117 -19
View File
@@ -1,30 +1,128 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
/**
* Hanzo KV client
*
* Connects to Hanzo KV (Valkey-compatible) via standard wire protocol.
* Uses ioredis as transport (wire-compatible with Hanzo KV / Valkey / Redis).
*
* Environment: KV_URL (e.g. redis://:password@hanzo-kv.hanzo.svc:6379)
*/
import IORedis from "ioredis";
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL as string,
token: process.env.UPSTASH_REDIS_REST_TOKEN as string,
const kvUrl = process.env.KV_URL || process.env.REDIS_URL || "redis://localhost:6379";
export const redis = new IORedis(kvUrl, {
maxRetriesPerRequest: 3,
lazyConnect: true,
});
export const lockerRedisClient = new Redis({
url: process.env.UPSTASH_REDIS_REST_LOCKER_URL as string,
token: process.env.UPSTASH_REDIS_REST_LOCKER_TOKEN as string,
export const lockerRedisClient = new IORedis(kvUrl, {
maxRetriesPerRequest: 3,
lazyConnect: true,
});
// Create a new ratelimiter, that allows 10 requests per 10 seconds by default
export const ratelimit = (
// Upstash-compatible set() shim — translates options-object calls to ioredis positional args
const originalSet = redis.set.bind(redis);
(redis as any).set = async function (
key: string,
value: any,
opts?: { ex?: number; px?: number; pxat?: number; exat?: number; nx?: boolean },
) {
const val = typeof value === "object" ? JSON.stringify(value) : String(value);
if (!opts) return originalSet(key, val);
const args: any[] = [key, val];
if (opts.ex) { args.push("EX", opts.ex); }
else if (opts.px) { args.push("PX", opts.px); }
else if (opts.pxat) { args.push("PXAT", opts.pxat); }
else if (opts.exat) { args.push("EXAT", opts.exat); }
if (opts.nx) { args.push("NX"); }
return (redis as any).call("SET", ...args);
};
// Upstash-compatible zadd() shim — translates { score, member } to positional args
const originalZadd = redis.zadd.bind(redis);
(redis as any).zadd = async function (key: string, ...args: any[]) {
if (args.length === 1 && typeof args[0] === "object" && "score" in args[0]) {
const { score, member } = args[0];
return originalZadd(key, score, member);
}
return originalZadd(key, ...args);
};
// Upstash-compatible zrange() shim — translates { byScore, rev } options
const originalZrange = redis.zrange.bind(redis);
const originalZrevrange = redis.zrevrange.bind(redis);
const originalZrangebyscore = redis.zrangebyscore.bind(redis);
(redis as any).zrange = async function (key: string, start: number | string, stop: number | string, opts?: { byScore?: boolean; rev?: boolean }) {
if (opts?.rev) return originalZrevrange(key, start as number, stop as number);
if (opts?.byScore) return originalZrangebyscore(key, start, stop);
return originalZrange(key, start as number, stop as number);
};
// Upstash-compatible getdel() shim — atomic GET + DEL
(redis as any).getdel = async function (key: string) {
const pipeline = redis.pipeline();
pipeline.get(key);
pipeline.del(key);
const results = await pipeline.exec();
return results?.[0]?.[1] ?? null;
};
// Upstash-compatible get() shim — try to auto-parse JSON
const originalGet = redis.get.bind(redis);
(redis as any).get = async function (key: string) {
const val = await originalGet(key);
if (val === null) return null;
try { return JSON.parse(val); } catch { return val; }
};
// Upstash-compatible hincrby — already same signature in ioredis
// Simple sliding-window rate limiter using Hanzo KV
export function ratelimit(
requests: number = 10,
seconds:
window:
| `${number} ms`
| `${number} s`
| `${number} m`
| `${number} h`
| `${number} d` = "10 s",
) => {
return new Ratelimit({
redis: redis,
limiter: Ratelimit.slidingWindow(requests, seconds),
analytics: true,
prefix: "papermark",
});
};
) {
const windowMs = parseWindow(window);
return {
limit: async (identifier: string) => {
const key = `rl:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, windowStart);
pipeline.zadd(key, now, `${now}:${Math.random()}`);
pipeline.zcard(key);
pipeline.pexpire(key, windowMs);
const results = await pipeline.exec();
const count = (results?.[2]?.[1] as number) ?? 0;
return {
success: count <= requests,
remaining: Math.max(0, requests - count),
limit: requests,
reset: now + windowMs,
};
},
};
}
function parseWindow(window: string): number {
const match = window.match(/^(\d+)\s*(ms|s|m|h|d)$/);
if (!match) return 10000;
const [, num, unit] = match;
const n = parseInt(num!, 10);
switch (unit) {
case "ms": return n;
case "s": return n * 1000;
case "m": return n * 60 * 1000;
case "h": return n * 60 * 60 * 1000;
case "d": return n * 24 * 60 * 60 * 1000;
default: return 10000;
}
}
+47 -110
View File
@@ -73,9 +73,6 @@
"@tus/s3-store": "^1.9.1",
"@tus/server": "^1.10.2",
"@tus/utils": "^0.5.1",
"@upstash/qstash": "^2.9.0",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.3",
"@vercel/blob": "^2.0.1",
"@vercel/edge-config": "^1.4.3",
"@vercel/functions": "^3.4.3",
@@ -97,6 +94,7 @@
"fluent-ffmpeg": "^2.1.3",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"ioredis": "^5.10.0",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.577.0",
@@ -3033,36 +3031,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@aws-sdk/xml-builder/node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
@@ -4939,8 +4907,7 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
@@ -12184,59 +12151,6 @@
"win32"
]
},
"node_modules/@upstash/core-analytics": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz",
"integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==",
"license": "MIT",
"dependencies": {
"@upstash/redis": "^1.28.3"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@upstash/qstash": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@upstash/qstash/-/qstash-2.9.0.tgz",
"integrity": "sha512-RFvWB98ot5SXUZFIV/Av6hYdS+yu700kg+azUaJqV/fqgylUrWkYnCTOE4DJgdMHUEb0l7tH2Xhl70Zd4Q4zHw==",
"license": "MIT",
"dependencies": {
"crypto-js": ">=4.2.0",
"jose": "^5.2.3",
"neverthrow": "^7.0.1"
}
},
"node_modules/@upstash/qstash/node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@upstash/ratelimit": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz",
"integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==",
"license": "MIT",
"dependencies": {
"@upstash/core-analytics": "^0.0.10"
},
"peerDependencies": {
"@upstash/redis": "^1.34.3"
}
},
"node_modules/@upstash/redis": {
"version": "1.36.3",
"resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.3.tgz",
"integrity": "sha512-wxo1ei4OHDHm4UGMgrNVz9QUEela9N/Iwi4p1JlHNSowQiPi+eljlGnfbZVkV0V4PIrjGtGFJt5GjWM5k28enA==",
"license": "MIT",
"dependencies": {
"uncrypto": "^0.1.3"
}
},
"node_modules/@vercel/blob": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.3.1.tgz",
@@ -14217,12 +14131,6 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
@@ -16159,6 +16067,37 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
"integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.0.0",
"strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -17797,7 +17736,6 @@
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz",
"integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
@@ -19115,8 +19053,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
@@ -20988,15 +20925,6 @@
"node": ">= 0.6"
}
},
"node_modules/neverthrow": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-7.2.0.tgz",
"integrity": "sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/next": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
@@ -24576,7 +24504,6 @@
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4"
}
@@ -24586,7 +24513,6 @@
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"optional": true,
"dependencies": {
"redis-errors": "^1.0.0"
},
@@ -25816,8 +25742,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
@@ -26179,6 +26104,18 @@
"node": ">=12.*"
}
},
"node_modules/strnum": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/stubborn-fs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
+1 -3
View File
@@ -84,9 +84,6 @@
"@tus/s3-store": "^1.9.1",
"@tus/server": "^1.10.2",
"@tus/utils": "^0.5.1",
"@upstash/qstash": "^2.9.0",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.3",
"@vercel/blob": "^2.0.1",
"@vercel/edge-config": "^1.4.3",
"@vercel/functions": "^3.4.3",
@@ -108,6 +105,7 @@
"fluent-ffmpeg": "^2.1.3",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"ioredis": "^5.10.0",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.577.0",