fix(console-ui): render binary invocation output safely

- decode UTF-8 responses in fatal mode and reject control-byte payloads
- fall back to hexadecimal output instead of replacement characters
- show response byte count, selected representation, automatic fallback, and latency
- preserve the existing counter u64 representation and cover the wiring in SSR tests
This commit is contained in:
Maofeng
2026-07-29 19:20:15 +08:00
parent 51b452ecc5
commit 72c901c478
3 changed files with 88 additions and 12 deletions
+74 -12
View File
@@ -191,16 +191,65 @@ function invocationInputSize(value: string, format: "utf8" | "hex") {
}
}
type FormattedInvocationOutput = {
text: string;
format: "UTF-8" | "HEX" | "U64 LE";
automatic: boolean;
};
function formatHex(bytes: Uint8Array) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
}
function decodeReadableUtf8(bytes: Uint8Array) {
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return null;
}
for (const character of text) {
const codePoint = character.codePointAt(0) ?? 0;
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
if ((codePoint < 0x20 && !allowedWhitespace) || codePoint === 0x7f) {
return null;
}
}
return text;
}
function formatInvocationOutput(service: Service, output: Uint8Array, format: "utf8" | "hex") {
if (service.id === "counter" && output.byteLength === 8) {
return new DataView(output.buffer, output.byteOffset, output.byteLength)
.getBigUint64(0, true)
.toString();
return {
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
.getBigUint64(0, true)
.toString(),
format: "U64 LE",
automatic: false,
} satisfies FormattedInvocationOutput;
}
if (format === "hex") {
return Array.from(output, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
return {
text: formatHex(output) || "(empty)",
format: "HEX",
automatic: false,
} satisfies FormattedInvocationOutput;
}
return new TextDecoder().decode(output) || "(empty)";
const text = decodeReadableUtf8(output);
if (text !== null) {
return {
text: text || "(empty)",
format: "UTF-8",
automatic: false,
} satisfies FormattedInvocationOutput;
}
return {
text: formatHex(output),
format: "HEX",
automatic: true,
} satisfies FormattedInvocationOutput;
}
export const Route = createFileRoute("/")({
@@ -1841,6 +1890,8 @@ function InvokeDialog({
const [input, setInput] = useState(service.id === "echo" ? "hello wasm" : "");
const [format, setFormat] = useState<"utf8" | "hex">("utf8");
const [output, setOutput] = useState("");
const [outputBytes, setOutputBytes] = useState<number | null>(null);
const [outputFormat, setOutputFormat] = useState<FormattedInvocationOutput | null>(null);
const [running, setRunning] = useState(false);
const [failed, setFailed] = useState(false);
const [latency, setLatency] = useState<number | null>(null);
@@ -1848,11 +1899,16 @@ function InvokeDialog({
async function invoke() {
setRunning(true);
setOutput("");
setOutputBytes(null);
setOutputFormat(null);
setFailed(false);
setLatency(null);
try {
const result = await onInvoke(service, parseInvocationInput(input, format));
setOutput(formatInvocationOutput(service, result.output, format));
const formatted = formatInvocationOutput(service, result.output, format);
setOutput(formatted.text);
setOutputBytes(result.outputBytes);
setOutputFormat(formatted);
setLatency(result.latencyMs);
} catch (invokeError) {
setFailed(true);
@@ -1871,7 +1927,7 @@ function InvokeDialog({
>
<div className="invoke-body">
<div className="invoke-toolbar">
<div className="segmented">
<div className="segmented" aria-label="输入编码">
<button
type="button"
className={format === "utf8" ? "active" : ""}
@@ -1902,11 +1958,17 @@ function InvokeDialog({
<div className={failed ? "invoke-output failed" : "invoke-output"}>
<div>
<span></span>
{output && latency !== null && (
<small>
<Clock3 size={13} />
{latency} ms
</small>
{output && latency !== null && outputBytes !== null && outputFormat && (
<div className="invoke-output-meta">
<span>
{outputBytes} B · {outputFormat.format}
{outputFormat.automatic ? " · 自动" : ""}
</span>
<small>
<Clock3 size={13} />
{latency} ms
</small>
</div>
)}
</div>
<pre>{running ? "invoking..." : output || "等待调用"}</pre>
+12
View File
@@ -1783,6 +1783,18 @@ button:disabled {
justify-content: space-between;
}
.invoke-output-meta {
display: flex;
align-items: center;
gap: 12px;
}
.invoke-output-meta > span {
color: var(--ink-faint);
font-family: var(--font-geist-mono), monospace;
font-size: 8px;
}
.invoke-output small {
display: flex;
align-items: center;
+2
View File
@@ -99,6 +99,8 @@ test("uses TanStack Start routing and produces Node artifacts", async () => {
assert.match(indexRoute, /切换对外版本/);
assert.match(indexRoute, /Host 能力/);
assert.match(indexRoute, /service\.capabilities/);
assert.match(indexRoute, /TextDecoder\("utf-8", \{ fatal: true \}\)/);
assert.match(indexRoute, /outputFormat\.automatic/);
assert.match(apiClient, /\/api\/v1\/deployments/);
assert.match(apiClient, /capabilities: BackendCapability\[\]/);
assert.match(router, /createRouter/);