Compare commits

...

16 Commits

Author SHA1 Message Date
Maofeng b5aa008729 docs(resident): document timer and TCP drivers
Describe ResidentSupervisor ownership, TCP policy and configuration, bounded stream backpressure, timer scheduling, restart behavior, and deterministic Host-resource shutdown order.
2026-07-31 10:04:46 +08:00
Maofeng 7939285e9f feat(console): drive resident timers and TCP streams
Add a revision-scoped ResidentSupervisor that serializes Host events through ResidentSession while Tokio tasks own timers, TCP listeners, and stream sockets.

Enforce deny-by-default endpoint policy, bounded event and stream queues, startup rollback, deterministic service shutdown, stale Actor replacement, and deployment restoration across Runtime restarts.

Add timer and real TCP integration coverage for port conflicts, multi-chunk echo traffic, service restart, and full Runtime recovery.
2026-07-31 10:04:36 +08:00
Maofeng ef2eddc31f feat(runtime): expose resident driver boundaries
Expose exact Actor instance identity and manifest mailbox capacity so Host supervisors can distinguish idempotent starts from fault recovery and preserve revision-specific limits.

Expose resident stream chunk and endpoint-policy validation to drivers, and mark terminal streams closing before dispatching their final callback.

Cover Actor identity reuse and replacement in the Runtime integration suite.
2026-07-31 10:04:27 +08:00
Maofeng c9a594d669 style(console-web): use canonical Tailwind utilities
Replace the hard-coded brand text color with its theme token and use Tailwind's standard z-index utility for Host notifications.
2026-07-30 15:25:12 +08:00
Maofeng 427abd15f9 chore(editor): configure Tailwind IntelliSense for Zed
Teach the Tailwind language server to inspect cva, cx, and cn class helpers and use the Tailwind-aware CSS language server for workspace styles.
2026-07-30 15:20:37 +08:00
Maofeng 8c08adc9c2 docs(console-web): document page lifecycle and UI boundaries
Explain the centralized page registry, primary-page retention TTL, typed lifecycle callbacks, and Host/Page state ownership. Document the cva and cn component convention and keep global CSS limited to theme, base rules, and shared keyframes.
2026-07-30 15:16:02 +08:00
Maofeng 9ff3223c44 refactor(console-web): centralize page lifecycle and UI composition
Declare navigation, page metadata, lazy loaders, and keep-alive policy in one registry. Retain primary iframe pages until a continuous inactive TTL expires and expose typed show, hide, and unload events through the Page SDK.

Move business-aware components out of the UI primitive directory, migrate views and Host dialogs to variant primitives and local Tailwind classes, and remove the global component style layer. Keep native dialogs inside the overlay flex context so they remain centered.

Cover iframe expiration, lifecycle dispatch, UI dependency direction, and global style ownership with focused tests.
2026-07-30 15:16:02 +08:00
Maofeng 23fa6137ff feat(console-web): add variant-based UI primitives
Introduce cva-driven Button, form, card, table, and segmented controls.

