@open-mercato/shared 0.6.8-develop.6911.1.f853dd04a9 → 0.6.8-develop.6913.1.721e521f55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/ratelimit/service.js +3 -1
- package/dist/lib/ratelimit/service.js.map +2 -2
- package/dist/lib/redis/connection.js +5 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/jest.config.cjs +1 -1
- package/package.json +2 -2
- package/src/lib/ratelimit/service.ts +2 -0
- package/src/lib/redis/__tests__/connection.test.ts +18 -1
- package/src/lib/redis/connection.ts +23 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
|
|
2
|
+
import { REDIS_WIRE_PROTOCOL } from "../redis/connection.js";
|
|
2
3
|
class RateLimiterService {
|
|
3
4
|
constructor(globalConfig) {
|
|
4
5
|
this.limiters = /* @__PURE__ */ new Map();
|
|
@@ -11,7 +12,8 @@ class RateLimiterService {
|
|
|
11
12
|
const { default: Redis } = await import("ioredis");
|
|
12
13
|
this.redisClient = new Redis(this.globalConfig.redisUrl, {
|
|
13
14
|
enableOfflineQueue: false,
|
|
14
|
-
maxRetriesPerRequest: 1
|
|
15
|
+
maxRetriesPerRequest: 1,
|
|
16
|
+
protocol: REDIS_WIRE_PROTOCOL
|
|
15
17
|
});
|
|
16
18
|
}
|
|
17
19
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/ratelimit/service.ts"],
|
|
4
|
-
"sourcesContent": ["import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'\nimport type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'\n\n/** Narrow interface for the ioredis client \u2014 only the methods we actually use. */\ninterface RedisClient {\n disconnect(): void\n}\n\nexport class RateLimiterService {\n private globalConfig: RateLimitGlobalConfig\n private limiters = new Map<string, RateLimiterMemory | RateLimiterRedis>()\n private redisClient: RedisClient | null = null\n\n readonly trustProxyDepth: number\n\n constructor(globalConfig: RateLimitGlobalConfig) {\n this.globalConfig = globalConfig\n this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0\n }\n\n async initialize(): Promise<void> {\n if (this.globalConfig.strategy === 'redis' && this.globalConfig.redisUrl) {\n const { default: Redis } = await import('ioredis')\n this.redisClient = new Redis(this.globalConfig.redisUrl, {\n enableOfflineQueue: false,\n maxRetriesPerRequest: 1,\n })\n }\n }\n\n async consume(key: string, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n\n const limiter = this.getOrCreateLimiter(config)\n\n try {\n const res = await limiter.consume(key, 1)\n return this.toResult(res, true)\n } catch (error) {\n if (error instanceof RateLimiterRes) {\n return this.toResult(error, false)\n }\n return this.disabledResult(config)\n }\n }\n\n async get(key: string, config: RateLimitConfig): Promise<RateLimitResult | null> {\n if (!this.globalConfig.enabled) return null\n\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.get(key)\n return res ? this.toResult(res, res.remainingPoints > 0) : null\n }\n\n async delete(key: string, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.delete(key)\n }\n\n async penalty(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.penalty(key, points)\n return this.toResult(res, res.remainingPoints > 0)\n }\n\n async reward(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.reward(key, points)\n return this.toResult(res, true)\n }\n\n async block(key: string, durationSec: number, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.block(key, durationSec)\n }\n\n async destroy(): Promise<void> {\n if (this.redisClient) {\n this.redisClient.disconnect()\n }\n this.limiters.clear()\n }\n\n private disabledResult(config: RateLimitConfig): RateLimitResult {\n return { allowed: true, remainingPoints: config.points, msBeforeNext: 0, consumedPoints: 0 }\n }\n\n private getOrCreateLimiter(config: RateLimitConfig): RateLimiterMemory | RateLimiterRedis {\n const cacheKey = `${config.keyPrefix ?? 'default'}:${config.points}:${config.duration}:${config.blockDuration ?? 0}`\n\n let limiter = this.limiters.get(cacheKey)\n if (limiter) return limiter\n\n const prefix = [this.globalConfig.keyPrefix, config.keyPrefix].filter(Boolean).join(':')\n\n const baseOpts = {\n keyPrefix: prefix,\n points: config.points,\n duration: config.duration,\n blockDuration: config.blockDuration ?? 0,\n }\n\n if (this.globalConfig.strategy === 'redis' && this.redisClient) {\n const insuranceLimiter = new RateLimiterMemory(baseOpts)\n limiter = new RateLimiterRedis({\n ...baseOpts,\n storeClient: this.redisClient,\n insuranceLimiter,\n rejectIfRedisNotReady: false,\n })\n } else {\n limiter = new RateLimiterMemory(baseOpts)\n }\n\n this.limiters.set(cacheKey, limiter)\n return limiter\n }\n\n private toResult(res: RateLimiterRes, allowed: boolean): RateLimitResult {\n return {\n allowed,\n remainingPoints: Math.max(res.remainingPoints, 0),\n msBeforeNext: res.msBeforeNext,\n consumedPoints: res.consumedPoints,\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,mBAAmB,kBAAkB,sBAAsB;
|
|
4
|
+
"sourcesContent": ["import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'\nimport { REDIS_WIRE_PROTOCOL } from '../redis/connection'\nimport type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'\n\n/** Narrow interface for the ioredis client \u2014 only the methods we actually use. */\ninterface RedisClient {\n disconnect(): void\n}\n\nexport class RateLimiterService {\n private globalConfig: RateLimitGlobalConfig\n private limiters = new Map<string, RateLimiterMemory | RateLimiterRedis>()\n private redisClient: RedisClient | null = null\n\n readonly trustProxyDepth: number\n\n constructor(globalConfig: RateLimitGlobalConfig) {\n this.globalConfig = globalConfig\n this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0\n }\n\n async initialize(): Promise<void> {\n if (this.globalConfig.strategy === 'redis' && this.globalConfig.redisUrl) {\n const { default: Redis } = await import('ioredis')\n this.redisClient = new Redis(this.globalConfig.redisUrl, {\n enableOfflineQueue: false,\n maxRetriesPerRequest: 1,\n protocol: REDIS_WIRE_PROTOCOL,\n })\n }\n }\n\n async consume(key: string, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n\n const limiter = this.getOrCreateLimiter(config)\n\n try {\n const res = await limiter.consume(key, 1)\n return this.toResult(res, true)\n } catch (error) {\n if (error instanceof RateLimiterRes) {\n return this.toResult(error, false)\n }\n return this.disabledResult(config)\n }\n }\n\n async get(key: string, config: RateLimitConfig): Promise<RateLimitResult | null> {\n if (!this.globalConfig.enabled) return null\n\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.get(key)\n return res ? this.toResult(res, res.remainingPoints > 0) : null\n }\n\n async delete(key: string, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.delete(key)\n }\n\n async penalty(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.penalty(key, points)\n return this.toResult(res, res.remainingPoints > 0)\n }\n\n async reward(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.reward(key, points)\n return this.toResult(res, true)\n }\n\n async block(key: string, durationSec: number, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.block(key, durationSec)\n }\n\n async destroy(): Promise<void> {\n if (this.redisClient) {\n this.redisClient.disconnect()\n }\n this.limiters.clear()\n }\n\n private disabledResult(config: RateLimitConfig): RateLimitResult {\n return { allowed: true, remainingPoints: config.points, msBeforeNext: 0, consumedPoints: 0 }\n }\n\n private getOrCreateLimiter(config: RateLimitConfig): RateLimiterMemory | RateLimiterRedis {\n const cacheKey = `${config.keyPrefix ?? 'default'}:${config.points}:${config.duration}:${config.blockDuration ?? 0}`\n\n let limiter = this.limiters.get(cacheKey)\n if (limiter) return limiter\n\n const prefix = [this.globalConfig.keyPrefix, config.keyPrefix].filter(Boolean).join(':')\n\n const baseOpts = {\n keyPrefix: prefix,\n points: config.points,\n duration: config.duration,\n blockDuration: config.blockDuration ?? 0,\n }\n\n if (this.globalConfig.strategy === 'redis' && this.redisClient) {\n const insuranceLimiter = new RateLimiterMemory(baseOpts)\n limiter = new RateLimiterRedis({\n ...baseOpts,\n storeClient: this.redisClient,\n insuranceLimiter,\n rejectIfRedisNotReady: false,\n })\n } else {\n limiter = new RateLimiterMemory(baseOpts)\n }\n\n this.limiters.set(cacheKey, limiter)\n return limiter\n }\n\n private toResult(res: RateLimiterRes, allowed: boolean): RateLimitResult {\n return {\n allowed,\n remainingPoints: Math.max(res.remainingPoints, 0),\n msBeforeNext: res.msBeforeNext,\n consumedPoints: res.consumedPoints,\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createLogger } from "../logger/index.js";
|
|
2
2
|
const logger = createLogger("shared").child({ component: "redis" });
|
|
3
|
+
const REDIS_WIRE_PROTOCOL = 2;
|
|
3
4
|
function getRedisUrl(prefix) {
|
|
4
5
|
if (prefix) {
|
|
5
6
|
const prefixed = process.env[`${prefix}_REDIS_URL`];
|
|
@@ -31,12 +32,13 @@ function parseRedisUrl(url) {
|
|
|
31
32
|
password: parsed.password ? decodeURIComponent(parsed.password) : void 0,
|
|
32
33
|
db,
|
|
33
34
|
tls: parsed.protocol === "rediss:" ? {} : void 0,
|
|
34
|
-
family
|
|
35
|
+
family,
|
|
36
|
+
protocol: REDIS_WIRE_PROTOCOL
|
|
35
37
|
};
|
|
36
38
|
} catch {
|
|
37
39
|
const safeUrl = url.replace(/\/\/[^:]*:[^@]*@/, "//<redacted>@");
|
|
38
40
|
logger.warn("Failed to parse Redis URL, falling back to localhost:6379", { url: safeUrl });
|
|
39
|
-
return { host: "localhost", port: 6379 };
|
|
41
|
+
return { host: "localhost", port: 6379, protocol: REDIS_WIRE_PROTOCOL };
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
function resolveRedisConnection(prefix) {
|
|
@@ -45,6 +47,7 @@ function resolveRedisConnection(prefix) {
|
|
|
45
47
|
return { url, ...parseRedisUrl(url) };
|
|
46
48
|
}
|
|
47
49
|
export {
|
|
50
|
+
REDIS_WIRE_PROTOCOL,
|
|
48
51
|
getRedisUrl,
|
|
49
52
|
getRedisUrlOrThrow,
|
|
50
53
|
parseRedisUrl,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/redis/connection.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'redis' })\n\n/**\n * Shared Redis connection utilities.\n *\n * Every package that needs a Redis URL or parsed connection options\n * should import from here instead of reading env vars directly.\n *\n * The `prefix` parameter lets each subsystem define its own override:\n * getRedisUrl('QUEUE') \u2192 QUEUE_REDIS_URL > REDIS_URL > null\n * getRedisUrl('CACHE') \u2192 CACHE_REDIS_URL > REDIS_URL > null\n * getRedisUrl() \u2192 REDIS_URL > null\n *\n * Returns null when Redis is not configured so callers can explicitly\n * decide whether to fall back to a non-Redis strategy or fail loudly.\n * Use getRedisUrlOrThrow() when Redis is mandatory for the caller.\n */\n\nexport type ParsedRedisConnection = {\n host: string\n port: number\n username?: string\n password?: string\n db?: number\n tls?: Record<string, unknown>\n family?: number\n}\n\n/**\n * Resolve a Redis URL from environment variables.\n *\n * Priority: <PREFIX>_REDIS_URL \u2192 REDIS_URL \u2192 null\n *\n * Returns null when no env var is set. Callers MUST NOT assume a\n * localhost default \u2014 silently connecting to localhost masks missing\n * configuration and stalls on platforms where nothing listens there\n * (e.g., WSL2 without a local Redis).\n */\nexport function getRedisUrl(prefix?: string): string | null {\n if (prefix) {\n const prefixed = process.env[`${prefix}_REDIS_URL`]\n if (prefixed) return prefixed\n }\n return process.env.REDIS_URL || null\n}\n\n/**\n * Like getRedisUrl, but throws a descriptive error when Redis is not\n * configured. Use from code paths that require Redis (e.g. BullMQ\n * async queue, Redis cache strategy, scheduler service).\n */\nexport function getRedisUrlOrThrow(prefix?: string): string {\n const url = getRedisUrl(prefix)\n if (url) return url\n const which = prefix ? `${prefix}_REDIS_URL or REDIS_URL` : 'REDIS_URL'\n throw new Error(\n `Redis URL is not configured. Set ${which} in your environment to use a Redis-backed strategy.`\n )\n}\n\n/**\n * Parse a redis:// URL into structured connection options suitable for\n * BullMQ and ioredis.\n */\nexport function parseRedisUrl(url: string): ParsedRedisConnection {\n try {\n const parsed = new URL(url)\n const dbStr = parsed.pathname ? parsed.pathname.slice(1) : ''\n const dbParsed = dbStr !== '' ? parseInt(dbStr, 10) : NaN\n const db = Number.isNaN(dbParsed) ? undefined : dbParsed\n const familyValue = parsed.searchParams.get('family')\n const familyParsed = familyValue !== null ? parseInt(familyValue, 10) : NaN\n const family = [0, 4, 6].includes(familyParsed) ? familyParsed : undefined\n return {\n host: parsed.hostname || 'localhost',\n port: parseInt(parsed.port, 10) || 6379,\n username: parsed.username ? decodeURIComponent(parsed.username) : undefined,\n password: parsed.password ? decodeURIComponent(parsed.password) : undefined,\n db,\n tls: parsed.protocol === 'rediss:' ? {} : undefined,\n family,\n }\n } catch {\n const safeUrl = url.replace(/\\/\\/[^:]*:[^@]*@/, '//<redacted>@')\n logger.warn('Failed to parse Redis URL, falling back to localhost:6379', { url: safeUrl })\n return { host: 'localhost', port: 6379 }\n }\n}\n\n/**\n * Convenience: resolve the URL from env and parse it in one step.\n * Returns null when Redis is not configured.\n */\nexport function resolveRedisConnection(\n prefix?: string,\n): (ParsedRedisConnection & { url: string }) | null {\n const url = getRedisUrl(prefix)\n if (!url) return null\n return { url, ...parseRedisUrl(url) }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'redis' })\n\n/**\n * Shared Redis connection utilities.\n *\n * Every package that needs a Redis URL or parsed connection options\n * should import from here instead of reading env vars directly.\n *\n * The `prefix` parameter lets each subsystem define its own override:\n * getRedisUrl('QUEUE') \u2192 QUEUE_REDIS_URL > REDIS_URL > null\n * getRedisUrl('CACHE') \u2192 CACHE_REDIS_URL > REDIS_URL > null\n * getRedisUrl() \u2192 REDIS_URL > null\n *\n * Returns null when Redis is not configured so callers can explicitly\n * decide whether to fall back to a non-Redis strategy or fail loudly.\n * Use getRedisUrlOrThrow() when Redis is mandatory for the caller.\n */\n\nexport type ParsedRedisConnection = {\n host: string\n port: number\n username?: string\n password?: string\n db?: number\n tls?: Record<string, unknown>\n family?: number\n protocol?: RedisProtocolVersion\n}\n\n/**\n * RESP wire protocol version, mirroring ioredis' own `ProtocolVersion` union.\n * Declared locally because ioredis is an optional peer of the packages that\n * consume these options.\n */\nexport type RedisProtocolVersion = 2 | 3\n\n/**\n * Wire protocol every Redis client in the monorepo negotiates.\n *\n * ioredis 6 switched its default from RESP2 to RESP3. RESP3 changes the reply\n * shape of map-style commands and delivers pub/sub over push frames instead of\n * ordinary replies, which neither BullMQ nor rate-limiter-flexible declare\n * support for. Pinning the protocol keeps the client byte-compatible with the\n * ioredis 5 behaviour the rest of the stack was built and tested against.\n * Adopting RESP3 is a deliberate, separately testable change \u2014 not a side\n * effect of a version bump.\n */\nexport const REDIS_WIRE_PROTOCOL: RedisProtocolVersion = 2\n\n/**\n * Resolve a Redis URL from environment variables.\n *\n * Priority: <PREFIX>_REDIS_URL \u2192 REDIS_URL \u2192 null\n *\n * Returns null when no env var is set. Callers MUST NOT assume a\n * localhost default \u2014 silently connecting to localhost masks missing\n * configuration and stalls on platforms where nothing listens there\n * (e.g., WSL2 without a local Redis).\n */\nexport function getRedisUrl(prefix?: string): string | null {\n if (prefix) {\n const prefixed = process.env[`${prefix}_REDIS_URL`]\n if (prefixed) return prefixed\n }\n return process.env.REDIS_URL || null\n}\n\n/**\n * Like getRedisUrl, but throws a descriptive error when Redis is not\n * configured. Use from code paths that require Redis (e.g. BullMQ\n * async queue, Redis cache strategy, scheduler service).\n */\nexport function getRedisUrlOrThrow(prefix?: string): string {\n const url = getRedisUrl(prefix)\n if (url) return url\n const which = prefix ? `${prefix}_REDIS_URL or REDIS_URL` : 'REDIS_URL'\n throw new Error(\n `Redis URL is not configured. Set ${which} in your environment to use a Redis-backed strategy.`\n )\n}\n\n/**\n * Parse a redis:// URL into structured connection options suitable for\n * BullMQ and ioredis.\n */\nexport function parseRedisUrl(url: string): ParsedRedisConnection {\n try {\n const parsed = new URL(url)\n const dbStr = parsed.pathname ? parsed.pathname.slice(1) : ''\n const dbParsed = dbStr !== '' ? parseInt(dbStr, 10) : NaN\n const db = Number.isNaN(dbParsed) ? undefined : dbParsed\n const familyValue = parsed.searchParams.get('family')\n const familyParsed = familyValue !== null ? parseInt(familyValue, 10) : NaN\n const family = [0, 4, 6].includes(familyParsed) ? familyParsed : undefined\n return {\n host: parsed.hostname || 'localhost',\n port: parseInt(parsed.port, 10) || 6379,\n username: parsed.username ? decodeURIComponent(parsed.username) : undefined,\n password: parsed.password ? decodeURIComponent(parsed.password) : undefined,\n db,\n tls: parsed.protocol === 'rediss:' ? {} : undefined,\n family,\n protocol: REDIS_WIRE_PROTOCOL,\n }\n } catch {\n const safeUrl = url.replace(/\\/\\/[^:]*:[^@]*@/, '//<redacted>@')\n logger.warn('Failed to parse Redis URL, falling back to localhost:6379', { url: safeUrl })\n return { host: 'localhost', port: 6379, protocol: REDIS_WIRE_PROTOCOL }\n }\n}\n\n/**\n * Convenience: resolve the URL from env and parse it in one step.\n * Returns null when Redis is not configured.\n */\nexport function resolveRedisConnection(\n prefix?: string,\n): (ParsedRedisConnection & { url: string }) | null {\n const url = getRedisUrl(prefix)\n if (!url) return null\n return { url, ...parseRedisUrl(url) }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;AA+C3D,MAAM,sBAA4C;AAYlD,SAAS,YAAY,QAAgC;AAC1D,MAAI,QAAQ;AACV,UAAM,WAAW,QAAQ,IAAI,GAAG,MAAM,YAAY;AAClD,QAAI,SAAU,QAAO;AAAA,EACvB;AACA,SAAO,QAAQ,IAAI,aAAa;AAClC;AAOO,SAAS,mBAAmB,QAAyB;AAC1D,QAAM,MAAM,YAAY,MAAM;AAC9B,MAAI,IAAK,QAAO;AAChB,QAAM,QAAQ,SAAS,GAAG,MAAM,4BAA4B;AAC5D,QAAM,IAAI;AAAA,IACR,oCAAoC,KAAK;AAAA,EAC3C;AACF;AAMO,SAAS,cAAc,KAAoC;AAChE,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAM,QAAQ,OAAO,WAAW,OAAO,SAAS,MAAM,CAAC,IAAI;AAC3D,UAAM,WAAW,UAAU,KAAK,SAAS,OAAO,EAAE,IAAI;AACtD,UAAM,KAAK,OAAO,MAAM,QAAQ,IAAI,SAAY;AAChD,UAAM,cAAc,OAAO,aAAa,IAAI,QAAQ;AACpD,UAAM,eAAe,gBAAgB,OAAO,SAAS,aAAa,EAAE,IAAI;AACxE,UAAM,SAAS,CAAC,GAAG,GAAG,CAAC,EAAE,SAAS,YAAY,IAAI,eAAe;AACjE,WAAO;AAAA,MACL,MAAM,OAAO,YAAY;AAAA,MACzB,MAAM,SAAS,OAAO,MAAM,EAAE,KAAK;AAAA,MACnC,UAAU,OAAO,WAAW,mBAAmB,OAAO,QAAQ,IAAI;AAAA,MAClE,UAAU,OAAO,WAAW,mBAAmB,OAAO,QAAQ,IAAI;AAAA,MAClE;AAAA,MACA,KAAK,OAAO,aAAa,YAAY,CAAC,IAAI;AAAA,MAC1C;AAAA,MACA,UAAU;AAAA,IACZ;AAAA,EACF,QAAQ;AACN,UAAM,UAAU,IAAI,QAAQ,oBAAoB,eAAe;AAC/D,WAAO,KAAK,6DAA6D,EAAE,KAAK,QAAQ,CAAC;AACzF,WAAO,EAAE,MAAM,aAAa,MAAM,MAAM,UAAU,oBAAoB;AAAA,EACxE;AACF;AAMO,SAAS,uBACd,QACkD;AAClD,QAAM,MAAM,YAAY,MAAM;AAC9B,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,EAAE,KAAK,GAAG,cAAc,GAAG,EAAE;AACtC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6913.1.721e521f55';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/jest.config.cjs
CHANGED
|
@@ -34,7 +34,7 @@ module.exports = {
|
|
|
34
34
|
[SCRIPTS_CJS_PATTERN]: transformer,
|
|
35
35
|
},
|
|
36
36
|
transformIgnorePatterns: [
|
|
37
|
-
'node_modules/(?!(@mikro-orm|kysely|ai|@ai-sdk|ai-sdk-ollama|@workflow|@standard-schema)/)',
|
|
37
|
+
'node_modules/(?!(@mikro-orm|kysely|ai|@ai-sdk|ai-sdk-ollama|@workflow|@standard-schema|@tanstack/react-table|@tanstack/table-core|@tanstack/react-store|@tanstack/store)/)',
|
|
38
38
|
],
|
|
39
39
|
testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
|
|
40
40
|
passWithNoTests: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6913.1.721e521f55",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@mikro-orm/core": "^7.1.8",
|
|
106
106
|
"@mikro-orm/decorators": "^7.1.8",
|
|
107
107
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
108
|
-
"@open-mercato/cache": "0.6.8-develop.
|
|
108
|
+
"@open-mercato/cache": "0.6.8-develop.6913.1.721e521f55",
|
|
109
109
|
"@types/sanitize-html": "^2.16.1",
|
|
110
110
|
"dotenv": "^17.4.2",
|
|
111
111
|
"pino": "^10.3.1",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'
|
|
2
|
+
import { REDIS_WIRE_PROTOCOL } from '../redis/connection'
|
|
2
3
|
import type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'
|
|
3
4
|
|
|
4
5
|
/** Narrow interface for the ioredis client — only the methods we actually use. */
|
|
@@ -24,6 +25,7 @@ export class RateLimiterService {
|
|
|
24
25
|
this.redisClient = new Redis(this.globalConfig.redisUrl, {
|
|
25
26
|
enableOfflineQueue: false,
|
|
26
27
|
maxRetriesPerRequest: 1,
|
|
28
|
+
protocol: REDIS_WIRE_PROTOCOL,
|
|
27
29
|
})
|
|
28
30
|
}
|
|
29
31
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseRedisUrl } from '../connection'
|
|
1
|
+
import { parseRedisUrl, REDIS_WIRE_PROTOCOL } from '../connection'
|
|
2
2
|
|
|
3
3
|
describe('parseRedisUrl', () => {
|
|
4
4
|
it('adds tls config for rediss URLs', () => {
|
|
@@ -10,6 +10,7 @@ describe('parseRedisUrl', () => {
|
|
|
10
10
|
db: 2,
|
|
11
11
|
tls: {},
|
|
12
12
|
family: undefined,
|
|
13
|
+
protocol: 2,
|
|
13
14
|
})
|
|
14
15
|
})
|
|
15
16
|
|
|
@@ -22,6 +23,7 @@ describe('parseRedisUrl', () => {
|
|
|
22
23
|
db: 0,
|
|
23
24
|
tls: undefined,
|
|
24
25
|
family: undefined,
|
|
26
|
+
protocol: 2,
|
|
25
27
|
})
|
|
26
28
|
})
|
|
27
29
|
|
|
@@ -34,6 +36,21 @@ describe('parseRedisUrl', () => {
|
|
|
34
36
|
db: 4,
|
|
35
37
|
tls: {},
|
|
36
38
|
family: 6,
|
|
39
|
+
protocol: 2,
|
|
37
40
|
})
|
|
38
41
|
})
|
|
42
|
+
|
|
43
|
+
it('pins the wire protocol to RESP2 even on the malformed-URL fallback', () => {
|
|
44
|
+
expect(parseRedisUrl('not a url')).toEqual({
|
|
45
|
+
host: 'localhost',
|
|
46
|
+
port: 6379,
|
|
47
|
+
protocol: 2,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('REDIS_WIRE_PROTOCOL', () => {
|
|
53
|
+
it('stays on RESP2 so ioredis 6 does not silently negotiate RESP3', () => {
|
|
54
|
+
expect(REDIS_WIRE_PROTOCOL).toBe(2)
|
|
55
|
+
})
|
|
39
56
|
})
|
|
@@ -26,8 +26,29 @@ export type ParsedRedisConnection = {
|
|
|
26
26
|
db?: number
|
|
27
27
|
tls?: Record<string, unknown>
|
|
28
28
|
family?: number
|
|
29
|
+
protocol?: RedisProtocolVersion
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* RESP wire protocol version, mirroring ioredis' own `ProtocolVersion` union.
|
|
34
|
+
* Declared locally because ioredis is an optional peer of the packages that
|
|
35
|
+
* consume these options.
|
|
36
|
+
*/
|
|
37
|
+
export type RedisProtocolVersion = 2 | 3
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wire protocol every Redis client in the monorepo negotiates.
|
|
41
|
+
*
|
|
42
|
+
* ioredis 6 switched its default from RESP2 to RESP3. RESP3 changes the reply
|
|
43
|
+
* shape of map-style commands and delivers pub/sub over push frames instead of
|
|
44
|
+
* ordinary replies, which neither BullMQ nor rate-limiter-flexible declare
|
|
45
|
+
* support for. Pinning the protocol keeps the client byte-compatible with the
|
|
46
|
+
* ioredis 5 behaviour the rest of the stack was built and tested against.
|
|
47
|
+
* Adopting RESP3 is a deliberate, separately testable change — not a side
|
|
48
|
+
* effect of a version bump.
|
|
49
|
+
*/
|
|
50
|
+
export const REDIS_WIRE_PROTOCOL: RedisProtocolVersion = 2
|
|
51
|
+
|
|
31
52
|
/**
|
|
32
53
|
* Resolve a Redis URL from environment variables.
|
|
33
54
|
*
|
|
@@ -81,11 +102,12 @@ export function parseRedisUrl(url: string): ParsedRedisConnection {
|
|
|
81
102
|
db,
|
|
82
103
|
tls: parsed.protocol === 'rediss:' ? {} : undefined,
|
|
83
104
|
family,
|
|
105
|
+
protocol: REDIS_WIRE_PROTOCOL,
|
|
84
106
|
}
|
|
85
107
|
} catch {
|
|
86
108
|
const safeUrl = url.replace(/\/\/[^:]*:[^@]*@/, '//<redacted>@')
|
|
87
109
|
logger.warn('Failed to parse Redis URL, falling back to localhost:6379', { url: safeUrl })
|
|
88
|
-
return { host: 'localhost', port: 6379 }
|
|
110
|
+
return { host: 'localhost', port: 6379, protocol: REDIS_WIRE_PROTOCOL }
|
|
89
111
|
}
|
|
90
112
|
}
|
|
91
113
|
|