diff --git a/apps/frontend/src/components/key-browser/key-details/key-details-string.tsx b/apps/frontend/src/components/key-browser/key-details/key-details-string.tsx index f4c40cf3..6b6b950c 100644 --- a/apps/frontend/src/components/key-browser/key-details/key-details-string.tsx +++ b/apps/frontend/src/components/key-browser/key-details/key-details-string.tsx @@ -14,6 +14,7 @@ interface KeyDetailsStringProps { ttl: number; size: number; elements: string; + isBinary?: boolean; }; connectionId: string; readOnly: boolean; @@ -58,6 +59,8 @@ export default function KeyDetailsString( { [{ key: "name", value: "json" }, { key: "ver", value: 10002 }], ]), } - assert.strictEqual(await checkJsonModuleAvailability(mockClient as any), true) + assert.strictEqual(await checkJsonModuleAvailability(mockClient as any, "test-conn-id"), true) }) it("should return false when JSON module is not present", async () => { const mockClient = { customCommand: mock.fn(async () => { throw Error }) } - assert.strictEqual(await checkJsonModuleAvailability(mockClient as any), false) + assert.strictEqual(await checkJsonModuleAvailability(mockClient as any, "test-conn-id"), false) }) type ReplaceCase = { diff --git a/apps/server/src/check-json-module.ts b/apps/server/src/check-json-module.ts index 8e2f5b06..300c24e4 100644 --- a/apps/server/src/check-json-module.ts +++ b/apps/server/src/check-json-module.ts @@ -1,14 +1,27 @@ import { GlideClient, GlideClusterClient } from "@valkey/valkey-glide" -export async function checkJsonModuleAvailability( - client: GlideClient | GlideClusterClient, -): Promise { +// Probe JSON commands directly for compatibility with services like ElastiCache, +// where JSON may be available even though MODULE commands are unsupported. +async function checkJsonModule(client: GlideClient | GlideClusterClient): Promise { try { - // Elasticache restricts MODULE command - await client.customCommand(["JSON.TYPE", "nonexistent_key"]) - return true + const reply = await client.customCommand(["COMMAND", "INFO", "JSON.TYPE"]) + return Array.isArray(reply) && reply[0] != null } catch { - return false + try { + await client.customCommand(["JSON.TYPE", "nonexistent_key"]) + return true + } catch { + return false + } } } +export async function checkJsonModuleAvailability( + client: GlideClient | GlideClusterClient, + connectionId: string, +): Promise { + const available = await checkJsonModule(client) + + console.log(`JSON module ${available ? "available" : "not available"} for ${connectionId}`) + return available +} diff --git a/apps/server/src/connection.ts b/apps/server/src/connection.ts index 7587490d..9ace034f 100644 --- a/apps/server/src/connection.ts +++ b/apps/server/src/connection.ts @@ -238,7 +238,7 @@ async function connectToValkeyLocked( const existingStandalone = existingConnection.client as GlideClient const [keyEvictionPolicy, jsonModuleAvailable, existingDatabasesCount] = await Promise.all([ getKeyEvictionPolicy(existingStandalone), - checkJsonModuleAvailability(existingStandalone), + checkJsonModuleAvailability(existingStandalone, connectionId), getDatabasesCount(existingStandalone), ]) sendStandaloneConnectFulfilled(ws, { @@ -421,7 +421,7 @@ async function connectToValkeyLocked( const [keyEvictionPolicy, jsonModuleAvailable] = await Promise.all([ getKeyEvictionPolicy(standaloneClient), - checkJsonModuleAvailability(standaloneClient), + checkJsonModuleAvailability(standaloneClient, connectionId), ]) sendStandaloneConnectFulfilled(ws, { connectionId, @@ -561,7 +561,7 @@ async function commitClusterConnection( const [clusterSlotStatsEnabled, keyEvictionPolicy, jsonModuleAvailable, databasesCount] = await Promise.all([ getClusterSlotStatsEnabled(clusterClient), getKeyEvictionPolicy(clusterClient), - checkJsonModuleAvailability(clusterClient), + checkJsonModuleAvailability(clusterClient, connectionId), getDatabasesCount(clusterClient, ["cluster-databases", "databases"]), ]) diff --git a/apps/server/src/keys-browser.ts b/apps/server/src/keys-browser.ts index c73d2e95..2cdfcc96 100644 --- a/apps/server/src/keys-browser.ts +++ b/apps/server/src/keys-browser.ts @@ -28,6 +28,7 @@ interface EnrichedKeyInfo { // eslint-disable-next-line @typescript-eslint/no-explicit-any elements?: any; // this can be array, object, or string depending on the key type. elementsWarning?: string; // alternative for elements when they cannot be displayed. + isBinary?: boolean; } async function getScanKeyInfo( @@ -295,47 +296,43 @@ async function getPaginatedJsonInfo( } } +// Valkey strings are binary-safe, so a value may not be valid UTF-8. Strict decoding tells +// text apart from binary: binary is escaped for display and flagged so edits can be blocked. +const utf8Decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) + +function decodeStringValue(raw: GlideReturnType): { value: string; isBinary: boolean } { + const bytes = Buffer.from(raw as Buffer | string) + try { + return { value: utf8Decoder.decode(bytes), isBinary: false } + } catch { + const escaped = bytes.reduce((s, byte) => + s + (byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : "\\x" + byte.toString(16).padStart(2, "0")), "", + ) + return { value: escaped, isBinary: true } + } +} + async function getFullKeyInfo( client: GlideClient | GlideClusterClient, keyInfo: EnrichedKeyInfo, commands: { sizeCmd: string; elementsCmd: string[] }, -): Promise{ +): Promise { try { - const promises = [client.customCommand(commands.elementsCmd)] - - if (commands.sizeCmd) { - promises.push(client.customCommand([commands.sizeCmd, keyInfo.name])) - } - - const results = await Promise.all(promises) + const [raw, collectionSize] = await Promise.all([ + client.customCommand(commands.elementsCmd, { decoder: Decoder.Bytes }), + commands.sizeCmd ? client.customCommand([commands.sizeCmd, keyInfo.name]) : undefined, + ]) + if (raw == null) return keyInfo - if (commands.sizeCmd) { - return { - ...keyInfo, - collectionSize: results[1] as number, - elements: results[0], - } - } else { - // in case of string with no collectionSize - return { - ...keyInfo, - elements: results[0], - } + const { value, isBinary } = decodeStringValue(raw) + return { + ...keyInfo, + ...(commands.sizeCmd ? { collectionSize: collectionSize as number } : {}), + elements: value, + ...(isBinary ? { isBinary } : {}), } } catch (err) { console.log(`Could not get elements for key ${keyInfo.name}:`, err) - // Valkey client uses String decoder, which throws this error when it encounters non-UTF-8 bytes - if (err instanceof Error && err.message.includes("Decoding error")) { - try { - const raw = await client.customCommand(commands.elementsCmd, { decoder: Decoder.Bytes }) as Buffer - const hex = Buffer.from(raw).reduce((s, byte) => - s + (byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : "\\x" + byte.toString(16).padStart(2, "0")), "", - ) - return { ...keyInfo, elements: hex } - } catch { - return { ...keyInfo, elementsWarning: VALKEY_CLIENT.MESSAGES.NOT_READABLE } - } - } return keyInfo } } @@ -947,6 +944,11 @@ async function updateStringKey( value: string, ttl?: number, ) { + const current = await client.customCommand(["GET", key], { decoder: Decoder.Bytes }) + if (current != null && decodeStringValue(current).isBinary) { + throw new Error("This key holds binary data and cannot be edited as text.") + } + if (ttl && ttl > 0) { await client.customCommand(["SETEX", key, ttl.toString(), value]) } else { diff --git a/apps/server/src/set-dashboard-data.ts b/apps/server/src/set-dashboard-data.ts index f1a7c058..5723dafd 100644 --- a/apps/server/src/set-dashboard-data.ts +++ b/apps/server/src/set-dashboard-data.ts @@ -33,7 +33,10 @@ const sendSetDataError = ( error: unknown, errorKind?: string, // distinguish "not ready yet" from "real error" for better UI handling ) => { - console.error(error) + // The metrics server registers a moment after connect, so an early stats request + // expectedly finds no URI. The frontend retries up to METRICS_MAX_RETRIES and surfaces + // the error itself, so logging a stack trace here is just noise. Real failures still log. + if (errorKind !== METRICS_SERVER_NOT_READY) console.error(error) ws.send( JSON.stringify({ type: VALKEY.STATS.setError,