veryfront 0.1.217 → 0.1.219
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/esm/cli/commands/init/config-generator.d.ts +13 -1
- package/esm/cli/commands/init/config-generator.d.ts.map +1 -1
- package/esm/cli/commands/init/config-generator.js +15 -1
- package/esm/cli/commands/init/init-command.d.ts.map +1 -1
- package/esm/cli/commands/init/init-command.js +9 -2
- package/esm/deno.d.ts +1 -3
- package/esm/deno.js +6 -5
- package/esm/src/agent/ag-ui-detached-start.d.ts +84 -0
- package/esm/src/agent/ag-ui-detached-start.d.ts.map +1 -0
- package/esm/src/agent/ag-ui-detached-start.js +273 -0
- package/esm/src/agent/index.d.ts +1 -0
- package/esm/src/agent/index.d.ts.map +1 -1
- package/esm/src/agent/index.js +1 -0
- package/esm/src/extensions/interfaces/auth-provider.d.ts +30 -3
- package/esm/src/extensions/interfaces/auth-provider.d.ts.map +1 -1
- package/esm/src/extensions/interfaces/index.d.ts +2 -1
- package/esm/src/extensions/interfaces/index.d.ts.map +1 -1
- package/esm/src/extensions/interfaces/token-cache-store.d.ts +56 -0
- package/esm/src/extensions/interfaces/token-cache-store.d.ts.map +1 -0
- package/esm/src/extensions/interfaces/token-cache-store.js +12 -0
- package/esm/src/extensions/recommendations.d.ts.map +1 -1
- package/esm/src/extensions/recommendations.js +1 -0
- package/esm/src/integrations/_data.js +1 -1
- package/esm/src/integrations/schema.d.ts +1 -0
- package/esm/src/integrations/schema.d.ts.map +1 -1
- package/esm/src/integrations/schema.js +8 -0
- package/esm/src/proxy/cache/index.d.ts +1 -1
- package/esm/src/proxy/cache/index.d.ts.map +1 -1
- package/esm/src/proxy/cache/index.js +25 -15
- package/esm/src/proxy/cache/tracing-cache.d.ts +31 -0
- package/esm/src/proxy/cache/tracing-cache.d.ts.map +1 -0
- package/esm/src/proxy/cache/tracing-cache.js +44 -0
- package/esm/src/proxy/cache/types.d.ts +1 -1
- package/esm/src/proxy/cache/types.js +1 -1
- package/esm/src/proxy/handler.d.ts +7 -0
- package/esm/src/proxy/handler.d.ts.map +1 -1
- package/esm/src/proxy/handler.js +50 -29
- package/esm/src/server/runtime-handler/request-tracker.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/request-tracker.js +5 -6
- package/esm/src/server/runtime-handler/request-utils.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/request-utils.js +1 -0
- package/esm/src/utils/version-constant.d.ts +1 -1
- package/esm/src/utils/version-constant.js +1 -1
- package/package.json +66 -35
- package/src/cli/commands/init/config-generator.ts +33 -0
- package/src/cli/commands/init/init-command.ts +9 -2
- package/src/deno.js +6 -5
- package/src/src/agent/ag-ui-detached-start.ts +397 -0
- package/src/src/agent/index.ts +8 -0
- package/src/src/extensions/interfaces/auth-provider.ts +35 -3
- package/src/src/extensions/interfaces/index.ts +10 -1
- package/src/src/extensions/interfaces/token-cache-store.ts +58 -0
- package/src/src/extensions/recommendations.ts +1 -0
- package/src/src/integrations/_data.ts +1 -1
- package/src/src/integrations/schema.ts +8 -0
- package/src/src/proxy/cache/index.ts +27 -15
- package/src/src/proxy/cache/tracing-cache.ts +77 -0
- package/src/src/proxy/cache/types.ts +1 -1
- package/src/src/proxy/handler.ts +57 -31
- package/src/src/server/runtime-handler/request-tracker.ts +5 -7
- package/src/src/server/runtime-handler/request-utils.ts +1 -0
- package/src/src/utils/version-constant.ts +1 -1
- package/esm/src/proxy/cache/redis-cache.d.ts +0 -25
- package/esm/src/proxy/cache/redis-cache.d.ts.map +0 -1
- package/esm/src/proxy/cache/redis-cache.js +0 -219
- package/src/src/proxy/cache/redis-cache.ts +0 -255
|
@@ -13,24 +13,34 @@ export type {
|
|
|
13
13
|
TokenCacheEntry,
|
|
14
14
|
} from "./types.js";
|
|
15
15
|
export { MemoryCache } from "./memory-cache.js";
|
|
16
|
-
export { RedisCache } from "./redis-cache.js";
|
|
17
16
|
export { ResilientCache } from "./resilient-cache.js";
|
|
17
|
+
export { TracingTokenCache } from "./tracing-cache.js";
|
|
18
18
|
|
|
19
19
|
import type { CacheOptions, TokenCache } from "./types.js";
|
|
20
|
+
import type { TokenCacheStore } from "../../extensions/interfaces/token-cache-store.js";
|
|
20
21
|
import { MemoryCache } from "./memory-cache.js";
|
|
21
|
-
import { RedisCache } from "./redis-cache.js";
|
|
22
22
|
import { ResilientCache } from "./resilient-cache.js";
|
|
23
|
+
import { TracingTokenCache } from "./tracing-cache.js";
|
|
24
|
+
import { tryResolve } from "../../extensions/contracts.js";
|
|
23
25
|
import { getEnv } from "../../platform/compat/process.js";
|
|
24
26
|
import { proxyLogger } from "../logger.js";
|
|
25
27
|
import { withSpan } from "../tracing.js";
|
|
26
28
|
|
|
27
29
|
const logger = proxyLogger.child({ module: "cache" });
|
|
28
30
|
|
|
31
|
+
const MISSING_EXTENSION_INFO =
|
|
32
|
+
"TokenCacheStore contract not provided — install @veryfront/ext-redis or scaffold extensions/ext-redis/";
|
|
33
|
+
|
|
29
34
|
export async function createCache(options: CacheOptions): Promise<TokenCache> {
|
|
30
35
|
return withSpan(
|
|
31
36
|
"cache.create",
|
|
32
37
|
async () => {
|
|
33
|
-
if (options.type === "redis")
|
|
38
|
+
if (options.type === "redis") {
|
|
39
|
+
const tokenCache = tryResolve<TokenCacheStore>("TokenCacheStore");
|
|
40
|
+
if (tokenCache) return new TracingTokenCache(tokenCache as unknown as TokenCache);
|
|
41
|
+
logger.info(MISSING_EXTENSION_INFO);
|
|
42
|
+
return new MemoryCache(undefined);
|
|
43
|
+
}
|
|
34
44
|
return new MemoryCache(options.options);
|
|
35
45
|
},
|
|
36
46
|
{ "cache.type": options.type },
|
|
@@ -45,21 +55,23 @@ export async function createCacheFromEnv(): Promise<TokenCache> {
|
|
|
45
55
|
async () => {
|
|
46
56
|
if (cacheType !== "redis") return new MemoryCache();
|
|
47
57
|
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
50
|
-
|
|
58
|
+
const tokenCache = tryResolve<TokenCacheStore>("TokenCacheStore");
|
|
59
|
+
if (!tokenCache) {
|
|
60
|
+
// Redis was requested via config/env but no extension registered the
|
|
61
|
+
// TokenCacheStore contract. Log an info (misconfiguration, not error),
|
|
62
|
+
// then fall back to an in-memory cache so the proxy still boots.
|
|
63
|
+
logger.info(MISSING_EXTENSION_INFO);
|
|
51
64
|
return new MemoryCache();
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return new ResilientCache(redisCache, new MemoryCache());
|
|
67
|
+
// Wrap the extension-provided cache with a memory fallback so a Redis
|
|
68
|
+
// outage does not take the proxy down. TracingTokenCache sits between
|
|
69
|
+
// ResilientCache and the extension impl so spans wrap the actual
|
|
70
|
+
// primary-cache attempt (mirrors the pre-extraction RedisCache which
|
|
71
|
+
// had inner withSpan calls).
|
|
72
|
+
logger.debug("[Cache] Using TokenCacheStore extension with memory fallback (ResilientCache)");
|
|
73
|
+
const traced = new TracingTokenCache(tokenCache as unknown as TokenCache);
|
|
74
|
+
return new ResilientCache(traced, new MemoryCache());
|
|
63
75
|
},
|
|
64
76
|
{ "cache.type": cacheType },
|
|
65
77
|
);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TracingTokenCache
|
|
3
|
+
*
|
|
4
|
+
* Wraps a {@link TokenCache} implementation and emits an OpenTelemetry span
|
|
5
|
+
* around each public method. This keeps extension-provided caches (such as
|
|
6
|
+
* `@veryfront/ext-redis`) tracer-agnostic: the extension implements the
|
|
7
|
+
* contract only, and the proxy applies observability at the factory boundary.
|
|
8
|
+
*
|
|
9
|
+
* Span names default to `cache.redis.<op>` to preserve the pre-extraction
|
|
10
|
+
* behavior of the in-tree RedisCache. Callers may override via
|
|
11
|
+
* {@link TracingTokenCacheOptions.spanPrefix} when wrapping a different
|
|
12
|
+
* backend.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CacheStats, TokenCache, TokenCacheEntry } from "./types.js";
|
|
16
|
+
import { withSpan } from "../tracing.js";
|
|
17
|
+
|
|
18
|
+
export interface TracingTokenCacheOptions {
|
|
19
|
+
/** Span name prefix, e.g. "cache.redis" produces "cache.redis.get". */
|
|
20
|
+
spanPrefix?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SPAN_PREFIX = "cache.redis";
|
|
24
|
+
|
|
25
|
+
export class TracingTokenCache implements TokenCache {
|
|
26
|
+
private readonly inner: TokenCache;
|
|
27
|
+
private readonly prefix: string;
|
|
28
|
+
|
|
29
|
+
constructor(inner: TokenCache, options: TracingTokenCacheOptions = {}) {
|
|
30
|
+
this.inner = inner;
|
|
31
|
+
this.prefix = options.spanPrefix ?? DEFAULT_SPAN_PREFIX;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(key: string): Promise<TokenCacheEntry | null> {
|
|
35
|
+
return withSpan(
|
|
36
|
+
`${this.prefix}.get`,
|
|
37
|
+
() => this.inner.get(key),
|
|
38
|
+
{ "cache.key": key },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
set(key: string, entry: TokenCacheEntry): Promise<void> {
|
|
43
|
+
return withSpan(
|
|
44
|
+
`${this.prefix}.set`,
|
|
45
|
+
() => this.inner.set(key, entry),
|
|
46
|
+
{ "cache.key": key },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
delete(key: string): Promise<void> {
|
|
51
|
+
return withSpan(
|
|
52
|
+
`${this.prefix}.delete`,
|
|
53
|
+
() => this.inner.delete(key),
|
|
54
|
+
{ "cache.key": key },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
clear(): Promise<void> {
|
|
59
|
+
return withSpan(`${this.prefix}.clear`, () => this.inner.clear());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
has(key: string): Promise<boolean> {
|
|
63
|
+
return withSpan(
|
|
64
|
+
`${this.prefix}.has`,
|
|
65
|
+
() => this.inner.has(key),
|
|
66
|
+
{ "cache.key": key },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
stats(): Promise<CacheStats> {
|
|
71
|
+
return withSpan(`${this.prefix}.stats`, () => this.inner.stats());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
close(): Promise<void> {
|
|
75
|
+
return withSpan(`${this.prefix}.close`, () => this.inner.close());
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/src/proxy/handler.ts
CHANGED
|
@@ -6,7 +6,48 @@ import { cwd, getEnv } from "../platform/compat/process.js";
|
|
|
6
6
|
import { join } from "../platform/compat/path/index.js";
|
|
7
7
|
import { injectContext, ProxySpanNames, withSpan } from "./tracing.js";
|
|
8
8
|
import { computeContentSourceId } from "../cache/keys.js";
|
|
9
|
-
import {
|
|
9
|
+
import { resolve as resolveContract } from "../extensions/contracts.js";
|
|
10
|
+
import type { AuthProvider } from "../extensions/interfaces/auth-provider.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cache the resolved AuthProvider at module scope so the proxy does not pay
|
|
14
|
+
* the registry lookup on every request. The cache is cleared implicitly when
|
|
15
|
+
* `ExtensionLoader.teardownAll()` clears the registry — the next call
|
|
16
|
+
* re-resolves (or surfaces the "install ext-jwt" hint if the extension was
|
|
17
|
+
* removed).
|
|
18
|
+
*/
|
|
19
|
+
let cachedAuthProvider: AuthProvider | undefined;
|
|
20
|
+
|
|
21
|
+
function getAuthProvider(): AuthProvider {
|
|
22
|
+
if (cachedAuthProvider) return cachedAuthProvider;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
cachedAuthProvider = resolveContract<AuthProvider>("AuthProvider");
|
|
26
|
+
return cachedAuthProvider;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// resolve() already throws with a helpful "Recommended: @veryfront/ext-jwt"
|
|
29
|
+
// message, but the proxy is a load-bearing code path — append a concrete
|
|
30
|
+
// remediation hint that names the project-root extension directory so
|
|
31
|
+
// the user knows exactly what's missing.
|
|
32
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
33
|
+
throw new Error(
|
|
34
|
+
`${base}\nTo enable JWT verification in the proxy, install ext-jwt ` +
|
|
35
|
+
`(scaffold with \`deno task cli extension init ext-jwt\` or add the ` +
|
|
36
|
+
`npm package @veryfront/ext-jwt).`,
|
|
37
|
+
{ cause: err },
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reset the cached AuthProvider. Intended for tests that `register()` a mock
|
|
44
|
+
* after the handler module has been imported.
|
|
45
|
+
*
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export function __resetCachedAuthProviderForTests(): void {
|
|
49
|
+
cachedAuthProvider = undefined;
|
|
50
|
+
}
|
|
10
51
|
|
|
11
52
|
export const INTERNAL_PROXY_HEADERS = [
|
|
12
53
|
"x-token",
|
|
@@ -64,24 +105,10 @@ interface DomainLookupResult {
|
|
|
64
105
|
}>;
|
|
65
106
|
}
|
|
66
107
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
function getApiJwks(apiBaseUrl: string, logger?: ProxyLogger) {
|
|
108
|
+
function resolveApiJwksUrl(apiBaseUrl: string, logger?: ProxyLogger): string | undefined {
|
|
70
109
|
try {
|
|
71
110
|
const normalizedBaseUrl = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
|
|
72
|
-
|
|
73
|
-
const cacheKey = jwksUrl.toString();
|
|
74
|
-
|
|
75
|
-
// Lazily initialize and cache JWKS in a single, idempotent step to avoid
|
|
76
|
-
// unsynchronized read/then-write on the shared Map across concurrent calls.
|
|
77
|
-
let jwks = remoteJwksByUrl.get(cacheKey);
|
|
78
|
-
if (!jwks) {
|
|
79
|
-
const created = createRemoteJWKSet(jwksUrl);
|
|
80
|
-
remoteJwksByUrl.set(cacheKey, created);
|
|
81
|
-
jwks = created;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return jwks;
|
|
111
|
+
return new URL(".well-known/jwks.json", normalizedBaseUrl).toString();
|
|
85
112
|
} catch (error) {
|
|
86
113
|
logger?.error("Invalid API base URL for JWKS lookup", error as Error, {
|
|
87
114
|
apiBaseUrl,
|
|
@@ -229,23 +256,22 @@ async function extractUserIdFromToken(
|
|
|
229
256
|
apiBaseUrl: string,
|
|
230
257
|
log?: ProxyLogger,
|
|
231
258
|
): Promise<string | undefined> {
|
|
232
|
-
|
|
259
|
+
const auth = getAuthProvider();
|
|
233
260
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
log?.debug("Failed to decode JWT header", {
|
|
238
|
-
error: error instanceof Error ? error.message : String(error),
|
|
239
|
-
});
|
|
261
|
+
const header = auth.decode(token);
|
|
262
|
+
if (!header) {
|
|
263
|
+
log?.debug("Failed to decode JWT header");
|
|
240
264
|
return undefined;
|
|
241
265
|
}
|
|
242
266
|
|
|
267
|
+
const algorithm = header.alg;
|
|
268
|
+
|
|
243
269
|
if (algorithm === "RS256") {
|
|
244
|
-
const
|
|
245
|
-
if (!
|
|
270
|
+
const jwksUrl = resolveApiJwksUrl(apiBaseUrl, log);
|
|
271
|
+
if (!jwksUrl) return undefined;
|
|
246
272
|
|
|
247
273
|
try {
|
|
248
|
-
const
|
|
274
|
+
const payload = await auth.verifyWithJwks(token, jwksUrl, {
|
|
249
275
|
algorithms: ["RS256"],
|
|
250
276
|
});
|
|
251
277
|
return (payload as { userId?: string }).userId;
|
|
@@ -270,10 +296,10 @@ async function extractUserIdFromToken(
|
|
|
270
296
|
}
|
|
271
297
|
|
|
272
298
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
});
|
|
299
|
+
// ext-jwt reads JWT_SECRET from the environment when no `secret` was
|
|
300
|
+
// passed to the extension factory; the explicit env check above is kept
|
|
301
|
+
// so callers can warn once before we attempt verification.
|
|
302
|
+
const payload = await auth.verify(token, { algorithms: ["HS256"] });
|
|
277
303
|
return (payload as { userId?: string }).userId;
|
|
278
304
|
} catch (error) {
|
|
279
305
|
log?.debug("JWT verification failed", {
|
|
@@ -9,7 +9,7 @@ import * as dntShim from "../../../_dnt.shims.js";
|
|
|
9
9
|
|
|
10
10
|
import { serverLogger } from "../../utils/index.js";
|
|
11
11
|
import { unrefTimer } from "../../platform/compat/process.js";
|
|
12
|
-
import { isWebSocketPath } from "./request-utils.js";
|
|
12
|
+
import { isLightweightPath, isWebSocketPath } from "./request-utils.js";
|
|
13
13
|
|
|
14
14
|
const logger = serverLogger.component("request-tracker");
|
|
15
15
|
|
|
@@ -100,8 +100,9 @@ class RequestTracker {
|
|
|
100
100
|
releaseId,
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
-
// WebSocket connections are long-lived by design
|
|
104
|
-
|
|
103
|
+
// WebSocket connections are long-lived by design and lightweight internal
|
|
104
|
+
// asset/module requests can be noisy under CI jitter — don't flag them as stuck.
|
|
105
|
+
if (!isWebSocketPath(path) && !isLightweightPath(path)) {
|
|
105
106
|
tracked.slowTimer = dntShim.setTimeout(() => {
|
|
106
107
|
const elapsedMs = Math.round(performance.now() - startTime);
|
|
107
108
|
logger.warn("Slow request detected", {
|
|
@@ -154,10 +155,7 @@ class RequestTracker {
|
|
|
154
155
|
if (timedOut) this.totalTimedOut++;
|
|
155
156
|
else this.totalCompleted++;
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
tracked.path.startsWith("/_veryfront/");
|
|
159
|
-
|
|
160
|
-
if (isModuleRequest) {
|
|
158
|
+
if (isLightweightPath(tracked.path)) {
|
|
161
159
|
if (durationMs > MODULE_REQUEST_LOG_THRESHOLD_MS) {
|
|
162
160
|
logger.debug(`${tracked.method} ${tracked.path} ${statusCode} ${durationMs}ms`);
|
|
163
161
|
}
|
|
@@ -58,6 +58,7 @@ export function isMonitoringPath(pathname: string): boolean {
|
|
|
58
58
|
/** Lightweight paths that should skip concurrency limiting (modules, static assets) */
|
|
59
59
|
export const LIGHTWEIGHT_PATH_PREFIXES = [
|
|
60
60
|
"/_vf_modules/",
|
|
61
|
+
"/_vf_styles/",
|
|
61
62
|
"/_veryfront/modules/",
|
|
62
63
|
"/_veryfront/hydration-runtime.js",
|
|
63
64
|
"/_veryfront/preview-hmr.js",
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { CacheStats, RedisCacheOptions, TokenCache, TokenCacheEntry } from "./types.js";
|
|
2
|
-
export declare class RedisCache implements TokenCache {
|
|
3
|
-
private client;
|
|
4
|
-
private readonly prefix;
|
|
5
|
-
private readonly url;
|
|
6
|
-
private readonly connectTimeout;
|
|
7
|
-
private readonly tls;
|
|
8
|
-
private readonly password?;
|
|
9
|
-
private readonly username?;
|
|
10
|
-
private hits;
|
|
11
|
-
private misses;
|
|
12
|
-
private connected;
|
|
13
|
-
constructor(options: RedisCacheOptions);
|
|
14
|
-
private key;
|
|
15
|
-
get(key: string): Promise<TokenCacheEntry | null>;
|
|
16
|
-
set(key: string, entry: TokenCacheEntry): Promise<void>;
|
|
17
|
-
delete(key: string): Promise<void>;
|
|
18
|
-
clear(): Promise<void>;
|
|
19
|
-
has(key: string): Promise<boolean>;
|
|
20
|
-
stats(): Promise<CacheStats>;
|
|
21
|
-
close(): Promise<void>;
|
|
22
|
-
private getConnectedClient;
|
|
23
|
-
private ensureConnected;
|
|
24
|
-
}
|
|
25
|
-
//# sourceMappingURL=redis-cache.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"redis-cache.d.ts","sourceRoot":"","sources":["../../../../src/src/proxy/cache/redis-cache.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,iBAAiB,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAY7F,qBAAa,UAAW,YAAW,UAAU;IAC3C,OAAO,CAAC,MAAM,CAAgC;IAC9C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAU;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAS;IACnC,OAAO,CAAC,IAAI,CAAK;IACjB,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,SAAS,CAAS;gBAEd,OAAO,EAAE,iBAAiB;IAStC,OAAO,CAAC,GAAG;IAIL,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IAoCjD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAqBvD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBlC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAwCtB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAmBlC,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;IAkB5B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAmBd,kBAAkB;YAQlB,eAAe;CAoC9B"}
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import { createClient } from "redis";
|
|
2
|
-
import { withSpan } from "../tracing.js";
|
|
3
|
-
import { proxyLogger } from "../logger.js";
|
|
4
|
-
const logger = proxyLogger.child({ module: "redis-cache" });
|
|
5
|
-
const DEFAULT_PREFIX = "vf:token:";
|
|
6
|
-
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
|
|
7
|
-
const DEFAULT_SCAN_COUNT = 100;
|
|
8
|
-
const MAX_RECONNECT_RETRIES = 3;
|
|
9
|
-
const RECONNECT_BACKOFF_BASE_MS = 100;
|
|
10
|
-
const RECONNECT_BACKOFF_MAX_MS = 3_000;
|
|
11
|
-
export class RedisCache {
|
|
12
|
-
client = null;
|
|
13
|
-
prefix;
|
|
14
|
-
url;
|
|
15
|
-
connectTimeout;
|
|
16
|
-
tls;
|
|
17
|
-
password;
|
|
18
|
-
username;
|
|
19
|
-
hits = 0;
|
|
20
|
-
misses = 0;
|
|
21
|
-
connected = false;
|
|
22
|
-
constructor(options) {
|
|
23
|
-
this.url = options.url;
|
|
24
|
-
this.prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
25
|
-
this.connectTimeout = options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
26
|
-
this.tls = options.tls ?? options.url.startsWith("rediss://");
|
|
27
|
-
this.password = options.password;
|
|
28
|
-
this.username = options.username;
|
|
29
|
-
}
|
|
30
|
-
key(k) {
|
|
31
|
-
return `${this.prefix}${k}`;
|
|
32
|
-
}
|
|
33
|
-
async get(key) {
|
|
34
|
-
return withSpan("cache.redis.get", async () => {
|
|
35
|
-
try {
|
|
36
|
-
const client = await this.getConnectedClient();
|
|
37
|
-
const data = await client.get(this.key(key));
|
|
38
|
-
if (!data) {
|
|
39
|
-
this.misses++;
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
const entry = JSON.parse(data);
|
|
43
|
-
if (Date.now() >= entry.expiresAt) {
|
|
44
|
-
await client.del(this.key(key));
|
|
45
|
-
this.misses++;
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
this.hits++;
|
|
49
|
-
return entry;
|
|
50
|
-
}
|
|
51
|
-
catch (error) {
|
|
52
|
-
logger.error("[RedisCache] Get error", {
|
|
53
|
-
error: error instanceof Error ? error.message : String(error),
|
|
54
|
-
});
|
|
55
|
-
this.connected = false;
|
|
56
|
-
this.misses++;
|
|
57
|
-
throw error;
|
|
58
|
-
}
|
|
59
|
-
}, { "cache.key": key });
|
|
60
|
-
}
|
|
61
|
-
async set(key, entry) {
|
|
62
|
-
return withSpan("cache.redis.set", async () => {
|
|
63
|
-
try {
|
|
64
|
-
const client = await this.getConnectedClient();
|
|
65
|
-
const ttlMs = entry.expiresAt - Date.now();
|
|
66
|
-
const ttlSeconds = Math.max(1, Math.floor(ttlMs / 1000));
|
|
67
|
-
await client.setEx(this.key(key), ttlSeconds, JSON.stringify(entry));
|
|
68
|
-
}
|
|
69
|
-
catch (error) {
|
|
70
|
-
logger.error("[RedisCache] Set error", {
|
|
71
|
-
error: error instanceof Error ? error.message : String(error),
|
|
72
|
-
});
|
|
73
|
-
this.connected = false;
|
|
74
|
-
throw error;
|
|
75
|
-
}
|
|
76
|
-
}, { "cache.key": key });
|
|
77
|
-
}
|
|
78
|
-
async delete(key) {
|
|
79
|
-
return withSpan("cache.redis.delete", async () => {
|
|
80
|
-
try {
|
|
81
|
-
const client = await this.getConnectedClient();
|
|
82
|
-
await client.del(this.key(key));
|
|
83
|
-
}
|
|
84
|
-
catch (error) {
|
|
85
|
-
logger.error("[RedisCache] Delete error", {
|
|
86
|
-
error: error instanceof Error ? error.message : String(error),
|
|
87
|
-
});
|
|
88
|
-
this.connected = false;
|
|
89
|
-
throw error;
|
|
90
|
-
}
|
|
91
|
-
}, { "cache.key": key });
|
|
92
|
-
}
|
|
93
|
-
async clear() {
|
|
94
|
-
return withSpan("cache.redis.clear", async () => {
|
|
95
|
-
try {
|
|
96
|
-
const client = await this.getConnectedClient();
|
|
97
|
-
const pattern = `${this.prefix}*`;
|
|
98
|
-
// redis v5: scan cursor is string-based to prevent Number.MAX_SAFE_INTEGER overflow
|
|
99
|
-
let cursor = "0";
|
|
100
|
-
let totalDeleted = 0;
|
|
101
|
-
do {
|
|
102
|
-
// deno-lint-ignore no-explicit-any
|
|
103
|
-
const result = await client.scan(cursor, {
|
|
104
|
-
MATCH: pattern,
|
|
105
|
-
COUNT: DEFAULT_SCAN_COUNT,
|
|
106
|
-
});
|
|
107
|
-
cursor = String(result.cursor);
|
|
108
|
-
if (result.keys.length > 0) {
|
|
109
|
-
totalDeleted += await client.del(result.keys);
|
|
110
|
-
}
|
|
111
|
-
} while (cursor !== "0");
|
|
112
|
-
if (totalDeleted > 0) {
|
|
113
|
-
logger.info(`[RedisCache] Cleared ${totalDeleted} keys`);
|
|
114
|
-
}
|
|
115
|
-
this.hits = 0;
|
|
116
|
-
this.misses = 0;
|
|
117
|
-
}
|
|
118
|
-
catch (error) {
|
|
119
|
-
logger.error("[RedisCache] Clear error", {
|
|
120
|
-
error: error instanceof Error ? error.message : String(error),
|
|
121
|
-
});
|
|
122
|
-
this.connected = false;
|
|
123
|
-
throw error;
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
async has(key) {
|
|
128
|
-
return withSpan("cache.redis.has", async () => {
|
|
129
|
-
try {
|
|
130
|
-
const client = await this.getConnectedClient();
|
|
131
|
-
return (await client.exists(this.key(key))) === 1;
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
logger.error("[RedisCache] Has error", {
|
|
135
|
-
error: error instanceof Error ? error.message : String(error),
|
|
136
|
-
});
|
|
137
|
-
this.connected = false;
|
|
138
|
-
throw error;
|
|
139
|
-
}
|
|
140
|
-
}, { "cache.key": key });
|
|
141
|
-
}
|
|
142
|
-
async stats() {
|
|
143
|
-
return withSpan("cache.redis.stats", async () => {
|
|
144
|
-
let size = 0;
|
|
145
|
-
try {
|
|
146
|
-
const client = await this.getConnectedClient();
|
|
147
|
-
size = await client.dbSize();
|
|
148
|
-
}
|
|
149
|
-
catch (error) {
|
|
150
|
-
this.connected = false;
|
|
151
|
-
logger.error("[RedisCache] Stats error", {
|
|
152
|
-
error: error instanceof Error ? error.message : String(error),
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
return { hits: this.hits, misses: this.misses, size, type: "redis" };
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
async close() {
|
|
159
|
-
return withSpan("cache.redis.close", async () => {
|
|
160
|
-
const client = this.client;
|
|
161
|
-
if (!client) {
|
|
162
|
-
this.connected = false;
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
await client.close();
|
|
167
|
-
}
|
|
168
|
-
catch (_) {
|
|
169
|
-
// expected: close errors are non-critical
|
|
170
|
-
}
|
|
171
|
-
finally {
|
|
172
|
-
this.client = null;
|
|
173
|
-
this.connected = false;
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
async getConnectedClient() {
|
|
178
|
-
await this.ensureConnected();
|
|
179
|
-
if (!this.client) {
|
|
180
|
-
throw new Error("Redis client not available after connect");
|
|
181
|
-
}
|
|
182
|
-
return this.client;
|
|
183
|
-
}
|
|
184
|
-
async ensureConnected() {
|
|
185
|
-
return withSpan("cache.redis.connect", async () => {
|
|
186
|
-
if (this.connected && this.client)
|
|
187
|
-
return;
|
|
188
|
-
// deno-lint-ignore no-explicit-any
|
|
189
|
-
const clientOpts = {
|
|
190
|
-
url: this.url,
|
|
191
|
-
socket: {
|
|
192
|
-
connectTimeout: this.connectTimeout,
|
|
193
|
-
tls: this.tls || undefined,
|
|
194
|
-
reconnectStrategy: (retries) => {
|
|
195
|
-
if (retries > MAX_RECONNECT_RETRIES) {
|
|
196
|
-
return new Error("Max reconnection attempts reached");
|
|
197
|
-
}
|
|
198
|
-
return Math.min(retries * RECONNECT_BACKOFF_BASE_MS, RECONNECT_BACKOFF_MAX_MS);
|
|
199
|
-
},
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
if (this.password)
|
|
203
|
-
clientOpts.password = this.password;
|
|
204
|
-
if (this.username)
|
|
205
|
-
clientOpts.username = this.username;
|
|
206
|
-
const client = createClient(clientOpts);
|
|
207
|
-
client.on("error", (err) => {
|
|
208
|
-
logger.error("[RedisCache] Client error", {
|
|
209
|
-
error: err instanceof Error ? err.message : String(err),
|
|
210
|
-
});
|
|
211
|
-
this.connected = false;
|
|
212
|
-
});
|
|
213
|
-
this.client = client;
|
|
214
|
-
await client.connect();
|
|
215
|
-
this.connected = true;
|
|
216
|
-
logger.info("[RedisCache] Connected");
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
}
|