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,
|