Use clsx and tailwind-merge through a shared cn helper so callers can override component defaults without relying on global semantic CSS. Control sizes define minimum heights while icon-only actions retain stable square dimensions.
2026-07-30 15:16:02 +08:00
Maofeng 868da49a52 feat(console-web): add swinging iframe load progress
- place an indeterminate progress track below the loading label
- animate the indicator between both ends of the track
- expose an accessible progressbar label for the active view
- honor reduced-motion preferences with a static indicator
2026-07-30 10:11:52 +08:00
Maofeng 391901a71a docs(console-web): define browser tab state ownership
- distinguish Host-global, tab-shell, and Page-private state
- describe the cross-tab handshake and ordering guards
- document mounted and keep-alive Page synchronization rules
- record the BroadcastChannel fallback behavior
2026-07-30 09:57:16 +08:00
Maofeng 88a7fba0b0 feat(console-web): synchronize Host state across tabs
- separate global Host state from tab-local shell and Page state
- replicate control-plane snapshots through a versioned BroadcastChannel
- handshake new tabs without publishing empty startup state
- reject self, stale, and malformed cross-tab messages
- suppress broadcast echoes while applying remote snapshots
- publish API snapshots and connection state atomically
- keep Page state private and update only mounted iframe documents
- test initial synchronization and protocol rejection paths
2026-07-30 09:57:06 +08:00
Maofeng 93f4a505ba docs(console-web): explain iframe retention lifecycle
- document opt-in page policy and the bounded cache
- define activate, deactivate, and dispose responsibilities
- explain hidden-frame state and command handling
- provide a Solid activity accessor example
2026-07-30 09:43:27 +08:00
Maofeng 1b2c50c951 feat(console-web): add bounded iframe keep-alive
- opt service and settings pages into document retention
- preserve iframe identity and local Solid state across navigation
- cap retained documents with deterministic LRU eviction
- deliver activate, deactivate, and dispose lifecycle messages
- reject commands emitted by inactive page documents
- expose page activity as a Solid accessor
- test retention, recreation, eviction, and invalid limits
2026-07-30 09:43:19 +08:00
Maofeng ccad05fa5a docs(console): document Host-page and embedded build workflows
- explain the persistent Host and disposable iframe ownership model
- define SDK compatibility and PWA cache boundaries
- document independent debug servers and release embedding
- record Node, npm, NPM, and WASMELD_BUILD_WEB requirements
- update repository structure and startup commands
2026-07-30 09:22:55 +08:00
Maofeng f6ad94e948 feat(console): embed the management UI in release binaries
- build locked Web dependencies automatically for release profiles
- keep debug Cargo builds independent from the Vite development server
- embed generated Host, Page, manifest, and service worker assets
- serve exact control-plane assets with MIME and cache headers
- reject non-canonical and unknown paths without an SPA fallback
- cover embedded document boundaries and path validation
2026-07-30 09:22:55 +08:00
Maofeng 70ee5e33da feat(console-web): replace TanStack UI with Solid host pages
- move the management frontend under the wasmeld-console crate
- use a persistent Host shell with disposable iframe page documents
- version Host/Page postMessage state and command contracts
- migrate runtime, service, WIT, invocation, and settings workflows
- add Tailwind CSS v4, responsive layouts, and Host-owned dialogs
- emit a dependency-free PWA worker with API cache exclusions
- remove the superseded root TanStack Start application
2026-07-30 09:22:55 +08:00
85 changed files with 7688 additions and 5688 deletions
+26
View File
@@ -0,0 +1,26 @@
{
"lsp": {
"tailwindcss-language-server": {
"settings": {
"classFunctions": [
"cva",
"cx",
"cn"
],
"experimental": {
"classRegex": [
"[cls|className]\\s\\:\\=\\s\"([^\"]*)"
]
}
}
}
},
"languages": {
"CSS": {
"language_servers": [
"tailwindcss-intellisense-css",
"!vscode-css-language-server"
]
}
}
}
Generated
+21
View File
@@ -1844,6 +1844,25 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "index_vec" name = "index_vec"
version = "0.1.4" version = "0.1.4"
@@ -4709,6 +4728,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64 0.23.0", "base64 0.23.0",
"include_dir",
"mime_guess",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
+3 -1
View File
@@ -23,6 +23,8 @@ license = "Apache-2.0"
[workspace.dependencies] [workspace.dependencies]
axum = { version = "0.8.9", features = ["multipart"] } axum = { version = "0.8.9", features = ["multipart"] }
base64 = "0.23.0" base64 = "0.23.0"
include_dir = "0.7.4"
mime_guess = "2.0.5"
reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "json", "multipart", "rustls"] } reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "json", "multipart", "rustls"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
@@ -31,7 +33,7 @@ sha2 = "0.10.9"
thiserror = "2.0.17" thiserror = "2.0.17"
toasty = "=0.9.0" toasty = "=0.9.0"
toasty-driver-turso = "=0.9.0" toasty-driver-turso = "=0.9.0"
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
toml = "0.9.8" toml = "0.9.8"
tower-http = { version = "0.7.0", features = ["cors", "trace"] } tower-http = { version = "0.7.0", features = ["cors", "trace"] }
tracing = "0.1.44" tracing = "0.1.44"
+22 -3
View File
@@ -29,14 +29,20 @@ Host driver <- validated operation <- raw effect <-
线程外;协议级驱动以及文件监听、串口、系统信号等其它来源通过独立版本的 WIT 能力演进, 线程外;协议级驱动以及文件监听、串口、系统信号等其它来源通过独立版本的 WIT 能力演进,
不需要扩大基础 service world。 不需要扩大基础 service world。
Console 已接入 Timer 与 TCP Host Driver:平台配置的 Timer、Listener 和 Stream 在
Tokio task 中等待,并通过有界队列交给专用 `ResidentSupervisor` 线程串行进入
`ResidentSession`。TCP 地址执行默认拒绝的 Host policy,字节流支持有界读写、半关闭和
pause/resume;服务停止、重启和 Runtime Deployment 恢复会按 Revision 创建或释放 Host
资源。UDP 和 Unix Driver 仍待接入。
## 结构 ## 结构
```text ```text
crates/wasmeld-runtime Component Runtime 与 Sandbox crates/wasmeld-runtime Component Runtime 与 Sandbox
crates/wasmeld-console 管理 API 与持久化 crates/wasmeld-console 管理 API 与持久化
└── web/ Solid + Tailwind v4 管理面
crates/wasmeld-package wasmeld CLI、组件包与 WIT 依赖管理 crates/wasmeld-package wasmeld CLI、组件包与 WIT 依赖管理
components/ 示例 Component components/ 示例 Component
console/ TanStack Start 管理面
wit/ Wasmeld WIT package 源码 wit/ Wasmeld WIT package 源码
``` ```
@@ -65,13 +71,26 @@ curl http://127.0.0.1:8081/v1/services/echo/invoke \
--data-binary 'hello' --data-binary 'hello'
``` ```
管理面: 管理面开发服务器
```bash ```bash
cd console cd crates/wasmeld-console/web
npm ci
npm run dev npm run dev
``` ```
打开 `http://127.0.0.1:3000`。开发期间 Rust 和 Web 独立增量构建;Release 构建会自动
构建 Web 并嵌入 `wasmeld-console`
```bash
cargo +stable build --release -p wasmeld-console
./target/release/wasmeld-console
```
此时管理面直接由 `http://127.0.0.1:8080` 提供,不需要部署单独的静态站点。Host +
iframe 页面生命周期、PWA 缓存和前端目录约定见
[`Console Web 文档`](crates/wasmeld-console/web/README.md)。
## 组件开发 ## 组件开发
Console 启动后,使用开发模式监听组件源码和本地 WIT `replace` Console 启动后,使用开发模式监听组件源码和本地 WIT `replace`
+4 -1
View File
@@ -70,9 +70,12 @@ impl ResidentGuest for ResidentProbe {
command: source.kind, command: source.kind,
payload: source.payload, payload: source.payload,
})], })],
// A TCP FIN is a read-half close. This fixture has no additional
// response to send, so it explicitly asks the Host to close the
// remaining write half and release the stream resource.
Event::StreamHalfClosed(stream_id) => vec![Effect::CloseStream(stream_id)],
Event::StreamOpened(_) Event::StreamOpened(_)
| Event::StreamWritable(_) | Event::StreamWritable(_)
| Event::StreamHalfClosed(_)
| Event::StreamClosed(_) | Event::StreamClosed(_)
| Event::Shutdown => Vec::new(), | Event::Shutdown => Vec::new(),
}) })
-4
View File
@@ -1,4 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": ["src/routeTree.gen.ts"]
}
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y", "node"],
"categories": {
"correctness": "error"
},
"rules": {},
"ignorePatterns": ["src/routeTree.gen.ts"],
"env": {
"builtin": true,
"browser": true,
"node": true
}
}
-36
View File
@@ -1,36 +0,0 @@
# Wasmeld Console
WebAssembly Runtime 的管理控制台,使用 TanStack Start、TanStack Router、
React 和 srvx 构建,通过 Node.js 运行。页面只调用 `wasmeld-console` HTTP API
不直接访问数据库或 Wasmeld Runtime。
运行概览可以启动、停止和重启整个 Runtime;服务版本与运行实例页面负责管理单个
Wasm Actor。停止 Runtime 不会断开管理 API。
## Development
```bash
npm install
npm run dev
```
默认访问 `http://localhost:3000`
管理 API 默认使用 `http://127.0.0.1:8080`。可以在控制台的“运行设置”中修改,
或在构建时设置 `VITE_WASMELD_API_URL`
## Validation
```bash
npm run format:check
npm run lint
npm test
```
生产构建完成后可以运行:
```bash
npm start
```
`npm test` 会生成 Node.js 生产构建,并验证 srvx 服务端渲染和静态资源。
-37
View File
@@ -1,37 +0,0 @@
{
"name": "wasmeld-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && tsc --noEmit",
"start": "srvx serve --prod --entry dist/server/server.js --static ../client",
"preview": "vite preview",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "oxlint . --deny-warnings",
"format": "oxfmt .",
"format:check": "oxfmt --check ."
},
"dependencies": {
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"lucide-react": "^1.27.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"srvx": "^0.12.4"
},
"devDependencies": {
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"oxfmt": "^0.60.0",
"oxlint": "^1.75.0",
"typescript": "5.9.3",
"vite": "^8.1.5"
},
"engines": {
"node": ">=22.13.0"
}
}
-379
View File
@@ -1,379 +0,0 @@
import {
ArrowRight,
Clock3,
CloudUpload,
FileCode2,
Package,
Play,
RadioTower,
RefreshCw,
ShieldCheck,
SquareTerminal,
X,
} from "lucide-react";
import { FormEvent, ReactNode, useState } from "react";
import { InvokeResult, RegisterComponentInput } from "../api";
import {
FormattedInvocationOutput,
Service,
formatInvocationOutput,
invocationInputSize,
parseInvocationInput,
} from "../console-model";
function DialogFrame({
title,
description,
icon,
onClose,
closeDisabled = false,
children,
}: {
title: string;
description: string;
icon: ReactNode;
onClose: () => void;
closeDisabled?: boolean;
children: ReactNode;
}) {
return (
<div className="dialog-backdrop">
<dialog
open
className="dialog"
aria-label={title}
onCancel={(event) => {
if (closeDisabled) event.preventDefault();
else onClose();
}}
onKeyDown={(event) => {
if (event.key === "Escape" && !closeDisabled) onClose();
}}
>
<div className="dialog-header">
<div className="dialog-title">
<span>{icon}</span>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
title="关闭"
disabled={closeDisabled}
onClick={onClose}
>
<X size={18} />
</button>
</div>
{children}
</dialog>
</div>
);
}
export function ActivateDeploymentDialog({
service,
currentRevision,
submitting,
onClose,
onConfirm,
}: {
service: Service;
currentRevision: string | null;
submitting: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<DialogFrame
title="切换对外版本"
description="更新该服务在 Gateway 上接收请求的活动版本。"
icon={<RadioTower size={19} />}
onClose={onClose}
closeDisabled={submitting}
>
<div className="deployment-confirmation">
<div className="deployment-route">
<div>
<span></span>
<strong>{currentRevision ?? "尚未部署"}</strong>
</div>
<ArrowRight size={18} />
<div className="deployment-target">
<span></span>
<strong>{service.revision}</strong>
</div>
</div>
<p>
Wasmeld {service.id}@{service.revision} Actor
</p>
</div>
<div className="dialog-footer">
<button className="secondary-button" type="button" disabled={submitting} onClick={onClose}>
</button>
<button className="primary-button" type="button" disabled={submitting} onClick={onConfirm}>
{submitting ? <RefreshCw className="spin" size={16} /> : <RadioTower size={16} />}
{submitting ? "正在切换" : "确认切换"}
</button>
</div>
</DialogFrame>
);
}
export function DeployDialog({
onClose,
onRegister,
}: {
onClose: () => void;
onRegister: (input: RegisterComponentInput) => Promise<void>;
}) {
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const fileName = file?.name ?? "";
async function submit(event: FormEvent) {
event.preventDefault();
if (!file) return;
if (!file.name.toLowerCase().endsWith(".wasmpkg")) {
setError("请选择 .wasmpkg 组件包");
return;
}
setSubmitting(true);
setError("");
try {
await onRegister({ file });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "组件注册失败");
setSubmitting(false);
}
}
return (
<DialogFrame
title="注册组件包"
description="组件元数据与运行边界由平台自动校验。"
icon={<CloudUpload size={19} />}
onClose={onClose}
>
<form className="dialog-form" onSubmit={submit}>
<label className={fileName ? "upload-zone has-file" : "upload-zone"}>
<input
type="file"
accept=".wasmpkg,application/zip,application/vnd.wasm.component-package"
onChange={(event) => {
setFile(event.target.files?.[0] ?? null);
setError("");
}}
/>
{fileName ? <Package size={28} /> : <CloudUpload size={28} />}
<strong>{fileName || "选择组件包"}</strong>
<span>
{file
? `${(file.size / 1024 / 1024).toFixed(2)} MB · 等待校验`
: ".wasmpkg · 最大 64 MB"}
</span>
</label>
{error && <div className="form-error">{error}</div>}
<div className="dialog-footer">
<button className="secondary-button" type="button" onClick={onClose}>
</button>
<button className="primary-button" type="submit" disabled={submitting || !fileName}>
{submitting ? <RefreshCw className="spin" size={16} /> : <ShieldCheck size={16} />}
{submitting ? "正在注册" : "校验并注册"}
</button>
</div>
</form>
</DialogFrame>
);
}
export function WitPackageDialog({
onClose,
onPublish,
}: {
onClose: () => void;
onPublish: (file: File) => Promise<void>;
}) {
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const fileName = file?.name ?? "";
async function submit(event: FormEvent) {
event.preventDefault();
if (!file) return;
if (!file.name.toLowerCase().endsWith(".wasm")) {
setError("请选择由 wasmeld wit build 生成的 .wasm 文件");
return;
}
if (file.size > 4 * 1024 * 1024) {
setError("WIT 包不能超过 4 MB");
return;
}
setSubmitting(true);
setError("");
try {
await onPublish(file);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "WIT 包发布失败");
setSubmitting(false);
}
}
return (
<DialogFrame
title="发布 WIT 包"
description="包名、语义版本和直接依赖将从二进制 WIT Package 读取。"
icon={<FileCode2 size={19} />}
onClose={onClose}
>
<form className="dialog-form" onSubmit={submit}>
<label className={fileName ? "upload-zone has-file" : "upload-zone"}>
<input
type="file"
accept=".wasm,application/wasm"
onChange={(event) => {
setFile(event.target.files?.[0] ?? null);
setError("");
}}
/>
{fileName ? <FileCode2 size={28} /> : <CloudUpload size={28} />}
<strong>{fileName || "选择二进制 WIT Package"}</strong>
<span>
{file ? `${(file.size / 1024).toFixed(1)} KB · 等待校验` : ".wasm · 最大 4 MB"}
</span>
</label>
{error && <div className="form-error">{error}</div>}
<div className="dialog-footer">
<button className="secondary-button" type="button" onClick={onClose}>
</button>
<button className="primary-button" type="submit" disabled={submitting || !fileName}>
{submitting ? <RefreshCw className="spin" size={16} /> : <ShieldCheck size={16} />}
{submitting ? "正在发布" : "校验并发布"}
</button>
</div>
</form>
</DialogFrame>
);
}
export function InvokeDialog({
service,
onClose,
onInvoke,
}: {
service: Service;
onClose: () => void;
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
}) {
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);
async function invoke() {
setRunning(true);
setOutput("");
setOutputBytes(null);
setOutputFormat(null);
setFailed(false);
setLatency(null);
try {
const result = await onInvoke(service, parseInvocationInput(input, format));
const formatted = formatInvocationOutput(service, result.output, format);
setOutput(formatted.text);
setOutputBytes(result.outputBytes);
setOutputFormat(formatted);
setLatency(result.latencyMs);
} catch (invokeError) {
setFailed(true);
setOutput(invokeError instanceof Error ? invokeError.message : "组件调用失败");
} finally {
setRunning(false);
}
}
return (
<DialogFrame
title={`调用 ${service.id}@${service.revision}`}
description="请求将进入该实例的串行 mailbox。"
icon={<SquareTerminal size={19} />}
onClose={onClose}
>
<div className="invoke-body">
<div className="invoke-toolbar">
<div className="segmented" aria-label="输入编码">
<button
type="button"
className={format === "utf8" ? "active" : ""}
onClick={() => setFormat("utf8")}
>
UTF-8
</button>
<button
type="button"
className={format === "hex" ? "active" : ""}
onClick={() => setFormat("hex")}
>
HEX
</button>
</div>
<span>{invocationInputSize(input, format)} B</span>
</div>
<label className="code-field">
<span></span>
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
spellCheck={false}
placeholder={format === "utf8" ? "输入请求内容" : "00 ff a1"}
/>
</label>
<div className={failed ? "invoke-output failed" : "invoke-output"}>
<div>
<span></span>
{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>
</div>
</div>
<div className="dialog-footer">
<button className="secondary-button" type="button" onClick={onClose}>
</button>
<button className="primary-button" type="button" disabled={running} onClick={invoke}>
{running ? <RefreshCw className="spin" size={16} /> : <Play size={16} />}
</button>
</div>
</DialogFrame>
);
}
-986
View File
@@ -1,986 +0,0 @@
import {
Activity,
AlertTriangle,
Archive,
ArrowRight,
Box,
Braces,
Check,
CircleStop,
CloudUpload,
Cpu,
Download,
FileCode2,
Package,
Play,
RadioTower,
RefreshCw,
RotateCcw,
Search,
Server,
SquareTerminal,
X,
Zap,
} from "lucide-react";
import { ReactNode, useState } from "react";
import {
BackendCapability,
BackendRuntime,
BackendWitPackage,
witPackageDownloadUrl,
} from "../api";
import {
EventTone,
RuntimeEvent,
Service,
ServiceStatus,
STATUS_META,
formatDuration,
serviceKey,
} from "../console-model";
function PageHeading({
eyebrow,
title,
description,
actions,
}: {
eyebrow: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<div className="page-heading">
<div>
<span className="eyebrow">{eyebrow}</span>
<h1>{title}</h1>
<p>{description}</p>
</div>
{actions && <div className="heading-actions">{actions}</div>}
</div>
);
}
export function Overview({
services,
filteredServices,
events,
running,
faulted,
totalCalls,
deploymentCount,
runtime,
selectedService,
onSelect,
onDeploy,
onInvoke,
onStatus,
onActivate,
onViewAll,
onRefresh,
onRuntimeAction,
runtimeAction,
deploymentAction,
}: {
services: Service[];
filteredServices: Service[];
events: RuntimeEvent[];
running: number;
faulted: number;
totalCalls: number;
deploymentCount: number;
runtime: BackendRuntime | null;
selectedService: Service | null;
onSelect: (service: Service) => void;
onDeploy: () => void;
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
onViewAll: () => void;
onRefresh: () => void;
onRuntimeAction: (action: "start" | "stop" | "restart") => void;
runtimeAction: "start" | "stop" | "restart" | null;
deploymentAction: string | null;
}) {
const errorCount = services.reduce((sum, service) => sum + service.errors, 0);
const latencies = services
.map((service) => service.latencyMs)
.filter((latency): latency is number => latency !== null);
const latestLatency = latencies.length ? Math.max(...latencies) : null;
return (
<>
<PageHeading
eyebrow="LOCAL RUNTIME"
title="运行概览"
description={`${services.length} 个已注册版本,${running} 个 Actor 正在运行。`}
actions={
<>
<button
className="icon-button"
type="button"
aria-label="刷新"
title="刷新"
onClick={onRefresh}
>
<RefreshCw size={17} />
</button>
<button className="primary-button" type="button" onClick={onDeploy}>
<CloudUpload size={17} />
</button>
</>
}
/>
<section className="metric-grid" aria-label="关键指标">
<Metric
icon={<Package size={18} />}
label="服务版本"
value={services.length.toString()}
note={`${deploymentCount} 个对外服务`}
tone="cyan"
/>
<Metric
icon={<Activity size={18} />}
label="运行实例"
value={running.toString()}
note={`${services.length - running} 个未运行`}
tone="green"
/>
<Metric
icon={<Zap size={18} />}
label="累计调用"
value={totalCalls.toLocaleString("zh-CN")}
note="持久化统计"
tone="amber"
/>
<Metric
icon={<AlertTriangle size={18} />}
label="异常"
value={(faulted + errorCount).toString()}
note={faulted + errorCount ? "需要检查" : "无异常"}
tone="coral"
/>
</section>
<section className="runtime-band">
<div className="runtime-band-title">
<div className="runtime-symbol">
<Cpu size={18} />
</div>
<div>
<strong>Wasmeld Runtime</strong>
<span>
{runtime?.status === "running"
? "Engine 已启用 fuel 与 epoch interruption"
: "Engine 与所有 Actor 已释放"}
</span>
</div>
<div className="runtime-controls" aria-label="Runtime 生命周期">
{runtime?.status === "stopped" ? (
<button
className="icon-button"
type="button"
aria-label="启动 Runtime"
title="启动 Runtime"
disabled={runtimeAction !== null}
onClick={() => onRuntimeAction("start")}
>
<Play size={15} />
</button>
) : (
<>
<button
className="icon-button"
type="button"
aria-label="重启 Runtime"
title="重启 Runtime"
disabled={runtimeAction !== null || !runtime}
onClick={() => onRuntimeAction("restart")}
>
<RotateCcw size={15} />
</button>
<button
className="icon-button runtime-stop-button"
type="button"
aria-label="停止 Runtime"
title="停止 Runtime"
disabled={runtimeAction !== null || !runtime}
onClick={() => onRuntimeAction("stop")}
>
<CircleStop size={15} />
</button>
</>
)}
</div>
</div>
<RuntimeStat
label="已加载版本"
value={(runtime?.registered_services ?? 0).toString()}
detail={`${runtime?.managed_services ?? services.length} 个受管制品`}
/>
<RuntimeStat
label="活跃 Store"
value={(runtime?.running_services ?? running).toString()}
detail="串行 Actor"
/>
<RuntimeStat
label="最近延迟"
value={latestLatency === null ? "—" : `${latestLatency} ms`}
detail="各服务最近调用"
/>
<RuntimeStat
label="运行时间"
value={runtime?.status === "running" ? formatDuration(runtime.uptime_ms) : "—"}
detail="当前 Runtime 实例"
/>
</section>
<div className="overview-grid">
<section className="panel service-panel">
<div className="panel-header">
<div>
<h2></h2>
<p> Actor </p>
</div>
<button className="text-button" type="button" onClick={onViewAll}>
<ArrowRight size={15} />
</button>
</div>
<ServiceTable
services={filteredServices.slice(0, 5)}
selectedKey={selectedService ? serviceKey(selectedService) : ""}
onSelect={onSelect}
onInvoke={onInvoke}
onStatus={onStatus}
onActivate={onActivate}
canActivate={runtime?.status === "running"}
deploymentAction={deploymentAction}
compact
/>
</section>
<aside className="panel event-panel">
<div className="panel-header">
<div>
<h2></h2>
<p>Runtime </p>
</div>
<span className="live-label">
<span />
LIVE
</span>
</div>
<EventList events={events.slice(0, 5)} />
</aside>
</div>
{selectedService && (
<section className="detail-strip">
<div className="detail-identity">
<div className="artifact-icon">
<FileCode2 size={20} />
</div>
<div>
<span></span>
<strong>
{selectedService.id}
<small>@{selectedService.revision}</small>
</strong>
</div>
</div>
<DetailValue label="内存上限" value={`${selectedService.memoryMb} MB`} />
<DetailValue label="Fuel / 调用" value={selectedService.fuel} />
<DetailValue label="Deadline" value={`${selectedService.deadlineMs} ms`} />
<DetailValue label="Mailbox" value={selectedService.mailbox.toString()} />
<div className="detail-actions">
{!selectedService.active && (
<button
className="secondary-button"
type="button"
disabled={runtime?.status !== "running" || deploymentAction !== null}
onClick={() => onActivate(selectedService)}
>
<RadioTower size={16} />
</button>
)}
<button
className="secondary-button"
type="button"
disabled={selectedService.status !== "running"}
onClick={() => onInvoke(selectedService)}
>
<SquareTerminal size={16} />
</button>
</div>
</section>
)}
</>
);
}
function Metric({
icon,
label,
value,
note,
tone,
}: {
icon: ReactNode;
label: string;
value: string;
note: string;
tone: string;
}) {
return (
<article className="metric">
<div className={`metric-icon metric-${tone}`}>{icon}</div>
<div className="metric-copy">
<span>{label}</span>
<strong>{value}</strong>
</div>
<small>{note}</small>
</article>
);
}
function RuntimeStat({ label, value, detail }: { label: string; value: string; detail: string }) {
return (
<div className="runtime-stat">
<span>{label}</span>
<strong>{value}</strong>
<small>{detail}</small>
</div>
);
}
function DetailValue({ label, value }: { label: string; value: string }) {
return (
<div className="detail-value">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function StatusBadge({ status }: { status: ServiceStatus }) {
const meta = STATUS_META[status];
return (
<span className={`status-badge ${meta.className}`}>
<span />
{meta.label}
</span>
);
}
function CapabilitySummary({ capabilities }: { capabilities: BackendCapability[] }) {
if (capabilities.length === 0) {
return <span className="capability-empty"></span>;
}
const [first, ...remaining] = capabilities;
return (
<span
className="capability-summary"
title={capabilities.map((capability) => capability.interface).join("\n")}
>
<Braces size={12} />
<span>
{first.package}/{first.name} @{first.version}
</span>
{remaining.length > 0 && <small>+{remaining.length}</small>}
</span>
);
}
function ServiceTable({
services,
selectedKey,
onSelect,
onInvoke,
onStatus,
onActivate,
canActivate,
deploymentAction,
compact = false,
}: {
services: Service[];
selectedKey?: string;
onSelect: (service: Service) => void;
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
canActivate: boolean;
deploymentAction: string | null;
compact?: boolean;
}) {
if (services.length === 0) {
return (
<div className="empty-state">
<Search size={22} />
<strong></strong>
<span></span>
</div>
);
}
return (
<div className="table-scroll">
<table className={`data-table ${compact ? "service-table-compact" : "service-table-full"}`}>
<thead>
<tr>
<th></th>
<th></th>
{!compact && <th></th>}
{!compact && <th>Host </th>}
<th></th>
<th></th>
<th>
<span className="sr-only"></span>
</th>
</tr>
</thead>
<tbody>
{services.map((service) => (
<tr
key={`${service.id}-${service.revision}`}
className={selectedKey === serviceKey(service) ? "selected" : ""}
>
<td>
<button
className="service-cell service-select"
type="button"
aria-label={`选择 ${service.id}@${service.revision}`}
onClick={() => onSelect(service)}
>
<span className="service-glyph">
<Box size={16} />
</span>
<span>
<span className="service-title">
<strong>{service.id}</strong>
{service.active && (
<span className="active-deployment-badge">
<RadioTower size={10} />
</span>
)}
</span>
<small>
{service.revision} · {service.description}
</small>
</span>
</button>
</td>
<td>
<StatusBadge status={service.status} />
</td>
{!compact && <td className="artifact-cell">{service.artifact}</td>}
{!compact && (
<td>
<CapabilitySummary capabilities={service.capabilities} />
</td>
)}
<td className="numeric-cell">{service.calls.toLocaleString("zh-CN")}</td>
<td className="numeric-cell">
{service.latencyMs ? `${service.latencyMs} ms` : "—"}
</td>
<td>
<div className="row-actions">
<button
className={service.active ? "active-deployment-button" : ""}
type="button"
aria-label={
service.active
? `${service.id}@${service.revision} 是当前对外版本`
: `${service.id}@${service.revision} 设为对外版本`
}
title={
service.active
? "当前对外版本"
: canActivate
? "设为对外版本"
: "请先启动 Runtime"
}
disabled={service.active || !canActivate || deploymentAction !== null}
onClick={() => onActivate(service)}
>
{deploymentAction === serviceKey(service) ? (
<RefreshCw className="spin" size={15} />
) : (
<RadioTower size={15} />
)}
</button>
{service.status === "running" ? (
<>
<button
type="button"
aria-label={`调用 ${service.id}`}
title="测试调用"
onClick={() => onInvoke(service)}
>
<SquareTerminal size={15} />
</button>
<button
type="button"
aria-label={`重启 ${service.id}`}
title="重启"
onClick={() => onStatus(service, "running")}
>
<RotateCcw size={15} />
</button>
<button
type="button"
aria-label={`停止 ${service.id}`}
title="停止"
onClick={() => onStatus(service, "stopped")}
>
<CircleStop size={15} />
</button>
</>
) : (
<button
type="button"
aria-label={`启动 ${service.id}`}
title="启动"
onClick={() => onStatus(service, "running")}
>
<Play size={15} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function EventList({ events }: { events: RuntimeEvent[] }) {
const icons: Record<EventTone, ReactNode> = {
success: <Check size={14} />,
warning: <AlertTriangle size={14} />,
danger: <X size={14} />,
neutral: <CircleStop size={14} />,
};
return (
<div className="event-list">
{events.map((event) => (
<div className="event-row" key={event.id}>
<span className={`event-icon event-${event.tone}`}>{icons[event.tone]}</span>
<div>
<strong>{event.title}</strong>
<span>{event.detail}</span>
</div>
<time>{event.time}</time>
</div>
))}
</div>
);
}
export function ServicesView({
services,
statusFilter,
onFilter,
onDeploy,
onInvoke,
onSelect,
onStatus,
onActivate,
canActivate,
deploymentAction,
}: {
services: Service[];
statusFilter: "all" | ServiceStatus;
onFilter: (status: "all" | ServiceStatus) => void;
onDeploy: () => void;
onInvoke: (service: Service) => void;
onSelect: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
canActivate: boolean;
deploymentAction: string | null;
}) {
return (
<>
<PageHeading
eyebrow="REGISTRY"
title="服务版本"
description="管理不可变 Wasm Component 制品及当前对外版本。"
actions={
<button className="primary-button" type="button" onClick={onDeploy}>
<CloudUpload size={17} />
</button>
}
/>
<section className="panel full-panel">
<div className="filterbar">
<div className="segmented" aria-label="状态筛选">
{[
["all", "全部"],
["running", "运行中"],
["stopped", "已停止"],
["faulted", "故障"],
].map(([value, label]) => (
<button
type="button"
key={value}
className={statusFilter === value ? "active" : ""}
onClick={() => onFilter(value as "all" | ServiceStatus)}
>
{label}
</button>
))}
</div>
<span className="result-count">{services.length} </span>
</div>
<ServiceTable
services={services}
onSelect={onSelect}
onInvoke={onInvoke}
onStatus={onStatus}
onActivate={onActivate}
canActivate={canActivate}
deploymentAction={deploymentAction}
/>
</section>
</>
);
}
export function WitPackagesView({
apiBase,
packages,
onPublish,
}: {
apiBase: string;
packages: BackendWitPackage[];
onPublish: () => void;
}) {
function downloadPackage(packageMetadata: BackendWitPackage) {
const anchor = document.createElement("a");
anchor.href = witPackageDownloadUrl(apiBase, packageMetadata);
anchor.download = `${packageMetadata.name.replace(":", "-")}-${packageMetadata.version}.wasm`;
document.body.append(anchor);
anchor.click();
anchor.remove();
}
return (
<>
<PageHeading
eyebrow="WIT REGISTRY"
title="WIT 包"
description="管理组件开发所依赖的不可变、版本化 WIT Package。"
actions={
<button className="primary-button" type="button" onClick={onPublish}>
<CloudUpload size={17} />
WIT
</button>
}
/>
<section className="panel full-panel">
<div className="filterbar">
<span className="registry-note"></span>
<span className="result-count">{packages.length} </span>
</div>
{packages.length === 0 ? (
<div className="empty-state">
<FileCode2 size={22} />
<strong> WIT </strong>
<span> WIT Package</span>
</div>
) : (
<div className="table-scroll">
<table className="data-table wit-package-table">
<thead>
<tr>
<th>Package</th>
<th></th>
<th>SHA-256</th>
<th>
<span className="sr-only"></span>
</th>
</tr>
</thead>
<tbody>
{packages.map((packageMetadata) => {
const dependencies = packageMetadata.dependencies
.map((dependency) => `${dependency.name}@${dependency.version}`)
.join(", ");
return (
<tr key={`${packageMetadata.name}@${packageMetadata.version}`}>
<td aria-label={`${packageMetadata.name}@${packageMetadata.version}`}>
<div className="service-cell">
<span className="service-glyph">
<FileCode2 size={16} />
</span>
<span>
<strong>{packageMetadata.name}</strong>
<small>{packageMetadata.version}</small>
</span>
</div>
</td>
<td className="artifact-cell" title={dependencies || "无直接依赖"}>
{dependencies || "无直接依赖"}
</td>
<td className="artifact-cell" title={packageMetadata.sha256}>
{packageMetadata.sha256}
</td>
<td>
<div className="row-actions">
<button
type="button"
title="下载 WIT 包"
aria-label={`下载 ${packageMetadata.name}@${packageMetadata.version}`}
onClick={() => downloadPackage(packageMetadata)}
>
<Download size={15} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</>
);
}
export function InstancesView({
services,
onInvoke,
onStatus,
onRefresh,
}: {
services: Service[];
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onRefresh: () => void;
}) {
const instances = services.filter(
(service) => service.status === "running" || service.status === "faulted",
);
return (
<>
<PageHeading
eyebrow="ACTORS"
title="运行实例"
description={`${instances.length} 个 Actor 保持独立 Store 与 Instance。`}
actions={
<button
className="icon-button"
type="button"
title="刷新"
aria-label="刷新"
onClick={onRefresh}
>
<RefreshCw size={17} />
</button>
}
/>
<section className="instance-list">
{instances.length === 0 ? (
<output className="instance-placeholder">
<div className="instance-placeholder-icon">
<Cpu size={22} />
</div>
<strong></strong>
<span>0 ACTIVE ACTORS</span>
</output>
) : (
instances.map((service) => (
<article className="instance-row" key={service.id}>
<div className="instance-main">
<span className={`instance-indicator ${service.status}`} />
<div className="artifact-icon">
<Cpu size={19} />
</div>
<div>
<strong>
{service.id}@{service.revision}
</strong>
<span>actor/{service.id}-01</span>
</div>
</div>
<DetailValue
label="Store 内存"
value={
service.status === "running"
? `${Math.round(service.memoryMb * 0.42)} MB`
: "已释放"
}
/>
<DetailValue label="调用总数" value={service.calls.toLocaleString("zh-CN")} />
<DetailValue
label="最近延迟"
value={service.latencyMs ? `${service.latencyMs} ms` : "—"}
/>
<div className="instance-status">
<StatusBadge status={service.status} />
<span>{service.updatedAt}</span>
</div>
<div className="row-actions">
<button
type="button"
title="测试调用"
aria-label={`调用 ${service.id}`}
disabled={service.status !== "running"}
onClick={() => onInvoke(service)}
>
<SquareTerminal size={16} />
</button>
<button
type="button"
title="重启"
aria-label={`重启 ${service.id}`}
onClick={() => onStatus(service, "running")}
>
<RotateCcw size={16} />
</button>
<button
type="button"
title="停止"
aria-label={`停止 ${service.id}`}
disabled={service.status !== "running"}
onClick={() => onStatus(service, "stopped")}
>
<CircleStop size={16} />
</button>
</div>
</article>
))
)}
</section>
</>
);
}
export function ActivityView({
events,
services,
}: {
events: RuntimeEvent[];
services: Service[];
}) {
const calls = services.reduce((sum, service) => sum + service.calls, 0);
const errors = services.reduce((sum, service) => sum + service.errors, 0);
const latencies = services
.map((service) => service.latencyMs)
.filter((latency): latency is number => latency !== null);
const averageLatency = latencies.length
? latencies.reduce((sum, latency) => sum + latency, 0) / latencies.length
: null;
function exportEvents() {
const blob = new Blob([JSON.stringify(events, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "wasmeld-events.json";
anchor.click();
URL.revokeObjectURL(url);
}
return (
<>
<PageHeading
eyebrow="EVENT STREAM"
title="调用记录"
description="Runtime 生命周期、调用结果与沙箱拒绝记录。"
actions={
<button className="secondary-button" type="button" onClick={exportEvents}>
<Archive size={16} />
</button>
}
/>
<section className="panel full-panel">
<div className="activity-summary">
<div>
<span></span>
<strong></strong>
</div>
<div>
<span></span>
<strong>{calls ? `${(((calls - errors) / calls) * 100).toFixed(2)}%` : "—"}</strong>
</div>
<div>
<span></span>
<strong>{averageLatency === null ? "—" : `${averageLatency.toFixed(1)} ms`}</strong>
</div>
<div>
<span></span>
<strong>{events.length}</strong>
</div>
</div>
<EventList events={events} />
</section>
</>
);
}
export function SettingsView({
endpoint,
onSaveEndpoint,
}: {
endpoint: string;
onSaveEndpoint: (value: string) => void;
}) {
const [value, setValue] = useState(endpoint);
return (
<>
<PageHeading
eyebrow="CONFIGURATION"
title="运行设置"
description="本地控制台连接与 Runtime 默认参数。"
/>
<form
className="settings-form"
onSubmit={(event) => {
event.preventDefault();
onSaveEndpoint(value);
}}
>
<section className="settings-section">
<div className="settings-heading">
<Server size={18} />
<div>
<h2></h2>
<p> API 访</p>
</div>
</div>
<div className="form-field wide-field">
<label htmlFor="endpoint">API </label>
<input id="endpoint" value={value} onChange={(event) => setValue(event.target.value)} />
<span></span>
</div>
</section>
<div className="form-footer">
<button className="primary-button" type="submit">
</button>
</div>
</form>
</>
);
}
-68
View File
@@ -1,68 +0,0 @@
/* oxlint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
-16
View File
@@ -1,16 +0,0 @@
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
return createRouter({
routeTree,
defaultPreload: "intent",
scrollRestoration: true,
});
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
-60
View File
@@ -1,60 +0,0 @@
/// <reference types="vite/client" />
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
import type { ReactNode } from "react";
import appCss from "../styles/app.css?url";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{ title: "Wasmeld Console" },
{
name: "description",
content: "WebAssembly Runtime 管理控制台",
},
{ property: "og:title", content: "Wasmeld Console" },
{
property: "og:description",
content: "WebAssembly Runtime 管理控制台",
},
{ property: "og:type", content: "website" },
{ property: "og:image", content: "/og.png" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: "Wasmeld Console" },
{
name: "twitter:description",
content: "WebAssembly Runtime 管理控制台",
},
{ name: "twitter:image", content: "/og.png" },
],
links: [{ rel: "stylesheet", href: appCss }],
}),
component: RootComponent,
});
function RootComponent() {
return (
<RootDocument>
<Outlet />
</RootDocument>
);
}
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="zh-CN">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
-489
View File
@@ -1,489 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { ArrowRight, Braces, CheckCircle2, Search, Server, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
BackendDeployment,
BackendRuntime,
BackendWitPackage,
RegisterComponentInput,
activateDeployment,
changeRuntimeState,
changeServiceState,
fetchSnapshot,
getApiBase,
invokeComponent,
publishWitPackage,
registerComponent,
saveApiBase,
} from "../api";
import {
ConnectionState,
NAV_ITEMS,
RuntimeEvent,
Service,
ServiceStatus,
View,
formatApiHost,
formatDuration,
serviceKey,
toEvent,
toService,
} from "../console-model";
import {
ActivateDeploymentDialog,
DeployDialog,
InvokeDialog,
WitPackageDialog,
} from "../components/dialogs";
import {
ActivityView,
InstancesView,
Overview,
ServicesView,
SettingsView,
WitPackagesView,
} from "../components/views";
export const Route = createFileRoute("/")({
component: Home,
});
function Home() {
const [view, setView] = useState<View>("overview");
const [services, setServices] = useState<Service[]>([]);
const [events, setEvents] = useState<RuntimeEvent[]>([]);
const [deployments, setDeployments] = useState<BackendDeployment[]>([]);
const [witPackages, setWitPackages] = useState<BackendWitPackage[]>([]);
const [runtime, setRuntime] = useState<BackendRuntime | null>(null);
const [runtimeAction, setRuntimeAction] = useState<"start" | "stop" | "restart" | null>(null);
const [connection, setConnection] = useState<ConnectionState>("connecting");
const [apiBase, setApiBase] = useState(getApiBase);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | ServiceStatus>("all");
const [selectedKey, setSelectedKey] = useState("");
const [deployOpen, setDeployOpen] = useState(false);
const [witPublishOpen, setWitPublishOpen] = useState(false);
const [invokeKey, setInvokeKey] = useState<string | null>(null);
const [activationKey, setActivationKey] = useState<string | null>(null);
const [deploymentAction, setDeploymentAction] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const selectedService =
services.find((service) => serviceKey(service) === selectedKey) ?? services[0] ?? null;
const invokeService = services.find((service) => serviceKey(service) === invokeKey) ?? null;
const activationService =
services.find((service) => serviceKey(service) === activationKey) ?? null;
const filteredServices = useMemo(() => {
const normalized = query.trim().toLowerCase();
return services.filter((service) => {
const matchesText =
!normalized ||
service.id.toLowerCase().includes(normalized) ||
service.description.toLowerCase().includes(normalized) ||
service.artifact.toLowerCase().includes(normalized);
const matchesStatus = statusFilter === "all" || service.status === statusFilter;
return matchesText && matchesStatus;
});
}, [query, services, statusFilter]);
const filteredWitPackages = useMemo(() => {
const normalized = query.trim().toLowerCase();
return witPackages.filter(
(packageMetadata) =>
!normalized ||
packageMetadata.name.toLowerCase().includes(normalized) ||
packageMetadata.version.toLowerCase().includes(normalized) ||
packageMetadata.sha256.toLowerCase().includes(normalized),
);
}, [query, witPackages]);
const running = services.filter((service) => service.status === "running").length;
const faulted = services.filter((service) => service.status === "faulted").length;
const totalCalls = services.reduce((sum, service) => sum + service.calls, 0);
const refreshSnapshot = useCallback(async () => {
try {
const snapshot = await fetchSnapshot(apiBase);
const activeRevisions = new Map(
snapshot.deployments.map((deployment) => [
deployment.service_id,
deployment.active_revision,
]),
);
const nextServices = snapshot.services.map((service) =>
toService(service, activeRevisions.get(service.id)),
);
setServices(nextServices);
setDeployments(snapshot.deployments);
setEvents(snapshot.events.map(toEvent));
setRuntime(snapshot.runtime);
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) =>
nextServices.some((service) => serviceKey(service) === current)
? current
: nextServices[0]
? serviceKey(nextServices[0])
: "",
);
setConnection("online");
} 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([]);
setDeployments([]);
setEvents([]);
setRuntime(null);
setWitPackages([]);
setConnection("offline");
}
}, [apiBase]);
useEffect(() => {
void refreshSnapshot();
const interval = window.setInterval(() => void refreshSnapshot(), 5000);
return () => window.clearInterval(interval);
}, [refreshSnapshot]);
function notify(message: string) {
setToast(message);
window.setTimeout(() => setToast(null), 2600);
}
async function setServiceStatus(service: Service, status: ServiceStatus) {
const action =
status === "stopped" ? "stop" : service.status === "running" ? "restart" : "start";
try {
await changeServiceState(apiBase, service, action);
await refreshSnapshot();
notify(
`${service.id} ${
action === "stop" ? "已停止" : action === "restart" ? "已重启" : "已启动"
}`,
);
} catch (error) {
notify(error instanceof Error ? error.message : "运行时操作失败");
}
}
async function setRuntimeStatus(action: "start" | "stop" | "restart") {
setRuntimeAction(action);
try {
await changeRuntimeState(apiBase, action);
await refreshSnapshot();
notify(
action === "start"
? "Runtime 已启动"
: action === "stop"
? "Runtime 已停止"
: "Runtime 已重启",
);
} catch (error) {
notify(error instanceof Error ? error.message : "Runtime 操作失败");
} finally {
setRuntimeAction(null);
}
}
async function activateService(service: Service) {
const key = serviceKey(service);
setDeploymentAction(key);
try {
await activateDeployment(apiBase, service);
await refreshSnapshot();
setActivationKey(null);
notify(`${service.id}@${service.revision} 已设为对外版本`);
} catch (error) {
notify(error instanceof Error ? error.message : "Deployment 切换失败");
} finally {
setDeploymentAction(null);
}
}
async function runInvocation(service: Service, input: Uint8Array) {
const result = await invokeComponent(apiBase, service, input);
await refreshSnapshot();
return result;
}
async function registerService(input: RegisterComponentInput) {
const registered = await registerComponent(apiBase, input);
await refreshSnapshot();
setSelectedKey(serviceKey(registered));
setView("services");
setDeployOpen(false);
notify("组件注册成功");
}
async function publishWit(file: File) {
const packageMetadata = await publishWitPackage(apiBase, file);
await refreshSnapshot();
setWitPublishOpen(false);
notify(`${packageMetadata.name}@${packageMetadata.version} 已发布`);
}
function updateApiBase(value: string) {
try {
const nextBase = saveApiBase(value);
setConnection("connecting");
setApiBase(nextBase);
notify("API 地址已保存");
} catch (error) {
notify(error instanceof Error ? error.message : "API 地址无效");
}
}
const title = NAV_ITEMS.find((item) => item.id === view)?.label ?? "运行概览";
return (
<div className="app-shell">
<Sidebar
view={view}
onChange={setView}
running={running}
connection={connection}
apiBase={apiBase}
uptimeMs={runtime?.status === "running" ? runtime.uptime_ms : null}
runtimeStatus={runtime?.status ?? null}
/>
<div className="app-main">
<header className="topbar">
<div className="breadcrumb">
<span></span>
<ArrowRight size={14} />
<strong>{title}</strong>
</div>
<div className="topbar-actions">
<label className="global-search">
<Search size={16} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索服务或制品"
aria-label="搜索服务或制品"
/>
</label>
<div className={`source-state source-${connection}`} title={`管理 API${apiBase}`}>
<span className="source-dot" />
{connection === "online"
? "后端在线"
: connection === "connecting"
? "连接中"
: "后端离线"}
</div>
<div className="avatar-button" aria-hidden="true">
BD
</div>
</div>
</header>
<main className="content">
{view === "overview" && (
<Overview
services={services}
filteredServices={filteredServices}
events={events}
running={running}
faulted={faulted}
totalCalls={totalCalls}
deploymentCount={deployments.length}
runtime={runtime}
selectedService={selectedService}
onSelect={(service) => setSelectedKey(serviceKey(service))}
onDeploy={() => setDeployOpen(true)}
onInvoke={(service) => setInvokeKey(serviceKey(service))}
onStatus={setServiceStatus}
onActivate={(service) => setActivationKey(serviceKey(service))}
onViewAll={() => setView("services")}
onRefresh={() => void refreshSnapshot()}
onRuntimeAction={(action) => void setRuntimeStatus(action)}
runtimeAction={runtimeAction}
deploymentAction={deploymentAction}
/>
)}
{view === "services" && (
<ServicesView
services={filteredServices}
statusFilter={statusFilter}
onFilter={setStatusFilter}
onDeploy={() => setDeployOpen(true)}
onInvoke={(service) => setInvokeKey(serviceKey(service))}
onSelect={(service) => setSelectedKey(serviceKey(service))}
onStatus={setServiceStatus}
onActivate={(service) => setActivationKey(serviceKey(service))}
canActivate={runtime?.status === "running"}
deploymentAction={deploymentAction}
/>
)}
{view === "instances" && (
<InstancesView
services={services}
onInvoke={(service) => setInvokeKey(serviceKey(service))}
onStatus={setServiceStatus}
onRefresh={() => void refreshSnapshot()}
/>
)}
{view === "activity" && <ActivityView events={events} services={services} />}
{view === "wit-packages" && (
<WitPackagesView
apiBase={apiBase}
packages={filteredWitPackages}
onPublish={() => setWitPublishOpen(true)}
/>
)}
{view === "settings" && (
<SettingsView endpoint={apiBase} onSaveEndpoint={updateApiBase} />
)}
</main>
<footer className="statusbar">
<span>
<ShieldCheck size={14} />
{connection !== "online"
? "等待 Wasmeld"
: runtime?.status === "running"
? "Wasmtime sandbox 正常"
: "Wasmeld Runtime 已停止"}
</span>
<span>Component Model · WASI P2</span>
<span>Runtime 0.1.0</span>
</footer>
</div>
{deployOpen && (
<DeployDialog onClose={() => setDeployOpen(false)} onRegister={registerService} />
)}
{witPublishOpen && (
<WitPackageDialog onClose={() => setWitPublishOpen(false)} onPublish={publishWit} />
)}
{invokeService && (
<InvokeDialog
service={invokeService}
onClose={() => setInvokeKey(null)}
onInvoke={runInvocation}
/>
)}
{activationService && (
<ActivateDeploymentDialog
service={activationService}
currentRevision={
services.find((service) => service.id === activationService.id && service.active)
?.revision ?? null
}
submitting={deploymentAction === serviceKey(activationService)}
onClose={() => setActivationKey(null)}
onConfirm={() => void activateService(activationService)}
/>
)}
{toast && (
<output className="toast">
<CheckCircle2 size={17} />
{toast}
</output>
)}
</div>
);
}
function Sidebar({
view,
onChange,
running,
connection,
apiBase,
uptimeMs,
runtimeStatus,
}: {
view: View;
onChange: (view: View) => void;
running: number;
connection: ConnectionState;
apiBase: string;
uptimeMs: number | null;
runtimeStatus: BackendRuntime["status"] | null;
}) {
const runtimeLabel =
connection !== "online"
? "离线"
: runtimeStatus === "running"
? "运行中"
: runtimeStatus === "stopped"
? "已停止"
: "连接中";
const runtimeHealthClass =
connection !== "online"
? "health-pulse health-offline"
: runtimeStatus === "running"
? "health-pulse"
: "health-pulse health-stopped";
return (
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">
<Braces size={19} strokeWidth={2.2} />
</div>
<div>
<strong>Wasmeld Console</strong>
<span>Runtime Control</span>
</div>
</div>
<div className="environment-switcher">
<span className="environment-icon">
<Server size={15} />
</span>
<span>
<small></small>
<strong>Local Runtime</strong>
</span>
</div>
<nav className="primary-nav" aria-label="主导航">
<span className="nav-label"></span>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
return (
<button
type="button"
key={item.id}
className={view === item.id ? "nav-item active" : "nav-item"}
onClick={() => onChange(item.id)}
>
<Icon size={17} />
<span>{item.label}</span>
{item.id === "instances" && running > 0 && (
<span className="nav-count">{running}</span>
)}
</button>
);
})}
</nav>
<div className="sidebar-runtime">
<div className="runtime-title">
<span className={runtimeHealthClass} />
Runtime {runtimeLabel}
</div>
<div className="runtime-meta">
<span>{formatApiHost(apiBase)}</span>
<span>{uptimeMs === null ? "—" : formatDuration(uptimeMs)}</span>
</div>
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
-127
View File
@@ -1,127 +0,0 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { createServer } from "node:net";
import { fileURLToPath } from "node:url";
import test, { after, before } from "node:test";
let serverProcess;
let serverOrigin;
async function reservePort() {
const server = createServer();
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
assert(address && typeof address === "object");
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return address.port;
}
before(async () => {
const port = await reservePort();
serverOrigin = `http://127.0.0.1:${port}`;
serverProcess = spawn(
process.execPath,
[
fileURLToPath(new URL("../node_modules/srvx/bin/srvx.mjs", import.meta.url)),
"serve",
"--prod",
"--entry",
"dist/server/server.js",
"--static",
"../client",
],
{
env: {
...process.env,
HOST: "127.0.0.1",
PORT: String(port),
},
stdio: ["ignore", "pipe", "pipe"],
},
);
for (let attempt = 0; attempt < 50; attempt += 1) {
if (serverProcess.exitCode !== null) {
throw new Error(`Node server exited with code ${serverProcess.exitCode}`);
}
try {
const response = await fetch(serverOrigin);
if (response.ok) return;
} catch {
// The server is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("Node server did not become ready");
});
after(() => {
serverProcess?.kill("SIGTERM");
});
test("server-renders the Wasm management console", async () => {
const response = await fetch(serverOrigin, {
headers: { accept: "text/html" },
});
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, /<title>Wasmeld Console<\/title>/i);
assert.match(html, /运行概览/);
assert.match(html, /WIT 包/);
assert.match(html, /连接中/);
assert.match(html, /0 个对外服务/);
assert.match(html, /Component Model/);
assert.doesNotMatch(html, /本地演示|codex-preview|vinext|next\.js/i);
});
test("uses TanStack Start routing and produces Node artifacts", async () => {
const [
rootRoute,
indexRoute,
views,
dialogs,
consoleModel,
apiClient,
router,
packageJson,
viteConfig,
] = await Promise.all([
readFile(new URL("../src/routes/__root.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/routes/index.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/components/views.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/components/dialogs.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/console-model.ts", import.meta.url), "utf8"),
readFile(new URL("../src/api.ts", import.meta.url), "utf8"),
readFile(new URL("../src/router.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../vite.config.ts", import.meta.url), "utf8"),
]);
assert.match(rootRoute, /createRootRoute/);
assert.match(rootRoute, /<HeadContent \/>/);
assert.match(rootRoute, /<Scripts \/>/);
assert.match(indexRoute, /createFileRoute\("\/"\)/);
assert.match(dialogs, /切换对外版本/);
assert.match(views, /Host 能力/);
assert.match(views, /service\.capabilities/);
assert.match(consoleModel, /TextDecoder\("utf-8", \{ fatal: true \}\)/);
assert.match(dialogs, /outputFormat\.automatic/);
assert.match(apiClient, /\/api\/v1\/deployments/);
assert.match(apiClient, /capabilities: BackendCapability\[\]/);
assert.match(router, /createRouter/);
assert.match(packageJson, /"@tanstack\/react-start"/);
assert.match(packageJson, /"srvx"/);
assert.doesNotMatch(packageJson, /"next"|"vinext"|"drizzle-orm"/);
assert.match(viteConfig, /tanstackStart\(\{/);
await access(new URL("../dist/server/server.js", import.meta.url));
await access(new URL("../dist/client/og.png", import.meta.url));
});
-19
View File
@@ -1,19 +0,0 @@
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
server: { port: 3000 },
plugins: [
tanstackStart({
router: {
routeTreeFileHeader: [
"/* oxlint-disable */",
"// @ts-nocheck",
"// noinspection JSUnusedGlobalSymbols",
],
},
}),
viteReact(),
],
});
+2
View File
@@ -8,6 +8,8 @@ license.workspace = true
[dependencies] [dependencies]
axum.workspace = true axum.workspace = true
base64.workspace = true base64.workspace = true
include_dir.workspace = true
mime_guess.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
semver.workspace = true semver.workspace = true
+85
View File
@@ -10,6 +10,7 @@
- 通过独立 Gateway 转发原始二进制调用 - 通过独立 Gateway 转发原始二进制调用
- 为导入 `wasmeld:kv/store@0.1.0` 的 Component 提供持久化 KV - 为导入 `wasmeld:kv/store@0.1.0` 的 Component 提供持久化 KV
- 暴露调用计数、状态和最近事件 - 暴露调用计数、状态和最近事件
- 在 Release 可执行文件中嵌入 Solid + Tailwind v4 管理面
Wasm 制品保存在本地文件系统。服务 manifest、调用计数和最近 256 条事件通过 Wasm 制品保存在本地文件系统。服务 manifest、调用计数和最近 256 条事件通过
[Toasty](https://github.com/tokio-rs/toasty) 写入本地 libSQL 数据库,默认路径是 [Toasty](https://github.com/tokio-rs/toasty) 写入本地 libSQL 数据库,默认路径是
@@ -29,6 +30,71 @@ Host KV 使用 `(service_id, key)` 复合主键存储在该数据库中:同一
编译后的本地机器码默认缓存在 `var/wasmeld/component-cache`,相同 Component 在 编译后的本地机器码默认缓存在 `var/wasmeld/component-cache`,相同 Component 在
Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Actor 内存。 Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Actor 内存。
## 常驻 Host Driver
导出 `wasmeld:resident/actor@0.1.0` 的 Component 启动时,Console 会为它创建独立的
`ResidentSupervisor`。Supervisor 在专用阻塞线程上独占 `ResidentSession`,避免同步
Actor mailbox 阻塞 Tokio executor。Timer、TCP Listener 和每条 TCP Stream 由 Tokio
task 等待外部事件,再通过容量受限的 channel 汇聚到同一个 Supervisor。
Host 资源属于平台配置,不由 Component 创建,也不写入 `.wasmpkg`。当前可通过
`ConsoleConfig::resident_services` 为稳定服务 ID 配置 TCP Listener 和 Timer
```rust
use std::collections::BTreeMap;
use wasmeld_console::{
ConsoleConfig, NetworkScope, ResidentPolicy, ResidentServiceConfig,
ResidentTcpListenerConfig, ResidentTimerConfig,
};
let mut resident_services = BTreeMap::new();
resident_services.insert(
"scheduler".to_owned(),
ResidentServiceConfig {
policy: ResidentPolicy {
tcp_listen: NetworkScope::Loopback,
..ResidentPolicy::default()
},
tcp_listeners: vec![ResidentTcpListenerConfig {
name: "internal-api".to_owned(),
bind: "127.0.0.1:9000".parse().unwrap(),
}],
timers: vec![ResidentTimerConfig {
name: "heartbeat".to_owned(),
initial_delay_ms: 1_000,
interval_ms: Some(30_000),
}],
..ResidentServiceConfig::default()
},
);
let config = ConsoleConfig {
resident_services,
..ConsoleConfig::default()
};
```
`ResidentPolicy` 默认拒绝全部网络地址;配置 Listener 时还必须显式选择 `Loopback`
`Any`。Listener 在 Actor 启动过程中完成策略校验和 bind,任何失败都会回滚刚启动的
Actor,避免出现“Actor 正在运行但端口未监听”的半启动状态。Listener、Timer 和其它
顶层资源名称在同一服务内必须唯一,每个 Revision 都会获得独立的 Host handle 和不复用
资源 ID。
TCP Driver 每次最多读取 `limits.resident.max_stream_chunk_bytes`,不会把 socket handle
交给 Wasm。TCP 字节在 Supervisor 队列满时等待并把背压传递到内核接收缓冲区,不能像
Timer occurrence 一样丢弃。Component 可通过 WIT 返回 `write-stream``close-stream`
`pause-stream``resume-stream`;每条连接的写入/控制队列也是有界的,慢客户端填满
队列时只关闭该连接,不阻塞同一 Actor 的其它连接。
Timer task 只负责等待,队列满时丢弃该次 occurrence,而不是创建无界积压。Component
返回 `arm-timer` 会替换 Host 初始日程,`cancel-timer` 会取消它;generation 使取消前
已经排队的迟到事件失效。
停止顺序固定为:停止接收新 Driver 事件、停止 accept、投递 `shutdown`、关闭 Stream
和 Timer task、先释放子 Stream 再释放 Listener、最后停止 Actor。服务重启和 Runtime
Deployment 恢复都会重新 bind 并创建新 Session,不会复用旧 Revision 的 socket、Timer
或 Component 内存。UDP 和 Unix Driver 尚未接入,后续继续复用相同的 Supervisor
序列化边界。
## 启动 ## 启动
`wasmeld-console` 需要 Rust 1.95 或更高版本;Wasm 组件继续使用项目约定的 Rust 1.90 `wasmeld-console` 需要 Rust 1.95 或更高版本;Wasm 组件继续使用项目约定的 Rust 1.90
@@ -38,6 +104,25 @@ Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Acto
cargo +stable run -p wasmeld-console cargo +stable run -p wasmeld-console
``` ```
Debug 构建只启动 API,不构建或提供 Web 静态文件。前端开发服务器需要单独启动:
```bash
cd crates/wasmeld-console/web
npm ci
npm run dev
```
Release 构建会自动使用 `package-lock.json` 构建并嵌入管理面:
```bash
cargo +stable build --release -p wasmeld-console
./target/release/wasmeld-console
```
发布构建要求 Node.js 22.13+ 和 npm。可用 `NPM` 指定 npm 可执行文件;设置
`WASMELD_BUILD_WEB=1` 可以在非 Release Cargo 构建中验证嵌入流程。前端结构和 PWA
边界见 [`web/README.md`](web/README.md)。
进程默认启动两个独立 Listener: 进程默认启动两个独立 Listener:
- 管理 API`127.0.0.1:8080` - 管理 API`127.0.0.1:8080`
+104
View File
@@ -0,0 +1,104 @@
//! Builds the Solid management UI for embedded Release binaries.
//!
//! Debug builds intentionally return before invoking npm: during development
//! Vite serves `web/` independently and proxies its API client to the Rust
//! listener. Release builds use the lockfile, write only into Cargo `OUT_DIR`,
//! and expose a cfg consumed by `src/web.rs`. The source tree therefore never
//! needs a generated `dist/` for `cargo build --release`.
//!
//! Set `WASMELD_BUILD_WEB=1` to exercise the embedded path in another Cargo
//! profile, or `NPM=/absolute/path/to/npm` when npm is not on `PATH`.
use std::{
env, fs,
path::PathBuf,
process::{Command, ExitStatus},
};
const WEB_INPUTS: &[&str] = &[
"web/src",
"web/public",
"web/index.html",
"web/page.html",
"web/package.json",
"web/package-lock.json",
"web/tsconfig.json",
"web/vite.config.ts",
];
fn main() {
println!("cargo:rustc-check-cfg=cfg(wasmeld_embedded_web)");
println!("cargo:rerun-if-env-changed=WASMELD_BUILD_WEB");
println!("cargo:rerun-if-env-changed=NPM");
for input in WEB_INPUTS {
println!("cargo:rerun-if-changed={input}");
}
let force = env::var("WASMELD_BUILD_WEB").is_ok_and(|value| value == "1");
if env::var("PROFILE").as_deref() != Ok("release") && !force {
return;
}
let crate_dir = PathBuf::from(required_env("CARGO_MANIFEST_DIR"));
let web_dir = crate_dir.join("web");
let output_dir = PathBuf::from(required_env("OUT_DIR")).join("web");
let npm = env::var_os("NPM")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "npm.cmd" } else { "npm" }));
if output_dir.exists() {
fs::remove_dir_all(&output_dir).unwrap_or_else(|error| {
panic!(
"failed to clear embedded Web output {}: {error}",
output_dir.display()
)
});
}
// `npm ci` is deliberate here: a release must not silently rewrite the
// dependency graph represented by package-lock.json.
run(
Command::new(&npm).current_dir(&web_dir).args([
"ci",
"--ignore-scripts",
"--no-audit",
"--no-fund",
]),
"install locked Web dependencies",
);
run(
Command::new(&npm)
.current_dir(&web_dir)
.env("WASMELD_WEB_OUT_DIR", &output_dir)
.args(["run", "build"]),
"build embedded Web application",
);
for required in ["index.html", "page.html", "sw.js", "manifest.webmanifest"] {
let path = output_dir.join(required);
assert!(
path.is_file(),
"Web build did not produce required asset {}",
path.display()
);
}
println!("cargo:rustc-cfg=wasmeld_embedded_web");
}
fn run(command: &mut Command, action: &str) {
let status = command
.status()
.unwrap_or_else(|error| panic!("failed to {action}: {error}"));
assert_success(status, action);
}
fn assert_success(status: ExitStatus, action: &str) {
assert!(
status.success(),
"failed to {action}: process exited with {status}"
);
}
fn required_env(name: &str) -> String {
env::var(name).unwrap_or_else(|_| panic!("Cargo did not provide {name}"))
}
+7 -2
View File
@@ -99,6 +99,7 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
.on_response(DefaultOnResponse::new().level(Level::INFO)), .on_response(DefaultOnResponse::new().level(Level::INFO)),
) )
.with_state(console) .with_state(console)
.merge(web::router())
} }
/// Builds the public data-plane API around the same resident Runtime. /// Builds the public data-plane API around the same resident Runtime.
@@ -584,7 +585,8 @@ impl IntoResponse for GatewayError {
| ConsoleError::InvalidDatabaseData(_) | ConsoleError::InvalidDatabaseData(_)
| ConsoleError::Runtime(_) | ConsoleError::Runtime(_)
| ConsoleError::Join(_) | ConsoleError::Join(_)
| ConsoleError::LockPoisoned => ( | ConsoleError::LockPoisoned
| ConsoleError::ResidentSupervisor(_) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"internal_error", "internal_error",
"internal gateway error".to_owned(), "internal gateway error".to_owned(),
@@ -679,7 +681,10 @@ impl IntoResponse for ApiError {
| ConsoleError::WitRegistry(WitRegistryError::Storage { .. }) | ConsoleError::WitRegistry(WitRegistryError::Storage { .. })
| ConsoleError::WitRegistry(WitRegistryError::LockPoisoned) | ConsoleError::WitRegistry(WitRegistryError::LockPoisoned)
| ConsoleError::Join(_) | ConsoleError::Join(_)
| ConsoleError::LockPoisoned => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"), | ConsoleError::LockPoisoned
| ConsoleError::ResidentSupervisor(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
}
}; };
( (
+194 -30
View File
@@ -36,10 +36,12 @@ mod api;
mod invocation_persistence; mod invocation_persistence;
mod kv_backend; mod kv_backend;
mod persistence; mod persistence;
mod resident_supervisor;
mod web;
mod wit_registry; mod wit_registry;
use std::{ use std::{
collections::{BTreeMap, VecDeque}, collections::{BTreeMap, HashMap, VecDeque},
fs, io, fs, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard}, sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard},
@@ -53,14 +55,19 @@ use wasmeld_package::{
ComponentPackage, PackageError, read_package, wit_package::WitPackageMetadata, ComponentPackage, PackageError, read_package, wit_package::WitPackageMetadata,
}; };
use wasmeld_runtime::{ use wasmeld_runtime::{
CapabilityDescriptor, ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceKey, ActorHandle, CapabilityDescriptor, ComponentExecution, ResourceLimits, Runtime, RuntimeConfig,
ServiceManifest, RuntimeError, ServiceKey, ServiceManifest,
}; };
pub use api::{app, gateway_app}; pub use api::{app, gateway_app};
use invocation_persistence::InvocationPersistence; use invocation_persistence::InvocationPersistence;
use kv_backend::ToastyKvBackend; use kv_backend::ToastyKvBackend;
use persistence::{InvocationUpdate, Persistence, StoredDeployment, StoredEvent, StoredService}; use persistence::{InvocationUpdate, Persistence, StoredDeployment, StoredEvent, StoredService};
pub use resident_supervisor::{
ResidentServiceConfig, ResidentTcpListenerConfig, ResidentTimerConfig,
};
use resident_supervisor::{ResidentSupervisor, ResidentSupervisorError};
pub use wasmeld_runtime::{NetworkScope, ResidentPolicy};
use wit_registry::WitRegistry; use wit_registry::WitRegistry;
pub use wit_registry::WitRegistryError; pub use wit_registry::WitRegistryError;
@@ -86,6 +93,12 @@ pub struct ConsoleConfig {
pub registration_limits: ResourceLimits, pub registration_limits: ResourceLimits,
/// Wasmtime Engine and epoch settings. /// Wasmtime Engine and epoch settings.
pub runtime: RuntimeConfig, pub runtime: RuntimeConfig,
/// Host-owned resident resources keyed by stable service ID.
///
/// Each started revision receives an independent copy of this
/// configuration and therefore never shares resource IDs, socket handles,
/// or driver tasks with another revision.
pub resident_services: BTreeMap<String, ResidentServiceConfig>,
} }
impl Default for ConsoleConfig { impl Default for ConsoleConfig {
@@ -98,6 +111,7 @@ impl Default for ConsoleConfig {
max_wit_package_bytes: DEFAULT_MAX_WIT_PACKAGE_BYTES, max_wit_package_bytes: DEFAULT_MAX_WIT_PACKAGE_BYTES,
registration_limits: ResourceLimits::default(), registration_limits: ResourceLimits::default(),
runtime: RuntimeConfig::default(), runtime: RuntimeConfig::default(),
resident_services: BTreeMap::new(),
} }
} }
} }
@@ -114,6 +128,9 @@ pub struct Console {
// to avoid deadlocks between HTTP control operations. // to avoid deadlocks between HTTP control operations.
runtime: RwLock<Option<Runtime>>, runtime: RwLock<Option<Runtime>>,
runtime_config: RuntimeConfig, runtime_config: RuntimeConfig,
async_runtime: tokio::runtime::Handle,
resident_services: BTreeMap<String, ResidentServiceConfig>,
resident_supervisors: Mutex<HashMap<ServiceKey, ResidentSupervisor>>,
artifact_dir: PathBuf, artifact_dir: PathBuf,
wit_registry: WitRegistry, wit_registry: WitRegistry,
max_artifact_bytes: usize, max_artifact_bytes: usize,
@@ -485,6 +502,10 @@ pub enum ConsoleError {
/// An internal lifecycle, state, or Runtime lock was poisoned. /// An internal lifecycle, state, or Runtime lock was poisoned.
#[error("console state lock was poisoned")] #[error("console state lock was poisoned")]
LockPoisoned, LockPoisoned,
/// A resident Host driver could not start, dispatch, or drain.
#[error("resident supervisor failed: {0}")]
ResidentSupervisor(String),
} }
impl Console { impl Console {
@@ -494,6 +515,12 @@ impl Console {
/// configuration. Their previous Store and linear memory are not restored. /// configuration. Their previous Store and linear memory are not restored.
pub async fn new(config: ConsoleConfig) -> Result<Self, ConsoleError> { pub async fn new(config: ConsoleConfig) -> Result<Self, ConsoleError> {
config.registration_limits.validate()?; config.registration_limits.validate()?;
for (service_id, resident) in &config.resident_services {
validate_path_segment("resident service id", service_id)?;
resident
.validate()
.map_err(ConsoleError::ResidentSupervisor)?;
}
fs::create_dir_all(&config.artifact_dir).map_err(|source| ConsoleError::Storage { fs::create_dir_all(&config.artifact_dir).map_err(|source| ConsoleError::Storage {
path: config.artifact_dir.clone(), path: config.artifact_dir.clone(),
source, source,
@@ -546,6 +573,9 @@ impl Console {
let console = Self { let console = Self {
runtime: RwLock::new(Some(runtime)), runtime: RwLock::new(Some(runtime)),
runtime_config, runtime_config,
async_runtime: tokio::runtime::Handle::current(),
resident_services: config.resident_services,
resident_supervisors: Mutex::new(HashMap::new()),
artifact_dir, artifact_dir,
wit_registry, wit_registry,
max_artifact_bytes: config.max_artifact_bytes, max_artifact_bytes: config.max_artifact_bytes,
@@ -670,6 +700,16 @@ impl Console {
Ok(started) => { Ok(started) => {
*runtime = Some(started); *runtime = Some(started);
drop(runtime); drop(runtime);
if let Err(error) = self.restore_deployments() {
self.rollback_runtime_start();
self.mark_runtime_stopped()?;
self.push_event(
EventKind::Failed,
None,
format!("Wasmeld Runtime failed to restore deployments: {error}"),
)?;
return Err(error);
}
self.mark_runtime_started()?; self.mark_runtime_started()?;
self.push_event( self.push_event(
EventKind::Started, EventKind::Started,
@@ -703,6 +743,7 @@ impl Console {
drop(runtime); drop(runtime);
return self.runtime_view(); return self.runtime_view();
}; };
self.shutdown_all_resident_supervisors();
let stopped = running.stop_all(); let stopped = running.stop_all();
drop(runtime); drop(runtime);
self.mark_runtime_stopped()?; self.mark_runtime_stopped()?;
@@ -737,23 +778,34 @@ impl Console {
.write() .write()
.map_err(|_| ConsoleError::LockPoisoned)?; .map_err(|_| ConsoleError::LockPoisoned)?;
if let Some(running) = runtime.take() if let Some(running) = runtime.take() {
&& let Err(error) = running.stop_all() self.shutdown_all_resident_supervisors();
{ if let Err(error) = running.stop_all() {
drop(runtime); drop(runtime);
self.mark_runtime_stopped()?; self.mark_runtime_stopped()?;
self.push_event( self.push_event(
EventKind::Failed, EventKind::Failed,
None, None,
format!("Wasmeld Runtime failed to restart while stopping actors: {error}"), format!("Wasmeld Runtime failed to restart while stopping actors: {error}"),
)?; )?;
return Err(error.into()); return Err(error.into());
}
} }
match self.build_runtime() { match self.build_runtime() {
Ok(started) => { Ok(started) => {
*runtime = Some(started); *runtime = Some(started);
drop(runtime); drop(runtime);
if let Err(error) = self.restore_deployments() {
self.rollback_runtime_start();
self.mark_runtime_stopped()?;
self.push_event(
EventKind::Failed,
None,
format!("Wasmeld Runtime failed to restore deployments: {error}"),
)?;
return Err(error);
}
self.mark_runtime_started()?; self.mark_runtime_started()?;
self.push_event( self.push_event(
EventKind::Started, EventKind::Started,
@@ -782,25 +834,120 @@ impl Console {
.values() .values()
.map(|service| service.manifest.clone()) .map(|service| service.manifest.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let deployments = state
.deployments
.iter()
.map(|(service_id, deployment)| {
ServiceKey::new(service_id.clone(), deployment.active_revision.clone())
})
.collect::<Result<Vec<_>, _>>()?;
drop(state); drop(state);
let runtime = Runtime::new(self.runtime_config.clone())?; let runtime = Runtime::new(self.runtime_config.clone())?;
for manifest in manifests { for manifest in manifests {
runtime.register_from_file(manifest)?; runtime.register_from_file(manifest)?;
} }
for key in deployments {
runtime.start(&key, Vec::new())?;
}
Ok(runtime) Ok(runtime)
} }
fn start_actor(
&self,
runtime: &Runtime,
key: &ServiceKey,
init_config: Vec<u8>,
) -> Result<ActorHandle, ConsoleError> {
let actor = runtime.start(key, init_config)?;
if let Err(error) = self.attach_resident_supervisor(actor.clone()) {
// A resident Actor without its Host driver would be running in a
// misleading half-state. Roll it back before surfacing the error.
let _ = runtime.stop(key);
return Err(error);
}
Ok(actor)
}
fn attach_resident_supervisor(&self, actor: ActorHandle) -> Result<(), ConsoleError> {
if actor.execution() != ComponentExecution::Resident {
return Ok(());
}
let key = actor.key().clone();
let mut supervisors = self
.resident_supervisors
.lock()
.map_err(|_| ConsoleError::LockPoisoned)?;
if supervisors
.get(&key)
.is_some_and(|supervisor| supervisor.owns_actor(&actor))
{
return Ok(());
}
if let Some(mut stale) = supervisors.remove(&key)
&& let Err(error) = stale.shutdown()
{
tracing::warn!(
service = %key,
%error,
"stale resident supervisor did not drain cleanly before Actor replacement"
);
}
let config = self
.resident_services
.get(key.id())
.cloned()
.unwrap_or_default();
let command_capacity = actor.mailbox_capacity();
let supervisor =
ResidentSupervisor::start(actor, config, self.async_runtime.clone(), command_capacity)
.map_err(supervisor_error)?;
supervisors.insert(key, supervisor);
Ok(())
}
fn shutdown_resident_supervisor(&self, key: &ServiceKey) {
let supervisor = self
.resident_supervisors
.lock()
.map(|mut supervisors| supervisors.remove(key));
match supervisor {
Ok(Some(mut supervisor)) => {
if let Err(error) = supervisor.shutdown() {
tracing::warn!(service = %key, %error, "resident supervisor did not drain cleanly");
}
}
Ok(None) => {}
Err(_) => tracing::warn!(service = %key, "resident supervisor lock was poisoned"),
}
}
fn shutdown_all_resident_supervisors(&self) {
let supervisors = self
.resident_supervisors
.lock()
.map(|mut supervisors| std::mem::take(&mut *supervisors));
match supervisors {
Ok(supervisors) => {
for (key, mut supervisor) in supervisors {
if let Err(error) = supervisor.shutdown() {
tracing::warn!(service = %key, %error, "resident supervisor did not drain cleanly");
}
}
}
Err(_) => tracing::warn!("resident supervisor lock was poisoned"),
}
}
fn stop_actor(&self, runtime: &Runtime, key: &ServiceKey) -> Result<(), RuntimeError> {
self.shutdown_resident_supervisor(key);
runtime.stop(key)
}
fn rollback_runtime_start(&self) {
self.shutdown_all_resident_supervisors();
match self.runtime.write() {
Ok(mut runtime) => {
if let Some(runtime) = runtime.take()
&& let Err(error) = runtime.stop_all()
{
tracing::warn!(%error, "failed to stop Runtime during start rollback");
}
}
Err(_) => tracing::warn!("Runtime lock was poisoned during start rollback"),
}
}
fn mark_runtime_started(&self) -> Result<(), ConsoleError> { fn mark_runtime_started(&self) -> Result<(), ConsoleError> {
let mut state = self.state()?; let mut state = self.state()?;
let deployed = state let deployed = state
@@ -930,6 +1077,7 @@ impl Console {
let runtime = self.runtime()?; let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?; let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
self.shutdown_resident_supervisor(key);
runtime.unregister(key)?; runtime.unregister(key)?;
self.state()?.services.remove(key); self.state()?.services.remove(key);
@@ -955,7 +1103,7 @@ impl Console {
let runtime = self.runtime()?; let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?; let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
self.ensure_managed(key)?; self.ensure_managed(key)?;
runtime.start(key, init_config)?; self.start_actor(runtime, key, init_config)?;
let view = self.update_status(key, ServiceStatus::Running)?; let view = self.update_status(key, ServiceStatus::Running)?;
self.push_event(EventKind::Started, Some(key), "actor started".to_owned())?; self.push_event(EventKind::Started, Some(key), "actor started".to_owned())?;
Ok(view) Ok(view)
@@ -965,7 +1113,7 @@ impl Console {
let runtime = self.runtime()?; let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?; let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
self.ensure_managed(key)?; self.ensure_managed(key)?;
runtime.stop(key)?; self.stop_actor(runtime, key)?;
let view = self.update_status(key, ServiceStatus::Stopped)?; let view = self.update_status(key, ServiceStatus::Stopped)?;
self.push_event(EventKind::Stopped, Some(key), "actor stopped".to_owned())?; self.push_event(EventKind::Stopped, Some(key), "actor stopped".to_owned())?;
Ok(view) Ok(view)
@@ -975,11 +1123,11 @@ impl Console {
let runtime = self.runtime()?; let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?; let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
self.ensure_managed(key)?; self.ensure_managed(key)?;
match runtime.stop(key) { match self.stop_actor(runtime, key) {
Ok(()) | Err(RuntimeError::ActorUnavailable(_)) => {} Ok(()) | Err(RuntimeError::ActorUnavailable(_)) => {}
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
} }
runtime.start(key, init_config)?; self.start_actor(runtime, key, init_config)?;
let view = self.update_status(key, ServiceStatus::Running)?; let view = self.update_status(key, ServiceStatus::Running)?;
self.push_event(EventKind::Started, Some(key), "actor restarted".to_owned())?; self.push_event(EventKind::Started, Some(key), "actor restarted".to_owned())?;
Ok(view) Ok(view)
@@ -1005,7 +1153,7 @@ impl Console {
// Starting is idempotent. The old revision stays resident so calls // Starting is idempotent. The old revision stays resident so calls
// that resolved immediately before this switch can finish safely and // that resolved immediately before this switch can finish safely and
// rollback does not require recompilation. // rollback does not require recompilation.
runtime.start(&key, init_config)?; self.start_actor(runtime, &key, init_config)?;
let updated_at_ms = unix_time_ms(); let updated_at_ms = unix_time_ms();
let view = { let view = {
@@ -1220,7 +1368,7 @@ impl Console {
let runtime = self.runtime()?; let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?; let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
for key in &deployments { for key in &deployments {
runtime.start(key, Vec::new())?; self.start_actor(runtime, key, Vec::new())?;
} }
} }
@@ -1361,6 +1509,22 @@ impl Console {
} }
} }
impl Drop for Console {
fn drop(&mut self) {
self.shutdown_all_resident_supervisors();
if let Ok(runtime) = self.runtime.get_mut()
&& let Some(runtime) = runtime.take()
&& let Err(error) = runtime.stop_all()
{
tracing::warn!(%error, "failed to stop Runtime while dropping Console");
}
}
}
fn supervisor_error(error: ResidentSupervisorError) -> ConsoleError {
ConsoleError::ResidentSupervisor(error.to_string())
}
fn runtime_capabilities( fn runtime_capabilities(
runtime: &Runtime, runtime: &Runtime,
key: &ServiceKey, key: &ServiceKey,
@@ -0,0 +1,877 @@
//! Revision-scoped execution of Host-owned resident event sources.
//!
//! The Wasmtime Actor API is intentionally synchronous: every call waits for a
//! bounded mailbox response. Running [`ResidentSession`] directly from a Tokio
//! task would therefore block an async worker for up to the Component deadline.
//! This module keeps one session on a dedicated supervisor thread. Async
//! drivers only wait for external activity and use a bounded channel to submit
//! facts such as a timer deadline, accepted TCP connection, or stream chunk.
//!
//! ```text
//! timer / listener / stream task --> bounded queue --> supervisor thread
//! ^ |
//! |-------- validated operations --------|
//! ```
//!
//! The supervisor is scoped to one immutable service revision. Shutdown first
//! prevents new driver events, then delivers the resident `shutdown` callback,
//! aborts every Host task, closes socket handles, releases Host resource IDs,
//! and only then allows the Console to stop the Actor.
mod tcp;
use std::{
collections::{HashMap, VecDeque},
io,
net::SocketAddr,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, SyncSender, TrySendError},
},
thread::{self, JoinHandle as ThreadJoinHandle},
time::{Duration, Instant},
};
use thiserror::Error;
use tokio::{net::TcpStream, runtime::Handle, task::JoinHandle as TaskJoinHandle};
use wasmeld_runtime::{
ActorHandle, ResidentEndpoint, ResidentHostError, ResidentOperation, ResidentPolicy,
ResidentSession, ResourceId, ServiceKey, StreamCloseReason,
};
pub use tcp::ResidentTcpListenerConfig;
use tcp::{TcpDriverError, TcpEndpointDriver, TcpStreamCommand, TcpStreamDriver};
/// Platform configuration for Host resources attached to one service ID.
///
/// A configuration is inherited by every started revision of that service, but
/// each revision receives independent Host handles and resource IDs.
#[derive(Clone, Debug, Default)]
pub struct ResidentServiceConfig {
/// Defense-in-depth policy checked before any Host endpoint is bound.
///
/// The default denies every network endpoint. Adding a TCP listener also
/// requires setting `tcp_listen` to `loopback` or `any`.
pub policy: ResidentPolicy,
/// Host-owned TCP listeners registered when a resident Actor starts.
pub tcp_listeners: Vec<ResidentTcpListenerConfig>,
/// Host-owned timers registered when a resident Actor starts.
pub timers: Vec<ResidentTimerConfig>,
}
impl ResidentServiceConfig {
pub(crate) fn validate(&self) -> Result<(), String> {
let mut names = std::collections::BTreeSet::new();
for listener in &self.tcp_listeners {
listener.validate()?;
self.policy
.validate_endpoint(&ResidentEndpoint::Tcp {
name: listener.name.clone(),
bind: listener.bind,
})
.map_err(|error| error.to_string())?;
if !names.insert(listener.name.as_str()) {
return Err(format!(
"resident resource name {:?} is duplicated",
listener.name
));
}
}
for timer in &self.timers {
timer.validate()?;
if !names.insert(timer.name.as_str()) {
return Err(format!(
"resident resource name {:?} is duplicated",
timer.name
));
}
}
Ok(())
}
}
/// One management-owned timer source for a resident Component.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResidentTimerConfig {
/// Stable operator-facing name, unique within one service configuration.
pub name: String,
/// Delay before the first event after the Actor starts.
pub initial_delay_ms: u64,
/// Optional Host-managed repeat period.
///
/// A Component may replace this schedule by returning `arm-timer` or stop
/// it with `cancel-timer`.
pub interval_ms: Option<u64>,
}
impl ResidentTimerConfig {
fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("resident timer name must not be empty".to_owned());
}
if self.initial_delay_ms == 0 {
return Err(format!(
"resident timer {:?} initial_delay_ms must be greater than zero",
self.name
));
}
if self.interval_ms == Some(0) {
return Err(format!(
"resident timer {:?} interval_ms must be greater than zero",
self.name
));
}
Ok(())
}
}
/// Failure while creating, driving, or draining a resident session.
#[derive(Debug, Error)]
pub(crate) enum ResidentSupervisorError {
#[error(transparent)]
Host(#[from] ResidentHostError),
#[error("resident driver queue capacity must be greater than zero")]
EmptyQueue,
#[error("failed to start resident supervisor thread: {0}")]
ThreadStart(#[source] io::Error),
#[error("resident supervisor for {0} stopped accepting commands")]
ChannelClosed(ServiceKey),
#[error("resident supervisor for {0} panicked")]
WorkerPanicked(ServiceKey),
#[error("resident timer operation referenced unknown resource {0}")]
UnknownTimer(u64),
#[error("resident stream operation referenced unknown resource {0}")]
UnknownStream(u64),
#[error(transparent)]
Tcp(#[from] TcpDriverError),
#[error("resident driver cannot apply {0} before its driver is installed")]
UnsupportedOperation(&'static str),
}
/// Clone-free owner for one revision's resident session and driver tasks.
pub(crate) struct ResidentSupervisor {
key: ServiceKey,
actor: ActorHandle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
worker: Option<ThreadJoinHandle<()>>,
}
impl ResidentSupervisor {
pub(crate) fn start(
actor: ActorHandle,
config: ResidentServiceConfig,
async_runtime: Handle,
command_capacity: usize,
) -> Result<Self, ResidentSupervisorError> {
if command_capacity == 0 {
return Err(ResidentSupervisorError::EmptyQueue);
}
let key = actor.key().clone();
let session = ResidentSession::new(actor.clone(), config.policy.clone())?;
let (sender, receiver) = mpsc::sync_channel(command_capacity);
let accepting_events = Arc::new(AtomicBool::new(true));
let core = SupervisorCore::new(
session,
config,
async_runtime,
sender.clone(),
accepting_events.clone(),
command_capacity,
Instant::now(),
)?;
let thread_name = format!("wasmeld-resident-{key}");
let worker_key = key.clone();
let worker = thread::Builder::new()
.name(thread_name)
.spawn(move || run_supervisor(worker_key, core, receiver))
.map_err(ResidentSupervisorError::ThreadStart)?;
Ok(Self {
key,
actor,
sender,
accepting_events,
worker: Some(worker),
})
}
pub(crate) fn owns_actor(&self, actor: &ActorHandle) -> bool {
self.actor.is_same_instance(actor)
}
/// Drains this revision and cancels all Host listener, stream, and timer tasks.
pub(crate) fn shutdown(&mut self) -> Result<(), ResidentSupervisorError> {
let Some(worker) = self.worker.take() else {
return Ok(());
};
// Async event producers observe this before retrying a full bounded
// queue, so the control-plane shutdown command cannot be starved by
// network traffic.
self.accepting_events.store(false, Ordering::Release);
let (response_sender, response_receiver) = mpsc::sync_channel(1);
let send_result = self
.sender
.send(SupervisorCommand::Shutdown { response_sender });
let response = match send_result {
Ok(()) => response_receiver
.recv()
.map_err(|_| ResidentSupervisorError::ChannelClosed(self.key.clone()))?,
Err(_) => Err(ResidentSupervisorError::ChannelClosed(self.key.clone())),
};
worker
.join()
.map_err(|_| ResidentSupervisorError::WorkerPanicked(self.key.clone()))?;
response
}
}
impl Drop for ResidentSupervisor {
fn drop(&mut self) {
if let Err(error) = self.shutdown() {
tracing::warn!(service = %self.key, %error, "resident supervisor drop could not drain cleanly");
}
}
}
enum SupervisorCommand {
TcpAccepted {
endpoint_id: ResourceId,
stream: TcpStream,
peer: SocketAddr,
},
StreamData {
stream_id: ResourceId,
bytes: Vec<u8>,
},
StreamHalfClosed {
stream_id: ResourceId,
},
StreamClosed {
stream_id: ResourceId,
reason: StreamCloseReason,
},
TimerFired {
timer_id: ResourceId,
generation: u64,
scheduled_at_ns: u64,
},
Shutdown {
response_sender: SyncSender<Result<(), ResidentSupervisorError>>,
},
}
struct SupervisorCore {
session: ResidentSession,
async_runtime: Handle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
stream_command_capacity: usize,
stream_read_bytes: usize,
origin: Instant,
tcp_endpoints: HashMap<ResourceId, TcpEndpointDriver>,
streams: HashMap<ResourceId, TcpStreamDriver>,
timers: HashMap<ResourceId, TimerDriver>,
}
impl SupervisorCore {
fn new(
session: ResidentSession,
config: ResidentServiceConfig,
async_runtime: Handle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
stream_command_capacity: usize,
origin: Instant,
) -> Result<Self, ResidentSupervisorError> {
let stream_read_bytes = session.max_stream_chunk_bytes();
let mut core = Self {
session,
async_runtime,
sender,
accepting_events,
stream_command_capacity,
stream_read_bytes,
origin,
tcp_endpoints: HashMap::new(),
streams: HashMap::new(),
timers: HashMap::new(),
};
for listener in config.tcp_listeners {
core.register_tcp_listener(listener)?;
}
for timer in config.timers {
let timer_id = core.session.register_timer(timer.name)?;
core.timers.insert(timer_id, TimerDriver::default());
core.arm_timer(timer_id, timer.initial_delay_ms, timer.interval_ms)?;
}
Ok(core)
}
fn register_tcp_listener(
&mut self,
config: ResidentTcpListenerConfig,
) -> Result<(), ResidentSupervisorError> {
let endpoint_id = self.session.register_endpoint(ResidentEndpoint::Tcp {
name: config.name.clone(),
bind: config.bind,
})?;
let driver = match TcpEndpointDriver::start(
&config,
self.session.owner().clone(),
endpoint_id,
&self.async_runtime,
self.sender.clone(),
self.accepting_events.clone(),
) {
Ok(driver) => driver,
Err(error) => {
let _ = self.session.release_resource(endpoint_id);
return Err(error.into());
}
};
self.tcp_endpoints.insert(endpoint_id, driver);
Ok(())
}
fn tcp_accepted(
&mut self,
endpoint_id: ResourceId,
stream: TcpStream,
peer: SocketAddr,
) -> Result<(), ResidentSupervisorError> {
let (stream_id, operations) = self
.session
.accept_stream(endpoint_id, Some(peer.to_string()))?;
let driver = TcpStreamDriver::start(
self.session.owner().clone(),
stream_id,
stream,
self.stream_read_bytes,
self.stream_command_capacity,
&self.async_runtime,
self.sender.clone(),
self.accepting_events.clone(),
);
self.streams.insert(stream_id, driver);
if let Err(error) = self.apply_operations(operations) {
self.discard_stream(stream_id);
return Err(error);
}
Ok(())
}
fn stream_data(
&mut self,
stream_id: ResourceId,
bytes: Vec<u8>,
) -> Result<(), ResidentSupervisorError> {
if !self.streams.contains_key(&stream_id) {
return Ok(());
}
let result = self
.session
.stream_data(stream_id, bytes)
.map_err(ResidentSupervisorError::from)
.and_then(|operations| self.apply_operations(operations));
if result.is_err() {
self.discard_stream(stream_id);
}
result
}
fn stream_half_closed(&mut self, stream_id: ResourceId) -> Result<(), ResidentSupervisorError> {
if !self.streams.contains_key(&stream_id) {
return Ok(());
}
let result = self
.session
.stream_half_closed(stream_id)
.map_err(ResidentSupervisorError::from)
.and_then(|operations| self.apply_operations(operations));
if result.is_err() {
self.discard_stream(stream_id);
}
result
}
fn stream_closed(
&mut self,
stream_id: ResourceId,
reason: StreamCloseReason,
) -> Result<(), ResidentSupervisorError> {
let Some(driver) = self.streams.remove(&stream_id) else {
// A task can race with Host-initiated termination. Resource IDs are
// never reused, so a late terminal fact is safe to ignore.
return Ok(());
};
drop(driver);
let operations = self.session.stream_closed(stream_id, reason)?;
self.apply_operations(operations)
}
fn discard_stream(&mut self, stream_id: ResourceId) {
self.streams.remove(&stream_id);
let _ = self.session.release_resource(stream_id);
}
fn timer_fired(
&mut self,
timer_id: ResourceId,
generation: u64,
scheduled_at_ns: u64,
) -> Result<(), ResidentSupervisorError> {
let interval_ms = {
let timer = self
.timers
.get_mut(&timer_id)
.ok_or(ResidentSupervisorError::UnknownTimer(timer_id.get()))?;
if timer.generation != generation {
return Ok(());
}
timer.task = None;
timer.interval_ms
};
let operations = self.session.timer_fired(timer_id, scheduled_at_ns)?;
let schedule_replaced = operations.iter().any(|operation| {
matches!(
operation,
ResidentOperation::ArmTimer {
timer_id: operation_timer,
..
} | ResidentOperation::CancelTimer {
timer_id: operation_timer
} if *operation_timer == timer_id
)
});
self.apply_operations(operations)?;
if !schedule_replaced && let Some(interval_ms) = interval_ms {
self.arm_timer(timer_id, interval_ms, Some(interval_ms))?;
}
Ok(())
}
fn apply_operations(
&mut self,
operations: Vec<ResidentOperation>,
) -> Result<(), ResidentSupervisorError> {
let mut pending = VecDeque::from(operations);
while let Some(operation) = pending.pop_front() {
match operation {
ResidentOperation::ArmTimer {
timer_id,
delay_ms,
interval_ms,
} => self.arm_timer(timer_id, delay_ms, interval_ms)?,
ResidentOperation::CancelTimer { timer_id } => self.cancel_timer(timer_id)?,
ResidentOperation::WriteStream { stream_id, bytes } => {
if !self.queue_stream_command(stream_id, TcpStreamCommand::Write(bytes))? {
pending.extend(
self.terminate_stream(stream_id, StreamCloseReason::TransportError)?,
);
}
}
ResidentOperation::CloseStream { stream_id } => {
if !self.queue_stream_command(stream_id, TcpStreamCommand::Close)? {
pending.extend(
self.terminate_stream(stream_id, StreamCloseReason::HostClosed)?,
);
}
}
ResidentOperation::PauseStream { stream_id } => {
if !self.queue_stream_command(stream_id, TcpStreamCommand::Pause)? {
pending.extend(
self.terminate_stream(stream_id, StreamCloseReason::TransportError)?,
);
}
}
ResidentOperation::ResumeStream { stream_id } => {
if !self.queue_stream_command(stream_id, TcpStreamCommand::Resume)? {
pending.extend(
self.terminate_stream(stream_id, StreamCloseReason::TransportError)?,
);
}
}
ResidentOperation::SendDatagram { .. } => {
return Err(ResidentSupervisorError::UnsupportedOperation(
"send-datagram",
));
}
ResidentOperation::AcknowledgeMessage { .. } => {
return Err(ResidentSupervisorError::UnsupportedOperation(
"acknowledge-message",
));
}
ResidentOperation::SourceCommand { .. } => {
return Err(ResidentSupervisorError::UnsupportedOperation(
"source-command",
));
}
}
}
Ok(())
}
/// Queues one bounded operation without ever blocking the supervisor.
///
/// A full per-stream queue means the peer is not consuming output quickly
/// enough. The caller terminates that stream instead of stalling unrelated
/// connections or allowing an unbounded Host buffer.
fn queue_stream_command(
&self,
stream_id: ResourceId,
command: TcpStreamCommand,
) -> Result<bool, ResidentSupervisorError> {
let driver = self
.streams
.get(&stream_id)
.ok_or(ResidentSupervisorError::UnknownStream(stream_id.get()))?;
match driver.try_send(command) {
Ok(()) => Ok(true),
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
tracing::warn!(
service = %self.session.owner(),
resource_id = stream_id.get(),
"resident TCP stream closed because its write/control queue is full"
);
Ok(false)
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => Ok(false),
}
}
fn terminate_stream(
&mut self,
stream_id: ResourceId,
reason: StreamCloseReason,
) -> Result<Vec<ResidentOperation>, ResidentSupervisorError> {
let driver = self
.streams
.remove(&stream_id)
.ok_or(ResidentSupervisorError::UnknownStream(stream_id.get()))?;
drop(driver);
Ok(self.session.stream_closed(stream_id, reason)?)
}
fn arm_timer(
&mut self,
timer_id: ResourceId,
delay_ms: u64,
interval_ms: Option<u64>,
) -> Result<(), ResidentSupervisorError> {
let timer = self
.timers
.get_mut(&timer_id)
.ok_or(ResidentSupervisorError::UnknownTimer(timer_id.get()))?;
timer.cancel_task();
timer.generation = next_generation(timer.generation);
timer.interval_ms = interval_ms;
let generation = timer.generation;
let delay = Duration::from_millis(delay_ms);
let scheduled_at_ns = u64::try_from(self.origin.elapsed().saturating_add(delay).as_nanos())
.unwrap_or(u64::MAX);
let sender = self.sender.clone();
let accepting_events = self.accepting_events.clone();
let owner = self.session.owner().clone();
timer.task = Some(self.async_runtime.spawn(async move {
tokio::time::sleep(delay).await;
if !accepting_events.load(Ordering::Acquire) {
return;
}
match sender.try_send(SupervisorCommand::TimerFired {
timer_id,
generation,
scheduled_at_ns,
}) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
tracing::warn!(
service = %owner,
resource_id = timer_id.get(),
"resident timer occurrence dropped because the supervisor queue is full"
);
}
Err(TrySendError::Disconnected(_)) => {}
}
}));
Ok(())
}
fn cancel_timer(&mut self, timer_id: ResourceId) -> Result<(), ResidentSupervisorError> {
let timer = self
.timers
.get_mut(&timer_id)
.ok_or(ResidentSupervisorError::UnknownTimer(timer_id.get()))?;
timer.cancel_task();
timer.generation = next_generation(timer.generation);
timer.interval_ms = None;
Ok(())
}
fn shutdown(&mut self) -> Result<(), ResidentSupervisorError> {
self.accepting_events.store(false, Ordering::Release);
for endpoint in self.tcp_endpoints.values_mut() {
endpoint.abort();
}
for timer in self.timers.values_mut() {
timer.cancel_task();
}
let mut first_error = match self.session.shutdown() {
Ok(operations) => self.apply_operations(operations).err(),
Err(error) => Some(error.into()),
};
// A shutdown callback may return stream or timer operations. They have
// been validated, but no source may remain active beyond this edge.
let stream_ids = self.streams.keys().copied().collect::<Vec<_>>();
for stream_id in stream_ids {
self.streams.remove(&stream_id);
if let Err(error) = self.session.release_resource(stream_id)
&& first_error.is_none()
{
first_error = Some(error.into());
}
}
self.streams.clear();
let endpoint_ids = self.tcp_endpoints.keys().copied().collect::<Vec<_>>();
for endpoint_id in endpoint_ids {
self.tcp_endpoints.remove(&endpoint_id);
if let Err(error) = self.session.release_resource(endpoint_id)
&& first_error.is_none()
{
first_error = Some(error.into());
}
}
self.tcp_endpoints.clear();
for timer in self.timers.values_mut() {
timer.cancel_task();
}
let timer_ids = self.timers.keys().copied().collect::<Vec<_>>();
for timer_id in timer_ids {
if let Err(error) = self.session.release_resource(timer_id)
&& first_error.is_none()
{
first_error = Some(error.into());
}
}
self.timers.clear();
match first_error {
Some(error) => Err(error),
None => Ok(()),
}
}
}
fn run_supervisor(
key: ServiceKey,
mut core: SupervisorCore,
receiver: Receiver<SupervisorCommand>,
) {
while let Ok(command) = receiver.recv() {
match command {
SupervisorCommand::TcpAccepted {
endpoint_id,
stream,
peer,
} => {
if let Err(error) = core.tcp_accepted(endpoint_id, stream, peer) {
tracing::warn!(
service = %key,
resource_id = endpoint_id.get(),
%peer,
%error,
"resident TCP connection was rejected"
);
}
}
SupervisorCommand::StreamData { stream_id, bytes } => {
if let Err(error) = core.stream_data(stream_id, bytes) {
tracing::warn!(
service = %key,
resource_id = stream_id.get(),
%error,
"resident TCP stream data failed and the connection was closed"
);
}
}
SupervisorCommand::StreamHalfClosed { stream_id } => {
if let Err(error) = core.stream_half_closed(stream_id) {
tracing::warn!(
service = %key,
resource_id = stream_id.get(),
%error,
"resident TCP half-close event failed and the connection was closed"
);
}
}
SupervisorCommand::StreamClosed { stream_id, reason } => {
if let Err(error) = core.stream_closed(stream_id, reason) {
tracing::warn!(
service = %key,
resource_id = stream_id.get(),
%error,
"resident TCP terminal event failed"
);
}
}
SupervisorCommand::TimerFired {
timer_id,
generation,
scheduled_at_ns,
} => {
if let Err(error) = core.timer_fired(timer_id, generation, scheduled_at_ns) {
let _ = core.cancel_timer(timer_id);
tracing::warn!(
service = %key,
resource_id = timer_id.get(),
%error,
"resident timer event failed and was cancelled"
);
}
}
SupervisorCommand::Shutdown { response_sender } => {
let result = core.shutdown();
let _ = response_sender.send(result);
return;
}
}
}
if let Err(error) = core.shutdown() {
tracing::warn!(service = %key, %error, "resident supervisor channel closed during drain");
}
}
#[derive(Default)]
struct TimerDriver {
generation: u64,
interval_ms: Option<u64>,
task: Option<TaskJoinHandle<()>>,
}
impl TimerDriver {
fn cancel_task(&mut self) {
if let Some(task) = self.task.take() {
task.abort();
}
}
}
impl Drop for TimerDriver {
fn drop(&mut self) {
self.cancel_task();
}
}
fn next_generation(current: u64) -> u64 {
let next = current.wrapping_add(1);
if next == 0 { 1 } else { next }
}
#[cfg(test)]
mod tests {
use super::{
ResidentServiceConfig, ResidentTcpListenerConfig, ResidentTimerConfig, next_generation,
};
#[test]
fn rejects_invalid_timer_configuration() {
for timer in [
ResidentTimerConfig {
name: String::new(),
initial_delay_ms: 1,
interval_ms: None,
},
ResidentTimerConfig {
name: "zero-delay".to_owned(),
initial_delay_ms: 0,
interval_ms: None,
},
ResidentTimerConfig {
name: "zero-interval".to_owned(),
initial_delay_ms: 1,
interval_ms: Some(0),
},
] {
assert!(
ResidentServiceConfig {
timers: vec![timer],
..ResidentServiceConfig::default()
}
.validate()
.is_err()
);
}
}
#[test]
fn rejects_duplicate_timer_names() {
let timer = ResidentTimerConfig {
name: "heartbeat".to_owned(),
initial_delay_ms: 1,
interval_ms: None,
};
assert!(
ResidentServiceConfig {
timers: vec![timer.clone(), timer],
..ResidentServiceConfig::default()
}
.validate()
.is_err()
);
}
#[test]
fn rejects_names_shared_by_listener_and_timer() {
assert!(
ResidentServiceConfig {
tcp_listeners: vec![ResidentTcpListenerConfig {
name: "events".to_owned(),
bind: "127.0.0.1:9000".parse().unwrap(),
}],
timers: vec![ResidentTimerConfig {
name: "events".to_owned(),
initial_delay_ms: 1,
interval_ms: None,
}],
..ResidentServiceConfig::default()
}
.validate()
.is_err()
);
}
#[test]
fn rejects_tcp_listener_outside_default_policy() {
assert!(
ResidentServiceConfig {
tcp_listeners: vec![ResidentTcpListenerConfig {
name: "denied".to_owned(),
bind: "127.0.0.1:9000".parse().unwrap(),
}],
..ResidentServiceConfig::default()
}
.validate()
.is_err()
);
}
#[test]
fn timer_generation_never_uses_zero() {
assert_eq!(next_generation(0), 1);
assert_eq!(next_generation(u64::MAX), 1);
}
}
@@ -0,0 +1,402 @@
//! Tokio TCP transport owned by one resident supervisor.
//!
//! This module owns operating-system socket handles and knows nothing about
//! Wasm bindings or Component effects. It reports accepted connections and
//! bounded stream facts through [`SupervisorCommand`], while the parent
//! supervisor validates resource identity and translates Component operations
//! back into [`TcpStreamCommand`] values.
use std::{
io,
net::SocketAddr,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{SyncSender, TrySendError},
},
time::Duration,
};
use thiserror::Error;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpSocket, TcpStream},
runtime::Handle,
sync::mpsc,
task::JoinHandle,
};
use wasmeld_runtime::{ResourceId, ServiceKey, StreamCloseReason};
use super::SupervisorCommand;
/// One management-owned TCP listener for a resident Component.
///
/// The address must be numeric; DNS and socket handles are never exposed to
/// the Component. Port zero is accepted for tests and private embedding, but a
/// fixed configured port is required when external callers must discover it.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResidentTcpListenerConfig {
/// Stable operator-facing name, unique among all resources for the service.
pub name: String,
/// Numeric address bound by the Host after policy validation.
pub bind: SocketAddr,
}
impl ResidentTcpListenerConfig {
pub(super) fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("resident TCP listener name must not be empty".to_owned());
}
Ok(())
}
}
#[derive(Debug, Error)]
pub(crate) enum TcpDriverError {
#[error("failed to {action} resident TCP listener {name:?} at {bind}: {source}")]
Listener {
name: String,
bind: SocketAddr,
action: &'static str,
#[source]
source: io::Error,
},
}
pub(super) struct TcpEndpointDriver {
task: JoinHandle<()>,
}
impl TcpEndpointDriver {
pub(super) fn start(
config: &ResidentTcpListenerConfig,
owner: ServiceKey,
endpoint_id: ResourceId,
async_runtime: &Handle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
) -> Result<Self, TcpDriverError> {
let listener = bind_listener(config, async_runtime)?;
let name = config.name.clone();
let task = async_runtime.spawn(async move {
run_listener(owner, endpoint_id, name, listener, sender, accepting_events).await;
});
Ok(Self { task })
}
pub(super) fn abort(&mut self) {
self.task.abort();
}
}
impl Drop for TcpEndpointDriver {
fn drop(&mut self) {
self.abort();
}
}
pub(super) struct TcpStreamDriver {
commands: mpsc::Sender<TcpStreamCommand>,
task: JoinHandle<()>,
}
impl TcpStreamDriver {
#[allow(clippy::too_many_arguments)]
pub(super) fn start(
owner: ServiceKey,
stream_id: ResourceId,
stream: TcpStream,
read_bytes: usize,
command_capacity: usize,
async_runtime: &Handle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
) -> Self {
let (commands, command_receiver) = mpsc::channel(command_capacity);
let task = async_runtime.spawn(async move {
run_stream(
owner,
stream_id,
stream,
read_bytes,
command_receiver,
sender,
accepting_events,
)
.await;
});
Self { commands, task }
}
pub(super) fn try_send(
&self,
command: TcpStreamCommand,
) -> Result<(), mpsc::error::TrySendError<TcpStreamCommand>> {
self.commands.try_send(command)
}
}
impl Drop for TcpStreamDriver {
fn drop(&mut self) {
// Aborting the task drops both owned socket halves. The session owner
// removes the resource identity separately and in deterministic order.
self.task.abort();
}
}
pub(super) enum TcpStreamCommand {
Write(Vec<u8>),
Close,
Pause,
Resume,
}
fn bind_listener(
config: &ResidentTcpListenerConfig,
async_runtime: &Handle,
) -> Result<TcpListener, TcpDriverError> {
// TcpSocket gives the platform an explicit backlog and SO_REUSEADDR, which
// makes an immediate service restart able to reclaim its stable address
// after existing connections are closed.
let _runtime_context = async_runtime.enter();
let socket = if config.bind.is_ipv4() {
TcpSocket::new_v4()
} else {
TcpSocket::new_v6()
}
.map_err(|source| listener_error(config, "create socket for", source))?;
socket
.set_reuseaddr(true)
.map_err(|source| listener_error(config, "configure", source))?;
socket
.bind(config.bind)
.map_err(|source| listener_error(config, "bind", source))?;
socket
.listen(1024)
.map_err(|source| listener_error(config, "listen on", source))
}
async fn run_listener(
owner: ServiceKey,
endpoint_id: ResourceId,
name: String,
listener: TcpListener,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
) {
while accepting_events.load(Ordering::Acquire) {
match listener.accept().await {
Ok((stream, peer)) => {
if let Err(error) = stream.set_nodelay(true) {
tracing::warn!(
service = %owner,
endpoint = %name,
%peer,
%error,
"resident TCP connection rejected because TCP_NODELAY could not be set"
);
continue;
}
if !send_stream_event(
&sender,
&accepting_events,
SupervisorCommand::TcpAccepted {
endpoint_id,
stream,
peer,
},
)
.await
{
return;
}
}
Err(error) => {
if !accepting_events.load(Ordering::Acquire) {
return;
}
tracing::warn!(
service = %owner,
endpoint = %name,
%error,
"resident TCP accept failed; listener will retry"
);
// Persistent descriptor exhaustion must not turn into a hot
// retry loop. A healthy listener resumes after the backoff.
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
}
async fn run_stream(
owner: ServiceKey,
stream_id: ResourceId,
stream: TcpStream,
read_bytes: usize,
mut commands: mpsc::Receiver<TcpStreamCommand>,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
) {
let (mut reader, mut writer) = stream.into_split();
let mut buffer = vec![0_u8; read_bytes];
let mut read_paused = false;
let mut peer_half_closed = false;
loop {
if !accepting_events.load(Ordering::Acquire) {
return;
}
if read_paused || peer_half_closed {
let Some(command) = commands.recv().await else {
return;
};
if let Some(reason) = apply_stream_command(command, &mut writer, &mut read_paused).await
{
let _ = send_stream_terminal(&sender, &accepting_events, stream_id, reason).await;
return;
}
continue;
}
tokio::select! {
biased;
command = commands.recv() => {
let Some(command) = command else {
return;
};
if let Some(reason) =
apply_stream_command(command, &mut writer, &mut read_paused).await
{
let _ =
send_stream_terminal(&sender, &accepting_events, stream_id, reason).await;
return;
}
}
read = reader.read(&mut buffer) => {
match read {
Ok(0) => {
peer_half_closed = true;
if !send_stream_event(
&sender,
&accepting_events,
SupervisorCommand::StreamHalfClosed { stream_id },
)
.await
{
return;
}
}
Ok(read) => {
if !send_stream_event(
&sender,
&accepting_events,
SupervisorCommand::StreamData {
stream_id,
bytes: buffer[..read].to_vec(),
},
)
.await
{
return;
}
}
Err(error) => {
tracing::debug!(
service = %owner,
resource_id = stream_id.get(),
%error,
"resident TCP read failed"
);
let _ = send_stream_terminal(
&sender,
&accepting_events,
stream_id,
StreamCloseReason::TransportError,
)
.await;
return;
}
}
}
}
}
}
async fn apply_stream_command(
command: TcpStreamCommand,
writer: &mut tokio::net::tcp::OwnedWriteHalf,
read_paused: &mut bool,
) -> Option<StreamCloseReason> {
match command {
TcpStreamCommand::Write(bytes) => writer
.write_all(&bytes)
.await
.err()
.map(|_| StreamCloseReason::TransportError),
TcpStreamCommand::Close => {
let _ = writer.shutdown().await;
Some(StreamCloseReason::HostClosed)
}
TcpStreamCommand::Pause => {
*read_paused = true;
None
}
TcpStreamCommand::Resume => {
*read_paused = false;
None
}
}
}
async fn send_stream_terminal(
sender: &SyncSender<SupervisorCommand>,
accepting_events: &AtomicBool,
stream_id: ResourceId,
reason: StreamCloseReason,
) -> bool {
send_stream_event(
sender,
accepting_events,
SupervisorCommand::StreamClosed { stream_id, reason },
)
.await
}
/// Sends a non-lossy stream fact through the bounded supervisor queue.
///
/// Unlike timer ticks, TCP chunks cannot be dropped. Waiting here naturally
/// stops the connection task from reading more bytes and propagates
/// backpressure into the kernel receive buffer. Shutdown flips
/// `accepting_events`, which cancels the retry without blocking Actor drain.
async fn send_stream_event(
sender: &SyncSender<SupervisorCommand>,
accepting_events: &AtomicBool,
mut command: SupervisorCommand,
) -> bool {
while accepting_events.load(Ordering::Acquire) {
match sender.try_send(command) {
Ok(()) => return true,
Err(TrySendError::Full(returned)) => {
command = returned;
tokio::time::sleep(Duration::from_millis(1)).await;
}
Err(TrySendError::Disconnected(_)) => return false,
}
}
false
}
fn listener_error(
config: &ResidentTcpListenerConfig,
action: &'static str,
source: io::Error,
) -> TcpDriverError {
TcpDriverError::Listener {
name: config.name.clone(),
bind: config.bind,
action,
source,
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Embedded Solid management UI served by the control-plane listener.
//!
//! The router is empty in normal Debug builds because Vite owns Web assets
//! during development. Release builds embed Cargo `OUT_DIR/web`; requests only
//! resolve exact files, so an unknown URL cannot accidentally receive the Host
//! document as an SPA fallback.
use axum::Router;
#[cfg(wasmeld_embedded_web)]
use axum::{
body::{Body, Bytes},
extract::Path,
http::{StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
#[cfg(wasmeld_embedded_web)]
use include_dir::{Dir, include_dir};
#[cfg(wasmeld_embedded_web)]
static WEB_ASSETS: Dir<'static> = include_dir!("$OUT_DIR/web");
pub(crate) fn router() -> Router {
#[cfg(wasmeld_embedded_web)]
{
Router::new()
.route("/", get(index))
.route("/{*path}", get(asset))
}
#[cfg(not(wasmeld_embedded_web))]
{
Router::new()
}
}
#[cfg(wasmeld_embedded_web)]
async fn index() -> Response {
response("index.html")
}
#[cfg(wasmeld_embedded_web)]
async fn asset(Path(path): Path<String>) -> Response {
response(&path)
}
#[cfg(wasmeld_embedded_web)]
fn response(path: &str) -> Response {
// The wildcard route is public input. Reject non-canonical paths before
// looking inside the compile-time directory, even though `include_dir`
// itself cannot access the host filesystem at runtime.
if !valid_asset_path(path) {
return StatusCode::NOT_FOUND.into_response();
}
let Some(file) = WEB_ASSETS.get_file(path) else {
return StatusCode::NOT_FOUND.into_response();
};
let content_type = mime_guess::from_path(path)
.first_or_octet_stream()
.essence_str()
.to_owned();
let cache_control = if path.starts_with("assets/") {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, cache_control)
.header("x-content-type-options", "nosniff");
if path == "sw.js" {
builder = builder.header("service-worker-allowed", "/");
}
builder
.body(Body::from(Bytes::from_static(file.contents())))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
#[cfg(wasmeld_embedded_web)]
fn valid_asset_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& !path.contains('\\')
&& path
.split('/')
.all(|segment| !matches!(segment, "" | "." | ".."))
}
#[cfg(all(test, wasmeld_embedded_web))]
mod tests {
use super::*;
#[test]
fn embeds_both_document_boundaries_and_pwa_assets() {
for path in ["index.html", "page.html", "manifest.webmanifest", "sw.js"] {
assert!(WEB_ASSETS.get_file(path).is_some(), "missing {path}");
}
}
#[test]
fn rejects_non_canonical_asset_paths() {
assert!(!valid_asset_path("../index.html"));
assert!(!valid_asset_path("assets//app.js"));
assert!(!valid_asset_path(r"assets\app.js"));
}
}
+245 -1
View File
@@ -1,9 +1,12 @@
use std::{ use std::{
collections::BTreeMap,
fs, fs,
io::Cursor, io::Cursor,
net::{SocketAddr, TcpListener as StdTcpListener},
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
sync::{Arc, OnceLock}, sync::{Arc, OnceLock},
time::Duration,
}; };
use axum::{ use axum::{
@@ -14,8 +17,15 @@ use axum::{
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde_json::{Value, json}; use serde_json::{Value, json};
use tempfile::TempDir; use tempfile::TempDir;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
use tower::ServiceExt; use tower::ServiceExt;
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app}; use wasmeld_console::{
Console, ConsoleConfig, NetworkScope, ResidentPolicy, ResidentServiceConfig,
ResidentTcpListenerConfig, ResidentTimerConfig, app, gateway_app,
};
use wasmeld_package::{ use wasmeld_package::{
module::{ModuleLock, sync_dependencies}, module::{ModuleLock, sync_dependencies},
wit_package::build_wit_package, wit_package::build_wit_package,
@@ -90,6 +100,192 @@ async fn manages_a_resident_component_over_http() {
assert!(events["events"].as_array().unwrap().len() >= 5); assert!(events["events"].as_array().unwrap().len() >= 5);
} }
#[tokio::test]
async fn drives_resident_timers_across_service_and_runtime_restarts() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let mut resident_services = BTreeMap::new();
resident_services.insert(
"resident-probe".to_owned(),
ResidentServiceConfig {
timers: vec![ResidentTimerConfig {
name: "heartbeat".to_owned(),
initial_delay_ms: 5,
interval_ms: None,
}],
..ResidentServiceConfig::default()
},
);
let console = Arc::new(
Console::new(ConsoleConfig {
artifact_dir: artifact_dir.path().to_path_buf(),
wit_registry_dir: artifact_dir.path().join("wit-packages"),
database_path: artifact_dir.path().join("console.db"),
resident_services,
..ConsoleConfig::default()
})
.await
.expect("console should start"),
);
let application = app(console, Vec::new());
let component = fs::read(component_artifact("resident_probe_component.wasm"))
.expect("resident probe component should be readable");
let response = application
.clone()
.oneshot(package_request_with_world(
"resident-probe",
"0.1.0",
"component:resident-probe/resident-probe-component@0.1.0",
&component,
))
.await
.expect("resident package registration should complete");
assert_eq!(response.status(), StatusCode::CREATED);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/resident-probe/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("resident deployment activation should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(
wait_for_resident_count(&application).await > 0,
"configured timer should reach the resident Component"
);
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/stop"))
.await
.expect("resident service stop should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/restart"))
.await
.expect("resident service restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(
wait_for_resident_count(&application).await > 0,
"service restart should create a new timer session"
);
let response = application
.clone()
.oneshot(empty_post("/api/v1/runtime/restart"))
.await
.expect("Runtime restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(
wait_for_resident_count(&application).await > 0,
"deployment restore should recreate the timer supervisor"
);
}
#[tokio::test]
async fn drives_tcp_streams_across_service_and_runtime_restarts() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let occupied_listener =
StdTcpListener::bind("127.0.0.1:0").expect("occupied test listener should bind");
let address = occupied_listener
.local_addr()
.expect("occupied test listener should have an address");
let mut resident_services = BTreeMap::new();
resident_services.insert(
"resident-probe".to_owned(),
ResidentServiceConfig {
policy: ResidentPolicy {
tcp_listen: NetworkScope::Loopback,
..ResidentPolicy::default()
},
tcp_listeners: vec![ResidentTcpListenerConfig {
name: "echo".to_owned(),
bind: address,
}],
..ResidentServiceConfig::default()
},
);
let console = Arc::new(
Console::new(ConsoleConfig {
artifact_dir: artifact_dir.path().to_path_buf(),
wit_registry_dir: artifact_dir.path().join("wit-packages"),
database_path: artifact_dir.path().join("console.db"),
resident_services,
..ConsoleConfig::default()
})
.await
.expect("console should start"),
);
let application = app(console, Vec::new());
let component = fs::read(component_artifact("resident_probe_component.wasm"))
.expect("resident probe component should be readable");
let response = application
.clone()
.oneshot(package_request_with_world(
"resident-probe",
"0.1.0",
"component:resident-probe/resident-probe-component@0.1.0",
&component,
))
.await
.expect("resident package registration should complete");
assert_eq!(response.status(), StatusCode::CREATED);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/resident-probe/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("conflicting resident deployment activation should complete");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
// Driver startup failure must stop the Actor and leave deployment state
// untouched. Once the external conflict is gone, the same revision can
// start normally without repairing Runtime state manually.
drop(occupied_listener);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/resident-probe/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("resident deployment activation should complete");
assert_eq!(response.status(), StatusCode::OK);
let large_payload = vec![0xA5; 2 * 64 * 1024 + 17];
assert_tcp_echo(address, &large_payload).await;
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/stop"))
.await
.expect("resident service stop should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/restart"))
.await
.expect("resident service restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_tcp_echo(address, b"service restart").await;
let response = application
.clone()
.oneshot(empty_post("/api/v1/runtime/restart"))
.await
.expect("Runtime restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_tcp_echo(address, b"runtime restart").await;
}
#[tokio::test] #[tokio::test]
async fn reports_exact_component_host_capabilities() { async fn reports_exact_component_host_capabilities() {
let artifact_dir = TempDir::new().expect("temporary artifact directory"); let artifact_dir = TempDir::new().expect("temporary artifact directory");
@@ -999,6 +1195,53 @@ async fn invoke_service(application: &Router, id: &str, revision: &str, input: &
.unwrap() .unwrap()
} }
async fn wait_for_resident_count(application: &Router) -> u64 {
for _ in 0..100 {
let output = invoke_service(application, "resident-probe", "0.1.0", &[]).await;
let count = u64::from_le_bytes(
output
.try_into()
.expect("resident probe response should be a u64"),
);
if count > 0 {
return count;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
0
}
async fn assert_tcp_echo(address: SocketAddr, payload: &[u8]) {
let mut stream = connect_tcp(address).await;
stream
.write_all(payload)
.await
.expect("TCP request should be written");
let mut echoed = vec![0_u8; payload.len()];
tokio::time::timeout(Duration::from_secs(2), stream.read_exact(&mut echoed))
.await
.expect("resident TCP echo should not time out")
.expect("resident TCP echo should be readable");
assert_eq!(echoed, payload);
}
async fn connect_tcp(address: SocketAddr) -> TcpStream {
let mut last_error = None;
for _ in 0..100 {
match TcpStream::connect(address).await {
Ok(stream) => return stream,
Err(error) => {
last_error = Some(error);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
panic!(
"resident TCP listener {address} did not accept connections: {}",
last_error.expect("at least one connection should have been attempted")
);
}
fn echo_component() -> PathBuf { fn echo_component() -> PathBuf {
component_artifact("echo_component.wasm") component_artifact("echo_component.wasm")
} }
@@ -1010,6 +1253,7 @@ fn component_artifact(name: &str) -> PathBuf {
"components/counter/Cargo.toml", "components/counter/Cargo.toml",
"components/clock-probe/Cargo.toml", "components/clock-probe/Cargo.toml",
"components/kv-probe/Cargo.toml", "components/kv-probe/Cargo.toml",
"components/resident-probe/Cargo.toml",
] { ] {
let status = Command::new("rustup") let status = Command::new("rustup")
.args([ .args([
@@ -1,6 +1,5 @@
/node_modules/ /node_modules/
/dist/ /dist/
/.tanstack/
*.tsbuildinfo *.tsbuildinfo
.env* .env*
!.env.example !.env.example
+8
View File
@@ -0,0 +1,8 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
+12
View File
@@ -0,0 +1,12 @@
{
"plugins": ["typescript"],
"categories": {
"correctness": "error",
"suspicious": "warn"
},
"rules": {
"typescript/no-explicit-any": "error",
"no-console": "warn"
},
"ignorePatterns": ["dist/**", "node_modules/**"]
}
+192
View File
@@ -0,0 +1,192 @@
# Wasmeld Console Web
管理面使用 Solid 和 Tailwind CSS v4。它不是传统单文档 SPA,而是由一个常驻 Host
文档和一个可替换 Page iframe 组成:
```text
index.html / src/host
├── 导航、搜索、弹窗、通知
├── 管理 API 轮询和共享状态
└── page.html?view=<view>&instance=<n>
└── src/pages/<view>
```
普通页面切换时,Host 先发送 `dispose`,再销毁 iframe 并创建新的文档。页面所属的
Solid reactive owner、事件监听器、第三方 UI 库和页面局部缓存会随文档一起释放,避免
长期导航后把所有页面资源都留在同一个 JavaScript realm 中。
页面 ID、标题、导航图标、保活偏好和懒加载入口统一声明在
`src/pages/registry.ts``PAGE_DEFINITIONS`。新增页面时创建
`src/pages/<id>/index.tsx` 并添加一条注册记录即可,不需要分别维护路由、导航和加载映射。
一级导航页面默认在对应注册记录中设置 `keepAlive: true`。它们数量固定,切换时保留
DOM、JavaScript realm、Solid 状态、表单和滚动位置。未来的详情页、临时编辑页等短期
页面应设置 `keepAlive: false`,离开后立即销毁:
- 离开时发送 `deactivate`,页面应暂停轮询、媒体、动画或其它后台工作。
- 返回时发送 `activate`,恢复页面任务,iframe 的 `instance``timeOrigin` 不变。
- 页面连续隐藏 30 分钟后失效,Host 发送 `dispose` 并销毁 iframe;再次访问会创建新文档。
- 失效时间从 `deactivate` 开始计算,页面在前台显示的时间不计入;到期前返回会重置计时。
- 关闭 Host 或离开非保活页面时同样发送 `dispose`,随后销毁文档。
这里不使用按页面数量淘汰的 LRU。打开另一个一级页面不会造成先前页面被立即释放,
同时长期不再使用的隐藏页面最终仍会归还内存。
Page SDK 通过 `PageProps.lifecycle` 提供框架无关的 `onShow``onHide`
`onUnload` 事件。它们分别对应 Host 协议内部的 `activate``deactivate`
`dispose`,重复消息会被过滤;浏览器 `pagehide` 还会作为 unload 兜底。每次订阅都会
返回取消函数。如果懒加载组件订阅时页面已经显示或隐藏,`onShow``onHide` 会立即
重放当前事件,避免错过首次初始化;通用的 `lifecycle.on(type, listener)` 只监听未来
变化。`PageProps.active` 继续用于 Solid 响应式渲染和 effect,页面自身不应通过
`display: none` 判断状态,因为 Host 可能改变具体的隐藏实现。
```tsx
export default function StreamPage(props: PageProps) {
onCleanup(props.lifecycle.onShow(refreshData));
onCleanup(props.lifecycle.onHide(pauseStream));
onCleanup(props.lifecycle.onUnload(closeStream));
createEffect(() => {
if (props.active()) {
resumeStream();
} else {
pauseStream();
}
});
// ...
}
```
`onShow` 在首次显示和保活后再次显示时触发,适合主动刷新可能已经过期的数据。调用记录
页面使用该事件请求 Host 刷新 Runtime 快照。`onUnload` 最多触发一次;收到该事件后
不得再创建定时器、连接或其它长期资源。浏览器销毁文档时不会等待异步任务,因此
`onUnload` 只应用于同步清理;必须发送的少量遥测数据应使用 `sendBeacon`。旧的
`wasmeld:activate`
`wasmeld:deactivate``wasmeld:dispose``wasmeld:lifecycle` DOM 事件暂时保留,
新页面应使用类型化 SDK。
iframe 是资源生命周期边界,不是安全边界。Host 和 Page 都是 Wasmeld 自己构建并同源
发布的可信代码;Wasm 服务的安全边界仍然在后端 Wasmtime Sandbox 和 Host Capability
Registry 中。
## 目录
```text
src/host/ 常驻 Host Shell、iframe 隐藏超时管理和 Host-owned dialogs
src/components/
feedback/ 空状态等反馈组合
layout/ PageHeading 等页面结构
services/ ServiceTable、StatusBadge 等服务领域组件
ui/ Button、Input、Select、Textarea、Card 等无业务基础组件
src/lib/ API client、领域模型和纯函数
src/primitives/ Solid reactive primitivesSolid 不使用 React Hooks 约定
src/pages/ 集中式页面注册表;每个 iframe 页面一个目录和独立懒加载 chunk
src/sdk/ Host/Page postMessage 协议与两侧 client
src/styles/ Tailwind v4 theme、文档基础规则和全局 keyframes
```
`components/ui` 只能依赖通用样式和基础函数,不得引用 Runtime、Service、WIT 或具体页面
状态。领域组件可以组合 UI 基础组件,但 UI 基础组件不能反向依赖领域目录。
UI 基础组件采用与 shadcn 相同的源码内样式模式:
- 组件基础样式和可枚举变体使用 Tailwind class 与 `class-variance-authority` 声明。
- 调用者的 `class` 通过 `cn()` 合并;`clsx` 处理条件值,`tailwind-merge` 解决冲突。
- Button、Input、Select、Textarea、Card、Table 等组件暴露类型化的变体属性,不依赖
`.btn``.select` 之类的全局语义 class。
- `src/styles/app.css` 不定义组件层,只保留主题 token、文档基础规则和无法内联的全局
keyframes。
业务特有的结构留在 `feedback``layout``services` 或页面目录中,并组合 UI primitive。
导航项等只在一个业务上下文出现的控件可以使用就地 Tailwind class,不需要为了形式统一
强行包装成通用组件。
`src/sdk/protocol.ts` 中的 `SDK_VERSION` 是文档间协议版本。新增可选消息可以保持原版本;
删除字段、改变字段语义或产生不兼容状态时必须升级版本,并让两侧同时发布。消息接收端
同时检查 origin、source、channel 和 version,不接收任意窗口的控制命令。
Host 会继续向隐藏的保活页面发送只读状态快照,但拒绝其管理命令。这样页面可以在恢复时
立即显示最新 Runtime 状态,同时停用后的定时器不能意外触发注册、启停或 Deployment
操作。
## 浏览器 Tab 状态
同源普通 Tab 和已安装 PWA 窗口通过 `BroadcastChannel` 共享 Host 全局状态。状态按
所有权分为三层:
| 状态 | 跨 Tab | Host 到 Page | 生命周期 |
| ------------------------------------------ | -------- | --------------------- | --------------- |
| Runtime 快照、连接、API 地址、控制操作状态 | 始终共享 | 分发给已存在的 iframe | Host Tab |
| 当前路由、搜索、弹窗、toast、iframe 保活集 | 不共享 | 当前 Tab 自己管理 | Browser Tab |
| 页面筛选、表单、滚动位置、页面资源 | 不共享 | 不进入 Host | iframe document |
`src/host/tab-sync.ts` 使用独立版本的 Host Tab 协议:
- 每个 Host Tab 有随机 `sender` 和单调递增 `sequence`
- 新 Tab 发送 `hello`,已打开 Tab 立即返回当前 Host 全局状态。
- 所有 Host Tab 都可以发布,不依赖可能失效的 leader。
- 接收方拒绝自身消息、旧序列和字段不合法的消息。
- 应用远端状态时抑制本地广播 effect,避免 Tab 间回声循环。
- 浏览器不支持 `BroadcastChannel` 时退化为单 Tab,不影响页面和 API 操作。
Host 收到本地或远端全局状态后,只遍历当前 Tab 的 FrameCache。活动 iframe 和隐藏的
keep-alive iframe 会收到最新快照;从未打开或已被销毁的 Page 没有同步目标,创建并
发送 `ready` 后才取得当时的最新状态。Page 私有信号永远不会上传到 Host Tab channel。
## 开发
先在仓库根目录启动 Rust 后端:
```bash
cargo +stable run -p wasmeld-console
```
再启动 Web 开发服务器:
```bash
cd crates/wasmeld-console/web
npm ci
npm run dev
```
打开 `http://127.0.0.1:3000`。Vite 开发环境默认连接
`http://127.0.0.1:8080`;设置页可以覆盖该地址。Debug Cargo 构建不会调用 npm
因此 Rust 与 Web 的增量开发互不阻塞。
常用检查:
```bash
npm run format:check
npm run lint
npm test
npm audit --audit-level=high
```
## 发布与嵌入
Release 构建由 `../build.rs` 自动执行:
```bash
cargo +stable build --release -p wasmeld-console
```
构建脚本使用 `npm ci` 严格读取 `package-lock.json`,将 Vite 输出写入 Cargo
`OUT_DIR`,再由 Rust `include_dir!` 嵌入可执行文件。发布机器必须提供 Node.js 22.13+
和 npm。可以用 `NPM=/absolute/path/to/npm` 指定 npm;只有需要调试嵌入行为时,才在
非 Release 构建中设置 `WASMELD_BUILD_WEB=1`
Release 管理 Listener 同时服务 `/``/page.html`、静态资源、PWA manifest 和
`/sw.js`。资源路径必须精确存在,不做 SPA fallback。带内容哈希的 `assets/` 使用长期
immutable 缓存,其它入口使用 `no-cache`
## PWA 边界
一个 Host-owned service worker 管理整个应用:
- 构建时预缓存 Host、Page 和带哈希静态资源。
- 文档请求使用 network-first,离线时分别回退到 `index.html``page.html`
- `/api/*``/healthz` 永远走网络,不缓存运行状态或管理操作。
- 新 worker 激活时删除旧的 `wasmeld-*` 缓存。
PWA 只提供安装和静态壳离线能力。没有后端连接时页面会显示离线状态,不会用旧 API
响应伪造 Runtime 状态。
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#173f35" />
<meta name="description" content="WebAssembly Component Runtime 管理控制台" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Wasmeld Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/host/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "wasmeld-console-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && tsc --noEmit",
"preview": "vite preview",
"test": "npm run build && node --experimental-strip-types --test tests/*.test.mjs",
"lint": "oxlint . --deny-warnings",
"format": "oxfmt .",
"format:check": "oxfmt --check ."
},
"dependencies": {
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-solid": "1.27.0",
"solid-js": "1.9.14",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@types/node": "24.10.1",
"oxfmt": "0.61.0",
"oxlint": "1.76.0",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.1.5",
"vite-plugin-solid": "2.11.14"
},
"engines": {
"node": ">=22.13.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN" class="frame-root">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f4f7f6" />
<title>Wasmeld Console Page</title>
</head>
<body class="frame-document">
<div id="app"></div>
<script type="module" src="/src/pages/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="88" fill="#173f35"/>
<path d="M104 132h70l36 196 46-143h52l46 143 36-196h70l-69 248h-67l-42-128-42 128h-67z" fill="#e5f4ef"/>
<rect x="104" y="98" width="304" height="18" rx="9" fill="#45b68d"/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

@@ -0,0 +1,18 @@
{
"name": "Wasmeld Console",
"short_name": "Wasmeld",
"description": "WebAssembly Component Runtime management console",
"theme_color": "#173f35",
"background_color": "#f3f6f5",
"display": "standalone",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import type { LucideProps } from "lucide-solid";
export function EmptyState(props: { icon: Component<LucideProps>; title: string; detail: string }) {
const Icon = props.icon;
return (
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted">
<Icon size={22} />
<strong class="text-sm text-ink">{props.title}</strong>
<span class="text-xs">{props.detail}</span>
</div>
);
}
@@ -0,0 +1,19 @@
import type { JSXElement } from "solid-js";
export function PageHeading(props: {
eyebrow: string;
title: string;
description: string;
actions?: JSXElement;
}) {
return (
<header class="mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
<span class="text-[11px] font-bold uppercase text-cyan-strong">{props.eyebrow}</span>
<h1 class="mt-1 text-2xl font-bold text-ink">{props.title}</h1>
<p class="mt-1 max-w-3xl text-sm text-muted">{props.description}</p>
</div>
{props.actions && <div class="flex shrink-0 items-center gap-2">{props.actions}</div>}
</header>
);
}
@@ -0,0 +1,18 @@
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export type PageProps = JSX.HTMLAttributes<HTMLElement>;
/** Standard constrained content area rendered inside a Page iframe. */
export function Page(props: PageProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<main
{...rest}
data-slot="page"
class={cn("mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7", local.class)}
>
{local.children}
</main>
);
}
@@ -0,0 +1,201 @@
import {
Box,
Braces,
CircleStop,
Play,
RadioTower,
RefreshCw,
RotateCcw,
Search,
SquareTerminal,
} from "lucide-solid";
import { For, Show } from "solid-js";
import { serviceKey, type Service } from "../../lib/model";
import type { HostSharedState, PageCommand } from "../../sdk/protocol";
import { EmptyState } from "../feedback/empty-state";
import { IconButton } from "../ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
import { StatusBadge } from "./status-badge";
export function ServiceTable(props: {
services: Service[];
state: HostSharedState;
command: (command: PageCommand) => void;
compact?: boolean;
}) {
return (
<Show
when={props.services.length > 0}
fallback={
<EmptyState icon={Search} title="没有匹配的服务" detail="调整搜索关键词或状态筛选。" />
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<Show when={!props.compact}>
<TableHead></TableHead>
<TableHead>Host </TableHead>
</Show>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>
<span class="sr-only"></span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={props.services}>
{(service) => {
const key = serviceKey(service);
return (
<TableRow>
<TableCell>
<div class="flex min-w-48 items-center gap-3">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
<Box size={15} />
</span>
<span class="min-w-0">
<span class="flex items-center gap-2">
<strong class="truncate">{service.id}</strong>
<Show when={service.active}>
<span class="inline-flex items-center gap-1 rounded-sm bg-cyan-soft px-1.5 py-0.5 text-[10px] font-semibold text-cyan-strong">
<RadioTower size={10} />
</span>
</Show>
</span>
<small class="mt-0.5 block text-xs text-muted">{service.revision}</small>
</span>
</div>
</TableCell>
<TableCell>
<StatusBadge status={service.status} />
</TableCell>
<Show when={!props.compact}>
<TableCell
class="max-w-48 truncate font-mono text-xs text-muted"
title={service.artifact}
>
{service.artifact}
</TableCell>
<TableCell>
<Show
when={service.capabilities.length > 0}
fallback={<span class="text-xs text-muted"></span>}
>
<span
class="inline-flex max-w-56 items-center gap-1.5 text-xs text-muted"
title={service.capabilities
.map((capability) => capability.interface)
.join("\n")}
>
<Braces size={12} />
<span class="truncate">
{service.capabilities[0]?.package}/{service.capabilities[0]?.name}
</span>
<Show when={service.capabilities.length > 1}>
<small>+{service.capabilities.length - 1}</small>
</Show>
</span>
</Show>
</TableCell>
</Show>
<TableCell class="font-mono text-xs">
{service.calls.toLocaleString("zh-CN")}
</TableCell>
<TableCell class="font-mono text-xs">
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
</TableCell>
<TableCell>
<div class="flex items-center justify-end gap-1">
<IconButton
size="small"
aria-label="设为对外版本"
title={service.active ? "当前对外版本" : "设为对外版本"}
disabled={
service.active ||
props.state.snapshot?.runtime.status !== "running" ||
props.state.deploymentAction !== null
}
onClick={() => props.command({ type: "open-activate", serviceKey: key })}
>
{props.state.deploymentAction === key ? (
<RefreshCw class="animate-spin" size={15} />
) : (
<RadioTower size={15} />
)}
</IconButton>
<Show
when={service.status === "running"}
fallback={
<IconButton
size="small"
aria-label={`启动 ${service.id}`}
title="启动"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "start",
})
}
>
<Play size={15} />
</IconButton>
}
>
<IconButton
size="small"
aria-label={`调用 ${service.id}`}
title="测试调用"
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
>
<SquareTerminal size={15} />
</IconButton>
<IconButton
size="small"
aria-label={`重启 ${service.id}`}
title="重启"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "restart",
})
}
>
<RotateCcw size={15} />
</IconButton>
<IconButton
size="small"
tone="danger"
aria-label={`停止 ${service.id}`}
title="停止"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "stop",
})
}
>
<CircleStop size={15} />
</IconButton>
</Show>
</div>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</Show>
);
}
@@ -0,0 +1,23 @@
import { cva } from "class-variance-authority";
import { STATUS_META, type ServiceStatus } from "../../lib/model";
const statusBadgeVariants = cva(
"inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold before:size-1.5 before:rounded-full before:bg-current before:content-['']",
{
variants: {
status: {
running: "text-brand",
stopped: "text-muted",
faulted: "text-coral-strong",
},
},
},
);
export function StatusBadge(props: { status: ServiceStatus }) {
return (
<span data-slot="service-status" class={statusBadgeVariants({ status: props.status })}>
{STATUS_META[props.status].label}
</span>
);
}
@@ -0,0 +1,113 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md border text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45",
{
variants: {
variant: {
primary: "border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong",
secondary: "border-line bg-white text-ink hover:bg-canvas",
danger: "border-coral-strong bg-white text-coral-strong hover:bg-coral-soft",
},
size: {
default: "min-h-10 px-4 py-2",
small: "min-h-9 px-4 py-1.5",
},
},
defaultVariants: {
variant: "secondary",
size: "default",
},
},
);
export type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
/**
* Standard text or icon-and-text command button.
*
* Size variants define a minimum target height instead of fixing the block
* height, so wrapped labels and enlarged user fonts can grow without clipping.
* The component defaults to `type="button"` so using it inside a form never
* submits accidentally. Set `type="submit"` explicitly for form actions.
*/
export function Button(props: ButtonProps) {
const [local, rest] = splitProps(props, ["variant", "size", "class", "children", "type"]);
return (
<button
{...rest}
data-slot="button"
type={local.type ?? "button"}
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
>
{local.children}
</button>
);
}
export const iconButtonVariants = cva(
"inline-flex shrink-0 items-center justify-center rounded-md border border-line bg-white text-muted transition-colors hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-45",
{
variants: {
tone: {
default: "",
danger: "text-coral-strong",
},
size: {
default: "size-9",
small: "size-8",
},
},
defaultVariants: {
tone: "default",
size: "default",
},
},
);
export type IconButtonProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> &
VariantProps<typeof iconButtonVariants> & {
"aria-label": string;
};
/**
* Square icon-only button for familiar toolbar and row actions.
*
* Callers must provide an accessible `aria-label`; `title` is recommended when
* the icon's meaning benefits from a visible hover explanation.
*/
export function IconButton(props: IconButtonProps) {
const [local, rest] = splitProps(props, ["tone", "size", "class", "children", "type"]);
return (
<button
{...rest}
data-slot="icon-button"
type={local.type ?? "button"}
class={cn(iconButtonVariants({ tone: local.tone, size: local.size }), local.class)}
>
{local.children}
</button>
);
}
export type IconLinkProps = Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "aria-label"> &
Pick<VariantProps<typeof iconButtonVariants>, "size"> & {
"aria-label": string;
};
/** Anchor counterpart to IconButton for downloads and external navigation. */
export function IconLink(props: IconLinkProps) {
const [local, rest] = splitProps(props, ["size", "class", "children"]);
return (
<a
{...rest}
data-slot="icon-link"
class={cn(iconButtonVariants({ size: local.size }), local.class)}
>
{local.children}
</a>
);
}
@@ -0,0 +1,68 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { Dynamic } from "solid-js/web";
import { cn } from "../../lib/cn";
type CardElement = "article" | "aside" | "div" | "section";
export const cardVariants = cva("rounded-md border border-line bg-surface", {
variants: {
padding: {
none: "",
small: "p-4",
medium: "p-5",
},
elevation: {
flat: "",
raised: "shadow-sm",
},
},
defaultVariants: {
padding: "none",
elevation: "flat",
},
});
export type CardProps = JSX.HTMLAttributes<HTMLElement> &
VariantProps<typeof cardVariants> & {
as?: CardElement;
};
/**
* Generic bordered surface.
*
* `as` preserves document semantics at the call site; Card only owns visual
* framing and must not encode page or domain behavior.
*/
export function Card(props: CardProps) {
const [local, rest] = splitProps(props, ["as", "padding", "elevation", "class", "children"]);
return (
<Dynamic
component={local.as ?? "div"}
{...rest}
data-slot="card"
class={cn(cardVariants({ padding: local.padding, elevation: local.elevation }), local.class)}
>
{local.children}
</Dynamic>
);
}
export type CardHeaderProps = JSX.HTMLAttributes<HTMLDivElement>;
/** Standard Card heading/action row. */
export function CardHeader(props: CardHeaderProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<div
{...rest}
data-slot="card-header"
class={cn(
"flex min-h-16 items-center justify-between gap-4 border-b border-line px-5 py-3 [&_h2]:text-base [&_h2]:font-semibold [&_p]:mt-0.5 [&_p]:text-xs [&_p]:text-muted",
local.class,
)}
>
{local.children}
</div>
);
}
@@ -0,0 +1,34 @@
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export type FieldProps = JSX.LabelHTMLAttributes<HTMLLabelElement>;
/**
* Associates a label and form control while keeping field layout consistent.
*
* Keep validation and help text at the call site: those messages are domain
* content, while Field only owns spacing and label typography.
*/
export function Field(props: FieldProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<label {...rest} data-slot="field" class={cn("block", local.class)}>
{local.children}
</label>
);
}
export type FieldLabelProps = JSX.HTMLAttributes<HTMLSpanElement>;
export function FieldLabel(props: FieldLabelProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<span
{...rest}
data-slot="field-label"
class={cn("mb-1.5 block text-xs font-semibold text-muted", local.class)}
>
{local.children}
</span>
);
}
@@ -0,0 +1,33 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const inputVariants = cva(
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
{
variants: {
density: {
default: "min-h-10 py-2",
compact: "min-h-9 py-1.5",
},
},
defaultVariants: {
density: "default",
},
},
);
export type InputProps = JSX.InputHTMLAttributes<HTMLInputElement> &
VariantProps<typeof inputVariants>;
/** Native single-line input with a minimum, rather than fixed, control height. */
export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["density", "class"]);
return (
<input
{...rest}
data-slot="input"
class={cn(inputVariants({ density: local.density }), local.class)}
/>
);
}
@@ -0,0 +1,50 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export function SegmentedControl(props: JSX.HTMLAttributes<HTMLDivElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<div
{...rest}
data-slot="segmented-control"
class={cn("inline-flex rounded-md border border-line bg-canvas p-1", local.class)}
>
{local.children}
</div>
);
}
export const segmentVariants = cva(
"min-h-8 rounded-sm px-3 py-1 text-xs font-semibold text-muted transition-colors",
{
variants: {
active: {
true: "bg-white text-ink shadow-sm",
false: "hover:text-ink",
},
},
defaultVariants: {
active: false,
},
},
);
export type SegmentProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof segmentVariants>;
/** One mutually exclusive option inside a SegmentedControl. */
export function Segment(props: SegmentProps) {
const [local, rest] = splitProps(props, ["active", "class", "children", "type"]);
return (
<button
{...rest}
data-slot="segment"
type={local.type ?? "button"}
aria-pressed={local.active === true}
class={cn(segmentVariants({ active: local.active }), local.class)}
>
{local.children}
</button>
);
}
@@ -0,0 +1,35 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const selectVariants = cva(
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
{
variants: {
density: {
default: "min-h-10 py-2",
compact: "min-h-9 py-1.5",
},
},
defaultVariants: {
density: "default",
},
},
);
export type SelectProps = JSX.SelectHTMLAttributes<HTMLSelectElement> &
VariantProps<typeof selectVariants>;
/** Native select with browser behavior preserved and a content-growable height. */
export function Select(props: SelectProps) {
const [local, rest] = splitProps(props, ["density", "class", "children"]);
return (
<select
{...rest}
data-slot="select"
class={cn(selectVariants({ density: local.density }), local.class)}
>
{local.children}
</select>
);
}
@@ -0,0 +1,82 @@
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export type TableProps = JSX.HTMLAttributes<HTMLTableElement> & {
containerClass?: string;
};
/** Responsive native table with a horizontal overflow boundary. */
export function Table(props: TableProps) {
const [local, rest] = splitProps(props, ["class", "containerClass", "children"]);
return (
<div data-slot="table-container" class={cn("w-full overflow-x-auto", local.containerClass)}>
<table
{...rest}
data-slot="table"
class={cn("w-full min-w-[760px] border-collapse text-left text-sm", local.class)}
>
{local.children}
</table>
</div>
);
}
export function TableHeader(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<thead {...rest} data-slot="table-header" class={cn(local.class)}>
{local.children}
</thead>
);
}
export function TableBody(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<tbody {...rest} data-slot="table-body" class={cn(local.class)}>
{local.children}
</tbody>
);
}
export function TableRow(props: JSX.HTMLAttributes<HTMLTableRowElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<tr
{...rest}
data-slot="table-row"
class={cn("transition-colors hover:bg-[#f8faf9] [&:last-child_td]:border-b-0", local.class)}
>
{local.children}
</tr>
);
}
export function TableHead(props: JSX.ThHTMLAttributes<HTMLTableCellElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<th
{...rest}
data-slot="table-head"
class={cn(
"border-b border-line bg-canvas px-4 py-2.5 text-[11px] font-semibold uppercase text-muted",
local.class,
)}
>
{local.children}
</th>
);
}
export function TableCell(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<td
{...rest}
data-slot="table-cell"
class={cn("border-b border-line px-4 py-3 align-middle", local.class)}
>
{local.children}
</td>
);
}
@@ -0,0 +1,43 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const textareaVariants = cva(
"min-h-36 w-full rounded-md border border-line bg-white px-3 py-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
{
variants: {
resize: {
vertical: "resize-y",
none: "resize-none",
},
typography: {
mono: "font-mono",
sans: "font-sans",
},
},
defaultVariants: {
resize: "vertical",
typography: "mono",
},
},
);
export type TextareaProps = JSX.TextareaHTMLAttributes<HTMLTextAreaElement> &
VariantProps<typeof textareaVariants>;
/** Standard multiline input with explicit resize and typography variants. */
export function Textarea(props: TextareaProps) {
const [local, rest] = splitProps(props, ["resize", "typography", "class", "children"]);
return (
<textarea
{...rest}
data-slot="textarea"
class={cn(
textareaVariants({ resize: local.resize, typography: local.typography }),
local.class,
)}
>
{local.children}
</textarea>
);
}
+577
View File
@@ -0,0 +1,577 @@
import { Boxes, Search, ShieldCheck, WifiOff } from "lucide-solid";
import {
batch,
createEffect,
createMemo,
createSignal,
For,
on,
onCleanup,
onMount,
Show,
} from "solid-js";
import {
activateDeployment,
changeRuntimeState,
changeServiceState,
fetchSnapshot,
getApiBase,
invokeComponent,
publishWitPackage,
registerComponent,
saveApiBase,
type BackendSnapshot,
type InvokeResult,
} from "../lib/api";
import { serviceKey, type ConnectionState, type Service } from "../lib/model";
import { servicesFrom } from "../lib/view-state";
import { isView, PAGE_DEFINITIONS, pageDefinition, type View } from "../pages/registry";
import { Input } from "../components/ui/input";
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
import {
isPageMessage,
type HostGlobalState,
type HostSharedState,
type PageCommand,
type RuntimeAction,
} from "../sdk/protocol";
import { DialogLayer, type DialogState } from "./dialogs";
import { FrameCache, type FrameEntry } from "./frame-cache";
import { createBrowserHostTabSync, type HostTabSync } from "./tab-sync";
const KEEP_ALIVE_INACTIVE_TTL_MS = 30 * 60 * 1000;
export default function HostApp() {
// All backend ownership stays in this persistent document. Page iframes are
// disposable renderers and can only request typed commands through the SDK.
const initial = initialView();
const frameCache = new FrameCache(initial, {
inactiveTtlMs: KEEP_ALIVE_INACTIVE_TTL_MS,
shouldKeepAlive: (view) => pageDefinition(view).keepAlive,
});
const [view, setView] = createSignal(initial);
const [frames, setFrames] = createSignal(frameCache.snapshot());
const [readyViews, setReadyViews] = createSignal<ReadonlySet<View>>(new Set<View>());
const [snapshot, setSnapshot] = createSignal<BackendSnapshot | null>(null);
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
const [apiBase, setApiBase] = createSignal(getApiBase());
const [query, setQuery] = createSignal("");
const [runtimeAction, setRuntimeAction] = createSignal<RuntimeAction | null>(null);
const [serviceAction, setServiceAction] = createSignal<string | null>(null);
const [deploymentAction, setDeploymentAction] = createSignal<string | null>(null);
const [dialog, setDialog] = createSignal<DialogState | null>(null);
const [dialogBusy, setDialogBusy] = createSignal(false);
const [toast, setToast] = createSignal<string | null>(null);
const frameElements = new Map<View, HTMLIFrameElement>();
let tabSync: HostTabSync | null = null;
let applyingSharedHostState = false;
let toastTimer: number | undefined;
let frameExpirationTimer: number | undefined;
let refreshGeneration = 0;
const globalState = createMemo<HostGlobalState>(() => ({
snapshot: snapshot(),
connection: connection(),
apiBase: apiBase(),
runtimeAction: runtimeAction(),
serviceAction: serviceAction(),
deploymentAction: deploymentAction(),
}));
const state = createMemo<HostSharedState>(() => ({
...globalState(),
view: view(),
query: query(),
}));
const activeServices = createMemo(
() => servicesFrom(state()).filter((service) => service.status === "running").length,
);
const frameReady = createMemo(() => readyViews().has(view()));
const title = createMemo(() => pageDefinition(view()).label);
function stateFor(targetView: View): HostSharedState {
return { ...state(), view: targetView };
}
function applySharedHostState(next: HostGlobalState) {
applyingSharedHostState = true;
refreshGeneration += 1;
batch(() => {
setSnapshot(next.snapshot);
setConnection(next.connection);
setApiBase(next.apiBase);
setRuntimeAction(next.runtimeAction);
setServiceAction(next.serviceAction);
setDeploymentAction(next.deploymentAction);
});
applyingSharedHostState = false;
}
async function refresh() {
const generation = ++refreshGeneration;
try {
const next = await fetchSnapshot(apiBase());
if (generation !== refreshGeneration) return;
batch(() => {
setSnapshot(next);
setConnection("online");
});
} catch {
if (generation !== refreshGeneration) return;
batch(() => {
setSnapshot(null);
setConnection("offline");
});
}
}
function notify(message: string) {
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
setToast(message);
toastTimer = window.setTimeout(() => setToast(null), 2800);
}
function navigate(next: View, pushHistory = true) {
if (next === view()) return;
const previous = view();
const transition = frameCache.activate(next);
const removedViews = new Set(transition.removed.map((entry) => entry.view));
// Retained frames are paused before being hidden. Transient and expired
// frames receive dispose before Solid removes their document from the DOM.
if (!removedViews.has(previous)) sendFrameLifecycle(previous, "deactivate");
for (const entry of transition.removed) sendFrameLifecycle(entry.view, "dispose");
setReadyViews((current) => {
const remaining = new Set(current);
for (const removed of removedViews) remaining.delete(removed);
return remaining;
});
setFrames(transition.entries);
setView(next);
setQuery("");
if (readyViews().has(next)) {
sendFrameState(next);
sendFrameLifecycle(next, "activate");
}
if (pushHistory) {
const url = new URL(window.location.href);
url.searchParams.set("view", next);
window.history.pushState({ view: next }, "", url);
}
scheduleFrameExpiration();
}
function findService(key: string): Service | null {
return servicesFrom(state()).find((service) => serviceKey(service) === key) ?? null;
}
async function performRuntimeAction(action: RuntimeAction) {
setRuntimeAction(action);
try {
await changeRuntimeState(apiBase(), action);
await refresh();
notify(
action === "start"
? "Runtime 已启动"
: action === "stop"
? "Runtime 已停止"
: "Runtime 已重启",
);
} catch (error) {
notify(error instanceof Error ? error.message : "Runtime 操作失败");
} finally {
setRuntimeAction(null);
}
}
async function performServiceAction(key: string, action: "start" | "stop" | "restart") {
const service = findService(key);
if (!service) return;
setServiceAction(key);
try {
await changeServiceState(apiBase(), service, action);
await refresh();
notify(
`${service.id} ${action === "stop" ? "已停止" : action === "restart" ? "已重启" : "已启动"}`,
);
} catch (error) {
notify(error instanceof Error ? error.message : "Actor 操作失败");
} finally {
setServiceAction(null);
}
}
async function handleCommand(command: PageCommand) {
switch (command.type) {
case "refresh":
await refresh();
return;
case "navigate":
navigate(command.view);
return;
case "open-register":
setDialog({ type: "register" });
return;
case "open-wit-publish":
setDialog({ type: "wit-publish" });
return;
case "open-invoke": {
const service = findService(command.serviceKey);
if (service) setDialog({ type: "invoke", service });
return;
}
case "open-activate": {
const service = findService(command.serviceKey);
if (!service) return;
const currentRevision =
snapshot()?.deployments.find((item) => item.service_id === service.id)?.active_revision ??
null;
setDialog({ type: "activate", service, currentRevision });
return;
}
case "runtime-action":
await performRuntimeAction(command.action);
return;
case "service-action":
await performServiceAction(command.serviceKey, command.action);
return;
case "save-api-base":
try {
const next = saveApiBase(command.value);
setApiBase(next);
setSnapshot(null);
setConnection("connecting");
await refresh();
notify("API 地址已保存");
} catch (error) {
notify(error instanceof Error ? error.message : "API 地址无效");
}
}
}
const receive = (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || !isPageMessage(event.data)) return;
const sourceView = viewForSource(event.source);
if (!sourceView || event.data.view !== sourceView) return;
if (event.data.type === "ready") {
setReadyViews((current) => new Set(current).add(sourceView));
sendFrameState(sourceView);
sendFrameLifecycle(sourceView, sourceView === view() ? "activate" : "deactivate");
return;
}
// Hidden documents cannot initiate management operations. This matters for
// page-owned timers or libraries that may finish work after deactivation.
if (sourceView !== view()) return;
void handleCommand(event.data.command);
};
function viewForSource(source: MessageEventSource | null): View | null {
for (const [frameView, element] of frameElements) {
if (source === element.contentWindow) return frameView;
}
return null;
}
function sendFrameState(targetView: View) {
const target = frameElements.get(targetView)?.contentWindow;
if (target) sendState(target, stateFor(targetView));
}
function sendFrameLifecycle(targetView: View, phase: "activate" | "deactivate" | "dispose") {
const target = frameElements.get(targetView)?.contentWindow;
if (target) sendLifecycle(target, phase);
}
function scheduleFrameExpiration() {
if (frameExpirationTimer !== undefined) {
window.clearTimeout(frameExpirationTimer);
frameExpirationTimer = undefined;
}
const delay = frameCache.timeUntilExpiration();
if (delay === null) return;
frameExpirationTimer = window.setTimeout(expireInactiveFrames, delay);
}
function expireInactiveFrames() {
frameExpirationTimer = undefined;
const expiration = frameCache.expireInactive();
if (expiration.removed.length > 0) {
const removedViews = new Set(expiration.removed.map((entry) => entry.view));
for (const entry of expiration.removed) sendFrameLifecycle(entry.view, "dispose");
setReadyViews((current) => {
const remaining = new Set(current);
for (const removed of removedViews) remaining.delete(removed);
return remaining;
});
setFrames(expiration.entries);
}
scheduleFrameExpiration();
}
const popState = () => {
const requested = new URLSearchParams(window.location.search).get("view");
navigate(isView(requested) ? requested : "overview", false);
};
onMount(() => {
window.addEventListener("message", receive);
window.addEventListener("popstate", popState);
tabSync = createBrowserHostTabSync({
getState: globalState,
applyState: applySharedHostState,
});
void refresh();
const interval = window.setInterval(() => void refresh(), 5000);
onCleanup(() => window.clearInterval(interval));
});
onCleanup(() => {
window.removeEventListener("message", receive);
window.removeEventListener("popstate", popState);
tabSync?.close();
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
if (frameExpirationTimer !== undefined) window.clearTimeout(frameExpirationTimer);
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
});
createEffect(() => {
state();
const ready = readyViews();
for (const frame of frames()) {
if (ready.has(frame.view)) sendFrameState(frame.view);
}
});
createEffect(
on(
globalState,
(next) => {
if (!applyingSharedHostState) tabSync?.publish(next);
},
{ defer: true },
),
);
async function register(file: File) {
setDialogBusy(true);
try {
const registered = await registerComponent(apiBase(), file);
await refresh();
setDialog(null);
notify(`${registered.id}@${registered.revision} 已注册`);
} finally {
setDialogBusy(false);
}
}
async function publishWit(file: File) {
setDialogBusy(true);
try {
const published = await publishWitPackage(apiBase(), file);
await refresh();
setDialog(null);
notify(`${published.name}@${published.version} 已发布`);
} finally {
setDialogBusy(false);
}
}
async function activate(service: Service) {
setDialogBusy(true);
setDeploymentAction(serviceKey(service));
try {
await activateDeployment(apiBase(), service);
await refresh();
setDialog(null);
notify(`${service.id}@${service.revision} 已设为对外版本`);
} finally {
setDialogBusy(false);
setDeploymentAction(null);
}
}
async function invoke(service: Service, input: Uint8Array): Promise<InvokeResult> {
const result = await invokeComponent(apiBase(), service, input);
await refresh();
return result;
}
return (
<div class="grid h-dvh min-w-0 grid-cols-[minmax(0,1fr)] grid-rows-[auto_56px_minmax(0,1fr)_30px] overflow-hidden bg-canvas lg:grid-cols-[232px_minmax(0,1fr)] lg:grid-rows-[64px_minmax(0,1fr)_30px]">
<aside class="border-line min-w-0 bg-[#16221f] text-white lg:row-span-3 lg:flex lg:min-h-0 lg:flex-col lg:border-r">
<div class="flex h-16 shrink-0 items-center gap-3 px-4 lg:px-5">
<span class="flex size-9 items-center justify-center rounded-md bg-[#e5f4ef] text-brand-strong">
<Boxes size={19} />
</span>
<div>
<strong class="block text-[15px]">Wasmeld</strong>
<span class="block text-[10px] text-[#9ab0aa]">COMPONENT RUNTIME</span>
</div>
</div>
<nav class="flex gap-1 overflow-x-auto px-3 pb-3 lg:block lg:min-h-0 lg:flex-1 lg:space-y-1 lg:overflow-y-auto lg:pb-0">
<For each={PAGE_DEFINITIONS}>
{(item) => {
const Icon = item.icon;
return (
<button
class="flex h-10 shrink-0 items-center gap-3 rounded-md px-3 text-sm text-[#b8c7c3] transition-colors hover:bg-white/7 hover:text-white lg:w-full"
classList={{ "bg-white/10 text-white": view() === item.id }}
type="button"
onClick={() => navigate(item.id)}
>
<Icon size={16} />
{item.label}
</button>
);
}}
</For>
</nav>
<div class="hidden border-t border-white/10 p-4 lg:block">
<div class="flex items-center justify-between text-xs">
<span class="text-[#9ab0aa]"></span>
<span
classList={{
"font-semibold text-[#76d5b4]": connection() === "online",
"font-semibold text-[#ffac93]": connection() === "offline",
"font-semibold text-[#f5cf7a]": connection() === "connecting",
}}
>
{connection() === "online" ? "在线" : connection() === "offline" ? "离线" : "连接中"}
</span>
</div>
<div class="mt-3 flex items-center justify-between text-xs">
<span class="text-[#9ab0aa]"> Actor</span>
<strong>{activeServices()}</strong>
</div>
</div>
</aside>
<header class="border-line col-start-1 flex min-w-0 items-center justify-between gap-3 border-b bg-white px-4 lg:col-start-2 lg:px-6">
<div class="min-w-0">
<span class="hidden text-xs text-muted sm:inline"> / </span>
<strong class="text-sm">{title()}</strong>
</div>
<label class="relative w-full max-w-80">
<Search
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
size={15}
/>
<Input
density="compact"
class="pl-9"
value={query()}
aria-label="搜索"
placeholder="搜索服务、制品或事件"
onInput={(event) => setQuery(event.currentTarget.value)}
/>
</label>
</header>
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
<For each={frames()}>
{(frame) => (
<PageFrame
frame={frame}
active={view() === frame.view}
title={pageDefinition(frame.view).label}
register={(frameView, element) => frameElements.set(frameView, element)}
unregister={(frameView, element) => {
if (frameElements.get(frameView) === element) frameElements.delete(frameView);
}}
/>
)}
</For>
<Show when={!frameReady()}>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-canvas">
<div class="flex flex-col items-center gap-3 text-sm text-muted">
<div class="flex items-center gap-2">
<span class="size-2 animate-pulse rounded-full bg-brand" />
{title()}
</div>
<div
class="h-1 w-40 overflow-hidden rounded-sm bg-line"
role="progressbar"
aria-label={`${title()}加载进度`}
>
<span class="block h-full w-[36%] animate-[loading-progress-swing_1s_ease-in-out_infinite_alternate] rounded-sm bg-brand motion-reduce:translate-x-[89%] motion-reduce:animate-none" />
</div>
</div>
</div>
</Show>
<Show when={connection() === "offline"}>
<div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-md border border-coral-strong/20 bg-coral-soft px-4 py-2 text-xs font-semibold text-coral-strong shadow-lg">
<WifiOff size={14} />
API
</div>
</Show>
</section>
<footer class="border-line col-start-1 flex items-center justify-between border-t bg-white px-4 text-[10px] text-muted lg:col-start-2 lg:px-6">
<span class="inline-flex items-center gap-1.5">
<ShieldCheck size={12} />
{snapshot()?.runtime.status === "running" ? "Sandbox 正常" : "Runtime 已停止"}
</span>
<span>Component Model · WASI P2</span>
</footer>
<Show when={dialog()}>
{(activeDialog) => (
<DialogLayer
dialog={activeDialog()}
busy={dialogBusy()}
onClose={() => !dialogBusy() && setDialog(null)}
onRegister={register}
onPublishWit={publishWit}
onActivate={activate}
onInvoke={invoke}
/>
)}
</Show>
<Show when={toast()}>
{(message) => (
<div class="fixed bottom-12 right-5 z-70 max-w-sm rounded-md bg-[#16221f] px-4 py-3 text-sm text-white shadow-xl">
{message()}
</div>
)}
</Show>
</div>
);
}
function initialView(): View {
const requested = new URLSearchParams(window.location.search).get("view");
return isView(requested) ? requested : "overview";
}
function PageFrame(props: {
frame: FrameEntry;
active: boolean;
title: string;
register: (view: View, element: HTMLIFrameElement) => void;
unregister: (view: View, element: HTMLIFrameElement) => void;
}) {
let element: HTMLIFrameElement | undefined;
onCleanup(() => {
if (element) props.unregister(props.frame.view, element);
});
return (
<iframe
ref={(next) => {
element = next;
props.register(props.frame.view, next);
}}
class="size-full border-0 bg-canvas"
classList={{ hidden: !props.active }}
src={pageUrl(props.frame.view, props.frame.instance)}
title={props.title}
aria-hidden={!props.active}
tabIndex={props.active ? 0 : -1}
/>
);
}
@@ -0,0 +1,368 @@
import {
ArrowRight,
Clock3,
CloudUpload,
FileCode2,
Package,
Play,
RadioTower,
RefreshCw,
ShieldCheck,
SquareTerminal,
X,
} from "lucide-solid";
import { createMemo, createSignal, onCleanup, onMount, Show, type JSXElement } from "solid-js";
import { Button, IconButton } from "../components/ui/button";
import { Field, FieldLabel } from "../components/ui/field";
import { Segment, SegmentedControl } from "../components/ui/segmented-control";
import { Textarea } from "../components/ui/textarea";
import type { InvokeResult } from "../lib/api";
import {
formatInvocationOutput,
invocationInputSize,
parseInvocationInput,
type Service,
} from "../lib/model";
export type DialogState =
| { type: "register" }
| { type: "wit-publish" }
| { type: "invoke"; service: Service }
| { type: "activate"; service: Service; currentRevision: string | null };
export function DialogLayer(props: {
dialog: DialogState;
busy: boolean;
onClose: () => void;
onRegister: (file: File) => Promise<void>;
onPublishWit: (file: File) => Promise<void>;
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
onActivate: (service: Service) => Promise<void>;
}) {
return (
<>
<Show when={props.dialog.type === "register"}>
<UploadDialog
kind="component"
busy={props.busy}
onClose={props.onClose}
onSubmit={props.onRegister}
/>
</Show>
<Show when={props.dialog.type === "wit-publish"}>
<UploadDialog
kind="wit"
busy={props.busy}
onClose={props.onClose}
onSubmit={props.onPublishWit}
/>
</Show>
<Show when={props.dialog.type === "invoke" && "service" in props.dialog}>
<InvokeDialog
service={(props.dialog as Extract<DialogState, { type: "invoke" }>).service}
onClose={props.onClose}
onInvoke={props.onInvoke}
/>
</Show>
<Show when={props.dialog.type === "activate" && "service" in props.dialog}>
<ActivateDialog
dialog={props.dialog as Extract<DialogState, { type: "activate" }>}
busy={props.busy}
onClose={props.onClose}
onActivate={props.onActivate}
/>
</Show>
</>
);
}
function DialogFrame(props: {
title: string;
description: string;
icon: JSXElement;
closeDisabled?: boolean;
onClose: () => void;
children: JSXElement;
footer: JSXElement;
}) {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !props.closeDisabled) props.onClose();
};
onMount(() => window.addEventListener("keydown", onKeyDown));
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
return (
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-[#0d1715]/55 p-3 backdrop-blur-[1px]"
role="presentation"
>
<dialog
open
class="relative m-0 max-h-[calc(100dvh-24px)] w-full max-w-3xl overflow-auto rounded-md border border-line bg-white p-0 shadow-2xl"
aria-label={props.title}
>
<header class="flex items-start justify-between gap-4 border-b border-line px-5 py-4 sm:px-7 sm:py-5">
<div class="flex min-w-0 items-start gap-3">
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
{props.icon}
</span>
<div>
<h2 class="text-lg font-bold sm:text-xl">{props.title}</h2>
<p class="mt-1 text-sm text-muted">{props.description}</p>
</div>
</div>
<IconButton
aria-label="关闭"
title="关闭"
disabled={props.closeDisabled}
onClick={props.onClose}
>
<X size={18} />
</IconButton>
</header>
<div class="px-5 py-5 sm:px-7">{props.children}</div>
<footer class="flex items-center justify-end gap-2 border-t border-line bg-[#fafbfb] px-5 py-4 sm:px-7">
{props.footer}
</footer>
</dialog>
</div>
);
}
function UploadDialog(props: {
kind: "component" | "wit";
busy: boolean;
onClose: () => void;
onSubmit: (file: File) => Promise<void>;
}) {
const [file, setFile] = createSignal<File | null>(null);
const [error, setError] = createSignal("");
const isWit = () => props.kind === "wit";
const title = () => (isWit() ? "发布 WIT 包" : "注册组件包");
async function submit() {
const selected = file();
if (!selected || props.busy) return;
const extension = isWit() ? ".wasm" : ".wasmpkg";
const limit = isWit() ? 4 * 1024 * 1024 : 64 * 1024 * 1024;
if (!selected.name.toLowerCase().endsWith(extension)) {
setError(`请选择 ${extension} 文件`);
return;
}
if (selected.size > limit) {
setError(`文件不能超过 ${isWit() ? "4 MB" : "64 MB"}`);
return;
}
setError("");
await props.onSubmit(selected).catch((submitError: unknown) => {
setError(submitError instanceof Error ? submitError.message : `${title()}失败`);
});
}
return (
<DialogFrame
title={title()}
description={
isWit()
? "包名、版本和直接依赖从二进制 WIT Package 读取。"
: "组件身份、WIT World 和运行边界由平台自动校验。"
}
icon={isWit() ? <FileCode2 size={19} /> : <CloudUpload size={19} />}
closeDisabled={props.busy}
onClose={props.onClose}
footer={
<>
<Button disabled={props.busy} onClick={props.onClose}>
</Button>
<Button variant="primary" disabled={props.busy || !file()} onClick={() => void submit()}>
{props.busy ? <RefreshCw class="animate-spin" size={16} /> : <ShieldCheck size={16} />}
{props.busy ? "正在校验" : isWit() ? "校验并发布" : "校验并注册"}
</Button>
</>
}
>
<label class="flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-line bg-[#fbfcfc] px-5 text-center text-muted transition-colors hover:border-brand hover:bg-brand-soft">
<input
class="sr-only"
type="file"
accept={isWit() ? ".wasm,application/wasm" : ".wasmpkg,application/zip"}
onChange={(event) => {
setFile(event.currentTarget.files?.[0] ?? null);
setError("");
}}
/>
{file() ? (
isWit() ? (
<FileCode2 size={28} />
) : (
<Package size={28} />
)
) : (
<CloudUpload size={28} />
)}
<strong class="text-sm text-ink">
{file()?.name ?? `选择 ${isWit() ? "WIT Package" : "组件包"}`}
</strong>
<span class="text-xs">
{file()
? `${(file()!.size / 1024).toFixed(1)} KB · 等待校验`
: `${isWit() ? ".wasm · 最大 4 MB" : ".wasmpkg · 最大 64 MB"}`}
</span>
</label>
<Show when={error()}>
<div class="mt-3 rounded-md border border-coral-strong/30 bg-coral-soft px-3 py-2 text-sm text-coral-strong">
{error()}
</div>
</Show>
</DialogFrame>
);
}
function ActivateDialog(props: {
dialog: Extract<DialogState, { type: "activate" }>;
busy: boolean;
onClose: () => void;
onActivate: (service: Service) => Promise<void>;
}) {
return (
<DialogFrame
title="切换对外版本"
description="更新该服务在 Gateway 上接收请求的活动版本。"
icon={<RadioTower size={19} />}
closeDisabled={props.busy}
onClose={props.onClose}
footer={
<>
<Button disabled={props.busy} onClick={props.onClose}>
</Button>
<Button
variant="primary"
disabled={props.busy}
onClick={() => void props.onActivate(props.dialog.service)}
>
{props.busy ? <RefreshCw class="animate-spin" size={16} /> : <RadioTower size={16} />}
{props.busy ? "正在切换" : "确认切换"}
</Button>
</>
}
>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-md border border-line bg-canvas p-4">
<div>
<span class="text-xs text-muted"></span>
<strong class="mt-1 block font-mono text-xs">
{props.dialog.currentRevision ?? "尚未部署"}
</strong>
</div>
<ArrowRight size={18} class="text-muted" />
<div>
<span class="text-xs text-muted"></span>
<strong class="mt-1 block font-mono text-xs text-brand">
{props.dialog.service.revision}
</strong>
</div>
</div>
<p class="mt-4 text-sm leading-6 text-muted">
Wasmeld {props.dialog.service.id}@{props.dialog.service.revision} Actor
</p>
</DialogFrame>
);
}
function InvokeDialog(props: {
service: Service;
onClose: () => void;
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
}) {
const [input, setInput] = createSignal(props.service.id === "echo" ? "hello wasm" : "");
const [format, setFormat] = createSignal<"utf8" | "hex">("utf8");
const [output, setOutput] = createSignal("");
const [outputMeta, setOutputMeta] = createSignal("");
const [running, setRunning] = createSignal(false);
const [failed, setFailed] = createSignal(false);
const inputBytes = createMemo(() => invocationInputSize(input(), format()));
async function invoke() {
setRunning(true);
setOutput("");
setOutputMeta("");
setFailed(false);
try {
const result = await props.onInvoke(props.service, parseInvocationInput(input(), format()));
const formatted = formatInvocationOutput(props.service, result.output, format());
setOutput(formatted.text);
setOutputMeta(
`${result.outputBytes} B · ${formatted.format}${formatted.automatic ? " · 自动" : ""} · ${result.latencyMs} ms`,
);
} catch (error) {
setFailed(true);
setOutput(error instanceof Error ? error.message : "组件调用失败");
} finally {
setRunning(false);
}
}
return (
<DialogFrame
title={`调用 ${props.service.id}@${props.service.revision}`}
description="请求将进入该实例的串行 mailbox。"
icon={<SquareTerminal size={19} />}
closeDisabled={running()}
onClose={props.onClose}
footer={
<>
<Button disabled={running()} onClick={props.onClose}>
</Button>
<Button variant="primary" disabled={running()} onClick={() => void invoke()}>
{running() ? <RefreshCw class="animate-spin" size={16} /> : <Play size={16} />}
</Button>
</>
}
>
<div class="mb-3 flex items-center justify-between gap-3">
<SegmentedControl aria-label="输入格式">
<Segment active={format() === "utf8"} onClick={() => setFormat("utf8")}>
UTF-8
</Segment>
<Segment active={format() === "hex"} onClick={() => setFormat("hex")}>
HEX
</Segment>
</SegmentedControl>
<span class="font-mono text-xs text-muted">{inputBytes()} B</span>
</div>
<Field>
<FieldLabel></FieldLabel>
<Textarea
value={input()}
spellcheck={false}
placeholder={format() === "utf8" ? "输入请求内容" : "00 ff a1"}
onInput={(event) => setInput(event.currentTarget.value)}
/>
</Field>
<div class="mt-4">
<div class="mb-1.5 flex items-center justify-between">
<span class="text-xs font-semibold text-muted"></span>
<Show when={outputMeta()}>
<span class="inline-flex items-center gap-1 font-mono text-[11px] text-brand">
<Clock3 size={12} />
{outputMeta()}
</span>
</Show>
</div>
<pre
classList={{
"min-h-32 overflow-auto rounded-md border p-4 font-mono text-sm whitespace-pre-wrap": true,
"border-coral-strong/30 bg-coral-soft text-coral-strong": failed(),
"border-[#263532] bg-[#16221f] text-[#c8eee1]": !failed(),
}}
>
{running() ? "invoking..." : output() || "等待调用"}
</pre>
</div>
</DialogFrame>
);
}
@@ -0,0 +1,163 @@
import type { View } from "../pages/registry";
export type FrameEntry = {
view: View;
instance: number;
keepAlive: boolean;
/**
* Time at which this document became hidden, using the cache clock.
*
* Active and transient documents use `null`. Measuring from deactivation
* prevents a page that has been visible for a long time from expiring as
* soon as the user navigates away.
*/
inactiveSince: number | null;
};
export type FrameCacheOptions = {
/**
* How long a keep-alive document may remain continuously hidden.
*/
inactiveTtlMs: number;
shouldKeepAlive: (view: View) => boolean;
/** Injectable monotonic clock for deterministic tests. */
now?: () => number;
};
export type FrameTransition = {
entries: FrameEntry[];
removed: FrameEntry[];
activated: FrameEntry;
};
export type FrameExpiration = Omit<FrameTransition, "activated">;
/**
* Owns iframe document identities and expires retained, hidden documents.
*
* Entries retain object identity between snapshots so Solid's `<For>` keeps
* the corresponding iframe DOM node alive. `instance` is never reused, which
* makes a recreated document observable in its URL and prevents stale browser
* history or service-worker responses from masquerading as a retained frame.
*
* Primary pages may all opt into retention because their set is finite.
* Transient/detail pages opt out and are removed immediately after navigation.
* Retained pages are released only after a continuous inactive interval, not
* because another primary page happened to be opened.
*/
export class FrameCache {
readonly #options: FrameCacheOptions;
readonly #entries = new Map<View, FrameEntry>();
#active: View;
#nextInstance = 1;
constructor(initialView: View, options: FrameCacheOptions) {
if (!Number.isFinite(options.inactiveTtlMs) || options.inactiveTtlMs <= 0) {
throw new RangeError("inactiveTtlMs must be a positive finite number");
}
this.#options = options;
this.#active = initialView;
this.#entries.set(initialView, this.#create(initialView));
}
get active(): View {
return this.#active;
}
snapshot(): FrameEntry[] {
return [...this.#entries.values()];
}
activate(view: View): FrameTransition {
const removed: FrameEntry[] = [];
const previous = this.#entries.get(this.#active);
const now = this.#now();
if (previous && previous.view !== view) {
if (previous.keepAlive) {
previous.inactiveSince = now;
} else {
this.#entries.delete(previous.view);
removed.push(previous);
}
}
// Prune before activation so revisiting an already-expired page creates a
// fresh iframe instead of reviving stale document state.
removed.push(...this.#removeExpired(now));
let activated = this.#entries.get(view);
if (!activated) {
activated = this.#create(view);
this.#entries.set(view, activated);
}
activated.inactiveSince = null;
this.#active = view;
return {
entries: this.snapshot(),
removed,
activated,
};
}
/**
* Removes retained documents whose continuous hidden interval has elapsed.
*
* Host calls this from a timer and sends `dispose` before applying the
* returned snapshot to the DOM.
*/
expireInactive(): FrameExpiration {
return {
removed: this.#removeExpired(this.#now()),
entries: this.snapshot(),
};
}
/**
* Returns the delay until the next hidden document expires.
*
* `null` means no retained page is currently hidden. Recomputing the delay
* after every navigation and expiration avoids one interval timer per page.
*/
timeUntilExpiration(): number | null {
const now = this.#now();
let delay: number | null = null;
for (const entry of this.#entries.values()) {
if (!entry.keepAlive || entry.inactiveSince === null) continue;
const remaining = Math.max(0, entry.inactiveSince + this.#options.inactiveTtlMs - now);
if (delay === null || remaining < delay) delay = remaining;
}
return delay;
}
#create(view: View): FrameEntry {
return {
view,
instance: this.#nextInstance++,
keepAlive: this.#options.shouldKeepAlive(view),
inactiveSince: null,
};
}
#removeExpired(now: number): FrameEntry[] {
const removed: FrameEntry[] = [];
for (const entry of this.#entries.values()) {
if (
entry.view === this.#active ||
!entry.keepAlive ||
entry.inactiveSince === null ||
now - entry.inactiveSince < this.#options.inactiveTtlMs
) {
continue;
}
this.#entries.delete(entry.view);
removed.push(entry);
}
return removed;
}
#now(): number {
return this.#options.now?.() ?? performance.now();
}
}
@@ -0,0 +1,13 @@
import { render } from "solid-js/web";
import "../styles/app.css";
import HostApp from "./app";
const root = document.getElementById("app");
if (!root) throw new Error("missing #app root");
render(() => <HostApp />, root);
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js");
});
}
@@ -0,0 +1,152 @@
import type { HostGlobalState, RuntimeAction } from "../sdk/protocol";
const HOST_TAB_CHANNEL = "wasmeld-console:host-state";
const HOST_TAB_PROTOCOL_VERSION = 1;
type HostTabMessage =
| {
channel: typeof HOST_TAB_CHANNEL;
version: typeof HOST_TAB_PROTOCOL_VERSION;
type: "hello";
sender: string;
}
| {
channel: typeof HOST_TAB_CHANNEL;
version: typeof HOST_TAB_PROTOCOL_VERSION;
type: "state";
sender: string;
sequence: number;
state: HostGlobalState;
};
export type TabChannel = {
postMessage: (message: unknown) => void;
addEventListener: (type: "message", listener: (event: MessageEvent<unknown>) => void) => void;
removeEventListener: (type: "message", listener: (event: MessageEvent<unknown>) => void) => void;
close: () => void;
};
export type HostTabSyncOptions = {
getState: () => HostGlobalState;
applyState: (state: HostGlobalState) => void;
channel?: TabChannel;
tabId?: string;
};
/**
* Replicates Host-owned control-plane state between same-origin browser tabs.
*
* Every tab may publish; there is deliberately no leader election. Sequence
* numbers are scoped to a sender and suppress stale delivery, while consumers
* suppress their own Solid broadcast effect when applying a remote snapshot.
* Page-local state never enters this channel.
*/
export class HostTabSync {
readonly #channel: TabChannel;
readonly #tabId: string;
readonly #getState: () => HostGlobalState;
readonly #applyState: (state: HostGlobalState) => void;
readonly #latestSequence = new Map<string, number>();
#sequence = 0;
#closed = false;
constructor(options: HostTabSyncOptions) {
this.#channel = options.channel ?? new BroadcastChannel(HOST_TAB_CHANNEL);
this.#tabId = options.tabId ?? crypto.randomUUID();
this.#getState = options.getState;
this.#applyState = options.applyState;
this.#channel.addEventListener("message", this.#receive);
this.#send({
channel: HOST_TAB_CHANNEL,
version: HOST_TAB_PROTOCOL_VERSION,
type: "hello",
sender: this.#tabId,
});
}
publish(state: HostGlobalState): void {
this.#send({
channel: HOST_TAB_CHANNEL,
version: HOST_TAB_PROTOCOL_VERSION,
type: "state",
sender: this.#tabId,
sequence: ++this.#sequence,
state,
});
}
close(): void {
if (this.#closed) return;
this.#closed = true;
this.#channel.removeEventListener("message", this.#receive);
this.#channel.close();
}
readonly #receive = (event: MessageEvent<unknown>) => {
const message = event.data;
if (!isHostTabMessage(message) || message.sender === this.#tabId) return;
if (message.type === "hello") {
this.publish(this.#getState());
return;
}
const latest = this.#latestSequence.get(message.sender) ?? 0;
if (message.sequence <= latest) return;
this.#latestSequence.set(message.sender, message.sequence);
this.#applyState(message.state);
};
#send(message: HostTabMessage): void {
if (!this.#closed) this.#channel.postMessage(message);
}
}
export function createBrowserHostTabSync(
options: Omit<HostTabSyncOptions, "channel">,
): HostTabSync | null {
if (typeof BroadcastChannel === "undefined") return null;
return new HostTabSync(options);
}
function isHostTabMessage(value: unknown): value is HostTabMessage {
if (!isRecord(value)) return false;
if (
value.channel !== HOST_TAB_CHANNEL ||
value.version !== HOST_TAB_PROTOCOL_VERSION ||
typeof value.sender !== "string"
) {
return false;
}
if (value.type === "hello") return true;
return (
value.type === "state" &&
Number.isSafeInteger(value.sequence) &&
(value.sequence as number) > 0 &&
isHostGlobalState(value.state)
);
}
function isHostGlobalState(value: unknown): value is HostGlobalState {
if (!isRecord(value)) return false;
return (
(value.snapshot === null || isRecord(value.snapshot)) &&
isConnection(value.connection) &&
typeof value.apiBase === "string" &&
(value.runtimeAction === null || isRuntimeAction(value.runtimeAction)) &&
(value.serviceAction === null || typeof value.serviceAction === "string") &&
(value.deploymentAction === null || typeof value.deploymentAction === "string")
);
}
function isConnection(value: unknown): value is HostGlobalState["connection"] {
return value === "connecting" || value === "online" || value === "offline";
}
function isRuntimeAction(value: unknown): value is RuntimeAction {
return value === "start" || value === "stop" || value === "restart";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -1,13 +1,13 @@
/** /**
* Typed management-plane client for `wasmeld-console`. * Typed client for the Rust management API.
* *
* These DTOs intentionally preserve the Rust API's snake_case fields; view * Production uses a same-origin base so the embedded Console follows the
* formatting belongs in `console-model.ts`. Management invocation bytes use * address used to open it. Vite development keeps the existing local backend
* base64 inside JSON for inspectability. Public clients should call the * default and relies on Wasmeld's development CORS policy.
* 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 ?? (import.meta.env.DEV ? "http://127.0.0.1:8080" : "");
const API_BASE_STORAGE_KEY = "wasmeld-api-base"; const API_BASE_STORAGE_KEY = "wasmeld-api-base";
@@ -78,8 +78,12 @@ export type BackendWitPackage = {
dependencies: BackendWitDependency[]; dependencies: BackendWitDependency[];
}; };
export type RegisterComponentInput = { export type BackendSnapshot = {
file: File; services: BackendService[];
deployments: BackendDeployment[];
events: BackendEvent[];
runtime: BackendRuntime;
witPackages: BackendWitPackage[];
}; };
export type InvokeResult = { export type InvokeResult = {
@@ -89,14 +93,20 @@ 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;
} }
export function displayApiBase(value: string): string {
return value || (typeof window === "undefined" ? "same origin" : window.location.origin);
}
export function saveApiBase(value: string): string { export function saveApiBase(value: string): string {
const normalized = value.trim().replace(/\/+$/, ""); const normalized = value.trim().replace(/\/+$/, "");
if (!normalized) {
window.localStorage.removeItem(API_BASE_STORAGE_KEY);
return "";
}
const parsed = new URL(normalized); const parsed = new URL(normalized);
if (!["http:", "https:"].includes(parsed.protocol)) { if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("API 地址必须使用 http 或 https"); throw new Error("API 地址必须使用 http 或 https");
@@ -105,10 +115,7 @@ export function saveApiBase(value: string): string {
return normalized; return normalized;
} }
export async function fetchSnapshot(apiBase: string) { export async function fetchSnapshot(apiBase: string): Promise<BackendSnapshot> {
// 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"),
@@ -125,30 +132,27 @@ export async function fetchSnapshot(apiBase: string) {
}; };
} }
export async function activateDeployment( export function activateDeployment(
apiBase: string, apiBase: string,
service: Pick<BackendService, "id" | "revision">, service: Pick<BackendService, "id" | "revision">,
): Promise<BackendDeployment> { ): Promise<BackendDeployment> {
return request(apiBase, `/api/v1/deployments/${encodeURIComponent(service.id)}/activate`, { return request(apiBase, `/api/v1/deployments/${segment(service.id)}/activate`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ revision: service.revision }), body: JSON.stringify({ revision: service.revision }),
}); });
} }
export async function registerComponent( export function registerComponent(apiBase: string, file: File): Promise<BackendService> {
apiBase: string,
input: RegisterComponentInput,
): Promise<BackendService> {
const form = new FormData(); const form = new FormData();
form.set("package", input.file, input.file.name); form.set("package", file, file.name);
return request(apiBase, "/api/v1/services", { return request(apiBase, "/api/v1/services", {
method: "POST", method: "POST",
body: form, body: form,
}); });
} }
export async function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> { export function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> {
const form = new FormData(); const form = new FormData();
form.set("package", file, file.name); form.set("package", file, file.name);
return request(apiBase, "/api/v1/wit/packages", { return request(apiBase, "/api/v1/wit/packages", {
@@ -162,17 +166,17 @@ export function witPackageDownloadUrl(
packageMetadata: Pick<BackendWitPackage, "name" | "version">, packageMetadata: Pick<BackendWitPackage, "name" | "version">,
): string { ): string {
const [namespace, name] = packageMetadata.name.split(":"); const [namespace, name] = packageMetadata.name.split(":");
return `${apiBase}/api/v1/wit/packages/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/${encodeURIComponent(packageMetadata.version)}/content`; return `${apiBase}/api/v1/wit/packages/${segment(namespace)}/${segment(name)}/${segment(packageMetadata.version)}/content`;
} }
export async function changeServiceState( export function changeServiceState(
apiBase: string, apiBase: string,
service: Pick<BackendService, "id" | "revision">, service: Pick<BackendService, "id" | "revision">,
action: "start" | "stop" | "restart", action: "start" | "stop" | "restart",
): Promise<BackendService> { ): Promise<BackendService> {
return request( return request(
apiBase, apiBase,
`/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/${action}`, `/api/v1/services/${segment(service.id)}/${segment(service.revision)}/${action}`,
{ {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -181,13 +185,11 @@ export async function changeServiceState(
); );
} }
export async function changeRuntimeState( export function changeRuntimeState(
apiBase: string, apiBase: string,
action: "start" | "stop" | "restart", action: "start" | "stop" | "restart",
): Promise<BackendRuntime> { ): Promise<BackendRuntime> {
return request(apiBase, `/api/v1/runtime/${action}`, { return request(apiBase, `/api/v1/runtime/${action}`, { method: "POST" });
method: "POST",
});
} }
export async function invokeComponent( export async function invokeComponent(
@@ -195,22 +197,15 @@ 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;
latency_ms: number; latency_ms: number;
}>( }>(apiBase, `/api/v1/services/${segment(service.id)}/${segment(service.revision)}/invoke`, {
apiBase, method: "POST",
`/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/invoke`, headers: { "content-type": "application/json" },
{ body: JSON.stringify({ input_base64: bytesToBase64(input) }),
method: "POST", });
headers: { "content-type": "application/json" },
body: JSON.stringify({ input_base64: bytesToBase64(input) }),
},
);
return { return {
output: base64ToBytes(response.output_base64), output: base64ToBytes(response.output_base64),
outputBytes: response.output_bytes, outputBytes: response.output_bytes,
@@ -229,13 +224,10 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
}, },
}); });
} catch { } catch {
throw new Error(`无法连接 Wasmeld${apiBase}`); throw new Error(`无法连接 Wasmeld${displayApiBase(apiBase)}`);
} }
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;
@@ -244,9 +236,11 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
return (await response.json()) as T; return (await response.json()) as T;
} }
function segment(value: string): string {
return encodeURIComponent(value);
}
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
@@ -0,0 +1,16 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merges conditional Tailwind classes using the same pattern as shadcn.
*
* `clsx` evaluates conditional input while `tailwind-merge` lets a caller's
* class override a component default from the same Tailwind class group:
*
* ```ts
* cn("h-10 px-4", compact && "h-8", props.class)
* ```
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
@@ -1,23 +1,14 @@
/** import type { BackendEvent, BackendService } from "./api";
* 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"; export type ConnectionState = "connecting" | "online" | "offline";
import { BackendCapability, BackendEvent, BackendService } from "./api";
export type View = "overview" | "services" | "instances" | "wit-packages" | "activity" | "settings";
export type ServiceStatus = "running" | "stopped" | "faulted"; export type ServiceStatus = "running" | "stopped" | "faulted";
export type EventTone = "success" | "warning" | "danger" | "neutral"; export type EventTone = "success" | "warning" | "danger" | "neutral";
export type Service = { export type Service = {
id: string; id: string;
revision: string; revision: string;
description: string;
artifact: string; artifact: string;
world: string;
status: ServiceStatus; status: ServiceStatus;
updatedAt: string; updatedAt: string;
memoryMb: number; memoryMb: number;
@@ -25,7 +16,7 @@ export type Service = {
deadlineMs: number; deadlineMs: number;
mailbox: number; mailbox: number;
maxInputKb: number; maxInputKb: number;
capabilities: BackendCapability[]; capabilities: BackendService["capabilities"];
calls: number; calls: number;
errors: number; errors: number;
latencyMs: number | null; latencyMs: number | null;
@@ -40,28 +31,19 @@ export type RuntimeEvent = {
tone: EventTone; tone: EventTone;
}; };
export type ConnectionState = "connecting" | "online" | "offline"; export type FormattedInvocationOutput = {
text: string;
export const NAV_ITEMS: Array<{ format: "UTF-8" | "HEX" | "U64 LE";
id: View; automatic: boolean;
label: string;
icon: typeof LayoutDashboard;
}> = [
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
{ id: "services", label: "服务版本", icon: Package },
{ id: "instances", label: "运行实例", icon: Server },
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
{ id: "activity", label: "调用记录", icon: History },
{ id: "settings", label: "运行设置", icon: Settings },
];
export const STATUS_META: Record<ServiceStatus, { label: string; className: string }> = {
running: { label: "运行中", className: "status-running" },
stopped: { label: "已停止", className: "status-stopped" },
faulted: { label: "故障", className: "status-faulted" },
}; };
export function serviceKey(service: Pick<Service, "id" | "revision">) { export const STATUS_META: Record<ServiceStatus, { label: string }> = {
running: { label: "运行中" },
stopped: { label: "已停止" },
faulted: { label: "故障" },
};
export function serviceKey(service: Pick<Service, "id" | "revision">): string {
return `${service.id}@${service.revision}`; return `${service.id}@${service.revision}`;
} }
@@ -69,12 +51,10 @@ export function toService(service: BackendService, activeRevision?: string): Ser
return { return {
id: service.id, id: service.id,
revision: service.revision, revision: service.revision,
description: "Wasm Component",
artifact: service.artifact.split(/[\\/]/).pop() ?? service.artifact, artifact: service.artifact.split(/[\\/]/).pop() ?? service.artifact,
world: service.world,
status: service.status, status: service.status,
updatedAt: new Date(service.updated_at_ms).toLocaleString("zh-CN", { updatedAt: new Date(service.updated_at_ms).toLocaleString("zh-CN", { hour12: false }),
hour12: false,
}),
memoryMb: Math.round(service.limits.memory_bytes / 1024 / 1024), memoryMb: Math.round(service.limits.memory_bytes / 1024 / 1024),
fuel: compactNumber(service.limits.fuel_per_call), fuel: compactNumber(service.limits.fuel_per_call),
deadlineMs: service.limits.deadline_ms, deadlineMs: service.limits.deadline_ms,
@@ -102,22 +82,20 @@ export function toEvent(event: BackendEvent): RuntimeEvent {
}; };
return { return {
id: event.id, id: event.id,
time: new Date(event.timestamp_ms).toLocaleTimeString("zh-CN", { time: new Date(event.timestamp_ms).toLocaleTimeString("zh-CN", { hour12: false }),
hour12: false,
}),
title: `${identity} ${meta[event.kind].label}`, title: `${identity} ${meta[event.kind].label}`,
detail: event.message, detail: event.message,
tone: meta[event.kind].tone, tone: meta[event.kind].tone,
}; };
} }
export function compactNumber(value: number) { export function compactNumber(value: number): string {
if (value >= 1_000_000) return `${value / 1_000_000}M`; if (value >= 1_000_000) return `${value / 1_000_000}M`;
if (value >= 1_000) return `${value / 1_000}K`; if (value >= 1_000) return `${value / 1_000}K`;
return value.toString(); return value.toString();
} }
export function formatDuration(milliseconds: number) { export function formatDuration(milliseconds: number): string {
const totalSeconds = Math.floor(milliseconds / 1000); const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60); const minutes = Math.floor((totalSeconds % 3600) / 60);
@@ -125,15 +103,7 @@ export function formatDuration(milliseconds: number) {
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m ${seconds}s`; return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m ${seconds}s`;
} }
export function formatApiHost(value: string) { export function parseInvocationInput(value: string, format: "utf8" | "hex"): Uint8Array {
try {
return new URL(value).host;
} catch {
return value;
}
}
export function parseInvocationInput(value: string, format: "utf8" | "hex") {
if (format === "utf8") return new TextEncoder().encode(value); if (format === "utf8") return new TextEncoder().encode(value);
const compact = value.replace(/\s+/g, ""); const compact = value.replace(/\s+/g, "");
if (compact.length % 2 !== 0 || /[^0-9a-f]/i.test(compact)) { if (compact.length % 2 !== 0 || /[^0-9a-f]/i.test(compact)) {
@@ -142,7 +112,7 @@ export function parseInvocationInput(value: string, format: "utf8" | "hex") {
return Uint8Array.from(compact.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? []); return Uint8Array.from(compact.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? []);
} }
export function invocationInputSize(value: string, format: "utf8" | "hex") { export function invocationInputSize(value: string, format: "utf8" | "hex"): number {
try { try {
return parseInvocationInput(value, format).byteLength; return parseInvocationInput(value, format).byteLength;
} catch { } catch {
@@ -150,45 +120,11 @@ export function invocationInputSize(value: string, format: "utf8" | "hex") {
} }
} }
export type FormattedInvocationOutput = {
text: string;
format: "UTF-8" | "HEX" | "U64 LE";
automatic: boolean;
};
export function formatHex(bytes: Uint8Array) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
}
export function decodeReadableUtf8(bytes: Uint8Array) {
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
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) {
const codePoint = character.codePointAt(0) ?? 0;
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
if ((codePoint < 0x20 && !allowedWhitespace) || codePoint === 0x7f) {
return null;
}
}
return text;
}
export function formatInvocationOutput( export function formatInvocationOutput(
service: Service, service: Service,
output: Uint8Array, output: Uint8Array,
format: "utf8" | "hex", format: "utf8" | "hex",
) { ): FormattedInvocationOutput {
// 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)
@@ -196,29 +132,33 @@ export function formatInvocationOutput(
.toString(), .toString(),
format: "U64 LE", format: "U64 LE",
automatic: false, automatic: false,
} satisfies FormattedInvocationOutput; };
} }
if (format === "hex") { if (format === "hex") {
return { return { text: formatHex(output) || "(empty)", format: "HEX", automatic: false };
text: formatHex(output) || "(empty)",
format: "HEX",
automatic: false,
} 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 { text: text || "(empty)", format: "UTF-8", automatic: false };
text: text || "(empty)",
format: "UTF-8",
automatic: false,
} satisfies FormattedInvocationOutput;
} }
return { return { text: formatHex(output), format: "HEX", automatic: true };
text: formatHex(output), }
format: "HEX",
automatic: true, function formatHex(bytes: Uint8Array): string {
} satisfies FormattedInvocationOutput; return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
}
function decodeReadableUtf8(bytes: Uint8Array): string | null {
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;
} }
@@ -0,0 +1,17 @@
import type { HostSharedState } from "../sdk/protocol";
import { toEvent, toService, type RuntimeEvent, type Service } from "./model";
export function servicesFrom(state: HostSharedState | null): Service[] {
if (!state?.snapshot) return [];
const deployments = new Map(
state.snapshot.deployments.map((deployment) => [
deployment.service_id,
deployment.active_revision,
]),
);
return state.snapshot.services.map((service) => toService(service, deployments.get(service.id)));
}
export function eventsFrom(state: HostSharedState | null): RuntimeEvent[] {
return state?.snapshot?.events.map(toEvent) ?? [];
}
@@ -0,0 +1,73 @@
import { History, RefreshCw } from "lucide-solid";
import { createMemo, For, onCleanup, Show } from "solid-js";
import { EmptyState } from "../../components/feedback/empty-state";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { IconButton } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { eventsFrom } from "../../lib/view-state";
import type { PageProps } from "../types";
const toneClass = {
success: "bg-brand",
warning: "bg-amber-strong",
danger: "bg-coral-strong",
neutral: "bg-muted",
};
export default function ActivityPage(props: PageProps) {
// A retained Activity iframe may have been hidden for a while. Request a
// fresh snapshot whenever it becomes visible instead of waiting for the
// Host's next background poll.
onCleanup(props.lifecycle.onShow(() => props.command({ type: "refresh" })));
const events = createMemo(() => {
const query = props.state()?.query.trim().toLowerCase() ?? "";
return eventsFrom(props.state()).filter(
(event) =>
!query ||
event.title.toLowerCase().includes(query) ||
event.detail.toLowerCase().includes(query),
);
});
return (
<Page>
<PageHeading
eyebrow="EVENTS"
title="调用记录"
description="最近 256 条持久化生命周期与调用事件。"
actions={
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</IconButton>
}
/>
<Card as="section">
<Show
when={events().length > 0}
fallback={
<EmptyState icon={History} title="暂无事件" detail="Runtime 操作和调用会显示在这里。" />
}
>
<div class="divide-y divide-line">
<For each={events()}>
{(event) => (
<article class="grid gap-2 px-5 py-4 sm:grid-cols-[10px_minmax(180px,0.8fr)_2fr_84px] sm:items-center">
<span class={`size-2 rounded-full ${toneClass[event.tone]}`} />
<strong class="text-sm">{event.title}</strong>
<p class="text-xs leading-5 text-muted">{event.detail}</p>
<time class="font-mono text-xs text-muted sm:text-right">{event.time}</time>
</article>
)}
</For>
</div>
</Show>
</Card>
</Page>
);
}
@@ -0,0 +1,117 @@
import { Cpu, RefreshCw, Server, SquareTerminal } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/feedback/empty-state";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { StatusBadge } from "../../components/services/status-badge";
import { Button, IconButton } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { serviceKey } from "../../lib/model";
import { servicesFrom } from "../../lib/view-state";
import type { PageProps } from "../types";
export default function InstancesPage(props: PageProps) {
const services = createMemo(() => servicesFrom(props.state()));
const running = createMemo(
() => services().filter((service) => service.status === "running").length,
);
return (
<Page>
<PageHeading
eyebrow="ACTORS"
title="运行实例"
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
actions={
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</IconButton>
}
/>
<Show
when={services().length > 0}
fallback={
<Card>
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
</Card>
}
>
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<For each={services()}>
{(service) => (
<Card as="article" padding="medium">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-cyan-soft text-cyan-strong">
<Cpu size={18} />
</span>
<div class="min-w-0">
<strong class="block truncate">{service.id}</strong>
<span class="mt-1 block truncate font-mono text-xs text-muted">
{service.revision}
</span>
</div>
</div>
<StatusBadge status={service.status} />
</div>
<dl class="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-line pt-4 text-xs">
<div>
<dt class="text-muted"></dt>
<dd class="mt-1 font-semibold">{service.memoryMb} MB</dd>
</div>
<div>
<dt class="text-muted">Mailbox</dt>
<dd class="mt-1 font-semibold">{service.mailbox}</dd>
</div>
<div>
<dt class="text-muted">Deadline</dt>
<dd class="mt-1 font-semibold">{service.deadlineMs} ms</dd>
</div>
<div>
<dt class="text-muted"></dt>
<dd class="mt-1 font-semibold">
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
</dd>
</div>
</dl>
<div class="mt-5 flex justify-end gap-2">
<Show when={service.status === "running"}>
<Button
size="small"
onClick={() =>
props.command({
type: "open-invoke",
serviceKey: serviceKey(service),
})
}
>
<SquareTerminal size={15} />
</Button>
</Show>
<Button
size="small"
disabled={props.state()?.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: serviceKey(service),
action: service.status === "running" ? "stop" : "start",
})
}
>
{service.status === "running" ? "停止" : "启动"}
</Button>
</div>
</Card>
)}
</For>
</section>
</Show>
</Page>
);
}
@@ -0,0 +1,53 @@
import { AlertTriangle, LoaderCircle } from "lucide-solid";
import { lazy, Show, Suspense } from "solid-js";
import { render } from "solid-js/web";
import { createFrameBridge } from "../primitives/create-frame-bridge";
import "../styles/app.css";
import { isView, pageDefinition } from "./registry";
function FramePage() {
const requested = new URLSearchParams(window.location.search).get("view");
const view = isView(requested) ? requested : null;
return (
<Show
when={view}
fallback={
<main class="flex min-h-dvh items-center justify-center p-6 text-center text-coral-strong">
<div>
<AlertTriangle class="mx-auto mb-3" size={28} />
<strong class="block"></strong>
<span class="mt-1 block text-sm text-muted">{requested ?? "(empty)"}</span>
</div>
</main>
}
>
{(activeView) => {
const bridge = createFrameBridge(activeView());
const definition = pageDefinition(activeView());
const Page = lazy(definition.load);
document.title = `${definition.label} · Wasmeld`;
return (
<Suspense
fallback={
<main class="flex min-h-dvh items-center justify-center text-muted">
<LoaderCircle class="animate-spin" size={24} />
</main>
}
>
<Page
state={bridge.state}
active={bridge.active}
lifecycle={bridge.lifecycle}
command={bridge.command}
/>
</Suspense>
);
}}
</Show>
);
}
const root = document.getElementById("app");
if (!root) throw new Error("missing #app root");
render(() => <FramePage />, root);
@@ -0,0 +1,267 @@
import {
Activity,
AlertTriangle,
CircleStop,
CloudUpload,
Cpu,
Package,
Play,
RefreshCw,
RotateCcw,
Zap,
} from "lucide-solid";
import { createMemo, For, Show, type JSXElement } from "solid-js";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { ServiceTable } from "../../components/services/service-table";
import { Button, IconButton } from "../../components/ui/button";
import { Card, CardHeader } from "../../components/ui/card";
import { eventsFrom, servicesFrom } from "../../lib/view-state";
import { formatDuration } from "../../lib/model";
import type { PageProps } from "../types";
export default function OverviewPage(props: PageProps) {
const services = createMemo(() => servicesFrom(props.state()));
const events = createMemo(() => eventsFrom(props.state()));
const running = createMemo(
() => services().filter((service) => service.status === "running").length,
);
const totalCalls = createMemo(() =>
services().reduce((total, service) => total + service.calls, 0),
);
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
return (
<Page>
<Show when={props.state()} fallback={<OverviewSkeleton />}>
{(state) => (
<>
<PageHeading
eyebrow="LOCAL RUNTIME"
title="运行概览"
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
actions={
<>
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</IconButton>
<Button
variant="primary"
onClick={() => props.command({ type: "open-register" })}
>
<CloudUpload size={16} />
</Button>
</>
}
/>
<section
class="mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4"
aria-label="关键指标"
>
<Metric
icon={<Package size={18} />}
tone="bg-cyan-soft text-cyan-strong"
label="服务版本"
value={services().length.toString()}
note={`${state().snapshot?.deployments.length ?? 0} 个对外服务`}
/>
<Metric
icon={<Activity size={18} />}
tone="bg-brand-soft text-brand"
label="运行实例"
value={running().toString()}
note={`${services().length - running()} 个未运行`}
/>
<Metric
icon={<Zap size={18} />}
tone="bg-amber-soft text-amber-strong"
label="累计调用"
value={totalCalls().toLocaleString("zh-CN")}
note="持久化统计"
/>
<Metric
icon={<AlertTriangle size={18} />}
tone="bg-coral-soft text-coral-strong"
label="调用异常"
value={errors().toString()}
note={errors() > 0 ? "需要检查" : "无异常"}
/>
</section>
<Card
as="section"
class="mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]"
>
<div class="flex items-center gap-3 border-b border-line px-5 py-4 lg:border-b-0 lg:border-r">
<span class="flex size-10 items-center justify-center rounded-md bg-brand-soft text-brand">
<Cpu size={18} />
</span>
<div class="min-w-0 flex-1">
<strong class="block">Wasmeld Runtime</strong>
<span class="mt-0.5 block truncate text-xs text-muted">
{state().snapshot?.runtime.status === "running"
? "Wasmtime sandbox 正常"
: "Engine 与 Actor 已释放"}
</span>
</div>
<div class="flex gap-1">
<Show
when={state().snapshot?.runtime.status === "running"}
fallback={
<IconButton
aria-label="启动 Runtime"
title="启动 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "start" })}
>
<Play size={15} />
</IconButton>
}
>
<IconButton
aria-label="重启 Runtime"
title="重启 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "restart" })}
>
<RotateCcw size={15} />
</IconButton>
<IconButton
tone="danger"
aria-label="停止 Runtime"
title="停止 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
>
<CircleStop size={15} />
</IconButton>
</Show>
</div>
</div>
<RuntimeStat
label="已加载版本"
value={(state().snapshot?.runtime.registered_services ?? 0).toString()}
detail="已编译 Component"
/>
<RuntimeStat
label="活跃 Store"
value={(state().snapshot?.runtime.running_services ?? 0).toString()}
detail="串行 Actor"
/>
<RuntimeStat
label="运行时间"
value={
state().snapshot?.runtime.status === "running"
? formatDuration(state().snapshot?.runtime.uptime_ms ?? 0)
: "—"
}
detail="当前 Runtime"
/>
</Card>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,0.8fr)]">
<Card as="section" class="min-w-0">
<CardHeader>
<div>
<h2></h2>
<p> Actor </p>
</div>
<Button
size="small"
onClick={() => props.command({ type: "navigate", view: "services" })}
>
</Button>
</CardHeader>
<ServiceTable
services={services().slice(0, 5)}
state={state()}
command={props.command}
compact
/>
</Card>
<Card as="aside">
<CardHeader>
<div>
<h2></h2>
<p></p>
</div>
<span class="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand">
<span class="size-1.5 rounded-full bg-brand" />
LIVE
</span>
</CardHeader>
<div class="divide-y divide-line">
<For
each={events().slice(0, 6)}
fallback={<p class="px-5 py-12 text-center text-sm text-muted"></p>}
>
{(event) => (
<div class="px-5 py-3">
<div class="flex items-center justify-between gap-3">
<strong class="truncate text-xs">{event.title}</strong>
<span class="font-mono text-[10px] text-muted">{event.time}</span>
</div>
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">{event.detail}</p>
</div>
)}
</For>
</div>
</Card>
</div>
</>
)}
</Show>
</Page>
);
}
function Metric(props: {
icon: JSXElement;
tone: string;
label: string;
value: string;
note: string;
}) {
return (
<article class="flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3">
<div class={`flex size-10 shrink-0 items-center justify-center rounded-md ${props.tone}`}>
{props.icon}
</div>
<div class="min-w-0 flex-1">
<span class="block text-xs text-muted">{props.label}</span>
<strong class="mt-1 block text-2xl font-bold">{props.value}</strong>
</div>
<small class="self-end whitespace-nowrap pb-1 text-[11px] text-muted">{props.note}</small>
</article>
);
}
function RuntimeStat(props: { label: string; value: string; detail: string }) {
return (
<div class="border-b border-line px-5 py-4 last:border-b-0 lg:border-b-0 lg:border-r lg:last:border-r-0">
<span class="block text-xs text-muted">{props.label}</span>
<strong class="mt-1 block text-lg">{props.value}</strong>
<small class="mt-0.5 block text-[11px] text-muted">{props.detail}</small>
</div>
);
}
function OverviewSkeleton() {
return (
<div class="space-y-5 py-5">
<div class="h-3 w-36 animate-pulse rounded-sm bg-[#dfe6e4]" />
<div class="h-8 w-64 animate-pulse rounded-sm bg-[#dfe6e4]" />
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<For each={[1, 2, 3, 4]}>{() => <div class="h-24 animate-pulse bg-white" />}</For>
</div>
</div>
);
}
@@ -0,0 +1,106 @@
import {
FileCode2,
History,
LayoutDashboard,
Package,
Server,
Settings,
type LucideProps,
} from "lucide-solid";
import type { Component } from "solid-js";
import type { PageProps } from "./types";
type PageModule = {
default: Component<PageProps>;
};
type PageDefinition = {
id: string;
label: string;
icon: Component<LucideProps>;
/**
* Requests that Host retain this page's iframe after navigation.
*
* Primary navigation pages normally opt in; detail and short-lived workflow
* pages should opt out. This is still a preference rather than ownership of
* the iframe lifetime: Host expires documents that remain hidden beyond its
* inactivity TTL. A retained page must pause background work while
* `active()` is false and release all resources when disposed.
*/
keepAlive: boolean;
/**
* Loads only this page's code inside the iframe document.
*
* Keep this as a dynamic import. Turning it into a static import would make
* every iframe evaluate every page and weaken the document lifecycle
* boundary used to release page-owned memory.
*/
load: () => Promise<PageModule>;
};
/**
* The single source of truth for Wasmeld Console pages.
*
* Add a page here after creating `src/pages/<id>/index.tsx`. Host navigation,
* route validation, iframe titles, keep-alive policy and page module loading
* are all derived from this registry, so they cannot drift independently.
*/
export const PAGE_DEFINITIONS = [
{
id: "overview",
label: "运行概览",
icon: LayoutDashboard,
keepAlive: true,
load: () => import("./overview"),
},
{
id: "services",
label: "服务版本",
icon: Package,
keepAlive: true,
load: () => import("./services"),
},
{
id: "instances",
label: "运行实例",
icon: Server,
keepAlive: true,
load: () => import("./instances"),
},
{
id: "wit-packages",
label: "WIT 包",
icon: FileCode2,
keepAlive: true,
load: () => import("./wit-packages"),
},
{
id: "activity",
label: "调用记录",
icon: History,
keepAlive: true,
load: () => import("./activity"),
},
{
id: "settings",
label: "运行设置",
icon: Settings,
keepAlive: true,
load: () => import("./settings"),
},
] as const satisfies readonly PageDefinition[];
export type View = (typeof PAGE_DEFINITIONS)[number]["id"];
export const VIEWS: readonly View[] = PAGE_DEFINITIONS.map((page) => page.id);
const PAGE_BY_ID = new Map<View, PageDefinition>(PAGE_DEFINITIONS.map((page) => [page.id, page]));
export function isView(value: string | null): value is View {
return value !== null && PAGE_BY_ID.has(value as View);
}
export function pageDefinition(view: View): PageDefinition {
// `View` is derived from PAGE_DEFINITIONS, so every valid value has an entry.
return PAGE_BY_ID.get(view)!;
}
@@ -0,0 +1,81 @@
import { CloudUpload, Filter, RefreshCw } from "lucide-solid";
import { createMemo, createSignal, Show } from "solid-js";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { ServiceTable } from "../../components/services/service-table";
import { Button, IconButton } from "../../components/ui/button";
import { Card, CardHeader } from "../../components/ui/card";
import { Select } from "../../components/ui/select";
import { servicesFrom } from "../../lib/view-state";
import type { ServiceStatus } from "../../lib/model";
import type { PageProps } from "../types";
export default function ServicesPage(props: PageProps) {
const [status, setStatus] = createSignal<"all" | ServiceStatus>("all");
const services = createMemo(() => servicesFrom(props.state()));
const filtered = createMemo(() => {
const query = props.state()?.query.trim().toLowerCase() ?? "";
return services().filter(
(service) =>
(status() === "all" || service.status === status()) &&
(!query ||
service.id.toLowerCase().includes(query) ||
service.revision.toLowerCase().includes(query) ||
service.artifact.toLowerCase().includes(query)),
);
});
return (
<Page>
<PageHeading
eyebrow="COMPONENTS"
title="服务版本"
description="管理不可变 Component Revision、Actor 状态和对外 Deployment。"
actions={
<>
<label class="relative">
<Filter
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
size={14}
/>
<Select
class="w-36 pl-9"
value={status()}
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
>
<option value="all"></option>
<option value="running"></option>
<option value="stopped"></option>
<option value="faulted"></option>
</Select>
</label>
<Button variant="primary" onClick={() => props.command({ type: "open-register" })}>
<CloudUpload size={16} />
</Button>
</>
}
/>
<Card as="section">
<CardHeader>
<div>
<h2></h2>
<p>{filtered().length} </p>
</div>
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</IconButton>
</CardHeader>
<Show when={props.state()}>
{(state) => (
<ServiceTable services={filtered()} state={state()} command={props.command} />
)}
</Show>
</Card>
</Page>
);
}
@@ -0,0 +1,92 @@
import { Database, Globe2, Save, ShieldCheck } from "lucide-solid";
import { createEffect, createSignal } from "solid-js";
import { displayApiBase } from "../../lib/api";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { Button } from "../../components/ui/button";
import { Card, CardHeader } from "../../components/ui/card";
import { Field, FieldLabel } from "../../components/ui/field";
import { Input } from "../../components/ui/input";
import type { PageProps } from "../types";
export default function SettingsPage(props: PageProps) {
const [endpoint, setEndpoint] = createSignal("");
let initialized = false;
createEffect(() => {
const state = props.state();
if (state && !initialized) {
setEndpoint(state.apiBase);
initialized = true;
}
});
return (
<Page>
<PageHeading
eyebrow="CONFIGURATION"
title="运行设置"
description="配置管理 API 连接;运行边界由后端策略统一控制。"
/>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.7fr)]">
<Card as="section">
<CardHeader>
<div>
<h2> API</h2>
<p>使</p>
</div>
<Globe2 size={18} class="text-cyan-strong" />
</CardHeader>
<form
class="p-5"
onSubmit={(event) => {
event.preventDefault();
props.command({ type: "save-api-base", value: endpoint() });
}}
>
<Field>
<FieldLabel>Endpoint</FieldLabel>
<Input
type="url"
value={endpoint()}
placeholder={window.location.origin}
onInput={(event) => setEndpoint(event.currentTarget.value)}
/>
</Field>
<p class="mt-2 text-xs text-muted">
{displayApiBase(props.state()?.apiBase ?? "")}
</p>
<div class="mt-5 flex justify-end">
<Button variant="primary" type="submit">
<Save size={15} />
</Button>
</div>
</form>
</Card>
<aside class="space-y-3">
<Info
icon={ShieldCheck}
title="Sandbox"
detail="网络与 Host 能力默认拒绝,按 WIT import 精确链接。"
/>
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
</aside>
</div>
</Page>
);
}
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
const Icon = props.icon;
return (
<Card as="article" padding="small" class="flex gap-3">
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
<Icon size={16} />
</span>
<div>
<strong class="text-sm">{props.title}</strong>
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
</div>
</Card>
);
}
@@ -0,0 +1,20 @@
import type { Accessor } from "solid-js";
import type { PageLifecycle } from "../sdk/lifecycle";
import type { HostSharedState, PageCommand } from "../sdk/protocol";
export type PageProps = {
state: Accessor<HostSharedState | null>;
/**
* False while a keep-alive iframe is hidden. Page-owned polling, media or
* render loops should pause until this accessor becomes true again.
*/
active: Accessor<boolean>;
/**
* Transition events for imperative Page work.
*
* Use `onShow` to refresh stale data, `onHide` to pause background work, and
* `onUnload` for final cleanup. Subscriptions return an unsubscribe function.
*/
lifecycle: PageLifecycle;
command: (command: PageCommand) => void;
};
@@ -0,0 +1,114 @@
import { CloudUpload, Download, FileCode2, Search } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/feedback/empty-state";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { Button, IconLink } from "../../components/ui/button";
import { Card, CardHeader } from "../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { witPackageDownloadUrl } from "../../lib/api";
import type { PageProps } from "../types";
export default function WitPackagesPage(props: PageProps) {
const packages = createMemo(() => {
const state = props.state();
const query = state?.query.trim().toLowerCase() ?? "";
return (
state?.snapshot?.witPackages.filter(
(item) =>
!query ||
item.name.toLowerCase().includes(query) ||
item.version.toLowerCase().includes(query) ||
item.sha256.toLowerCase().includes(query),
) ?? []
);
});
return (
<Page>
<PageHeading
eyebrow="INTERFACES"
title="WIT 包"
description="管理不可变、版本化的 Component 接口依赖。"
actions={
<Button variant="primary" onClick={() => props.command({ type: "open-wit-publish" })}>
<CloudUpload size={16} />
WIT
</Button>
}
/>
<Card as="section">
<CardHeader>
<div>
<h2>Registry</h2>
<p>{packages().length} </p>
</div>
</CardHeader>
<Show
when={packages().length > 0}
fallback={
<EmptyState icon={Search} title="没有匹配的 WIT 包" detail="发布或调整搜索关键词。" />
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>SHA-256</TableHead>
<TableHead>
<span class="sr-only"></span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={packages()}>
{(item) => (
<TableRow>
<TableCell>
<span class="flex items-center gap-2">
<FileCode2 size={15} class="text-cyan-strong" />
<strong>{item.name}</strong>
</span>
</TableCell>
<TableCell class="font-mono text-xs">{item.version}</TableCell>
<TableCell class="text-xs text-muted">
{item.dependencies.length
? item.dependencies
.map((dependency) => `${dependency.name}@${dependency.version}`)
.join(", ")
: "无"}
</TableCell>
<TableCell
class="max-w-64 truncate font-mono text-xs text-muted"
title={item.sha256}
>
{item.sha256}
</TableCell>
<TableCell class="text-right">
<IconLink
aria-label={`下载 ${item.name}@${item.version}`}
title="下载"
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
>
<Download size={15} />
</IconLink>
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</Show>
</Card>
</Page>
);
}
@@ -0,0 +1,80 @@
import { createSignal, onCleanup, onMount } from "solid-js";
import type { View } from "../pages/registry";
import { createPageLifecycleDispatcher, type PageLifecycleEventType } from "../sdk/lifecycle";
import { isHostMessage, type HostSharedState, type PageCommand } from "../sdk/protocol";
import { notifyReady, sendCommand } from "../sdk/page";
/**
* Connects one iframe document to the persistent Host Shell.
*
* A keep-alive document remains mounted while inactive and exposes that state
* through `active`. Host `dispose`, browser `pagehide` and Solid cleanup all
* converge on one deduplicated SDK unload transition. The document boundary
* itself remains the final guarantee that an evicted Page releases its realm.
*/
export function createFrameBridge(view: View) {
const [state, setState] = createSignal<HostSharedState | null>(null);
const [active, setActive] = createSignal(false);
const pageLifecycle = createPageLifecycleDispatcher();
const receive = (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
if (!isHostMessage(event.data)) return;
if (event.data.type === "state" && event.data.state.view === view) {
setState(event.data.state);
}
if (event.data.type === "lifecycle") {
const phase = event.data.phase;
const lifecycleEvent = pageLifecycle.dispatch(eventForPhase(phase), "host");
if (!lifecycleEvent) return;
setActive(lifecycleEvent.current === "visible");
// Preserve the original DOM events for existing pages. New code should
// use the typed SDK lifecycle returned by this bridge.
window.dispatchEvent(
new CustomEvent("wasmeld:lifecycle", {
detail: { phase },
}),
);
window.dispatchEvent(new Event(`wasmeld:${phase}`));
}
};
onMount(() => {
window.addEventListener("message", receive);
const unload = (event: PageTransitionEvent) => {
// A persisted document is frozen in the back-forward cache, not unloaded.
// Its Host and iframe will resume together when the browser restores it.
if (event.persisted) return;
if (pageLifecycle.dispatch("unload", "document")) setActive(false);
};
window.addEventListener("pagehide", unload);
notifyReady(view);
onCleanup(() => window.removeEventListener("pagehide", unload));
});
onCleanup(() => {
window.removeEventListener("message", receive);
pageLifecycle.dispatch("unload", "document");
});
return {
state,
active,
lifecycle: pageLifecycle.lifecycle,
command(command: PageCommand) {
sendCommand(view, command);
},
};
}
function eventForPhase(phase: "activate" | "deactivate" | "dispose"): PageLifecycleEventType {
switch (phase) {
case "activate":
return "show";
case "deactivate":
return "hide";
case "dispose":
return "unload";
}
}
@@ -0,0 +1,39 @@
import type { View } from "../pages/registry";
import {
SDK_CHANNEL,
SDK_VERSION,
type HostMessage,
type HostSharedState,
type PageLifecyclePhase,
} from "./protocol";
export function sendState(target: Window, state: HostSharedState): void {
send(target, {
channel: SDK_CHANNEL,
version: SDK_VERSION,
source: "host",
type: "state",
state,
});
}
export function sendLifecycle(target: Window, phase: PageLifecyclePhase): void {
send(target, {
channel: SDK_CHANNEL,
version: SDK_VERSION,
source: "host",
type: "lifecycle",
phase,
});
}
export function pageUrl(view: View, instance: number): string {
const url = new URL("/page.html", window.location.origin);
url.searchParams.set("view", view);
url.searchParams.set("instance", instance.toString());
return `${url.pathname}${url.search}`;
}
function send(target: Window, message: HostMessage): void {
target.postMessage(message, window.location.origin);
}
@@ -0,0 +1,124 @@
export type PageLifecycleEventType = "show" | "hide" | "unload";
export type PageLifecycleState = "initializing" | "visible" | "hidden" | "unloaded";
export type PageLifecycleSource = "host" | "document";
export type PageLifecycleEvent = {
type: PageLifecycleEventType;
previous: PageLifecycleState;
current: PageLifecycleState;
/**
* `host` means the event came from iframe navigation policy. `document`
* means the browser is discarding the document and acts as an unload
* fallback when a Host `dispose` message cannot be delivered first.
*/
source: PageLifecycleSource;
};
export type PageLifecycleListener = (event: PageLifecycleEvent) => void;
/**
* Framework-independent lifecycle exposed to every Page.
*
* Event subscriptions are best for imperative work at a transition boundary:
* refresh data on show, pause a stream on hide, and close external resources
* on unload. Each method returns an idempotent unsubscribe function that a UI
* framework should call during component cleanup.
*/
export type PageLifecycle = {
state: () => PageLifecycleState;
/** Subscribes only to future transitions of the selected type. */
on: (type: PageLifecycleEventType, listener: PageLifecycleListener) => () => void;
/**
* Subscribes to show and immediately replays it when already visible.
*
* Replay prevents a lazy-loaded Page from missing the initial Host event.
*/
onShow: (listener: PageLifecycleListener) => () => void;
/** Subscribes to hide and immediately replays it when already hidden. */
onHide: (listener: PageLifecycleListener) => () => void;
onUnload: (listener: PageLifecycleListener) => () => void;
};
export type PageLifecycleDispatcher = {
lifecycle: PageLifecycle;
/**
* Applies one lifecycle transition. Repeated and post-unload transitions are
* ignored, so Host `dispose` followed by browser `pagehide` emits one unload.
*/
dispatch: (
type: PageLifecycleEventType,
source: PageLifecycleSource,
) => PageLifecycleEvent | null;
};
/**
* Creates the lifecycle state machine for one iframe document.
*
* This module deliberately has no Solid or DOM dependency. A React, Vue or
* other Page adapter can expose the same object without changing SDK behavior.
*/
export function createPageLifecycleDispatcher(): PageLifecycleDispatcher {
let state: PageLifecycleState = "initializing";
let lastEvent: PageLifecycleEvent | null = null;
const listeners: Record<PageLifecycleEventType, Set<PageLifecycleListener>> = {
show: new Set(),
hide: new Set(),
unload: new Set(),
};
function on(type: PageLifecycleEventType, listener: PageLifecycleListener): () => void {
if (state === "unloaded") return () => {};
listeners[type].add(listener);
return () => listeners[type].delete(listener);
}
function onCurrent(type: "show" | "hide", listener: PageLifecycleListener): () => void {
const unsubscribe = on(type, listener);
if (lastEvent?.type === type) listener(lastEvent);
return unsubscribe;
}
const lifecycle: PageLifecycle = {
state: () => state,
on,
onShow: (listener) => onCurrent("show", listener),
onHide: (listener) => onCurrent("hide", listener),
onUnload: (listener) => on("unload", listener),
};
function dispatch(
type: PageLifecycleEventType,
source: PageLifecycleSource,
): PageLifecycleEvent | null {
if (state === "unloaded") return null;
const current = stateFor(type);
if (current === state) return null;
const event: PageLifecycleEvent = {
type,
previous: state,
current,
source,
};
state = current;
lastEvent = event;
for (const listener of [...listeners[type]]) listener(event);
if (current === "unloaded") {
for (const subscriptions of Object.values(listeners)) subscriptions.clear();
}
return event;
}
return { lifecycle, dispatch };
}
function stateFor(type: PageLifecycleEventType): PageLifecycleState {
switch (type) {
case "show":
return "visible";
case "hide":
return "hidden";
case "unload":
return "unloaded";
}
}
@@ -0,0 +1,36 @@
import type { View } from "../pages/registry";
import { SDK_CHANNEL, SDK_VERSION, type PageCommand, type PageMessage } from "./protocol";
export type {
PageLifecycle,
PageLifecycleEvent,
PageLifecycleEventType,
PageLifecycleListener,
PageLifecycleSource,
PageLifecycleState,
} from "./lifecycle";
export function notifyReady(view: View): void {
send({
channel: SDK_CHANNEL,
version: SDK_VERSION,
source: "page",
type: "ready",
view,
});
}
export function sendCommand(view: View, command: PageCommand): void {
send({
channel: SDK_CHANNEL,
version: SDK_VERSION,
source: "page",
type: "command",
view,
command,
});
}
function send(message: PageMessage): void {
window.parent.postMessage(message, window.location.origin);
}
@@ -0,0 +1,110 @@
import type { BackendSnapshot } from "../lib/api";
import type { ConnectionState } from "../lib/model";
import type { View } from "../pages/registry";
export const SDK_CHANNEL = "wasmeld-console";
/**
* Compatibility version for the same-origin Host/Page document protocol.
*
* Additive optional messages may keep the version. Removing a field or
* changing its meaning requires a version bump and an atomic Host/Page
* release, which is naturally provided by the embedded Console binary.
*/
export const SDK_VERSION = 1;
export type RuntimeAction = "start" | "stop" | "restart";
export type ServiceAction = "start" | "stop" | "restart";
export type PageLifecyclePhase = "activate" | "deactivate" | "dispose";
/**
* Control-plane state shared unconditionally between same-origin Host tabs.
*
* Navigation, search, dialogs, notifications and iframe caches are excluded:
* those values describe one browser tab rather than the Wasmeld runtime.
*/
export type HostGlobalState = {
snapshot: BackendSnapshot | null;
connection: ConnectionState;
apiBase: string;
runtimeAction: RuntimeAction | null;
serviceAction: string | null;
deploymentAction: string | null;
};
export type HostSharedState = HostGlobalState & {
// This is a serializable snapshot, never a Solid signal crossing realms.
view: View;
query: string;
};
export type PageCommand =
| { type: "refresh" }
| { type: "navigate"; view: View }
| { type: "open-register" }
| { type: "open-wit-publish" }
| { type: "open-invoke"; serviceKey: string }
| { type: "open-activate"; serviceKey: string }
| { type: "runtime-action"; action: RuntimeAction }
| { type: "service-action"; serviceKey: string; action: ServiceAction }
| { type: "save-api-base"; value: string };
export type PageMessage =
| {
channel: typeof SDK_CHANNEL;
version: typeof SDK_VERSION;
source: "page";
type: "ready";
view: View;
}
| {
channel: typeof SDK_CHANNEL;
version: typeof SDK_VERSION;
source: "page";
type: "command";
view: View;
command: PageCommand;
};
export type HostMessage =
| {
channel: typeof SDK_CHANNEL;
version: typeof SDK_VERSION;
source: "host";
type: "state";
state: HostSharedState;
}
| {
channel: typeof SDK_CHANNEL;
version: typeof SDK_VERSION;
source: "host";
type: "lifecycle";
phase: PageLifecyclePhase;
};
export function isPageMessage(value: unknown): value is PageMessage {
if (!isRecord(value)) return false;
return (
value.channel === SDK_CHANNEL &&
value.version === SDK_VERSION &&
value.source === "page" &&
(value.type === "ready" || value.type === "command")
);
}
export function isHostMessage(value: unknown): value is HostMessage {
if (!isRecord(value)) return false;
if (value.channel !== SDK_CHANNEL || value.version !== SDK_VERSION || value.source !== "host") {
return false;
}
if (value.type === "state") return isRecord(value.state);
return value.type === "lifecycle" && isPageLifecyclePhase(value.phase);
}
function isPageLifecyclePhase(value: unknown): value is PageLifecyclePhase {
return value === "activate" || value === "deactivate" || value === "dispose";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,75 @@
@import "tailwindcss";
@theme {
--color-canvas: #f3f6f5;
--color-surface: #ffffff;
--color-line: #d8e0de;
--color-ink: #17211f;
--color-muted: #64716e;
--color-brand: #1f775e;
--color-brand-strong: #155a46;
--color-brand-soft: #e7f3ef;
--color-cyan-soft: #e5f5f7;
--color-cyan-strong: #147386;
--color-amber-soft: #fff3d8;
--color-amber-strong: #9b6507;
--color-coral-soft: #ffebe5;
--color-coral-strong: #b9492f;
--font-sans:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC",
sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
@layer base {
* {
box-sizing: border-box;
letter-spacing: 0;
}
html,
body,
#app {
min-height: 100%;
margin: 0;
}
body {
@apply bg-canvas font-sans text-ink antialiased;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
@apply outline-2 outline-offset-2 outline-brand;
}
.frame-root,
.frame-document {
@apply overflow-x-hidden bg-canvas;
}
}
@keyframes loading-progress-swing {
from {
transform: translateX(0);
}
to {
transform: translateX(178%);
}
}
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { readFile, stat } from "node:fs/promises";
import test from "node:test";
test("build emits independent host and iframe documents", async () => {
const [host, page] = await Promise.all([
readFile(new URL("../dist/index.html", import.meta.url), "utf8"),
readFile(new URL("../dist/page.html", import.meta.url), "utf8"),
]);
assert.match(host, /Wasmeld Console/);
assert.match(page, /Wasmeld Console Page/);
assert.notEqual(host, page);
});
test("build emits an installable PWA worker and manifest", async () => {
const manifest = JSON.parse(
await readFile(new URL("../dist/manifest.webmanifest", import.meta.url), "utf8"),
);
assert.equal(manifest.short_name, "Wasmeld");
assert.equal(manifest.start_url, "/");
assert.ok((await stat(new URL("../dist/sw.js", import.meta.url))).size > 0);
});
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import test from "node:test";
const uiDirectory = new URL("../src/components/ui/", import.meta.url);
const appStylesheet = new URL("../src/styles/app.css", import.meta.url);
test("UI primitives do not depend on page, Host or domain modules", async () => {
const files = (await readdir(uiDirectory)).filter(
(name) => name.endsWith(".ts") || name.endsWith(".tsx"),
);
for (const file of files) {
const source = await readFile(new URL(file, uiDirectory), "utf8");
const imports = [...source.matchAll(/from\s+["']([^"']+)["']/g)].map((match) => match[1]);
for (const specifier of imports) {
assert.doesNotMatch(
specifier,
/(?:^|\/)(?:feedback|host|layout|pages|sdk|services)(?:\/|$)|\/lib\/(?:api|model|view-state)$/,
`${file} must remain business-independent: ${specifier}`,
);
}
}
});
test("global stylesheet does not own component variants", async () => {
const source = await readFile(appStylesheet, "utf8");
assert.doesNotMatch(
source,
/@layer\s+components/,
"component classes and variants belong beside their Solid components",
);
});
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import test from "node:test";
import { FrameCache } from "../src/host/frame-cache.ts";
test("retains opted-in iframe identities across navigation", () => {
const cache = new FrameCache("services", {
inactiveTtlMs: 1_000,
shouldKeepAlive: (view) => view === "services" || view === "settings",
});
const services = cache.snapshot()[0];
const away = cache.activate("overview");
assert.deepEqual(
away.entries.map((entry) => entry.view),
["services", "overview"],
);
const back = cache.activate("services");
assert.equal(back.activated, services);
assert.equal(back.activated.instance, services.instance);
assert.deepEqual(
back.removed.map((entry) => entry.view),
["overview"],
);
});
test("recreates non-keep-alive documents after leaving them", () => {
const cache = new FrameCache("overview", {
inactiveTtlMs: 1_000,
shouldKeepAlive: () => false,
});
const firstInstance = cache.snapshot()[0].instance;
cache.activate("activity");
const transition = cache.activate("overview");
assert.notEqual(transition.activated.instance, firstInstance);
assert.deepEqual(
transition.removed.map((entry) => entry.view),
["activity"],
);
});
test("expires a keep-alive document after its continuous hidden TTL", () => {
let now = 5_000;
const cache = new FrameCache("services", {
inactiveTtlMs: 1_000,
shouldKeepAlive: () => true,
now: () => now,
});
const servicesInstance = cache.snapshot()[0].instance;
// Time spent visible does not consume the inactive TTL.
now = 50_000;
cache.activate("settings");
now = 50_999;
assert.equal(cache.timeUntilExpiration(), 1);
assert.deepEqual(cache.expireInactive().removed, []);
now = 51_000;
const expiration = cache.expireInactive();
assert.deepEqual(
expiration.removed.map((entry) => entry.view),
["services"],
);
assert.deepEqual(
expiration.entries.map((entry) => entry.view),
["settings"],
);
const recreated = cache.activate("services");
assert.notEqual(recreated.activated.instance, servicesInstance);
});
test("revisiting a hidden page before its TTL preserves and resets it", () => {
let now = 0;
const cache = new FrameCache("services", {
inactiveTtlMs: 1_000,
shouldKeepAlive: () => true,
now: () => now,
});
const services = cache.snapshot()[0];
cache.activate("settings");
now = 999;
const revisited = cache.activate("services");
assert.equal(revisited.activated, services);
now = 1_500;
assert.deepEqual(cache.expireInactive().removed, []);
assert.equal(cache.timeUntilExpiration(), 499);
now = 1_999;
const expiration = cache.expireInactive();
assert.deepEqual(
expiration.removed.map((entry) => entry.view),
["settings"],
);
});
test("rejects invalid inactive TTL values", () => {
for (const inactiveTtlMs of [0, -1, Number.POSITIVE_INFINITY, Number.NaN]) {
assert.throws(
() =>
new FrameCache("overview", {
inactiveTtlMs,
shouldKeepAlive: () => true,
}),
RangeError,
);
}
});
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createPageLifecycleDispatcher } from "../src/sdk/lifecycle.ts";
test("maps page visibility transitions to show, hide and unload events", () => {
const dispatcher = createPageLifecycleDispatcher();
const events = [];
dispatcher.lifecycle.onShow((event) => events.push(event));
dispatcher.lifecycle.onHide((event) => events.push(event));
dispatcher.lifecycle.onUnload((event) => events.push(event));
assert.equal(dispatcher.lifecycle.state(), "initializing");
dispatcher.dispatch("show", "host");
dispatcher.dispatch("hide", "host");
dispatcher.dispatch("show", "host");
dispatcher.dispatch("unload", "host");
assert.equal(dispatcher.lifecycle.state(), "unloaded");
assert.deepEqual(
events.map(({ type, previous, current, source }) => ({
type,
previous,
current,
source,
})),
[
{ type: "show", previous: "initializing", current: "visible", source: "host" },
{ type: "hide", previous: "visible", current: "hidden", source: "host" },
{ type: "show", previous: "hidden", current: "visible", source: "host" },
{ type: "unload", previous: "visible", current: "unloaded", source: "host" },
],
);
});
test("deduplicates lifecycle events and ignores transitions after unload", () => {
const dispatcher = createPageLifecycleDispatcher();
const events = [];
dispatcher.lifecycle.on("show", (event) => events.push(event.type));
dispatcher.lifecycle.on("unload", (event) => events.push(event.type));
assert.equal(dispatcher.dispatch("show", "host")?.type, "show");
assert.equal(dispatcher.dispatch("show", "host"), null);
assert.equal(dispatcher.dispatch("unload", "host")?.type, "unload");
assert.equal(dispatcher.dispatch("unload", "document"), null);
assert.equal(dispatcher.dispatch("show", "host"), null);
assert.deepEqual(events, ["show", "unload"]);
});
test("unsubscribe removes a page lifecycle listener", () => {
const dispatcher = createPageLifecycleDispatcher();
let calls = 0;
const unsubscribe = dispatcher.lifecycle.onHide(() => {
calls += 1;
});
unsubscribe();
unsubscribe();
dispatcher.dispatch("hide", "host");
assert.equal(calls, 0);
});
test("show and hide helpers replay current state for lazy pages", () => {
const dispatcher = createPageLifecycleDispatcher();
dispatcher.dispatch("show", "host");
const replayed = [];
dispatcher.lifecycle.onShow((event) => replayed.push(event.type));
dispatcher.lifecycle.onHide((event) => replayed.push(event.type));
dispatcher.lifecycle.on("show", (event) => replayed.push(`future:${event.type}`));
assert.deepEqual(replayed, ["show"]);
dispatcher.dispatch("hide", "host");
dispatcher.lifecycle.onHide((event) => replayed.push(`late:${event.type}`));
assert.deepEqual(replayed, ["show", "hide", "late:hide"]);
});
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import test from "node:test";
import { HostTabSync } from "../src/host/tab-sync.ts";
class FakeBus {
channels = new Set();
messages = [];
createChannel() {
const channel = new FakeChannel(this);
this.channels.add(channel);
return channel;
}
deliver(sender, message) {
const copy = structuredClone(message);
this.messages.push(copy);
for (const channel of this.channels) {
if (channel !== sender) channel.receive(copy);
}
}
replay(message) {
for (const channel of this.channels) channel.receive(structuredClone(message));
}
}
class FakeChannel {
listeners = new Set();
constructor(bus) {
this.bus = bus;
}
postMessage(message) {
this.bus.deliver(this, message);
}
addEventListener(type, listener) {
if (type === "message") this.listeners.add(listener);
}
removeEventListener(type, listener) {
if (type === "message") this.listeners.delete(listener);
}
receive(data) {
for (const listener of this.listeners) listener({ data });
}
close() {
this.bus.channels.delete(this);
this.listeners.clear();
}
}
test("new tabs request and receive the current Host state", () => {
const bus = new FakeBus();
const firstState = hostState("http://runtime-a.test");
const received = [];
const first = new HostTabSync({
tabId: "tab-a",
channel: bus.createChannel(),
getState: () => firstState,
applyState: () => {},
});
const second = new HostTabSync({
tabId: "tab-b",
channel: bus.createChannel(),
getState: () => hostState("http://runtime-b.test"),
applyState: (state) => received.push(state),
});
assert.deepEqual(received, [firstState]);
first.close();
second.close();
});
test("publishes Host state but rejects self, stale and malformed messages", () => {
const bus = new FakeBus();
const received = [];
const first = new HostTabSync({
tabId: "tab-a",
channel: bus.createChannel(),
getState: () => hostState(""),
applyState: (state) => received.push(state),
});
const second = new HostTabSync({
tabId: "tab-b",
channel: bus.createChannel(),
getState: () => hostState(""),
applyState: () => {},
});
received.length = 0;
const update = hostState("http://shared.test");
second.publish(update);
assert.deepEqual(received, [update]);
const stateMessage = bus.messages.findLast(
(message) => message.type === "state" && message.sender === "tab-b",
);
bus.replay(stateMessage);
bus.replay({
...stateMessage,
sequence: stateMessage.sequence + 1,
state: { ...stateMessage.state, connection: "invalid" },
});
assert.deepEqual(received, [update]);
first.close();
second.close();
});
function hostState(apiBase) {
return {
snapshot: null,
connection: "online",
apiBase,
runtimeAction: null,
serviceAction: null,
deploymentAction: null,
};
}
@@ -1,17 +1,17 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "module": "ESNext",
"module": "esnext", "moduleResolution": "Bundler",
"moduleResolution": "bundler",
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"] "types": ["vite/client"]
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"], "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
"exclude": ["node_modules"] "exclude": ["dist", "node_modules"]
} }
+88
View File
@@ -0,0 +1,88 @@
import tailwindcss from "@tailwindcss/vite";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { defineConfig, type Plugin } from "vite";
import solid from "vite-plugin-solid";
const outputDirectory = process.env.WASMELD_WEB_OUT_DIR ?? "dist";
export default defineConfig({
server: {
port: 3000,
},
plugins: [solid(), tailwindcss(), pwaWorker()],
build: {
outDir: outputDirectory,
emptyOutDir: true,
rollupOptions: {
// Host and Page remain separate HTML documents. Dynamic page imports
// then create one lazy chunk per primary management view.
input: {
host: resolve(import.meta.dirname, "index.html"),
page: resolve(import.meta.dirname, "page.html"),
},
},
},
});
function pwaWorker(): Plugin {
return {
name: "wasmeld-pwa-worker",
apply: "build",
generateBundle(_, bundle) {
// Derive the cache name from emitted filenames and unhashed public
// assets. A changed build activates a new cache without another PWA
// dependency or a generated source file in the repository.
const publicFiles = ["icon.svg", "manifest.webmanifest", "og.png"];
const generatedFiles = Object.keys(bundle).filter((file) =>
/\.(?:html|js|css|svg|png|webmanifest)$/.test(file),
);
const files = [...new Set([...generatedFiles, ...publicFiles])].sort();
const digest = createHash("sha256");
for (const file of files) digest.update(file);
for (const file of publicFiles) {
digest.update(readFileSync(resolve(import.meta.dirname, "public", file)));
}
const cacheName = `wasmeld-${digest.digest("hex").slice(0, 16)}`;
const urls = files.map((file) => `/${file}`);
this.emitFile({
type: "asset",
fileName: "sw.js",
source: serviceWorkerSource(cacheName, urls),
});
},
};
}
function serviceWorkerSource(cacheName: string, urls: string[]): string {
// Runtime state must never be satisfied from an old cache. Only documents
// and build assets participate in offline behavior.
return `const CACHE = ${JSON.stringify(cacheName)};
const PRECACHE = ${JSON.stringify(urls)};
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key.startsWith("wasmeld-") && key !== CACHE).map((key) => caches.delete(key)))).then(() => self.clients.claim()));
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || url.pathname === "/healthz" || url.pathname.startsWith("/api/")) return;
if (event.request.mode === "navigate") {
event.respondWith(fetch(event.request).then((response) => {
const copy = response.clone();
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
return response;
}).catch(async () => {
const cached = await caches.match(event.request);
if (cached) return cached;
return caches.match(url.pathname === "/page.html" ? "/page.html" : "/index.html");
}));
return;
}
event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request)));
});
`;
}
+17 -1
View File
@@ -515,6 +515,15 @@ impl ResidentSession {
self.actor.key() self.actor.key()
} }
/// Returns the largest stream chunk that a driver may deliver.
///
/// A stream driver should size its read buffer to this value or a smaller
/// protocol-specific limit. The Runtime applies the same limit to both
/// incoming `stream-data` events and Component `write-stream` effects.
pub fn max_stream_chunk_bytes(&self) -> usize {
self.actor.resident_stream_chunk_limit()
}
/// Registers a configured listener or datagram endpoint after policy checks. /// Registers a configured listener or datagram endpoint after policy checks.
/// ///
/// Registration does not bind an operating-system socket. The driver binds /// Registration does not bind an operating-system socket. The driver binds
@@ -664,6 +673,7 @@ impl ResidentSession {
reason: StreamCloseReason, reason: StreamCloseReason,
) -> Result<Vec<ResidentOperation>, ResidentHostError> { ) -> Result<Vec<ResidentOperation>, ResidentHostError> {
self.ensure_kind(stream_id, ResidentResourceKind::Stream, "stream")?; self.ensure_kind(stream_id, ResidentResourceKind::Stream, "stream")?;
*self.stream_state_mut(stream_id)? = StreamState::Closing;
let result = self.dispatch(ResidentEvent::StreamClosed { stream_id, reason }); let result = self.dispatch(ResidentEvent::StreamClosed { stream_id, reason });
self.resources.remove(&stream_id); self.resources.remove(&stream_id);
result result
@@ -1056,7 +1066,13 @@ impl ResidentSession {
} }
impl ResidentPolicy { impl ResidentPolicy {
fn validate_endpoint(&self, endpoint: &ResidentEndpoint) -> Result<(), ResidentHostError> { /// Validates one endpoint without registering or binding it.
///
/// Management layers can call this while loading static configuration so
/// an address outside policy fails before any Actor is started. Drivers
/// must still register the endpoint immediately before bind because policy
/// is also a runtime ownership boundary.
pub fn validate_endpoint(&self, endpoint: &ResidentEndpoint) -> Result<(), ResidentHostError> {
match endpoint { match endpoint {
ResidentEndpoint::Tcp { bind, .. } if !self.tcp_listen.allows(*bind) => { ResidentEndpoint::Tcp { bind, .. } if !self.tcp_listen.allows(*bind) => {
Err(ResidentHostError::EndpointDenied(format!( Err(ResidentHostError::EndpointDenied(format!(
+23
View File
@@ -615,10 +615,33 @@ impl ActorHandle {
self.execution self.execution
} }
/// Returns whether two handles address the exact same Actor instance.
///
/// Service keys are revision identities and therefore survive an Actor
/// fault/restart. Host resource supervisors use this stronger check to
/// retain themselves for an idempotent start but replace stale resources
/// when the Runtime created a fresh Store for the same revision.
pub fn is_same_instance(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.status, &other.status)
}
/// Returns the immutable mailbox capacity configured for this Actor.
///
/// Host supervisors use the same bound for their external event queue so a
/// restored revision retains the limits stored in its own manifest instead
/// of inheriting newer process defaults.
pub fn mailbox_capacity(&self) -> usize {
self.limits.mailbox_capacity
}
pub(crate) fn resident_resource_limit(&self) -> usize { pub(crate) fn resident_resource_limit(&self) -> usize {
self.limits.resident.max_resources self.limits.resident.max_resources
} }
pub(crate) fn resident_stream_chunk_limit(&self) -> usize {
self.limits.resident.max_stream_chunk_bytes
}
/// Sends an invocation and waits up to the remaining service deadline. /// Sends an invocation and waits up to the remaining service deadline.
/// ///
/// Cloned handles compete for the same bounded mailbox. A Wasm trap is /// Cloned handles compete for the same bounded mailbox. A Wasm trap is
@@ -96,12 +96,16 @@ fn faulted_actor_can_be_started_again() {
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start"); let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
let (key, actor) = start_component(&runtime, "fault", "fault_component.wasm"); let (key, actor) = start_component(&runtime, "fault", "fault_component.wasm");
let already_running = runtime.start(&key, Vec::new()).unwrap();
assert!(actor.is_same_instance(&already_running));
assert!(matches!( assert!(matches!(
actor.invoke(b"fault".to_vec()), actor.invoke(b"fault".to_vec()),
Err(RuntimeError::ActorFault { .. }) Err(RuntimeError::ActorFault { .. })
)); ));
let restarted = runtime.start(&key, Vec::new()).unwrap(); let restarted = runtime.start(&key, Vec::new()).unwrap();
assert!(!actor.is_same_instance(&restarted));
assert_eq!(restarted.invoke(b"ready".to_vec()).unwrap(), b"ready"); assert_eq!(restarted.invoke(b"ready".to_vec()).unwrap(), b"ready");
runtime.stop(&key).unwrap(); runtime.stop(&key).unwrap();