docs(console-ui): explain data and byte transformations

This commit is contained in:
Maofeng
2026-07-30 08:13:37 +08:00
parent 223ead37c8
commit 4e32a1246b
3 changed files with 44 additions and 0 deletions
+22
View File
@@ -1,3 +1,12 @@
/**
* Typed management-plane client for `wasmeld-console`.
*
* These DTOs intentionally preserve the Rust API's snake_case fields; view
* formatting belongs in `console-model.ts`. Management invocation bytes use
* base64 inside JSON for inspectability. Public clients should call the
* gateway's `application/octet-stream` endpoint instead.
*/
export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080"; export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080";
const API_BASE_STORAGE_KEY = "wasmeld-api-base"; const API_BASE_STORAGE_KEY = "wasmeld-api-base";
@@ -80,6 +89,8 @@ export type InvokeResult = {
}; };
export function getApiBase(): string { export function getApiBase(): string {
// TanStack Start can evaluate modules during SSR, where localStorage does
// not exist. The environment default keeps server rendering deterministic.
if (typeof window === "undefined") return DEFAULT_API_BASE; if (typeof window === "undefined") return DEFAULT_API_BASE;
return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE; return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE;
} }
@@ -95,6 +106,9 @@ export function saveApiBase(value: string): string {
} }
export async function fetchSnapshot(apiBase: string) { export async function fetchSnapshot(apiBase: string) {
// Parallel reads minimize refresh latency but are not a database
// transaction. A deployment can change between responses; the five-second
// poll in the route reconciles that short-lived inconsistency.
const [services, deployments, events, runtime, witPackages] = await Promise.all([ const [services, deployments, events, runtime, witPackages] = await Promise.all([
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"), request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"), request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
@@ -181,6 +195,9 @@ export async function invokeComponent(
service: Pick<BackendService, "id" | "revision">, service: Pick<BackendService, "id" | "revision">,
input: Uint8Array, input: Uint8Array,
): Promise<InvokeResult> { ): Promise<InvokeResult> {
// Example: `invokeComponent(base, service, new TextEncoder().encode("ping"))`.
// The management endpoint targets an exact revision and is intended for the
// operator console, not public application traffic.
const response = await request<{ const response = await request<{
output_base64: string; output_base64: string;
output_bytes: number; output_bytes: number;
@@ -216,6 +233,9 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
} }
if (!response.ok) { if (!response.ok) {
// Both management and gateway errors use `{ error: { code, message } }`.
// The UI displays only the server-safe message and falls back to status
// when a proxy returns HTML or an otherwise unrelated response.
const body = (await response.json().catch(() => null)) as { const body = (await response.json().catch(() => null)) as {
error?: { message?: string }; error?: { message?: string };
} | null; } | null;
@@ -225,6 +245,8 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
} }
function bytesToBase64(bytes: Uint8Array): string { function bytesToBase64(bytes: Uint8Array): string {
// Invocation input is bounded by the Runtime, so constructing this binary
// string cannot grow beyond the configured management request limit.
let binary = ""; let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte); for (const byte of bytes) binary += String.fromCharCode(byte);
return window.btoa(binary); return window.btoa(binary);
+16
View File
@@ -1,3 +1,11 @@
/**
* Pure conversion and presentation helpers for the management UI.
*
* No function in this module performs I/O or changes backend state. Keeping
* byte decoding here prevents React views from silently treating arbitrary
* Component output as readable text.
*/
import { FileCode2, History, LayoutDashboard, Package, Server, Settings } from "lucide-react"; import { FileCode2, History, LayoutDashboard, Package, Server, Settings } from "lucide-react";
import { BackendCapability, BackendEvent, BackendService } from "./api"; import { BackendCapability, BackendEvent, BackendService } from "./api";
@@ -160,6 +168,9 @@ export function decodeReadableUtf8(bytes: Uint8Array) {
return null; return null;
} }
// Valid UTF-8 can still contain terminal controls. Reject them so the output
// panel cannot render invisible or misleading content; tab/newline/CR remain
// useful for ordinary textual responses.
for (const character of text) { for (const character of text) {
const codePoint = character.codePointAt(0) ?? 0; const codePoint = character.codePointAt(0) ?? 0;
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d; const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
@@ -175,6 +186,9 @@ export function formatInvocationOutput(
output: Uint8Array, output: Uint8Array,
format: "utf8" | "hex", format: "utf8" | "hex",
) { ) {
// Counter is the one built-in example with a documented numeric wire
// format. Other eight-byte services remain generic bytes and are not
// guessed as integers.
if (service.id === "counter" && output.byteLength === 8) { if (service.id === "counter" && output.byteLength === 8) {
return { return {
text: new DataView(output.buffer, output.byteOffset, output.byteLength) text: new DataView(output.buffer, output.byteOffset, output.byteLength)
@@ -192,6 +206,8 @@ export function formatInvocationOutput(
} satisfies FormattedInvocationOutput; } satisfies FormattedInvocationOutput;
} }
// UTF-8 decoding is fatal rather than replacement-based. If one byte is
// invalid or contains a disallowed control, show the exact bytes as HEX.
const text = decodeReadableUtf8(output); const text = decodeReadableUtf8(output);
if (text !== null) { if (text !== null) {
return { return {
+6
View File
@@ -119,6 +119,9 @@ function Home() {
setEvents(snapshot.events.map(toEvent)); setEvents(snapshot.events.map(toEvent));
setRuntime(snapshot.runtime); setRuntime(snapshot.runtime);
setWitPackages(snapshot.witPackages); setWitPackages(snapshot.witPackages);
// Preserve operator context when possible. Registrations and cleanup can
// remove the selected revision between polls, in which case the first
// remaining service becomes the deterministic fallback.
setSelectedKey((current) => setSelectedKey((current) =>
nextServices.some((service) => serviceKey(service) === current) nextServices.some((service) => serviceKey(service) === current)
? current ? current
@@ -128,6 +131,9 @@ function Home() {
); );
setConnection("online"); setConnection("online");
} catch { } catch {
// Clear the complete snapshot on any request failure. Mixing stale
// services with live deployments or Runtime state would present actions
// against identities that may no longer exist.
setServices([]); setServices([]);
setDeployments([]); setDeployments([]);
setEvents([]); setEvents([]);