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:
@@ -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)
|
||||
return {
|
||||
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
|
||||
.getBigUint64(0, true)
|
||||
.toString();
|
||||
.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 && (
|
||||
{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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user