From 551d7ec0295de9783a59b30329692f1832f1417c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:13:53 +0000 Subject: [PATCH 1/2] feat(widgets): add protobuf decoder widget Paste a protobuf payload as base64 or hex and read it, with or without the .proto that produced it. Everything runs in the browser. Without a definition, the payload is decoded straight from the wire format: field numbers, wire types, nested messages, and a reading of every value. The wire format does not carry names or declared types, so where the bytes are genuinely ambiguous the widget lists the other readings instead of picking one silently: a varint that could be a bool or a zigzag sint64, a length-delimited field that parses as a nested message and is also printable text, bytes that could be a packed repeated field. With a definition, protobufjs parses the .proto text at runtime (no codegen, no protoc) and the decoded message is shown as a named tree. The schema-less reading is kept alongside and the two are compared, because protobufjs drops in silence both the fields a definition does not declare and the declared fields that arrive as the wrong wire type. Either one is reported: the first as fields that were dropped, the second as the sign that the payload is probably a different message type. Other conveniences: the encoding is detected rather than asked for (hex wins a tie), hex dumps keep their separators and 0x prefixes, base64url and missing padding are accepted, and a 5-byte gRPC length prefix is recognized and skipped. Payloads are capped at 100,000 characters and 64 KB of bytes, definitions at 50,000 characters. protobufjs is added as a dependency; it lands in the widget's own lazy chunk (132 KB, 38 KB gzipped), so nothing changes for a dashboard that never opens this widget. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K2aiBGNxfoFs9oBpSVBTsE --- README.md | 6 +- package-lock.json | 19 ++ package.json | 1 + .../ProtobufDecoderWidget.test.tsx | 165 +++++++++ .../ProtobufDecoderWidget.tsx | 287 ++++++++++++++++ src/widgets/protobuf-decoder/definition.ts | 29 ++ .../protobuf-decoder/payloadBytes.test.ts | 78 +++++ src/widgets/protobuf-decoder/payloadBytes.ts | 137 ++++++++ .../protobuf-decoder/protoSchema.test.ts | 155 +++++++++ src/widgets/protobuf-decoder/protoSchema.ts | 219 ++++++++++++ .../protobuf-decoder/wireFormat.test.ts | 167 +++++++++ src/widgets/protobuf-decoder/wireFormat.ts | 321 ++++++++++++++++++ src/widgets/registry.ts | 2 + 13 files changed, 1583 insertions(+), 3 deletions(-) create mode 100644 src/widgets/protobuf-decoder/ProtobufDecoderWidget.test.tsx create mode 100644 src/widgets/protobuf-decoder/ProtobufDecoderWidget.tsx create mode 100644 src/widgets/protobuf-decoder/definition.ts create mode 100644 src/widgets/protobuf-decoder/payloadBytes.test.ts create mode 100644 src/widgets/protobuf-decoder/payloadBytes.ts create mode 100644 src/widgets/protobuf-decoder/protoSchema.test.ts create mode 100644 src/widgets/protobuf-decoder/protoSchema.ts create mode 100644 src/widgets/protobuf-decoder/wireFormat.test.ts create mode 100644 src/widgets/protobuf-decoder/wireFormat.ts diff --git a/README.md b/README.md index 6bfa5eb..a38fdb5 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,14 @@ always asks before it touches your local dashboard. ## Widgets -33 tools across 10 categories, and growing. +36 tools across 10 categories, and growing. | Category | Widgets | | ---------- | --------------------------------------------------------------------------------------------------------- | | Generators | UUID Generator | | Formatting | Cron Expression, JSON Formatter, Timestamp Converter, XML Formatter, YAML ↔ JSON Converter | -| Encoding | Base64, JWT Encoder, LZ-String, URL Encoder | -| Security | Certificate Viewer, Hash Generator, JWK Viewer, Password Generator | +| Encoding | Base64, JWT Encoder, LZ-String, Protobuf Decoder, URL Encoder | +| Security | Certificate Viewer, Hash Generator, JWK Viewer, Password Generator, Unix Permissions | | Text | Content Type Detector, Emoji Picker, Log Viewer, Notes, Regex Tester, Text Case Converter, Text Diff | | Math | Expression Evaluator, Number Base Converter, Percentage Calculator, Statistics Calculator, Unit Converter | | AI / LLM | Invisible Character Cleaner, Token Counter | diff --git a/package-lock.json b/package-lock.json index a205408..db8a349 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "lucide-react": "^1.30.0", "lz-string": "^1.5.0", "nanoid": "^6.0.1", + "protobufjs": "^8.8.0", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -7189,6 +7190,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7701,6 +7708,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", + "integrity": "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==", + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 3caf393..3a2868b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "lucide-react": "^1.30.0", "lz-string": "^1.5.0", "nanoid": "^6.0.1", + "protobufjs": "^8.8.0", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/src/widgets/protobuf-decoder/ProtobufDecoderWidget.test.tsx b/src/widgets/protobuf-decoder/ProtobufDecoderWidget.test.tsx new file mode 100644 index 0000000..50b58dd --- /dev/null +++ b/src/widgets/protobuf-decoder/ProtobufDecoderWidget.test.tsx @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { setCodeMirrorValue } from '@/test/codemirror' +import ProtobufDecoderWidget from './ProtobufDecoderWidget' + +/** The sample payload the widget opens on: Person { id: 150, name: + * "testing", tags: ["a", "b"], home: { city: "Paris" } }. */ +const SAMPLE_PAYLOAD = 'CJYBEgd0ZXN0aW5nGgFhGgFiIgcKBVBhcmlz' + +function payloadBox() { + return screen.getByPlaceholderText(/paste a base64 or hex protobuf payload/i) +} + +async function setPayload(user: ReturnType, text: string) { + await user.clear(payloadBox()) + if (text) await user.paste(text) +} + +describe('ProtobufDecoderWidget', () => { + it('decodes the sample payload without a schema, field by field', () => { + render() + + const rows = screen.getAllByRole('listitem') + expect(within(rows[0]).getByText('1')).toBeInTheDocument() + expect(within(rows[0]).getByText('150')).toBeInTheDocument() + expect(within(rows[0]).getByText('varint')).toBeInTheDocument() + expect(screen.getByText('"testing"')).toBeInTheDocument() + expect(screen.getByText('"Paris"')).toBeInTheDocument() + }) + + it('says what the encoding and size turned out to be', () => { + render() + expect(screen.getByText(/base64, 27 bytes/i)).toBeInTheDocument() + }) + + it('reads the same payload pasted as hex', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, '08 96 01 12 07 74 65 73 74 69 6e 67') + + expect(screen.getByText(/hex, 12 bytes/i)).toBeInTheDocument() + expect(screen.getByText('"testing"')).toBeInTheDocument() + }) + + it('strips a gRPC frame and says it did', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, '0000000003089601') + + expect(screen.getByText(/gRPC frame detected/i)).toBeInTheDocument() + expect(screen.getByText('150')).toBeInTheDocument() + }) + + it('offers the other readings of an ambiguous value', async () => { + const user = userEvent.setup() + render() + + // Field 1, varint 1: as likely a bool as a number. + await setPayload(user, '0801') + + expect(screen.getByText(/or bool true/i)).toBeInTheDocument() + }) + + it('explains what a schema-less decode cannot know', () => { + render() + expect(screen.getByText(/names and declared types are not in the bytes/i)).toBeInTheDocument() + }) + + it('names the fields once the .proto is supplied', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + + expect(screen.getByRole('combobox', { name: /message/i })).toHaveValue('Person') + expect(screen.getByText('name')).toBeInTheDocument() + expect(screen.getByText('"testing"')).toBeInTheDocument() + expect(screen.getByText('city')).toBeInTheDocument() + }) + + it('lets the message type be switched to another one in the definition', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + await user.selectOptions(screen.getByRole('combobox', { name: /message/i }), 'Address') + + // Person's payload is not an Address. protobufjs decodes it to an empty + // object without complaining, so the widget is what has to say why. + expect(screen.getByText(/Field 1 arrived as varint but is declared string city/i)).toBeInTheDocument() + expect(screen.getByText(/Fields 2, 3, 4 on the wire/i)).toBeInTheDocument() + }) + + it('reports a syntax error in the definition with its line', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + setCodeMirrorValue( + screen.getByRole('textbox', { name: 'Proto definition' }), + 'syntax = "proto3";\nmessage B { int32 id = ; }', + ) + + expect(screen.getByText(/line 2/i)).toBeInTheDocument() + }) + + it('warns when the payload carries fields the definition never declared', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + // Field 1 (declared) plus field 9 (not in Person). + await setPayload(user, '0896014a0568656c6c6f') + + expect(screen.getByText(/Field 9 on the wire is not in this definition/i)).toBeInTheDocument() + }) + + it('reports an unusable payload instead of decoding noise', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, 'not a payload!!') + + expect(screen.getByText(/neither valid hex nor valid base64/i)).toBeInTheDocument() + }) + + it('keeps what it read before a truncated payload gave out', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, '0896011209746573') + + expect(screen.getByText('150')).toBeInTheDocument() + expect(screen.getByText(/past the end of the payload/i)).toBeInTheDocument() + expect(screen.getByText(/everything above was read before that point/i)).toBeInTheDocument() + }) + + it('keeps payload, mode and definition across a remount of the same instance', async () => { + const user = userEvent.setup() + const { unmount } = render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + await setPayload(user, '089601') + unmount() + + render() + expect(screen.getByRole('button', { name: /with \.proto/i })).toHaveAttribute('aria-pressed', 'true') + expect(payloadBox()).toHaveValue('089601') + expect(screen.getByText('id')).toBeInTheDocument() + }) + + it('still decodes the sample payload after it has been re-pasted', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, '') + expect(screen.getByText(/paste a base64 or hex payload/i)).toBeInTheDocument() + + await user.paste(SAMPLE_PAYLOAD) + expect(screen.getByText('"testing"')).toBeInTheDocument() + }) +}) diff --git a/src/widgets/protobuf-decoder/ProtobufDecoderWidget.tsx b/src/widgets/protobuf-decoder/ProtobufDecoderWidget.tsx new file mode 100644 index 0000000..0ddd7be --- /dev/null +++ b/src/widgets/protobuf-decoder/ProtobufDecoderWidget.tsx @@ -0,0 +1,287 @@ +import { useMemo } from 'react' +import { CodeEditor } from '@/components/CodeEditor' +import { CopyButton } from '@/components/CopyButton' +import { DataTree } from '@/components/data-tree/DataTree' +import { buildJsonTree } from '@/components/data-tree/treeModel' +import { ErrorMessage } from '@/components/ErrorMessage' +import { SegmentedControl } from '@/components/SegmentedControl' +import { Textarea } from '@/components/ui/textarea' +import { cn } from '@/lib/utils' +import { useWidgetDirty } from '@/widgets/useWidgetDirty' +import { useWidgetState } from '@/widgets/useWidgetState' +import type { WidgetProps } from '@/widgets/types' +import { decodePayload, toHex, type PayloadEncoding } from './payloadBytes' +import { decodeWithSchema, parseSchema } from './protoSchema' +import { decodeWireFormat, formatFields, WIRE_TYPE_NAMES, type WireField } from './wireFormat' + +type Mode = 'raw' | 'schema' + +/** A Person message: an id, a name, two repeated tags, and a nested + * address. Enough shape to show what a schema-less decode can and cannot + * recover, which is the point of the widget. */ +const SAMPLE_PAYLOAD = 'CJYBEgd0ZXN0aW5nGgFhGgFiIgcKBVBhcmlz' + +const SAMPLE_SCHEMA = `syntax = "proto3"; + +message Person { + int32 id = 1; + string name = 2; + repeated string tags = 3; + Address home = 4; +} + +message Address { + string city = 1; +}` + +const SELECT_CLASS = + 'h-6 min-w-0 rounded-md border border-input bg-transparent px-1 text-[11px] outline-none transition-colors focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 dark:bg-input/30' + +const ENCODING_OPTIONS: { label: string; value: PayloadEncoding }[] = [ + { label: 'Auto', value: 'auto' }, + { label: 'Base64', value: 'base64' }, + { label: 'Hex', value: 'hex' }, +] + +export default function ProtobufDecoderWidget({ instanceId }: WidgetProps) { + const [payload, setPayload] = useWidgetState(instanceId, 'payload', SAMPLE_PAYLOAD) + const [encoding, setEncoding] = useWidgetState(instanceId, 'encoding', 'auto') + const [mode, setMode] = useWidgetState(instanceId, 'mode', 'raw') + const [schema, setSchema] = useWidgetState(instanceId, 'schema', SAMPLE_SCHEMA) + const [messageName, setMessageName] = useWidgetState(instanceId, 'messageName', '') + useWidgetDirty(instanceId, payload !== SAMPLE_PAYLOAD || schema !== SAMPLE_SCHEMA || mode !== 'raw') + + const decodedPayload = useMemo(() => decodePayload(payload, encoding), [payload, encoding]) + const bytes = 'error' in decodedPayload ? null : decodedPayload.bytes + + const wire = useMemo(() => (bytes ? decodeWireFormat(bytes) : null), [bytes]) + const parsedSchema = useMemo(() => (mode === 'schema' ? parseSchema(schema) : null), [mode, schema]) + + // Fall back to the first message the definition declares until one is + // picked, so a freshly pasted .proto decodes without another click. + const availableNames = parsedSchema?.ok ? parsedSchema.names : [] + const selectedName = availableNames.includes(messageName) ? messageName : (availableNames[0] ?? '') + + const schemaDecode = useMemo(() => { + if (mode !== 'schema' || !bytes || !parsedSchema?.ok || !selectedName) return null + return decodeWithSchema(parsedSchema.root, selectedName, bytes, wire?.fields ?? []) + }, [mode, bytes, parsedSchema, selectedName, wire]) + + const copyText = useMemo(() => { + if (mode === 'schema' && schemaDecode?.ok) return JSON.stringify(schemaDecode.value, null, 2) + return wire ? formatFields(wire.fields) : '' + }, [mode, schemaDecode, wire]) + + return ( +
+