feat(redis): support multiple value formats in viewer (#2766)
* feat(redis): improve value decoding and viewer formats
* fix(redis): preserve stream field/value blobs
* Revert "fix(redis): preserve stream field/value blobs"
This reverts commit 168af0619a.
This commit is contained in:
1 parent
ad9841ab77
commit
ba81fa4497
13 files changed
+2417
-685
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatRedisMemberDetail, formatRedisStringValue, getRedisMemberSelectionKey, sanitizeRedisDisplayText } from "@/lib/redis/redisValuePresentation";
|
||||
import { canRenderRedisValueFormat, formatRedisMemberDetail, formatRedisStringValue, getRedisMemberSelectionKey, preferredRedisValueFormat, redisMemberCopyText, sanitizeRedisDisplayText } from "@/lib/redis/redisValuePresentation";
|
||||
|
||||
describe("redisValuePresentation", () => {
|
||||
it("strips control bytes from display without mutating raw member text", () => {
|
||||
@@ -26,7 +26,119 @@ describe("redisValuePresentation", () => {
|
||||
expect(getRedisMemberSelectionKey("member", raw)).toBe(`member\n${raw}`);
|
||||
});
|
||||
|
||||
it("can disambiguate duplicate stream fields with an explicit identity", () => {
|
||||
expect(getRedisMemberSelectionKey("event", "login", "stream:1:0")).not.toBe(getRedisMemberSelectionKey("event", "login", "stream:1:1"));
|
||||
});
|
||||
|
||||
it("formats string values for display without changing plain text", () => {
|
||||
expect(formatRedisStringValue("plain-text")).toBe("plain-text");
|
||||
});
|
||||
|
||||
it("labels raw text views by encoding instead of generic raw text", () => {
|
||||
expect(formatRedisMemberDetail("plain-text").rawLabel).toBe("ASCII");
|
||||
expect(
|
||||
formatRedisMemberDetail({
|
||||
raw_base64: Buffer.from("你好", "utf8").toString("base64"),
|
||||
encoding: "utf8",
|
||||
}).rawLabel,
|
||||
).toBe("UTF-8");
|
||||
expect(
|
||||
formatRedisMemberDetail({
|
||||
raw_base64: "rO0ABQ==",
|
||||
encoding: "binary",
|
||||
}).rawLabel,
|
||||
).toBe("Binary");
|
||||
});
|
||||
|
||||
it("orders supported formats with the recommended view first", () => {
|
||||
expect(
|
||||
formatRedisMemberDetail({
|
||||
raw_base64: Buffer.from('{"id":1}', "utf8").toString("base64"),
|
||||
encoding: "utf8",
|
||||
}).availableFormats,
|
||||
).toEqual(["utf8", "ascii", "binary", "hex", "base64"]);
|
||||
expect(
|
||||
formatRedisMemberDetail({
|
||||
raw_base64: "rO0ABQ==",
|
||||
encoding: "binary",
|
||||
}).availableFormats,
|
||||
).toEqual(["hex", "binary", "base64"]);
|
||||
});
|
||||
|
||||
it("keeps a UTF-8 decoding available even for binary blobs", () => {
|
||||
const detail = formatRedisMemberDetail({
|
||||
raw_base64: "rO0ABQ==",
|
||||
encoding: "binary",
|
||||
});
|
||||
|
||||
expect(detail.utf8Text).toBe(new TextDecoder("utf-8").decode(Uint8Array.from([0xac, 0xed, 0x00, 0x05])));
|
||||
});
|
||||
|
||||
it("only exposes JSON view when payload text explicitly opts in", () => {
|
||||
expect(
|
||||
formatRedisMemberDetail(
|
||||
{
|
||||
raw_base64: Buffer.from('{"id":1}', "utf8").toString("base64"),
|
||||
encoding: "utf8",
|
||||
},
|
||||
{ allowJsonText: true },
|
||||
).availableFormats,
|
||||
).toEqual(["utf8", "ascii", "binary", "json", "hex", "base64"]);
|
||||
});
|
||||
|
||||
it("falls back to utf8 when a binary inspection view was stored for editable text", () => {
|
||||
const blob = {
|
||||
raw_base64: Buffer.from("Ada", "utf8").toString("base64"),
|
||||
encoding: "utf8" as const,
|
||||
};
|
||||
|
||||
expect(preferredRedisValueFormat(blob, "hex")).toBe("utf8");
|
||||
expect(preferredRedisValueFormat(blob, "base64")).toBe("utf8");
|
||||
expect(preferredRedisValueFormat(blob, "binary")).toBe("utf8");
|
||||
expect(preferredRedisValueFormat(blob, "json", { allowJsonText: true })).toBe("utf8");
|
||||
});
|
||||
|
||||
it("adds a Java serialized format when the blob uses Java object serialization", () => {
|
||||
const detail = formatRedisMemberDetail({
|
||||
raw_base64: "rO0ABXQACHNvbWV0ZXh0",
|
||||
encoding: "binary",
|
||||
});
|
||||
|
||||
expect(detail.availableFormats).toEqual(["javaserialize", "binary", "hex", "base64"]);
|
||||
expect(detail.defaultFormat).toBe("javaserialize");
|
||||
expect(detail.javaSerialized?.formattedText).toBe('"sometext"');
|
||||
expect(canRenderRedisValueFormat(detail, "javaserialize")).toBe(true);
|
||||
expect(canRenderRedisValueFormat(formatRedisMemberDetail("plain-text"), "javaserialize")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps self-referential Java maps representable via refs", () => {
|
||||
const detail = formatRedisMemberDetail({
|
||||
raw_base64: "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABdAAEc2VsZnEAfgABeA==",
|
||||
encoding: "binary",
|
||||
});
|
||||
|
||||
const normalized = detail.javaSerialized?.value as { map?: { $entries?: Array<{ value?: { $ref?: string } }> } } | undefined;
|
||||
const entries = normalized?.map?.$entries;
|
||||
expect(entries?.[0]?.value?.$ref).toBe("#1");
|
||||
});
|
||||
|
||||
it("keeps editable text round-trippable while exposing separate ascii/binary views", () => {
|
||||
const detail = formatRedisMemberDetail({
|
||||
raw_base64: Buffer.from("send_message_to_esb\x06\x16", "latin1").toString("base64"),
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(detail.rawText).toBe("send_message_to_esb\x06\x16");
|
||||
expect(detail.asciiText).toBe("send_message_to_esb\\x06\\x16");
|
||||
expect(detail.binaryText).toBe("011100110110010101101110011001000101111101101101011001010111001101110011011000010110011101100101010111110111010001101111010111110110010101110011011000100000011000010110");
|
||||
});
|
||||
|
||||
it("copies binary blobs as escaped raw bytes", () => {
|
||||
expect(
|
||||
redisMemberCopyText({
|
||||
raw_base64: "rO0ABQ==",
|
||||
encoding: "binary",
|
||||
}),
|
||||
).toBe("\\xac\\xed\\x00\\x05");
|
||||
});
|
||||
});
|
||||
@@ -471,9 +471,18 @@ export type {
|
||||
WebDavDownloadResult,
|
||||
McpServerStatus,
|
||||
UpdateInfo,
|
||||
RedisBlob,
|
||||
RedisCollectionPage,
|
||||
RedisDatabaseInfo,
|
||||
RedisHashItem,
|
||||
RedisKeyInfo,
|
||||
RedisListItem,
|
||||
RedisSetItem,
|
||||
RedisStreamEntry,
|
||||
RedisStreamField,
|
||||
RedisValue,
|
||||
RedisValueData,
|
||||
RedisZsetItem,
|
||||
RedisScanResult,
|
||||
RedisCommandSafety,
|
||||
RedisCommandResult,
|
||||
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
JavaRuntimeConfig,
|
||||
UpdateInfo,
|
||||
UpdateDownloadSource,
|
||||
RedisCollectionPage,
|
||||
RedisDatabaseInfo,
|
||||
RedisValue,
|
||||
RedisScanResult,
|
||||
@@ -1727,7 +1728,7 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm
|
||||
return post("/api/redis/execute-command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false });
|
||||
}
|
||||
|
||||
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisValue> {
|
||||
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisCollectionPage> {
|
||||
return post("/api/redis/load-more", { connectionId, db, keyRaw, keyType, cursor, count, filter });
|
||||
}
|
||||
|
||||
|
||||
@@ -1303,17 +1303,62 @@ export interface RedisDatabaseInfo {
|
||||
keys: number;
|
||||
}
|
||||
|
||||
export type RedisBlobEncoding = "utf8" | "binary";
|
||||
|
||||
export interface RedisBlob {
|
||||
raw_base64: string;
|
||||
encoding: RedisBlobEncoding;
|
||||
}
|
||||
|
||||
export interface RedisListItem {
|
||||
index: number;
|
||||
value: RedisBlob;
|
||||
}
|
||||
|
||||
export interface RedisSetItem {
|
||||
member: RedisBlob;
|
||||
}
|
||||
|
||||
export interface RedisHashItem {
|
||||
field: RedisBlob;
|
||||
value: RedisBlob;
|
||||
}
|
||||
|
||||
export interface RedisZsetItem {
|
||||
score: string;
|
||||
member: RedisBlob;
|
||||
}
|
||||
|
||||
export interface RedisStreamField {
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface RedisStreamEntry {
|
||||
id: string;
|
||||
fields: RedisStreamField[];
|
||||
}
|
||||
|
||||
export type RedisValueData =
|
||||
| { kind: "string"; content: RedisBlob }
|
||||
| { kind: "json"; value: unknown }
|
||||
| { kind: "list"; items: RedisListItem[]; total: number; scan_cursor?: number }
|
||||
| { kind: "set"; items: RedisSetItem[]; total: number; scan_cursor?: number }
|
||||
| { kind: "hash"; items: RedisHashItem[]; total: number; scan_cursor?: number }
|
||||
| { kind: "zset"; items: RedisZsetItem[]; total: number; scan_cursor?: number }
|
||||
| { kind: "stream"; entries: RedisStreamEntry[] }
|
||||
| { kind: "unknown" };
|
||||
|
||||
export interface RedisValue {
|
||||
key_display: string;
|
||||
key_raw: string;
|
||||
key_type: string;
|
||||
ttl: number;
|
||||
value_is_binary: boolean;
|
||||
value: any;
|
||||
total?: number;
|
||||
scan_cursor?: number;
|
||||
redis_type: string;
|
||||
data: RedisValueData;
|
||||
}
|
||||
|
||||
export type RedisCollectionPage = { kind: "list"; items: RedisListItem[]; scan_cursor?: number } | { kind: "set"; items: RedisSetItem[]; scan_cursor?: number } | { kind: "hash"; items: RedisHashItem[]; scan_cursor?: number } | { kind: "zset"; items: RedisZsetItem[]; scan_cursor?: number };
|
||||
|
||||
export interface RedisScanResult {
|
||||
cursor: number;
|
||||
keys: RedisKeyInfo[];
|
||||
@@ -1434,7 +1479,7 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm
|
||||
return invoke("redis_execute_command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false });
|
||||
}
|
||||
|
||||
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisValue> {
|
||||
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisCollectionPage> {
|
||||
return invoke("redis_load_more", { connectionId, db, keyRaw, keyType, cursor, count, filter });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
export interface RedisJavaSerializedDetail {
|
||||
formattedText: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
type JavaFieldDescriptor = {
|
||||
type: string;
|
||||
name: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type JavaClassDescriptor = {
|
||||
name: string;
|
||||
serialVersionUID: string;
|
||||
flags: number;
|
||||
isEnum: boolean;
|
||||
fields: JavaFieldDescriptor[];
|
||||
superClass: JavaClassDescriptor | null;
|
||||
};
|
||||
|
||||
type JavaObjectValue = Record<string, unknown> & {
|
||||
[JAVA_CLASS_META]?: JavaClassDescriptor;
|
||||
[JAVA_EXTENDS_META]?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
type JavaPostProcessor = (fields: Record<string, unknown>, annotations: unknown[]) => Record<string, unknown>;
|
||||
|
||||
const JAVA_CLASS_META = Symbol("javaClassMeta");
|
||||
const JAVA_EXTENDS_META = Symbol("javaExtendsMeta");
|
||||
const STREAM_MAGIC = 0xaced;
|
||||
const STREAM_VERSION = 5;
|
||||
const BASE_WIRE_HANDLE = 0x7e0000;
|
||||
const END_BLOCK = Symbol("endBlock");
|
||||
|
||||
const TAG_NAMES: Record<number, string> = {
|
||||
0x70: "Null",
|
||||
0x71: "Reference",
|
||||
0x72: "ClassDesc",
|
||||
0x73: "Object",
|
||||
0x74: "String",
|
||||
0x75: "Array",
|
||||
0x76: "Class",
|
||||
0x77: "BlockData",
|
||||
0x78: "EndBlockData",
|
||||
0x79: "Reset",
|
||||
0x7a: "BlockDataLong",
|
||||
0x7b: "Exception",
|
||||
0x7c: "LongString",
|
||||
0x7d: "ProxyClassDesc",
|
||||
0x7e: "Enum",
|
||||
};
|
||||
|
||||
const JAVA_POST_PROCESSORS = new Map<string, JavaPostProcessor>([
|
||||
["java.util.ArrayList@7881d21d99c7619d", (fields, annotations) => ({ ...fields, list: annotations.slice(1) })],
|
||||
["java.util.ArrayDeque@207cda2e240da08b", (fields, annotations) => ({ ...fields, list: annotations.slice(1) })],
|
||||
["java.util.Hashtable@13bb0f25214ae4b8", mapPostProcessor],
|
||||
["java.util.HashMap@0507dac1c31660d1", mapPostProcessor],
|
||||
["java.util.HashMap@0507b0c1331660d1", mapPostProcessor],
|
||||
["java.util.EnumMap@065d7df7be907ca1", enumMapPostProcessor],
|
||||
["java.util.HashSet@ba44859596b8b734", hashSetPostProcessor],
|
||||
]);
|
||||
|
||||
export function parseJavaSerializedDetail(bytes: Uint8Array): RedisJavaSerializedDetail | null {
|
||||
if (!isJavaSerialized(bytes)) return null;
|
||||
|
||||
try {
|
||||
const parser = new JavaSerializationParser(bytes);
|
||||
const contents = parser.parseContents();
|
||||
if (contents.length === 0) return null;
|
||||
const root = contents.length === 1 ? contents[0] : contents;
|
||||
const normalized = normalizeJavaSerializedValue(root, new WeakMap<object, string>(), { nextId: 1 });
|
||||
return {
|
||||
value: normalized,
|
||||
formattedText: JSON.stringify(normalized, null, 2),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isJavaSerialized(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 4 && bytes[0] === 0xac && bytes[1] === 0xed && bytes[2] === 0x00 && bytes[3] === 0x05;
|
||||
}
|
||||
|
||||
class JavaSerializationParser {
|
||||
private readonly view: DataView;
|
||||
private position = 0;
|
||||
private nextHandle = BASE_WIRE_HANDLE;
|
||||
private readonly handles = new Map<number, unknown>();
|
||||
|
||||
constructor(private readonly bytes: Uint8Array) {
|
||||
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
|
||||
parseContents(): unknown[] {
|
||||
if (this.readUint16() !== STREAM_MAGIC) throw new Error("STREAM_MAGIC not found");
|
||||
if (this.readUint16() !== STREAM_VERSION) throw new Error("Only understand protocol version 5");
|
||||
|
||||
const contents: unknown[] = [];
|
||||
while (this.position < this.bytes.length) {
|
||||
contents.push(this.readContent());
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
private readContent(allowed?: string[]): unknown {
|
||||
const tag = this.readUint8();
|
||||
const name = TAG_NAMES[tag];
|
||||
if (!name) throw new Error(`Don't know about type 0x${tag.toString(16)}`);
|
||||
if (allowed && !allowed.includes(name)) throw new Error(`${name} not allowed here`);
|
||||
|
||||
switch (tag) {
|
||||
case 0x70:
|
||||
return null;
|
||||
case 0x71:
|
||||
return this.readReference();
|
||||
case 0x72:
|
||||
return this.readClassDescriptor();
|
||||
case 0x73:
|
||||
return this.readObject();
|
||||
case 0x74:
|
||||
return this.addHandle(this.readUtf());
|
||||
case 0x75:
|
||||
return this.readArray();
|
||||
case 0x76:
|
||||
return this.addHandle(this.readClassDesc());
|
||||
case 0x77:
|
||||
return this.readBytes(this.readUint8());
|
||||
case 0x78:
|
||||
return END_BLOCK;
|
||||
case 0x79:
|
||||
case 0x7b:
|
||||
case 0x7d:
|
||||
throw new Error(`Don't know how to handle ${name}`);
|
||||
case 0x7a:
|
||||
return this.readBytes(this.readUint32());
|
||||
case 0x7c:
|
||||
return this.addHandle(this.readUtfLong());
|
||||
case 0x7e:
|
||||
return this.readEnum();
|
||||
default:
|
||||
throw new Error(`Don't know how to handle ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private readClassDesc(): JavaClassDescriptor | null {
|
||||
return this.readContent(["ClassDesc", "ProxyClassDesc", "Null", "Reference"]) as JavaClassDescriptor | null;
|
||||
}
|
||||
|
||||
private readClassDescriptor(): JavaClassDescriptor {
|
||||
const descriptor: JavaClassDescriptor = {
|
||||
name: this.readUtf(),
|
||||
serialVersionUID: bytesToHex(this.readBytes(8)),
|
||||
flags: 0,
|
||||
isEnum: false,
|
||||
fields: [],
|
||||
superClass: null,
|
||||
};
|
||||
this.addHandle(descriptor);
|
||||
|
||||
descriptor.flags = this.readUint8();
|
||||
descriptor.isEnum = Boolean(descriptor.flags & 0x10);
|
||||
const count = this.readUint16();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
descriptor.fields.push(this.readFieldDescriptor());
|
||||
}
|
||||
this.readAnnotations();
|
||||
descriptor.superClass = this.readClassDesc();
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private readFieldDescriptor(): JavaFieldDescriptor {
|
||||
const type = String.fromCharCode(this.readUint8());
|
||||
const field: JavaFieldDescriptor = { type, name: this.readUtf() };
|
||||
if (type === "[" || type === "L") {
|
||||
const className = this.readContent();
|
||||
field.className = typeof className === "string" ? className : String(className ?? "");
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
private readObject(): JavaObjectValue {
|
||||
const classDescriptor = this.readClassDesc();
|
||||
if (!classDescriptor) throw new Error("Object must have a class descriptor");
|
||||
|
||||
const value: JavaObjectValue = {};
|
||||
Object.defineProperty(value, JAVA_CLASS_META, { value: classDescriptor, configurable: true });
|
||||
Object.defineProperty(value, JAVA_EXTENDS_META, { value: {}, configurable: true });
|
||||
this.addHandle(value);
|
||||
this.readClassDataRecursive(classDescriptor, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private readClassDataRecursive(classDescriptor: JavaClassDescriptor, value: JavaObjectValue) {
|
||||
if (classDescriptor.superClass) {
|
||||
this.readClassDataRecursive(classDescriptor.superClass, value);
|
||||
}
|
||||
|
||||
const fields = this.readClassData(classDescriptor);
|
||||
value[JAVA_EXTENDS_META]![classDescriptor.name] = fields;
|
||||
for (const [name, fieldValue] of Object.entries(fields)) {
|
||||
value[name] = fieldValue;
|
||||
}
|
||||
}
|
||||
|
||||
private readClassData(classDescriptor: JavaClassDescriptor): Record<string, unknown> {
|
||||
const flags = classDescriptor.flags & 0x0f;
|
||||
const postProcessor = JAVA_POST_PROCESSORS.get(`${classDescriptor.name}@${classDescriptor.serialVersionUID}`);
|
||||
|
||||
if (flags === 0x02) return this.readFieldValues(classDescriptor);
|
||||
if (flags === 0x03) {
|
||||
const fields = this.readFieldValues(classDescriptor);
|
||||
const annotations = this.readAnnotations();
|
||||
fields["@"] = annotations;
|
||||
return postProcessor ? postProcessor(fields, annotations) : fields;
|
||||
}
|
||||
if (flags === 0x04) throw new Error("Can't parse version 1 external content");
|
||||
if (flags === 0x0c) {
|
||||
const annotations = this.readAnnotations();
|
||||
return { "@": annotations };
|
||||
}
|
||||
throw new Error(`Don't know how to deserialize class with flags 0x${classDescriptor.flags.toString(16)}`);
|
||||
}
|
||||
|
||||
private readArray(): unknown[] {
|
||||
const classDescriptor = this.readClassDesc();
|
||||
if (!classDescriptor) throw new Error("Array must have a class descriptor");
|
||||
|
||||
const value: unknown[] = [];
|
||||
Object.defineProperty(value, JAVA_CLASS_META, { value: classDescriptor, configurable: true });
|
||||
this.addHandle(value);
|
||||
|
||||
const length = this.readInt32();
|
||||
const reader = this.primitiveReader(classDescriptor.name.charAt(1));
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
value.push(reader());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private readEnum(): unknown {
|
||||
const classDescriptor = this.readClassDesc();
|
||||
if (!classDescriptor) throw new Error("Enum must have a class descriptor");
|
||||
|
||||
const assignHandle = this.reserveHandle();
|
||||
const constant = this.readContent();
|
||||
const value = new String(typeof constant === "string" ? constant : String(constant ?? ""));
|
||||
Object.defineProperty(value, JAVA_CLASS_META, { value: classDescriptor, configurable: true });
|
||||
assignHandle(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private readAnnotations(): unknown[] {
|
||||
const annotations: unknown[] = [];
|
||||
while (true) {
|
||||
const value = this.readContent();
|
||||
if (value === END_BLOCK) return annotations;
|
||||
annotations.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
private readFieldValues(classDescriptor: JavaClassDescriptor): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of classDescriptor.fields) {
|
||||
values[field.name] = this.primitiveReader(field.type)();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private primitiveReader(type: string): () => unknown {
|
||||
switch (type) {
|
||||
case "B":
|
||||
return () => this.readInt8();
|
||||
case "C":
|
||||
return () => String.fromCharCode(this.readUint16());
|
||||
case "D":
|
||||
return () => this.readFloat64();
|
||||
case "F":
|
||||
return () => this.readFloat32();
|
||||
case "I":
|
||||
return () => this.readInt32();
|
||||
case "J":
|
||||
return () => this.readInt64();
|
||||
case "S":
|
||||
return () => this.readInt16();
|
||||
case "Z":
|
||||
return () => this.readInt8() !== 0;
|
||||
case "L":
|
||||
case "[":
|
||||
return () => this.readContent();
|
||||
default:
|
||||
throw new Error(`Don't know how to read field of type '${type}'`);
|
||||
}
|
||||
}
|
||||
|
||||
private addHandle<T>(value: T): T {
|
||||
this.handles.set(this.nextHandle, value);
|
||||
this.nextHandle += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
private reserveHandle(): (value: unknown) => void {
|
||||
const handle = this.nextHandle;
|
||||
this.nextHandle += 1;
|
||||
this.handles.set(handle, null);
|
||||
return (value: unknown) => {
|
||||
this.handles.set(handle, value);
|
||||
};
|
||||
}
|
||||
|
||||
private readReference(): unknown {
|
||||
const handle = this.readInt32();
|
||||
if (!this.handles.has(handle)) throw new Error(`Unknown reference handle 0x${handle.toString(16)}`);
|
||||
return this.handles.get(handle);
|
||||
}
|
||||
|
||||
private readUint8(): number {
|
||||
const position = this.step(1);
|
||||
return this.view.getUint8(position);
|
||||
}
|
||||
|
||||
private readInt8(): number {
|
||||
const position = this.step(1);
|
||||
return this.view.getInt8(position);
|
||||
}
|
||||
|
||||
private readUint16(): number {
|
||||
const position = this.step(2);
|
||||
return this.view.getUint16(position, false);
|
||||
}
|
||||
|
||||
private readInt16(): number {
|
||||
const position = this.step(2);
|
||||
return this.view.getInt16(position, false);
|
||||
}
|
||||
|
||||
private readUint32(): number {
|
||||
const position = this.step(4);
|
||||
return this.view.getUint32(position, false);
|
||||
}
|
||||
|
||||
private readInt32(): number {
|
||||
const position = this.step(4);
|
||||
return this.view.getInt32(position, false);
|
||||
}
|
||||
|
||||
private readInt64(): bigint {
|
||||
const high = this.readUint32();
|
||||
const low = this.readUint32();
|
||||
let value = (BigInt(high) << 32n) | BigInt(low);
|
||||
if (high & 0x80000000) {
|
||||
value -= 1n << 64n;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private readFloat32(): number {
|
||||
const position = this.step(4);
|
||||
return this.view.getFloat32(position, false);
|
||||
}
|
||||
|
||||
private readFloat64(): number {
|
||||
const position = this.step(8);
|
||||
return this.view.getFloat64(position, false);
|
||||
}
|
||||
|
||||
private readUtf(): string {
|
||||
return decodeModifiedUtf8(this.readBytes(this.readUint16()));
|
||||
}
|
||||
|
||||
private readUtfLong(): string {
|
||||
const high = this.readUint32();
|
||||
if (high !== 0) throw new Error("Can't handle more than 2^32 bytes in a string");
|
||||
return decodeModifiedUtf8(this.readBytes(this.readUint32()));
|
||||
}
|
||||
|
||||
private readBytes(length: number): Uint8Array {
|
||||
const position = this.step(length);
|
||||
return this.bytes.slice(position, position + length);
|
||||
}
|
||||
|
||||
private step(length: number): number {
|
||||
const position = this.position;
|
||||
this.position += length;
|
||||
if (this.position > this.bytes.length) {
|
||||
throw new Error("Premature end of input");
|
||||
}
|
||||
return position;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeJavaSerializedValue(value: unknown, seen: WeakMap<object, string>, state: { nextId: number }): unknown {
|
||||
if (value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
return { $blockDataHex: bytesToHex(value) };
|
||||
}
|
||||
if (value instanceof String) {
|
||||
const classDescriptor = getJavaClassDescriptor(value);
|
||||
if (!classDescriptor) return value.toString();
|
||||
return {
|
||||
$class: classDescriptor.name,
|
||||
$enum: value.toString(),
|
||||
};
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return String(value);
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return { $ref: seen.get(value) };
|
||||
}
|
||||
|
||||
const objectId = `#${state.nextId}`;
|
||||
state.nextId += 1;
|
||||
seen.set(value, objectId);
|
||||
|
||||
const classDescriptor = getJavaClassDescriptor(value);
|
||||
const extendsValues = getJavaExtendsValues(value);
|
||||
|
||||
if (value instanceof Map) {
|
||||
return {
|
||||
$id: objectId,
|
||||
...(classDescriptor ? { $class: classDescriptor.name } : {}),
|
||||
$entries: Array.from(value.entries(), ([key, entryValue]) => ({
|
||||
key: normalizeJavaSerializedValue(key, seen, state),
|
||||
value: normalizeJavaSerializedValue(entryValue, seen, state),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (value instanceof Set) {
|
||||
return {
|
||||
$id: objectId,
|
||||
...(classDescriptor ? { $class: classDescriptor.name } : {}),
|
||||
$values: Array.from(value.values(), (entryValue) => normalizeJavaSerializedValue(entryValue, seen, state)),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = {
|
||||
$id: objectId,
|
||||
...(classDescriptor ? { $class: classDescriptor.name } : {}),
|
||||
$values: value.map((entryValue) => normalizeJavaSerializedValue(entryValue, seen, state)),
|
||||
};
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {
|
||||
$id: objectId,
|
||||
...(classDescriptor ? { $class: classDescriptor.name } : {}),
|
||||
};
|
||||
|
||||
for (const [name, entryValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
normalized[name] = normalizeJavaSerializedValue(entryValue, seen, state);
|
||||
}
|
||||
|
||||
if (extendsValues) {
|
||||
const classNames = Object.keys(extendsValues);
|
||||
if (classNames.length > 1) {
|
||||
normalized.$extends = Object.fromEntries(classNames.map((className) => [className, Object.fromEntries(Object.entries(extendsValues[className]).map(([name, entryValue]) => [name, normalizeJavaSerializedValue(entryValue, seen, state)]))]));
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getJavaClassDescriptor(value: object): JavaClassDescriptor | undefined {
|
||||
return (value as { [JAVA_CLASS_META]?: JavaClassDescriptor })[JAVA_CLASS_META];
|
||||
}
|
||||
|
||||
function getJavaExtendsValues(value: object): Record<string, Record<string, unknown>> | undefined {
|
||||
return (value as { [JAVA_EXTENDS_META]?: Record<string, Record<string, unknown>> })[JAVA_EXTENDS_META];
|
||||
}
|
||||
|
||||
function mapPostProcessor(fields: Record<string, unknown>, annotations: unknown[]): Record<string, unknown> {
|
||||
const header = annotations[0];
|
||||
if (!(header instanceof Uint8Array)) return fields;
|
||||
|
||||
const size = readInt32FromBytes(header, 4);
|
||||
const map = new Map<unknown, unknown>();
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
const key = annotations[2 * index + 1];
|
||||
const value = annotations[2 * index + 2];
|
||||
map.set(key, value);
|
||||
if (typeof key === "string") {
|
||||
obj[key] = value;
|
||||
}
|
||||
}
|
||||
return { ...fields, map, obj };
|
||||
}
|
||||
|
||||
function enumMapPostProcessor(fields: Record<string, unknown>, annotations: unknown[]): Record<string, unknown> {
|
||||
const header = annotations[0];
|
||||
if (!(header instanceof Uint8Array)) return fields;
|
||||
|
||||
const size = readInt32FromBytes(header, 0);
|
||||
const map = new Map<unknown, unknown>();
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
const key = annotations[2 * index + 1];
|
||||
const value = annotations[2 * index + 2];
|
||||
map.set(key, value);
|
||||
obj[String(key)] = value;
|
||||
}
|
||||
return { ...fields, map, obj };
|
||||
}
|
||||
|
||||
function hashSetPostProcessor(fields: Record<string, unknown>, annotations: unknown[]): Record<string, unknown> {
|
||||
const header = annotations[0];
|
||||
if (!(header instanceof Uint8Array)) return fields;
|
||||
|
||||
const size = readInt32FromBytes(header, 8);
|
||||
const values = annotations.slice(1);
|
||||
if (values.length !== size) return fields;
|
||||
return { ...fields, set: new Set(values) };
|
||||
}
|
||||
|
||||
function readInt32FromBytes(bytes: Uint8Array, offset: number): number {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return view.getInt32(offset, false);
|
||||
}
|
||||
|
||||
function decodeModifiedUtf8(bytes: Uint8Array): string {
|
||||
let output = "";
|
||||
for (let index = 0; index < bytes.length; ) {
|
||||
const byte = bytes[index];
|
||||
if ((byte & 0x80) === 0) {
|
||||
output += String.fromCharCode(byte);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if ((byte & 0xe0) === 0xc0) {
|
||||
const next = bytes[index + 1];
|
||||
if (next == null) throw new Error("Invalid modified UTF-8 sequence");
|
||||
output += String.fromCharCode(((byte & 0x1f) << 6) | (next & 0x3f));
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if ((byte & 0xf0) === 0xe0) {
|
||||
const next = bytes[index + 1];
|
||||
const third = bytes[index + 2];
|
||||
if (next == null || third == null) throw new Error("Invalid modified UTF-8 sequence");
|
||||
output += String.fromCharCode(((byte & 0x0f) << 12) | ((next & 0x3f) << 6) | (third & 0x3f));
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
throw new Error("Invalid modified UTF-8 sequence");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -1,10 +1,30 @@
|
||||
import type { BinaryHexViewRow } from "@/lib/dataGrid/binaryHexViewer";
|
||||
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
|
||||
import type { RedisBlob, RedisCollectionPage, RedisHashItem, RedisListItem, RedisSetItem, RedisValue, RedisZsetItem } from "@/lib/backend/api";
|
||||
import { parseJavaSerializedDetail, type RedisJavaSerializedDetail } from "@/lib/redis/javaSerialized";
|
||||
|
||||
export type RedisValueFormat = "utf8" | "ascii" | "binary" | "json" | "javaserialize" | "hex" | "base64";
|
||||
export type RedisMemberDetailFormat = "json" | "text";
|
||||
|
||||
export const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = ["utf8", "ascii", "binary", "json", "javaserialize", "hex", "base64"];
|
||||
|
||||
export interface RedisMemberDetail {
|
||||
text: string;
|
||||
rawText: string;
|
||||
rawLabel: string;
|
||||
utf8Text: string;
|
||||
asciiText: string;
|
||||
binaryText: string;
|
||||
format: RedisMemberDetailFormat;
|
||||
json?: RedisJsonDetail;
|
||||
javaSerialized?: RedisJavaSerializedDetail;
|
||||
availableFormats: RedisValueFormat[];
|
||||
defaultFormat: RedisValueFormat;
|
||||
byteCount: number;
|
||||
base64Text: string;
|
||||
hexRows: BinaryHexViewRow[];
|
||||
editable: boolean;
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
export interface RedisJsonDetail {
|
||||
@@ -13,13 +33,20 @@ export interface RedisJsonDetail {
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface RedisMemberDetailOptions {
|
||||
allowJsonText?: boolean;
|
||||
}
|
||||
|
||||
export type RedisMemberDetailKind = "list" | "set" | "hash" | "zset" | "stream";
|
||||
export type RedisCollectionItem = RedisListItem | RedisSetItem | RedisHashItem | RedisZsetItem;
|
||||
|
||||
export const REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH = 360;
|
||||
export const REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH = 900;
|
||||
|
||||
export function canEditRedisMemberDetail(kind: RedisMemberDetailKind): boolean {
|
||||
return kind !== "stream";
|
||||
export function canEditRedisMemberDetail(kind: RedisMemberDetailKind, value?: unknown): boolean {
|
||||
if (kind === "stream") return false;
|
||||
if (value == null) return true;
|
||||
return !isRedisBlob(value) || value.encoding !== "binary";
|
||||
}
|
||||
|
||||
export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: number): number {
|
||||
@@ -27,27 +54,97 @@ export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: n
|
||||
return Math.min(Math.min(REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH, viewportMax), Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
export function formatRedisMemberDetail(value: unknown): RedisMemberDetail {
|
||||
export function isRedisBlob(value: unknown): value is RedisBlob {
|
||||
return typeof value === "object" && value !== null && "raw_base64" in value && "encoding" in value;
|
||||
}
|
||||
|
||||
export function decodeRedisBlob(blob: RedisBlob): Uint8Array {
|
||||
return base64ToBytes(blob.raw_base64);
|
||||
}
|
||||
|
||||
export function redisBlobText(blob: RedisBlob): string | null {
|
||||
if (blob.encoding === "binary") return null;
|
||||
return decodeUtf8Bytes(decodeRedisBlob(blob));
|
||||
}
|
||||
|
||||
export function redisBlobRawText(blob: RedisBlob): string {
|
||||
return redisBlobText(blob) ?? escapeRedisBytes(decodeRedisBlob(blob));
|
||||
}
|
||||
|
||||
export function redisBlobDisplayText(blob: RedisBlob): string {
|
||||
return sanitizeRedisDisplayText(redisBlobRawText(blob));
|
||||
}
|
||||
|
||||
export function formatRedisMemberDetail(value: unknown, options: RedisMemberDetailOptions = {}): RedisMemberDetail {
|
||||
if (isRedisBlob(value)) return formatRedisBlobDetail(value, options);
|
||||
|
||||
if (typeof value === "string") {
|
||||
const json = parseRedisJsonDetail(value);
|
||||
return json ? { text: json.formattedText, rawText: value, format: "json", json } : { text: sanitizeRedisDisplayText(value), rawText: value, format: "text" };
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
const json = options.allowJsonText ? (parseRedisJsonDetail(value) ?? undefined) : undefined;
|
||||
const textFormats: RedisValueFormat[] = ["utf8", "ascii", "binary"];
|
||||
return {
|
||||
text: sanitizeRedisDisplayText(value),
|
||||
rawText: value,
|
||||
rawLabel: isAsciiBytes(bytes) ? "ASCII" : "UTF-8",
|
||||
utf8Text: value,
|
||||
asciiText: asciiBytesToText(bytes),
|
||||
binaryText: binaryBytesToText(bytes),
|
||||
format: "text",
|
||||
json,
|
||||
availableFormats: formatOrderForValue("utf8", textFormats, json ? ["json"] : []),
|
||||
defaultFormat: "utf8",
|
||||
byteCount: bytes.byteLength,
|
||||
base64Text: bytesToBase64(bytes),
|
||||
hexRows: buildBinaryHexViewRows(bytes),
|
||||
editable: true,
|
||||
binary: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const formattedText = JSON.stringify(value, null, 2);
|
||||
const bytes = new TextEncoder().encode(formattedText);
|
||||
return {
|
||||
text: formattedText,
|
||||
rawText: formattedText,
|
||||
rawLabel: "Raw",
|
||||
utf8Text: formattedText,
|
||||
asciiText: asciiBytesToText(bytes),
|
||||
binaryText: binaryBytesToText(bytes),
|
||||
format: "json",
|
||||
json: { rawText: formattedText, formattedText, value },
|
||||
availableFormats: formatOrderForValue("json", ["utf8"], ["json"]),
|
||||
defaultFormat: "json",
|
||||
byteCount: bytes.byteLength,
|
||||
base64Text: bytesToBase64(bytes),
|
||||
hexRows: buildBinaryHexViewRows(bytes),
|
||||
editable: false,
|
||||
binary: false,
|
||||
};
|
||||
} catch {
|
||||
const text = String(value);
|
||||
return { text, rawText: text, format: "text" };
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return {
|
||||
text,
|
||||
rawText: text,
|
||||
rawLabel: "Raw",
|
||||
utf8Text: text,
|
||||
asciiText: asciiBytesToText(bytes),
|
||||
binaryText: binaryBytesToText(bytes),
|
||||
format: "text",
|
||||
availableFormats: formatOrderForValue("utf8", ["utf8"], []),
|
||||
defaultFormat: "utf8",
|
||||
byteCount: bytes.byteLength,
|
||||
base64Text: bytesToBase64(bytes),
|
||||
hexRows: buildBinaryHexViewRows(bytes),
|
||||
editable: false,
|
||||
binary: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function formatRedisStringValue(value: unknown): string {
|
||||
if (isRedisBlob(value)) return formatRedisMemberDetail(value).text;
|
||||
if (typeof value !== "string") return String(value ?? "");
|
||||
return formatRedisJsonString(value) ?? sanitizeRedisDisplayText(value);
|
||||
}
|
||||
@@ -85,19 +182,13 @@ export function formatRedisConsoleValue(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function formatRedisJsonString(value: string): string | null {
|
||||
return parseRedisJsonDetail(value)?.formattedText ?? null;
|
||||
}
|
||||
|
||||
export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!looksLikeJsonContainer(trimmed)) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!isJsonContainer(parsed)) return null;
|
||||
return {
|
||||
rawText: value,
|
||||
formattedText: JSON.stringify(parsed, null, 2),
|
||||
@@ -108,17 +199,175 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeJsonContainer(value: string): boolean {
|
||||
return (value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]"));
|
||||
export function preferredRedisValueFormat(value: unknown, preferred?: RedisValueFormat | null, options: RedisMemberDetailOptions = {}): RedisValueFormat {
|
||||
const detail = formatRedisMemberDetail(value, options);
|
||||
if (preferred && detail.availableFormats.includes(preferred) && shouldReuseRedisValueFormatPreference(detail, preferred)) return preferred;
|
||||
return detail.defaultFormat;
|
||||
}
|
||||
|
||||
function isJsonContainer(value: unknown): boolean {
|
||||
return value !== null && typeof value === "object";
|
||||
export function canRenderRedisValueFormat(detail: RedisMemberDetail, format: RedisValueFormat): boolean {
|
||||
switch (format) {
|
||||
case "json":
|
||||
return Boolean(detail.json);
|
||||
case "javaserialize":
|
||||
return Boolean(detail.javaSerialized);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function getRedisMemberSelectionKey(title: string, value: unknown): string {
|
||||
export function redisMemberCopyText(value: unknown): string {
|
||||
return isRedisBlob(value) ? redisBlobRawText(value) : formatRedisMemberDetail(value).rawText;
|
||||
}
|
||||
|
||||
export function getRedisMemberSelectionKey(title: string, value: unknown, identity = title): string {
|
||||
if (isRedisBlob(value)) return `${identity}\n${value.raw_base64}`;
|
||||
const detail = formatRedisMemberDetail(value);
|
||||
return `${title}\n${detail.format === "json" ? detail.text : detail.rawText}`;
|
||||
return `${identity}\n${detail.rawText}`;
|
||||
}
|
||||
|
||||
export function redisValueCollectionItems(value: RedisValue): RedisCollectionItem[] {
|
||||
switch (value.data.kind) {
|
||||
case "list":
|
||||
case "set":
|
||||
case "hash":
|
||||
case "zset":
|
||||
return value.data.items;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function redisCollectionPageItems(page: RedisCollectionPage): RedisCollectionItem[] {
|
||||
return page.items;
|
||||
}
|
||||
|
||||
export function redisValueCollectionTotal(value: RedisValue): number | null {
|
||||
switch (value.data.kind) {
|
||||
case "list":
|
||||
case "set":
|
||||
case "hash":
|
||||
case "zset":
|
||||
return value.data.total;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function redisValueCollectionScanCursor(value: RedisValue): number | undefined {
|
||||
switch (value.data.kind) {
|
||||
case "list":
|
||||
case "set":
|
||||
case "hash":
|
||||
case "zset":
|
||||
return value.data.scan_cursor;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function redisValueSize(value: RedisValue): number {
|
||||
switch (value.data.kind) {
|
||||
case "string":
|
||||
return decodeRedisBlob(value.data.content).byteLength;
|
||||
case "json":
|
||||
return new TextEncoder().encode(JSON.stringify(value.data.value)).byteLength;
|
||||
case "list":
|
||||
case "set":
|
||||
case "hash":
|
||||
case "zset":
|
||||
return value.data.total;
|
||||
case "stream":
|
||||
return value.data.entries.length;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function redisValuePreview(value: RedisValue): string {
|
||||
switch (value.data.kind) {
|
||||
case "string":
|
||||
return previewText(redisBlobRawText(value.data.content));
|
||||
case "json":
|
||||
return previewText(JSON.stringify(value.data.value));
|
||||
case "list": {
|
||||
const first = value.data.items[0];
|
||||
return first ? previewText(redisBlobRawText(first.value)) : "";
|
||||
}
|
||||
case "set": {
|
||||
const first = value.data.items[0];
|
||||
return first ? previewText(redisBlobRawText(first.member)) : "";
|
||||
}
|
||||
case "hash": {
|
||||
const first = value.data.items[0];
|
||||
return first ? previewText(`${redisBlobRawText(first.field)} ${redisBlobRawText(first.value)}`) : "";
|
||||
}
|
||||
case "zset": {
|
||||
const first = value.data.items[0];
|
||||
return first ? previewText(`${first.score} ${redisBlobRawText(first.member)}`) : "";
|
||||
}
|
||||
case "stream": {
|
||||
const first = value.data.entries[0];
|
||||
if (!first) return "";
|
||||
const joined = first.fields.map(({ field, value: entryValue }) => `${field} ${entryValue}`).join(" ");
|
||||
return previewText(joined);
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function redisValueCopyText(value: RedisValue, collectionItems: RedisCollectionItem[] = redisValueCollectionItems(value)): string {
|
||||
switch (value.data.kind) {
|
||||
case "string":
|
||||
return redisBlobRawText(value.data.content);
|
||||
case "json":
|
||||
return JSON.stringify(value.data.value, null, 2);
|
||||
case "list":
|
||||
return JSON.stringify(
|
||||
(collectionItems as RedisListItem[]).map((item) => redisBlobRawText(item.value)),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
case "set":
|
||||
return JSON.stringify(
|
||||
(collectionItems as RedisSetItem[]).map((item) => redisBlobRawText(item.member)),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
case "hash":
|
||||
return JSON.stringify(
|
||||
(collectionItems as RedisHashItem[]).map((item) => ({
|
||||
field: redisBlobRawText(item.field),
|
||||
value: redisBlobRawText(item.value),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
case "zset":
|
||||
return JSON.stringify(
|
||||
(collectionItems as RedisZsetItem[]).map((item) => ({
|
||||
score: item.score,
|
||||
member: redisBlobRawText(item.member),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
case "stream":
|
||||
return JSON.stringify(
|
||||
value.data.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
fields: entry.fields.map((field) => ({
|
||||
field: field.field,
|
||||
value: field.value,
|
||||
})),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
default:
|
||||
return JSON.stringify(value.data, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeRedisDisplayText(value: string): string {
|
||||
@@ -135,10 +384,6 @@ export function sanitizeRedisDisplayText(value: string): string {
|
||||
return output;
|
||||
}
|
||||
|
||||
function isUtf8ControlCharacter(ch: string): boolean {
|
||||
return /\p{Cc}/u.test(ch);
|
||||
}
|
||||
|
||||
export function highlightRedisJsonDetail(json: string): string {
|
||||
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
@@ -150,3 +395,137 @@ export function highlightRedisJsonDetail(json: string): string {
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
function formatRedisBlobDetail(blob: RedisBlob, options: RedisMemberDetailOptions): RedisMemberDetail {
|
||||
const bytes = decodeRedisBlob(blob);
|
||||
const strictUtf8Text = blob.encoding === "binary" ? null : decodeUtf8Bytes(bytes);
|
||||
const utf8Text = strictUtf8Text ?? decodeUtf8BytesLossy(bytes);
|
||||
const asciiText = asciiBytesToText(bytes);
|
||||
const binaryText = binaryBytesToText(bytes);
|
||||
const rawText = strictUtf8Text ?? binaryText;
|
||||
const javaSerialized = parseJavaSerializedDetail(bytes);
|
||||
const defaultFormat: RedisValueFormat = javaSerialized ? "javaserialize" : strictUtf8Text != null ? "utf8" : "hex";
|
||||
const json = options.allowJsonText && strictUtf8Text != null ? (parseRedisJsonDetail(strictUtf8Text) ?? undefined) : undefined;
|
||||
const textFormats: RedisValueFormat[] = strictUtf8Text != null ? ["utf8", "ascii", "binary"] : ["binary"];
|
||||
const extraFormats: RedisValueFormat[] = [];
|
||||
if (json) extraFormats.push("json");
|
||||
if (javaSerialized) extraFormats.push("javaserialize");
|
||||
return {
|
||||
text: redisBlobDisplayText(blob),
|
||||
rawText,
|
||||
rawLabel: blob.encoding === "binary" ? "Binary" : isAsciiBytes(bytes) ? "ASCII" : "UTF-8",
|
||||
utf8Text,
|
||||
asciiText,
|
||||
binaryText,
|
||||
format: "text",
|
||||
json,
|
||||
javaSerialized: javaSerialized ?? undefined,
|
||||
availableFormats: formatOrderForValue(defaultFormat, textFormats, extraFormats),
|
||||
defaultFormat,
|
||||
byteCount: bytes.byteLength,
|
||||
base64Text: blob.raw_base64,
|
||||
hexRows: buildBinaryHexViewRows(bytes),
|
||||
editable: strictUtf8Text != null,
|
||||
binary: strictUtf8Text == null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatRedisJsonString(value: string): string | null {
|
||||
return parseRedisJsonDetail(value)?.formattedText ?? null;
|
||||
}
|
||||
|
||||
function formatOrderForValue(defaultFormat: RedisValueFormat, textFormats: RedisValueFormat[], extraFormats: RedisValueFormat[]): RedisValueFormat[] {
|
||||
const availableFormats: RedisValueFormat[] = [...textFormats];
|
||||
availableFormats.push(...extraFormats);
|
||||
availableFormats.push("hex", "base64");
|
||||
return [defaultFormat, ...availableFormats.filter((format) => format !== defaultFormat)];
|
||||
}
|
||||
|
||||
function shouldReuseRedisValueFormatPreference(detail: RedisMemberDetail, format: RedisValueFormat): boolean {
|
||||
if (!detail.editable) return true;
|
||||
return format === "utf8" || format === "json";
|
||||
}
|
||||
|
||||
function previewText(value: string): string {
|
||||
const singleLine = value.replace(/\s+/g, " ").trim();
|
||||
return singleLine.length > 160 ? `${singleLine.slice(0, 160)}…` : singleLine;
|
||||
}
|
||||
|
||||
function isUtf8ControlCharacter(ch: string): boolean {
|
||||
return /\p{Cc}/u.test(ch);
|
||||
}
|
||||
|
||||
function escapeRedisBytes(bytes: Uint8Array): string {
|
||||
let output = "";
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0x5c) {
|
||||
output += "\\\\";
|
||||
continue;
|
||||
}
|
||||
if (byte >= 0x20 && byte <= 0x7e) {
|
||||
output += String.fromCharCode(byte);
|
||||
continue;
|
||||
}
|
||||
output += `\\x${byte.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function decodeUtf8Bytes(bytes: Uint8Array): string | null {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8BytesLossy(bytes: Uint8Array): string {
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
|
||||
function asciiBytesToText(bytes: Uint8Array): string {
|
||||
let output = "";
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0x0a) {
|
||||
output += "\n";
|
||||
continue;
|
||||
}
|
||||
if (byte === 0x0d) {
|
||||
output += "\r";
|
||||
continue;
|
||||
}
|
||||
if (byte === 0x09) {
|
||||
output += "\t";
|
||||
continue;
|
||||
}
|
||||
if (byte >= 0x20 && byte <= 0x7e) {
|
||||
output += String.fromCharCode(byte);
|
||||
continue;
|
||||
}
|
||||
output += `\\x${byte.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function binaryBytesToText(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(2).padStart(8, "0")).join("");
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
if (typeof atob !== "function") throw new Error("Base64 decoding is unavailable in this runtime");
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
if (typeof btoa !== "function") throw new Error("Base64 encoding is unavailable in this runtime");
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function isAsciiBytes(bytes: Uint8Array): boolean {
|
||||
return bytes.every((byte) => byte <= 0x7f);
|
||||
}
|
||||
Reference in new issue
Block a user