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") {
|
function formatInvocationOutput(service: Service, output: Uint8Array, format: "utf8" | "hex") {
|
||||||
if (service.id === "counter" && output.byteLength === 8) {
|
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)
|
.getBigUint64(0, true)
|
||||||
.toString();
|
.toString(),
|
||||||
|
format: "U64 LE",
|
||||||
|
automatic: false,
|
||||||
|
} satisfies FormattedInvocationOutput;
|
||||||
}
|
}
|
||||||
if (format === "hex") {
|
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("/")({
|
export const Route = createFileRoute("/")({
|
||||||
@@ -1841,6 +1890,8 @@ function InvokeDialog({
|
|||||||
const [input, setInput] = useState(service.id === "echo" ? "hello wasm" : "");
|
const [input, setInput] = useState(service.id === "echo" ? "hello wasm" : "");
|
||||||
const [format, setFormat] = useState<"utf8" | "hex">("utf8");
|
const [format, setFormat] = useState<"utf8" | "hex">("utf8");
|
||||||
const [output, setOutput] = useState("");
|
const [output, setOutput] = useState("");
|
||||||
|
const [outputBytes, setOutputBytes] = useState<number | null>(null);
|
||||||
|
const [outputFormat, setOutputFormat] = useState<FormattedInvocationOutput | null>(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [latency, setLatency] = useState<number | null>(null);
|
const [latency, setLatency] = useState<number | null>(null);
|
||||||
@@ -1848,11 +1899,16 @@ function InvokeDialog({
|
|||||||
async function invoke() {
|
async function invoke() {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setOutput("");
|
setOutput("");
|
||||||
|
setOutputBytes(null);
|
||||||
|
setOutputFormat(null);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
setLatency(null);
|
setLatency(null);
|
||||||
try {
|
try {
|
||||||
const result = await onInvoke(service, parseInvocationInput(input, format));
|
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);
|
setLatency(result.latencyMs);
|
||||||
} catch (invokeError) {
|
} catch (invokeError) {
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
@@ -1871,7 +1927,7 @@ function InvokeDialog({
|
|||||||
>
|
>
|
||||||
<div className="invoke-body">
|
<div className="invoke-body">
|
||||||
<div className="invoke-toolbar">
|
<div className="invoke-toolbar">
|
||||||
<div className="segmented">
|
<div className="segmented" aria-label="输入编码">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={format === "utf8" ? "active" : ""}
|
className={format === "utf8" ? "active" : ""}
|
||||||
@@ -1902,11 +1958,17 @@ function InvokeDialog({
|
|||||||
<div className={failed ? "invoke-output failed" : "invoke-output"}>
|
<div className={failed ? "invoke-output failed" : "invoke-output"}>
|
||||||
<div>
|
<div>
|
||||||
<span>输出</span>
|
<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>
|
<small>
|
||||||
<Clock3 size={13} />
|
<Clock3 size={13} />
|
||||||
{latency} ms
|
{latency} ms
|
||||||
</small>
|
</small>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<pre>{running ? "invoking..." : output || "等待调用"}</pre>
|
<pre>{running ? "invoking..." : output || "等待调用"}</pre>
|
||||||
|
|||||||
@@ -1783,6 +1783,18 @@ button:disabled {
|
|||||||
justify-content: space-between;
|
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 {
|
.invoke-output small {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ test("uses TanStack Start routing and produces Node artifacts", async () => {
|
|||||||
assert.match(indexRoute, /切换对外版本/);
|
assert.match(indexRoute, /切换对外版本/);
|
||||||
assert.match(indexRoute, /Host 能力/);
|
assert.match(indexRoute, /Host 能力/);
|
||||||
assert.match(indexRoute, /service\.capabilities/);
|
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, /\/api\/v1\/deployments/);
|
||||||
assert.match(apiClient, /capabilities: BackendCapability\[\]/);
|
assert.match(apiClient, /capabilities: BackendCapability\[\]/);
|
||||||
assert.match(router, /createRouter/);
|
assert.match(router, /createRouter/);
|
||||||
|
|||||||
Reference in New Issue
Block a user