462 lines
15 KiB
TypeScript
462 lines
15 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
||
import { bytesToHex, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||
import {
|
||
BASE58_LIMIT,
|
||
decodeInput,
|
||
encodeOutput,
|
||
formatHexRows,
|
||
MAX_BYTES,
|
||
type ByteEncoding,
|
||
} from "../core/encoding";
|
||
import {
|
||
decodeStructured,
|
||
encodeStructured,
|
||
parseDer,
|
||
parseProtobuf,
|
||
parseSchema,
|
||
type ByteNode,
|
||
} from "../core/inspect";
|
||
|
||
type Inspector = "bytes" | "cbor" | "msgpack" | "der" | "protobuf";
|
||
const EXAMPLE = new TextEncoder().encode("Hello, binary world!\n");
|
||
const BYTE_PAGE_SIZE = 4_096;
|
||
|
||
export function Workbench() {
|
||
const [bytes, setBytes] = useState<Uint8Array>(EXAMPLE);
|
||
const [encoding, setEncoding] = useState<ByteEncoding>("utf8");
|
||
const [source, setSource] = useState("Hello, binary world!\n");
|
||
const [inspector, setInspector] = useState<Inspector>("bytes");
|
||
const [structured, setStructured] = useState(
|
||
'{\n "hello": "world",\n "count": 3\n}',
|
||
);
|
||
const [schema, setSchema] = useState(
|
||
'{\n "1": { "name": "id", "type": "uint" },\n "2": { "name": "name", "type": "string" }\n}',
|
||
);
|
||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||
const [bytePage, setBytePage] = useState(0);
|
||
const [error, setError] = useState("");
|
||
const fileInput = useRef<HTMLInputElement>(null);
|
||
|
||
const views = useMemo(
|
||
() => ({
|
||
utf8: encodeOutput(bytes, "utf8"),
|
||
hex: encodeOutput(bytes, "hex"),
|
||
base64: encodeOutput(bytes, "base64"),
|
||
base32: encodeOutput(bytes, "base32"),
|
||
base58:
|
||
bytes.length <= BASE58_LIMIT
|
||
? encodeOutput(bytes, "base58")
|
||
: "Base58 display is disabled above 4 KiB to keep this quadratic conversion responsive.",
|
||
}),
|
||
[bytes],
|
||
);
|
||
|
||
const decoded = useMemo(() => {
|
||
try {
|
||
if (inspector === "cbor" || inspector === "msgpack") {
|
||
return {
|
||
text: decodeStructured(inspector, bytes),
|
||
nodes: [] as ByteNode[],
|
||
error: "",
|
||
};
|
||
}
|
||
if (inspector === "der")
|
||
return { text: "", nodes: parseDer(bytes), error: "" };
|
||
if (inspector === "protobuf")
|
||
return {
|
||
text: "",
|
||
nodes: parseProtobuf(bytes, parseSchema(schema)),
|
||
error: "",
|
||
};
|
||
return { text: "", nodes: [] as ByteNode[], error: "" };
|
||
} catch (caught) {
|
||
return {
|
||
text: "",
|
||
nodes: [] as ByteNode[],
|
||
error: caught instanceof Error ? caught.message : String(caught),
|
||
};
|
||
}
|
||
}, [bytes, inspector, schema]);
|
||
const bytePageCount = Math.max(1, Math.ceil(bytes.length / BYTE_PAGE_SIZE));
|
||
const visibleBytePage = Math.min(bytePage, bytePageCount - 1);
|
||
const bytePageStart = visibleBytePage * BYTE_PAGE_SIZE;
|
||
const bytePageEnd = Math.min(bytes.length, bytePageStart + BYTE_PAGE_SIZE);
|
||
|
||
function selectRange(range: { start: number; end: number }): void {
|
||
setSelection(range);
|
||
if (range.start < bytes.length)
|
||
setBytePage(Math.floor(range.start / BYTE_PAGE_SIZE));
|
||
}
|
||
|
||
function applySource(): void {
|
||
try {
|
||
const next = decodeInput(source, encoding);
|
||
setBytes(next);
|
||
setSelection({ start: 0, end: 0 });
|
||
setBytePage(0);
|
||
setError("");
|
||
} catch (caught) {
|
||
setError(caught instanceof Error ? caught.message : String(caught));
|
||
}
|
||
}
|
||
|
||
function switchEncoding(next: ByteEncoding): void {
|
||
try {
|
||
const encoded = encodeOutput(bytes, next);
|
||
setEncoding(next);
|
||
setSource(encoded);
|
||
setError("");
|
||
} catch (caught) {
|
||
setError(caught instanceof Error ? caught.message : String(caught));
|
||
}
|
||
}
|
||
|
||
async function importFile(file: File | undefined): Promise<void> {
|
||
if (!file) return;
|
||
try {
|
||
if (file.size > MAX_BYTES)
|
||
throw new RangeError(
|
||
`Files are limited to ${MAX_BYTES.toLocaleString()} bytes.`,
|
||
);
|
||
const next = new Uint8Array(await file.arrayBuffer());
|
||
setBytes(next);
|
||
setEncoding("hex");
|
||
setSource(bytesToHex(next));
|
||
setSelection({ start: 0, end: 0 });
|
||
setBytePage(0);
|
||
setError("");
|
||
} catch (caught) {
|
||
setError(caught instanceof Error ? caught.message : String(caught));
|
||
} finally {
|
||
if (fileInput.current) fileInput.current.value = "";
|
||
}
|
||
}
|
||
|
||
function encodeDocument(): void {
|
||
try {
|
||
if (inspector !== "cbor" && inspector !== "msgpack")
|
||
throw new Error("Choose CBOR or MessagePack to encode JSON.");
|
||
const next = encodeStructured(inspector, structured);
|
||
setBytes(next);
|
||
setEncoding("hex");
|
||
setSource(bytesToHex(next));
|
||
setSelection({ start: 0, end: next.length });
|
||
setBytePage(0);
|
||
setError("");
|
||
} catch (caught) {
|
||
setError(caught instanceof Error ? caught.message : String(caught));
|
||
}
|
||
}
|
||
|
||
return (
|
||
<main className="workbench">
|
||
<section className="hero">
|
||
<div>
|
||
<p className="eyebrow">Local byte workbench</p>
|
||
<h1>Understand what the bytes say.</h1>
|
||
<p>
|
||
Convert encodings, inspect offsets and safely decode common binary
|
||
structures without uploading a file.
|
||
</p>
|
||
</div>
|
||
<span className="privacy-pill">Local only</span>
|
||
</section>
|
||
|
||
<section className="panel source-panel" aria-labelledby="source-title">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<p className="eyebrow">Canonical byte buffer</p>
|
||
<h2 id="source-title">Input</h2>
|
||
</div>
|
||
<span className="count-pill">
|
||
{bytes.length.toLocaleString()} / {MAX_BYTES.toLocaleString()} bytes
|
||
</span>
|
||
</div>
|
||
<div className="input-grid">
|
||
<label>
|
||
Encoding
|
||
<select
|
||
value={encoding}
|
||
onChange={(event) =>
|
||
switchEncoding(event.target.value as ByteEncoding)
|
||
}
|
||
>
|
||
<option value="utf8">UTF-8 text</option>
|
||
<option value="hex">Hexadecimal</option>
|
||
<option value="base64">Base64</option>
|
||
<option value="base32">Base32 (RFC 4648)</option>
|
||
<option value="base58">Base58 (Bitcoin alphabet)</option>
|
||
</select>
|
||
</label>
|
||
<div className="button-row bottom-aligned">
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
onClick={applySource}
|
||
>
|
||
Apply input
|
||
</button>
|
||
<button type="button" onClick={() => fileInput.current?.click()}>
|
||
Open binary file
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
triggerBlobDownload(
|
||
new Blob([new Uint8Array(bytes)]),
|
||
"binary-output.bin",
|
||
)
|
||
}
|
||
>
|
||
Save bytes
|
||
</button>
|
||
<input
|
||
ref={fileInput}
|
||
className="sr-only"
|
||
type="file"
|
||
onChange={(event) => void importFile(event.target.files?.[0])}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<label className="full-label">
|
||
Encoded input
|
||
<textarea
|
||
rows={7}
|
||
spellCheck={false}
|
||
value={source}
|
||
onChange={(event) => setSource(event.target.value)}
|
||
/>
|
||
</label>
|
||
{error && (
|
||
<p className="error" role="alert">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="panel" aria-labelledby="conversions-title">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<p className="eyebrow">Synchronized representations</p>
|
||
<h2 id="conversions-title">Conversions</h2>
|
||
</div>
|
||
</div>
|
||
<div className="conversion-grid">
|
||
{(Object.entries(views) as Array<[ByteEncoding, string]>).map(
|
||
([name, value]) => (
|
||
<label key={name}>
|
||
{name === "utf8"
|
||
? "UTF-8 (replacement for invalid sequences)"
|
||
: name.toUpperCase()}
|
||
<textarea
|
||
rows={4}
|
||
readOnly
|
||
value={value}
|
||
aria-label={`${name} output`}
|
||
/>
|
||
</label>
|
||
),
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="workspace-grid">
|
||
<section className="panel" aria-labelledby="inspector-title">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<p className="eyebrow">Structure</p>
|
||
<h2 id="inspector-title">Inspector</h2>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="mode-tabs"
|
||
role="tablist"
|
||
aria-label="Inspector format"
|
||
>
|
||
{(["bytes", "cbor", "msgpack", "der", "protobuf"] as const).map(
|
||
(mode) => (
|
||
<button
|
||
key={mode}
|
||
role="tab"
|
||
aria-selected={inspector === mode}
|
||
className={inspector === mode ? "active" : ""}
|
||
onClick={() => setInspector(mode)}
|
||
>
|
||
{mode === "der"
|
||
? "ASN.1 DER"
|
||
: mode === "msgpack"
|
||
? "MessagePack"
|
||
: mode.toUpperCase()}
|
||
</button>
|
||
),
|
||
)}
|
||
</div>
|
||
{(inspector === "cbor" || inspector === "msgpack") && (
|
||
<>
|
||
<label className="full-label">
|
||
JSON to encode
|
||
<textarea
|
||
rows={8}
|
||
value={structured}
|
||
spellCheck={false}
|
||
onChange={(event) => setStructured(event.target.value)}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
onClick={encodeDocument}
|
||
>
|
||
Encode JSON as {inspector === "cbor" ? "CBOR" : "MessagePack"}
|
||
</button>
|
||
</>
|
||
)}
|
||
{inspector === "protobuf" && (
|
||
<label className="full-label">
|
||
Optional field schema (JSON)
|
||
<textarea
|
||
rows={8}
|
||
value={schema}
|
||
spellCheck={false}
|
||
onChange={(event) => setSchema(event.target.value)}
|
||
/>
|
||
<small>
|
||
Keys are field numbers. Types: uint, sint, bool, string, bytes,
|
||
fixed32, fixed64, float, double, message.
|
||
</small>
|
||
</label>
|
||
)}
|
||
{decoded.error && inspector !== "bytes" && (
|
||
<p className="error" role="alert">
|
||
{decoded.error}
|
||
</p>
|
||
)}
|
||
{decoded.text && (
|
||
<pre className="decoded-output" tabIndex={0}>
|
||
{decoded.text}
|
||
</pre>
|
||
)}
|
||
{decoded.nodes.length > 0 && (
|
||
<Tree nodes={decoded.nodes} onSelect={selectRange} />
|
||
)}
|
||
{inspector === "bytes" && (
|
||
<p className="muted">
|
||
Select a byte below. Structural decoders expose clickable source
|
||
ranges here.
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="panel" aria-labelledby="bytes-title">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<p className="eyebrow">Offsets and ASCII</p>
|
||
<h2 id="bytes-title">Byte inspector</h2>
|
||
</div>
|
||
<span className="count-pill">
|
||
{selection.end > selection.start
|
||
? `${selection.start}–${selection.end - 1}`
|
||
: "No range"}
|
||
</span>
|
||
</div>
|
||
{bytePageCount > 1 && (
|
||
<div className="button-row byte-pager" aria-label="Byte pages">
|
||
<button
|
||
type="button"
|
||
disabled={visibleBytePage === 0}
|
||
onClick={() => setBytePage(visibleBytePage - 1)}
|
||
>
|
||
Previous 4 KiB
|
||
</button>
|
||
<span className="muted">
|
||
Page {visibleBytePage + 1} of {bytePageCount} · bytes{" "}
|
||
{bytePageStart}–{bytePageEnd - 1}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={visibleBytePage === bytePageCount - 1}
|
||
onClick={() => setBytePage(visibleBytePage + 1)}
|
||
>
|
||
Next 4 KiB
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div
|
||
className="byte-table"
|
||
role="grid"
|
||
aria-label="Byte inspector"
|
||
tabIndex={0}
|
||
>
|
||
{formatHexRows(
|
||
bytes.subarray(bytePageStart, bytePageEnd),
|
||
bytePageStart,
|
||
).map((row) => (
|
||
<div className="byte-row" role="row" key={row.offset}>
|
||
<code className="offset">
|
||
{row.offset.toString(16).padStart(8, "0")}
|
||
</code>
|
||
<div className="byte-cells">
|
||
{Array.from(row.bytes, (byte, index) => {
|
||
const absolute = row.offset + index;
|
||
const selected =
|
||
absolute >= selection.start && absolute < selection.end;
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={selected ? "selected" : ""}
|
||
key={absolute}
|
||
aria-label={`Byte ${absolute}: ${byte.toString(16).padStart(2, "0")}`}
|
||
onClick={() =>
|
||
setSelection({ start: absolute, end: absolute + 1 })
|
||
}
|
||
>
|
||
{byte.toString(16).padStart(2, "0")}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<code className="ascii">
|
||
{Array.from(row.bytes, (byte) =>
|
||
byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : ".",
|
||
).join("")}
|
||
</code>
|
||
</div>
|
||
))}
|
||
{bytes.length === 0 && (
|
||
<p className="muted">The buffer is empty.</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function Tree({
|
||
nodes,
|
||
onSelect,
|
||
}: {
|
||
nodes: ByteNode[];
|
||
onSelect: (range: { start: number; end: number }) => void;
|
||
}) {
|
||
return (
|
||
<ul className="tree">
|
||
{nodes.map((node, index) => (
|
||
<li key={`${node.start}-${index}`}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelect({ start: node.start, end: node.end })}
|
||
>
|
||
<strong>{node.label}</strong>
|
||
<span>
|
||
{node.start}–{node.end - 1}
|
||
{node.value ? ` · ${node.value}` : ""}
|
||
</span>
|
||
</button>
|
||
{node.children && <Tree nodes={node.children} onSelect={onSelect} />}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|