Compare commits

..

5 Commits

Author SHA1 Message Date
Maofeng c399781369 docs: document Wasmeld architecture and workflows
- describe resident Runtime boundaries and sandbox guarantees\n- specify component archive and host capability contracts\n- document Console ownership and persistence behavior\n- explain versioned WIT Registry, lock files, and path replace\n- provide startup, packaging, publication, and dependency commands
2026-07-27 05:03:24 +08:00
Maofeng d32895f5ca feat(console-ui): add TanStack management console
- provide Runtime overview, service lifecycle, invocation, and event views\n- add immutable WIT package listing, publication, and download workflows\n- connect all state to the Rust management API without direct database access\n- use TanStack Start with Node serving, Oxlint, Oxfmt, and SSR checks\n- include responsive operational layouts and empty Actor placeholders
2026-07-27 05:02:35 +08:00
Maofeng b7fffd0d0f feat(console): add runtime management and WIT Registry
- embed wasmeld-runtime behind an Axum management API\n- persist service metadata, metrics, and events with Toasty and libSQL\n- register immutable component artifacts with platform-owned limits\n- publish, list, inspect, and download binary WIT package versions\n- verify Runtime lifecycle, persistence, Registry immutability, and transitive fetch
2026-07-27 05:01:26 +08:00
Maofeng b63cf8c9eb feat(runtime): add resident Wasmtime actor runtime
- define versioned service and clock WIT contracts\n- enforce import allowlists, fuel, epoch, memory, I/O, and mailbox limits\n- keep one Store and Component Instance resident per serial Actor\n- add echo, counter, fault, spin, and capability probe components\n- cover lifecycle, concurrency, sandbox, and fault recovery behavior
2026-07-27 04:59:33 +08:00
Maofeng 892fc96f71 feat(package): add component and WIT package tooling
- define the constrained .wasmpkg archive and integrity checks\n- encode and inspect standard binary WIT packages\n- resolve exact Registry dependencies with wit.lock and path replace\n- provide wasmeld pack and wit build/fetch/publish CLI commands
2026-07-27 04:57:34 +08:00
138 changed files with 6570 additions and 13277 deletions
-1
View File
@@ -4,4 +4,3 @@
/.idea/ /.idea/
/components/*/wit/deps/ /components/*/wit/deps/
/components/*/wit/.deps.* /components/*/wit/.deps.*
/docs/design/
-26
View File
@@ -1,26 +0,0 @@
{
"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
+18 -135
View File
@@ -673,7 +673,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [ dependencies = [
"thiserror 2.0.19", "thiserror",
] ]
[[package]] [[package]]
@@ -1042,27 +1042,6 @@ dependencies = [
"crypto-common", "crypto-common",
] ]
[[package]]
name = "directories-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@@ -1844,25 +1823,6 @@ 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"
@@ -1977,7 +1937,7 @@ dependencies = [
"jni-sys", "jni-sys",
"log", "log",
"simd_cesu8", "simd_cesu8",
"thiserror 2.0.19", "thiserror",
"walkdir", "walkdir",
"windows-link", "windows-link",
] ]
@@ -2035,13 +1995,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "kv-probe-component"
version = "0.1.0"
dependencies = [
"wit-bindgen 0.41.0",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -2097,15 +2050,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "libredox"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "linkme" name = "linkme"
version = "0.3.37" version = "0.3.37"
@@ -2703,7 +2647,7 @@ dependencies = [
"rustc-hash 2.1.3", "rustc-hash 2.1.3",
"rustls", "rustls",
"socket2", "socket2",
"thiserror 2.0.19", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@@ -2726,7 +2670,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror 2.0.19", "thiserror",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@@ -2905,17 +2849,6 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "regalloc2" name = "regalloc2"
version = "0.13.5" version = "0.13.5"
@@ -2999,13 +2932,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "resident-probe-component"
version = "0.1.0"
dependencies = [
"wit-bindgen 0.41.0",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3649,7 +3575,7 @@ dependencies = [
"tantivy-stacker", "tantivy-stacker",
"tantivy-tokenizer-api", "tantivy-tokenizer-api",
"tempfile", "tempfile",
"thiserror 2.0.19", "thiserror",
"time", "time",
"typetag", "typetag",
"uuid", "uuid",
@@ -3785,33 +3711,13 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.19" version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [ dependencies = [
"thiserror-impl 2.0.19", "thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
] ]
[[package]] [[package]]
@@ -4171,7 +4077,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"symlink", "symlink",
"thiserror 2.0.19", "thiserror",
"time", "time",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -4239,7 +4145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b6c49aecd4abae3ffa88ab1e28b3de7a7497a9732be828e3a6f1855b6ea80fd" checksum = "6b6c49aecd4abae3ffa88ab1e28b3de7a7497a9732be828e3a6f1855b6ea80fd"
dependencies = [ dependencies = [
"mimalloc", "mimalloc",
"thiserror 2.0.19", "thiserror",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"turso_core", "turso_core",
@@ -4306,7 +4212,7 @@ dependencies = [
"strum_macros", "strum_macros",
"tantivy", "tantivy",
"tempfile", "tempfile",
"thiserror 2.0.19", "thiserror",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"turso_ext", "turso_ext",
@@ -4351,7 +4257,7 @@ dependencies = [
"miette", "miette",
"strum", "strum",
"strum_macros", "strum_macros",
"thiserror 2.0.19", "thiserror",
"turso_macros", "turso_macros",
] ]
@@ -4399,7 +4305,7 @@ dependencies = [
"roaring", "roaring",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.19", "thiserror",
"tracing", "tracing",
"turso_core", "turso_core",
"turso_parser", "turso_parser",
@@ -4728,13 +4634,11 @@ 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",
"tempfile", "tempfile",
"thiserror 2.0.19", "thiserror",
"toasty", "toasty",
"toasty-driver-turso", "toasty-driver-turso",
"tokio", "tokio",
@@ -4757,7 +4661,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile", "tempfile",
"thiserror 2.0.19", "thiserror",
"toml", "toml",
"wit-component 0.243.0", "wit-component 0.243.0",
"wit-parser 0.243.0", "wit-parser 0.243.0",
@@ -4769,7 +4673,7 @@ name = "wasmeld-runtime"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"serde", "serde",
"thiserror 2.0.19", "thiserror",
"toml", "toml",
"wasmeld-package", "wasmeld-package",
"wasmtime", "wasmtime",
@@ -4844,7 +4748,6 @@ dependencies = [
"target-lexicon", "target-lexicon",
"wasmparser 0.243.0", "wasmparser 0.243.0",
"wasmtime-environ", "wasmtime-environ",
"wasmtime-internal-cache",
"wasmtime-internal-component-macro", "wasmtime-internal-component-macro",
"wasmtime-internal-component-util", "wasmtime-internal-component-util",
"wasmtime-internal-cranelift", "wasmtime-internal-cranelift",
@@ -4884,26 +4787,6 @@ dependencies = [
"wasmtime-internal-component-util", "wasmtime-internal-component-util",
] ]
[[package]]
name = "wasmtime-internal-cache"
version = "41.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a31a582632f47753e4a9a5412ab5edfbc05a6df9031336a2f2eff46a70a1cac"
dependencies = [
"base64 0.22.1",
"directories-next",
"log",
"postcard",
"rustix 1.1.4",
"serde",
"serde_derive",
"sha2",
"toml",
"wasmtime-environ",
"windows-sys 0.61.2",
"zstd",
]
[[package]] [[package]]
name = "wasmtime-internal-component-macro" name = "wasmtime-internal-component-macro"
version = "41.0.0" version = "41.0.0"
@@ -4944,7 +4827,7 @@ dependencies = [
"pulley-interpreter", "pulley-interpreter",
"smallvec", "smallvec",
"target-lexicon", "target-lexicon",
"thiserror 2.0.19", "thiserror",
"wasmparser 0.243.0", "wasmparser 0.243.0",
"wasmtime-environ", "wasmtime-environ",
"wasmtime-internal-math", "wasmtime-internal-math",
@@ -5079,7 +4962,7 @@ dependencies = [
"io-lifetimes", "io-lifetimes",
"rustix 1.1.4", "rustix 1.1.4",
"system-interface", "system-interface",
"thiserror 2.0.19", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
"url", "url",
@@ -5186,7 +5069,7 @@ dependencies = [
"regalloc2", "regalloc2",
"smallvec", "smallvec",
"target-lexicon", "target-lexicon",
"thiserror 2.0.19", "thiserror",
"wasmparser 0.243.0", "wasmparser 0.243.0",
"wasmtime-environ", "wasmtime-environ",
"wasmtime-internal-cranelift", "wasmtime-internal-cranelift",
@@ -5650,7 +5533,7 @@ dependencies = [
"flate2", "flate2",
"indexmap", "indexmap",
"memchr", "memchr",
"thiserror 2.0.19", "thiserror",
"zopfli", "zopfli",
] ]
+1 -5
View File
@@ -8,8 +8,6 @@ members = [
"components/fault", "components/fault",
"components/spin", "components/spin",
"components/clock-probe", "components/clock-probe",
"components/kv-probe",
"components/resident-probe",
"components/wasi-clock-probe", "components/wasi-clock-probe",
] ]
default-members = ["crates/wasmeld-runtime"] default-members = ["crates/wasmeld-runtime"]
@@ -23,8 +21,6 @@ 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"
@@ -38,7 +34,7 @@ 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"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] }
wasmtime = { version = "=41.0.0", default-features = false, features = ["cache", "component-model", "cranelift", "runtime", "std"] } wasmtime = { version = "=41.0.0", default-features = false, features = ["component-model", "cranelift", "runtime", "std"] }
wasmtime-wasi = { version = "=41.0.0", default-features = false, features = ["p2"] } wasmtime-wasi = { version = "=41.0.0", default-features = false, features = ["p2"] }
wit-bindgen = "=0.41.0" wit-bindgen = "=0.41.0"
wit-component = "=0.243.0" wit-component = "=0.243.0"
+5 -102
View File
@@ -4,39 +4,14 @@ Wasmeld 是面向 WebAssembly Component 的服务运行与管理平台。内部
Component,在受限 Wasmtime Sandbox 中以常驻 Actor 运行,并通过稳定 WIT 契约 Component,在受限 Wasmtime Sandbox 中以常驻 Actor 运行,并通过稳定 WIT 契约
暴露能力。 暴露能力。
Runtime 内置版本化 Host Capability Registry。注册 Component 时会直接读取其 WIT
imports,只链接实际请求且版本完全匹配的 Host 能力;未知能力会被拒绝。能力不需要在
`.wasmpkg` 中重复声明,管理面会展示每个服务版本解析出的完整接口标识。
当前 Host 能力:
- `wasmeld:clock/monotonic-clock@0.1.0`Actor 内单调时钟
- `wasmeld:kv/store@0.1.0`:按服务隔离、跨 Revision 共享的持久化二进制 KV
常驻型 Component 可额外导出 `wasmeld:resident/actor@0.1.0`。Runtime 将定时器、
TCP/UDP/Unix、消息订阅和扩展事件放入同一个有界 Actor mailboxComponent 每次只处理
一个事件并返回 effect。系统 socket、timer task 和 broker consumer 始终由 Host 持有,
Component 只能引用当前 Revision 内不复用的资源 ID,不能直接取得文件描述符或绕过
端点策略。
```text
Host driver -> ResidentSession -> Actor mailbox -> Wasm Component
Host driver <- validated operation <- raw effect <-
```
`ResidentSession` 负责 deny-by-default 的网络策略、资源归属、数量限制、流的暂停/半关闭/
关闭状态以及 effect 批量原子校验。TCP、UDP、Unix listener 等异步驱动运行在 Actor
线程外;协议级驱动以及文件监听、串口、系统信号等其它来源通过独立版本的 WIT 能力演进,
不需要扩大基础 service world。
## 结构 ## 结构
```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 源码
``` ```
@@ -48,63 +23,13 @@ wit/ Wasmeld WIT package 源码
cargo +stable run -p wasmeld-console cargo +stable run -p wasmeld-console
``` ```
后端会启动本地管理 API `127.0.0.1:8080` 和公开数据面 管理面:
`0.0.0.0:8081`。注册 Component 后,通过管理 API 激活一个版本:
```bash ```bash
curl -X POST http://127.0.0.1:8080/api/v1/deployments/echo/activate \ cd console
-H 'Content-Type: application/json' \
-d '{"revision":"0.1.0"}'
```
客户端只访问 Gateway,并以原始二进制请求调用活动版本:
```bash
curl http://127.0.0.1:8081/v1/services/echo/invoke \
-H 'Content-Type: application/octet-stream' \
--data-binary 'hello'
```
管理面开发服务器:
```bash
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`
```bash
cargo run -p wasmeld-package --bin wasmeld -- \
dev components/echo/Cargo.toml
```
首次构建和每次源码变化都会自动完成 WIT 同步、Debug 编译、打包、注册和 Deployment
切换。默认使用 `echo-dev` 这类独立服务 ID,并根据 Component 内容生成
`0.1.0-dev.h<hash>` Revision,不会覆盖正式服务。新版本构建、校验或启动失败时,上一
版本继续运行;切换成功后旧开发 Revision 会被注销并释放。编译期间的新保存会继续
排队构建,Console 暂时不可用时则复用已构建制品重试部署。
管理面每 5 秒刷新一次,可以直接在调用面板预览新版本。只构建并部署一次可使用
`--once`;其它选项包括 `--id``--console``--release``--poll-ms`。管理 API
地址也可通过 `WASMELD_CONSOLE` 设置。默认允许本地 `replace` 更新 `wit.lock`;若开发
期间不允许 WIT 契约发生变化,再添加 `--locked`
构建组件: 构建组件:
```bash ```bash
@@ -112,26 +37,6 @@ cargo run -p wasmeld-package --bin wasmeld -- \
pack components/echo/Cargo.toml --locked pack components/echo/Cargo.toml --locked
``` ```
KV 示例组件:
```bash
cargo run -p wasmeld-package --bin wasmeld -- \
pack components/kv-probe/Cargo.toml --locked
```
`kv-probe` 接受 `set:<key>:<value>``get:<key>``delete:<key>`,用于验证 Host KV
能力;它不是公开 Gateway 的业务协议。
常驻事件示例组件:
```bash
cargo run -p wasmeld-package --bin wasmeld -- \
pack components/resident-probe/Cargo.toml --locked
```
该组件同时实现基础 service world 与 resident actor export,用于验证 stream、datagram、
timer、message 和扩展 source 的事件/effect 往返。
发布 WIT Package 发布 WIT Package
```bash ```bash
@@ -141,7 +46,5 @@ cargo run -p wasmeld-package --bin wasmeld -- \
组件在 `wasmeld.toml` 中声明 Registry 和精确版本依赖,`wasmeld wit fetch` 会递归 组件在 `wasmeld.toml` 中声明 Registry 和精确版本依赖,`wasmeld wit fetch` 会递归
解析依赖、生成 `wit/deps`,并将完整解析结果记录在 `wit.lock`。本地开发可使用 解析依赖、生成 `wit/deps`,并将完整解析结果记录在 `wit.lock`。本地开发可使用
`[replace]` 临时覆盖 Registry 来源。 `[replace]` 临时覆盖 Registry 来源。详见
[`docs/design/wit-package-management.md`](docs/design/wit-package-management.md)。
完整命令、开发选项、组件包格式和 WIT 依赖说明见
[`wasmeld-package` CLI 文档](crates/wasmeld-package/README.md)。
-8
View File
@@ -1,12 +1,4 @@
//! Example service that imports the versioned Wasmeld monotonic-clock capability.
//!
//! Each response is eight little-endian timestamp bytes followed by the input.
//! The timestamp is Actor-local monotonic time, not a Unix timestamp.
mod bindings { mod bindings {
// This macro generates Rust ABI adapters at compile time from the
// materialized local WIT graph. It does not make a remote protocol call or
// embed the WIT source files; the Component carries canonical ABI types.
wit_bindgen::generate!({ wit_bindgen::generate!({
path: "wit", path: "wit",
world: "clock-probe-component", world: "clock-probe-component",
+2 -2
View File
@@ -4,12 +4,12 @@ schema_version = 1
name = "wasmeld:clock" name = "wasmeld:clock"
version = "0.1.0" version = "0.1.0"
source = "path+../../wit/clock" source = "path+../../wit/clock"
sha256 = "43ece485a550eca94cf06cb711f0b42c9f4977d275e9a042c3ac84eeb1f941e1" sha256 = "93079c69d1b9cb3afc28da6fe4a7e37f724d0a09e780c847f8524d3920a90960"
replaced = true replaced = true
[[package]] [[package]]
name = "wasmeld:service" name = "wasmeld:service"
version = "0.1.0" version = "0.1.0"
source = "path+../../wit/service" source = "path+../../wit/service"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e" sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
replaced = true replaced = true
-2
View File
@@ -1,7 +1,5 @@
package component:clock-probe@0.1.0; package component:clock-probe@0.1.0;
/// Base service exports plus the exact monotonic-clock Host capability used by
/// this Component. Other Wasmeld capabilities are not linked implicitly.
world clock-probe-component { world clock-probe-component {
include wasmeld:service/service-component@0.1.0; include wasmeld:service/service-component@0.1.0;
import wasmeld:clock/monotonic-clock@0.1.0; import wasmeld:clock/monotonic-clock@0.1.0;
-8
View File
@@ -1,14 +1,6 @@
//! Minimal stateful Component used to demonstrate a warm Actor Store.
//!
//! Invoke returns an eight-byte little-endian counter. The value survives
//! calls to the same Actor but resets on `init`, restart, or process recovery.
use core::sync::atomic::{AtomicU64, Ordering}; use core::sync::atomic::{AtomicU64, Ordering};
mod bindings { mod bindings {
// Bindings are generated from `wit/world.wit` plus materialized
// `wit/deps`; WIT is a compile-time ABI contract, not downloaded at run
// time and not executed as code.
wit_bindgen::generate!({ wit_bindgen::generate!({
path: "wit", path: "wit",
world: "counter-component", world: "counter-component",
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service" name = "wasmeld:service"
version = "0.1.0" version = "0.1.0"
source = "path+../../wit/service" source = "path+../../wit/service"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e" sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
replaced = true replaced = true
-1
View File
@@ -1,6 +1,5 @@
package component:counter@0.1.0; package component:counter@0.1.0;
/// Counter only needs the base service lifecycle and imports no Host capability.
world counter-component { world counter-component {
include wasmeld:service/service-component@0.1.0; include wasmeld:service/service-component@0.1.0;
} }
-4
View File
@@ -1,8 +1,4 @@
//! Smallest request/response Component example: it returns input bytes unchanged.
mod bindings { mod bindings {
// Generate the guest trait and export adapter from the selected WIT world.
// Dependency sync must populate `wit/deps` before this macro is compiled.
wit_bindgen::generate!({ wit_bindgen::generate!({
path: "wit", path: "wit",
world: "echo-component", world: "echo-component",
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service" name = "wasmeld:service"
version = "0.1.0" version = "0.1.0"
source = "path+../../wit/service" source = "path+../../wit/service"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e" sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
replaced = true replaced = true
-1
View File
@@ -1,6 +1,5 @@
package component:echo@0.1.0; package component:echo@0.1.0;
/// Echo only needs the base service lifecycle and imports no Host capability.
world echo-component { world echo-component {
include wasmeld:service/service-component@0.1.0; include wasmeld:service/service-component@0.1.0;
} }
-8
View File
@@ -1,10 +1,4 @@
//! Deliberately trapping Component used to verify Actor fault isolation.
//!
//! Sending the exact bytes `fault` triggers a panic. This crate is a test
//! fixture and must not be deployed as an application service.
mod bindings { mod bindings {
// Compile the versioned WIT world into canonical ABI Rust bindings.
wit_bindgen::generate!({ wit_bindgen::generate!({
path: "wit", path: "wit",
world: "fault-component", world: "fault-component",
@@ -19,8 +13,6 @@ impl bindings::Guest for Fault {
} }
fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> { fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
// A panic becomes a Wasm trap so Runtime tests can assert that one
// faulted Actor does not corrupt other service instances.
assert_ne!(input, b"fault", "intentional test fault"); assert_ne!(input, b"fault", "intentional test fault");
Ok(input) Ok(input)
} }
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service" name = "wasmeld:service"
version = "0.1.0" version = "0.1.0"
source = "path+../../wit/service" source = "path+../../wit/service"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e" sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
replaced = true replaced = true
-1
View File
@@ -1,6 +1,5 @@
package component:fault@0.1.0; package component:fault@0.1.0;
/// Fault fixture exports only the base service lifecycle.
world fault-component { world fault-component {
include wasmeld:service/service-component@0.1.0; include wasmeld:service/service-component@0.1.0;
} }
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "kv-probe-component"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[package.metadata.wasmeld]
id = "kv-probe"
world = "component:kv-probe/kv-probe-component@0.1.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen.workspace = true
-71
View File
@@ -1,71 +0,0 @@
//! Example service that exercises persistent, service-scoped Host KV.
//!
//! Input uses the test protocol `get:<key>`, `set:<key>:<value>`, or
//! `delete:<key>`. Production Components should define an application-specific
//! request format instead of copying this colon-delimited probe protocol.
mod bindings {
// `generate_all` emits both the service export and imported KV module from
// the exact versions selected in `wit/world.wit`.
wit_bindgen::generate!({
path: "wit",
world: "kv-probe-component",
generate_all,
});
}
use bindings::wasmeld::kv::store;
struct KvProbe;
impl bindings::Guest for KvProbe {
fn init(_config: Vec<u8>) -> Result<(), bindings::ServiceError> {
Ok(())
}
fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
if let Some(key) = input.strip_prefix(b"get:") {
let key = parse_key(key)?;
return store::get(&key)
.map(|value| value.unwrap_or_default())
.map_err(kv_error);
}
if let Some(command) = input.strip_prefix(b"set:") {
let separator = command
.iter()
.position(|byte| *byte == b':')
.ok_or_else(|| {
bindings::ServiceError::CallFailed(
"set command must be set:<key>:<value>".to_owned(),
)
})?;
let key = parse_key(&command[..separator])?;
let value = command[separator + 1..].to_vec();
store::set(&key, &value).map_err(kv_error)?;
return Ok(Vec::new());
}
if let Some(key) = input.strip_prefix(b"delete:") {
let key = parse_key(key)?;
store::delete(&key).map_err(kv_error)?;
return Ok(Vec::new());
}
Err(bindings::ServiceError::CallFailed(
"expected get:<key>, set:<key>:<value>, or delete:<key>".to_owned(),
))
}
}
fn parse_key(bytes: &[u8]) -> Result<String, bindings::ServiceError> {
core::str::from_utf8(bytes)
.map(str::to_owned)
.map_err(|_| bindings::ServiceError::CallFailed("key must be valid UTF-8".to_owned()))
}
fn kv_error(error: store::KvError) -> bindings::ServiceError {
bindings::ServiceError::CallFailed(format!("KV operation failed: {error:?}"))
}
bindings::export!(KvProbe with_types_in bindings);
-11
View File
@@ -1,11 +0,0 @@
schema_version = 1
[dependencies]
"wasmeld:kv" = "0.1.0"
"wasmeld:service" = "0.1.0"
[replace."wasmeld:kv"]
path = "../../wit/kv"
[replace."wasmeld:service"]
path = "../../wit/service"
-15
View File
@@ -1,15 +0,0 @@
schema_version = 1
[[package]]
name = "wasmeld:kv"
version = "0.1.0"
source = "path+../../wit/kv"
sha256 = "10dc81b1f04bd9c8f7b324a96e0c1d24d5af0c2dabfc723d003b8729cfd933d9"
replaced = true
[[package]]
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
-8
View File
@@ -1,8 +0,0 @@
package component:kv-probe@0.1.0;
/// Base service exports plus the exact persistent KV Host capability used by
/// this Component.
world kv-probe-component {
include wasmeld:service/service-component@0.1.0;
import wasmeld:kv/store@0.1.0;
}
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "resident-probe-component"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[package.metadata.wasmeld]
id = "resident-probe"
world = "component:resident-probe/resident-probe-component@0.1.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen.workspace = true
-82
View File
@@ -1,82 +0,0 @@
//! Resident event/effect fixture covering every current Host-driven I/O family.
//!
//! The callback echoes stream chunks and datagrams, rearms fired timers,
//! acknowledges messages, and reflects extension commands. It owns no socket,
//! timer, or subscription handles; those resources remain in the Host driver.
use std::sync::atomic::{AtomicU64, Ordering};
mod bindings {
// One generated module contains both the base service export and resident
// actor export because this component world composes both WIT packages.
wit_bindgen::generate!({
path: "wit",
world: "resident-probe-component",
generate_all,
});
}
use bindings::exports::wasmeld::resident::actor::{
DatagramSend, Effect, Event, Guest as ResidentGuest, MessageAck, SourceCommand, StreamWrite,
TimerArm,
};
static EVENTS_HANDLED: AtomicU64 = AtomicU64::new(0);
struct ResidentProbe;
impl bindings::Guest for ResidentProbe {
fn init(_config: Vec<u8>) -> Result<(), bindings::ServiceError> {
EVENTS_HANDLED.store(0, Ordering::Relaxed);
Ok(())
}
fn invoke(_input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
Ok(EVENTS_HANDLED
.load(Ordering::Relaxed)
.to_le_bytes()
.to_vec())
}
}
impl ResidentGuest for ResidentProbe {
fn handle_event(
input: Event,
) -> Result<Vec<Effect>, bindings::exports::wasmeld::resident::actor::ResidentError> {
// Event callbacks are serialized with normal invocations by the same
// Actor mailbox, so this in-memory count is deterministic per Actor.
EVENTS_HANDLED.fetch_add(1, Ordering::Relaxed);
Ok(match input {
Event::StreamData(chunk) => vec![Effect::WriteStream(StreamWrite {
stream_id: chunk.stream_id,
bytes: chunk.bytes,
})],
Event::Datagram(datagram) => vec![Effect::SendDatagram(DatagramSend {
endpoint_id: datagram.endpoint_id,
peer: datagram.peer,
bytes: datagram.bytes,
})],
Event::Timer(timer) => vec![Effect::ArmTimer(TimerArm {
timer_id: timer.timer_id,
delay_ms: 10,
interval_ms: None,
})],
Event::Message(message) => vec![Effect::AcknowledgeMessage(MessageAck {
subscription_id: message.subscription_id,
message_id: message.message_id,
})],
Event::Source(source) => vec![Effect::SourceCommand(SourceCommand {
source_id: source.source_id,
command: source.kind,
payload: source.payload,
})],
Event::StreamOpened(_)
| Event::StreamWritable(_)
| Event::StreamHalfClosed(_)
| Event::StreamClosed(_)
| Event::Shutdown => Vec::new(),
})
}
}
bindings::export!(ResidentProbe with_types_in bindings);
-11
View File
@@ -1,11 +0,0 @@
schema_version = 1
[dependencies]
"wasmeld:resident" = "0.1.0"
"wasmeld:service" = "0.1.0"
[replace."wasmeld:resident"]
path = "../../wit/resident"
[replace."wasmeld:service"]
path = "../../wit/service"
-15
View File
@@ -1,15 +0,0 @@
schema_version = 1
[[package]]
name = "wasmeld:resident"
version = "0.1.0"
source = "path+../../wit/resident"
sha256 = "a13728b0e6f000c03eb0f88e2e54bad1c6e5160ac3ca4b9e87619247c0043d1b"
replaced = true
[[package]]
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
-8
View File
@@ -1,8 +0,0 @@
package component:resident-probe@0.1.0;
/// Composes ordinary request/response exports with the optional resident actor
/// export. Network and timer drivers remain Host-owned.
world resident-probe-component {
include wasmeld:service/service-component@0.1.0;
export wasmeld:resident/actor@0.1.0;
}
-7
View File
@@ -1,11 +1,4 @@
//! CPU-bound fixture used to verify fuel and epoch deadline interruption.
//!
//! The input's first eight bytes select a little-endian iteration count.
//! Initializing with `spin` intentionally runs without a finite loop bound.
//! This crate is a Runtime test fixture, not an application service.
mod bindings { mod bindings {
// Compile the base service WIT contract into guest and export adapters.
wit_bindgen::generate!({ wit_bindgen::generate!({
path: "wit", path: "wit",
world: "spin-component", world: "spin-component",
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service" name = "wasmeld:service"
version = "0.1.0" version = "0.1.0"
source = "path+../../wit/service" source = "path+../../wit/service"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e" sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
replaced = true replaced = true
-1
View File
@@ -1,6 +1,5 @@
package component:spin@0.1.0; package component:spin@0.1.0;
/// Spin fixture exports only the base service lifecycle.
world spin-component { world spin-component {
include wasmeld:service/service-component@0.1.0; include wasmeld:service/service-component@0.1.0;
} }
-8
View File
@@ -1,12 +1,4 @@
//! Fixture proving that ambient WASI clock imports are denied by the sandbox.
//!
//! Calling `std::time::Instant::now` causes this Component to import WASI
//! monotonic-clock directly. Wasmeld only links explicitly approved capability
//! packages, so registration or startup must reject this fixture.
mod bindings { mod bindings {
// The declared service WIT contains no WASI clock capability; the direct
// standard-library use below deliberately creates an undeclared import.
wit_bindgen::generate!({ wit_bindgen::generate!({
path: "wit", path: "wit",
world: "wasi-clock-probe-component", world: "wasi-clock-probe-component",
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service" name = "wasmeld:service"
version = "0.1.0" version = "0.1.0"
source = "path+../../wit/service" source = "path+../../wit/service"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e" sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
replaced = true replaced = true
@@ -1,7 +1,5 @@
package component:wasi-clock-probe@0.1.0; package component:wasi-clock-probe@0.1.0;
/// The declared world grants only the base service contract. Any direct WASI
/// clock import introduced by implementation code remains undeclared.
world wasi-clock-probe-component { world wasi-clock-probe-component {
include wasmeld:service/service-component@0.1.0; include wasmeld:service/service-component@0.1.0;
} }
@@ -1,5 +1,6 @@
/node_modules/ /node_modules/
/dist/ /dist/
/.tanstack/
*.tsbuildinfo *.tsbuildinfo
.env* .env*
!.env.example !.env.example
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": ["src/routeTree.gen.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"$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
@@ -0,0 +1,36 @@
# 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 服务端渲染和静态资源。
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"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"
}
}

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -1,31 +1,14 @@
/** export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080";
* Typed client for the Rust management API.
*
* Production uses a same-origin base so the embedded Console follows the
* address used to open it. Vite development keeps the existing local backend
* default and relies on Wasmeld's development CORS policy.
*/
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";
export type BackendServiceStatus = "running" | "stopped" | "faulted"; export type BackendServiceStatus = "running" | "stopped" | "faulted";
export type BackendCapability = {
interface: string;
package: string;
name: string;
version: string;
};
export type BackendService = { export type BackendService = {
id: string; id: string;
revision: string; revision: string;
artifact: string; artifact: string;
world: string; world: string;
capabilities: BackendCapability[];
status: BackendServiceStatus; status: BackendServiceStatus;
updated_at_ms: number; updated_at_ms: number;
limits: { limits: {
@@ -44,7 +27,7 @@ export type BackendService = {
export type BackendEvent = { export type BackendEvent = {
id: number; id: number;
timestamp_ms: number; timestamp_ms: number;
kind: "registered" | "unregistered" | "deployed" | "started" | "stopped" | "invoked" | "failed"; kind: "registered" | "started" | "stopped" | "invoked" | "failed";
service_id: string | null; service_id: string | null;
revision: string | null; revision: string | null;
message: string; message: string;
@@ -58,13 +41,6 @@ export type BackendRuntime = {
running_services: number; running_services: number;
}; };
export type BackendDeployment = {
service_id: string;
active_revision: string;
status: BackendServiceStatus;
updated_at_ms: number;
};
export type BackendWitDependency = { export type BackendWitDependency = {
name: string; name: string;
version: string; version: string;
@@ -78,12 +54,8 @@ export type BackendWitPackage = {
dependencies: BackendWitDependency[]; dependencies: BackendWitDependency[];
}; };
export type BackendSnapshot = { export type RegisterComponentInput = {
services: BackendService[]; file: File;
deployments: BackendDeployment[];
events: BackendEvent[];
runtime: BackendRuntime;
witPackages: BackendWitPackage[];
}; };
export type InvokeResult = { export type InvokeResult = {
@@ -97,16 +69,8 @@ export function getApiBase(): string {
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");
@@ -115,44 +79,34 @@ export function saveApiBase(value: string): string {
return normalized; return normalized;
} }
export async function fetchSnapshot(apiBase: string): Promise<BackendSnapshot> { export async function fetchSnapshot(apiBase: string) {
const [services, deployments, events, runtime, witPackages] = await Promise.all([ const [services, 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<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"), request<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"),
request<BackendRuntime>(apiBase, "/api/v1/runtime"), request<BackendRuntime>(apiBase, "/api/v1/runtime"),
request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"), request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"),
]); ]);
return { return {
services: services.services, services: services.services,
deployments: deployments.deployments,
events: events.events, events: events.events,
runtime, runtime,
witPackages: witPackages.packages, witPackages: witPackages.packages,
}; };
} }
export function activateDeployment( export async function registerComponent(
apiBase: string, apiBase: string,
service: Pick<BackendService, "id" | "revision">, input: RegisterComponentInput,
): Promise<BackendDeployment> { ): Promise<BackendService> {
return request(apiBase, `/api/v1/deployments/${segment(service.id)}/activate`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ revision: service.revision }),
});
}
export function registerComponent(apiBase: string, file: File): Promise<BackendService> {
const form = new FormData(); const form = new FormData();
form.set("package", file, file.name); form.set("package", input.file, input.file.name);
return request(apiBase, "/api/v1/services", { return request(apiBase, "/api/v1/services", {
method: "POST", method: "POST",
body: form, body: form,
}); });
} }
export function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> { export async 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", {
@@ -166,17 +120,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/${segment(namespace)}/${segment(name)}/${segment(packageMetadata.version)}/content`; return `${apiBase}/api/v1/wit/packages/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/${encodeURIComponent(packageMetadata.version)}/content`;
} }
export function changeServiceState( export async 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/${segment(service.id)}/${segment(service.revision)}/${action}`, `/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/${action}`,
{ {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -185,11 +139,13 @@ export function changeServiceState(
); );
} }
export function changeRuntimeState( export async 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}`, { method: "POST" }); return request(apiBase, `/api/v1/runtime/${action}`, {
method: "POST",
});
} }
export async function invokeComponent( export async function invokeComponent(
@@ -201,11 +157,15 @@ export async function invokeComponent(
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`, { }>(
method: "POST", apiBase,
headers: { "content-type": "application/json" }, `/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/invoke`,
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,
@@ -224,7 +184,7 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
}, },
}); });
} catch { } catch {
throw new Error(`无法连接 Wasmeld${displayApiBase(apiBase)}`); throw new Error(`无法连接 Wasmeld${apiBase}`);
} }
if (!response.ok) { if (!response.ok) {
@@ -236,10 +196,6 @@ 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 {
let binary = ""; let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte); for (const byte of bytes) binary += String.fromCharCode(byte);
+68
View File
@@ -0,0 +1,68 @@
/* 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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,60 @@
/// <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>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
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, /来自 Wasmeld/);
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, 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/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(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));
});
@@ -1,17 +1,17 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"], "lib": ["dom", "dom.iterable", "esnext"],
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"module": "ESNext", "esModuleInterop": true,
"moduleResolution": "Bundler", "module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"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": ["dist", "node_modules"] "exclude": ["node_modules"]
} }
+19
View File
@@ -0,0 +1,19 @@
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,8 +8,6 @@ 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
+11 -85
View File
@@ -6,29 +6,17 @@
- 发布、查询和下载不可变的二进制 WIT Package - 发布、查询和下载不可变的二进制 WIT Package
- 启动、停止和重启内嵌的 `wasmeld-runtime` - 启动、停止和重启内嵌的 `wasmeld-runtime`
- 启动、停止和重启常驻 Actor - 启动、停止和重启常驻 Actor
- 管理服务 ID 到活动版本的 Deployment - 转发二进制调用
- 通过独立 Gateway 转发原始二进制调用
- 为导入 `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 数据库,默认路径是
`var/wasmeld/console.db`Deployment 也存储在同一数据库中。 `var/wasmeld/console.db`
Host KV 使用 `(service_id, key)` 复合主键存储在该数据库中:同一服务的 Revision
共享数据,不同服务互相隔离,Runtime 或 Console 重启不会清空 KV。
调用计数、错误数、延迟和调用事件通过容量受限的异步队列批量写入数据库,Gateway
响应不等待 libSQL。队列不可用或进程异常退出时,尚未写入的遥测可能丢失,但不会把
已经成功的 Component 调用改写成失败。注册、Deployment 和生命周期变更仍在响应前
完成持久化。
`wasmeld-console` 启动时会在同一进程内创建 `wasmeld-runtime`、加载 Wasmtime Engine `wasmeld-console` 启动时会在同一进程内创建 `wasmeld-runtime`、加载 Wasmtime Engine
并重新注册已保存的 Component。Runtime 停止后 Console HTTP 和数据库仍然在线; 并重新注册已保存的 Component。Runtime 停止后 Console HTTP 和数据库仍然在线;
再次启动 Runtime 会重新注册 Component。未部署的版本恢复为 `stopped`Deployment 再次启动 Runtime 会重新注册 Component,但 Actor 内存不做快照,服务运行状态统一
指向的版本会用空初始化配置创建新的 Actor。Actor 的 Store 和线性内存不做快照 恢复为 `stopped`
编译后的本地机器码默认缓存在 `var/wasmeld/component-cache`,相同 Component 在
Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Actor 内存。
## 启动 ## 启动
@@ -39,37 +27,11 @@ Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Acto
cargo +stable run -p wasmeld-console cargo +stable run -p wasmeld-console
``` ```
Debug 构建只启动 API,不构建或提供 Web 静态文件。前端开发服务器需要单独启动: 默认监听 `127.0.0.1:8080`
```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:
- 管理 API`127.0.0.1:8080`
- Gateway`0.0.0.0:8081`
管理 Router 不会挂载到 Gateway ListenerGateway 也不暴露注册、Runtime 生命周期和
WIT Registry 路由。
可用环境变量: 可用环境变量:
- `WASMELD_ADDR` - `WASMELD_ADDR`
- `WASMELD_GATEWAY_ADDR`
- `WASMELD_ARTIFACT_DIR` - `WASMELD_ARTIFACT_DIR`
- `WASMELD_DATABASE_PATH` - `WASMELD_DATABASE_PATH`
- `WASMELD_WIT_REGISTRY_DIR` - `WASMELD_WIT_REGISTRY_DIR`
@@ -84,56 +46,20 @@ WIT Registry 路由。
| `GET` | `/api/v1/runtime` | Runtime 状态 | | `GET` | `/api/v1/runtime` | Runtime 状态 |
| `POST` | `/api/v1/runtime/start` | 启动 Runtime 并重新注册受管 Component | | `POST` | `/api/v1/runtime/start` | 启动 Runtime 并重新注册受管 Component |
| `POST` | `/api/v1/runtime/stop` | 停止 Runtime 并释放所有 Actor | | `POST` | `/api/v1/runtime/stop` | 停止 Runtime 并释放所有 Actor |
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime并重新创建活动 Deployment 的 Actor | | `POST` | `/api/v1/runtime/restart` | 重建 Runtime服务恢复为停止状态 |
| `GET` | `/api/v1/services` | 服务版本及精确 Host Capability 列表 | | `GET` | `/api/v1/services` | 服务版本列表 |
| `POST` | `/api/v1/services` | multipart 上传单个 `package` 字段(`.wasmpkg` | | `POST` | `/api/v1/services` | multipart 上传单个 `package` 字段(`.wasmpkg` |
| `DELETE` | `/api/v1/services/{id}/{revision}` | 注销非活动 Revision 并删除制品 |
| `POST` | `/api/v1/services/{id}/{revision}/start` | 启动 Actor | | `POST` | `/api/v1/services/{id}/{revision}/start` | 启动 Actor |
| `POST` | `/api/v1/services/{id}/{revision}/stop` | 停止 Actor | | `POST` | `/api/v1/services/{id}/{revision}/stop` | 停止 Actor |
| `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor | | `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor |
| `POST` | `/api/v1/services/{id}/{revision}/invoke` | Base64 二进制调用 | | `POST` | `/api/v1/services/{id}/{revision}/invoke` | Base64 二进制调用 |
| `GET` | `/api/v1/deployments` | 活动 Deployment 列表 |
| `GET` | `/api/v1/deployments/{id}` | 查询服务的活动版本 |
| `POST` | `/api/v1/deployments/{id}/activate` | 启动目标版本并原子切换活动版本 |
| `GET` | `/api/v1/events` | 最近 256 条持久化事件 | | `GET` | `/api/v1/events` | 最近 256 条持久化事件 |
| `GET` | `/api/v1/wit/packages` | WIT 包版本列表 | | `GET` | `/api/v1/wit/packages` | WIT 包版本列表 |
| `POST` | `/api/v1/wit/packages` | multipart 发布单个二进制 WIT `package` 字段 | | `POST` | `/api/v1/wit/packages` | multipart 发布单个二进制 WIT `package` 字段 |
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | WIT 包元数据 | | `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | WIT 包元数据 |
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载 WIT 包 | | `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载 WIT 包 |
活动 Deployment 不能直接注销。开发工具会先完成新 Revision 的编译、注册和切换, 组件包的构建与格式说明见
再注销上一份开发 Revision,因此失败的构建不会影响当前可调用版本 [`docs/design/wasmeld-component-package.md`](../../docs/design/wasmeld-component-package.md)
WIT 依赖、Registry 和本地 replace 说明见
激活已注册版本: [`docs/design/wit-package-management.md`](../../docs/design/wit-package-management.md)。
```bash
curl -X POST http://127.0.0.1:8080/api/v1/deployments/echo/activate \
-H 'Content-Type: application/json' \
-d '{"revision":"0.1.0"}'
```
Gateway 只提供 `GET /healthz`
`POST /v1/services/{id}/invoke`。调用请求与成功响应的
`Content-Type` 都是 `application/octet-stream`
```bash
curl http://127.0.0.1:8081/v1/services/echo/invoke \
-H 'Content-Type: application/octet-stream' \
--data-binary 'hello'
```
成功响应通过 `X-Wasmeld-Revision` 返回实际路由的版本。Gateway 错误统一返回 JSON
未部署为 `404`Actor 不可用为 `503`,过载为 `429`,执行超时为 `504`
服务响应的 `capabilities` 来自 Component 二进制中的 WIT imports,例如
`wasmeld:clock/monotonic-clock@0.1.0`。Console 不接受手工能力声明,Runtime 只会链接
注册表中存在且版本完全匹配的接口。
## Host KV
`wasmeld:kv/store@0.1.0` 提供 `get``set``delete`。Key 必须是非空 UTF-8
最大 256 BValue 最大 64 KiB。数据库命令通过有界专用工作线程执行,Host 等待时间
不会超过服务的 `deadline_ms`。v0.1.0 不提供 TTL、遍历、事务或 CAS。
组件包格式、开发循环、WIT 依赖、Registry 和本地 `replace` 说明见
[`wasmeld-package` CLI 文档](../wasmeld-package/README.md)。
-104
View File
@@ -1,104 +0,0 @@
//! 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}"))
}
-697
View File
@@ -1,697 +0,0 @@
//! Axum management and public gateway protocol adapters.
//!
//! The management router accepts JSON and multipart packages and exposes
//! lifecycle operations. The gateway router is intentionally smaller: it
//! accepts raw `application/octet-stream` input and returns raw bytes plus
//! `x-wasmeld-revision` and `x-wasmeld-latency-ms` response headers.
//!
//! Neither router installs authentication or authorization middleware. A
//! production deployment must place the appropriate identity, authorization,
//! TLS, rate-limit, and request-size policy in front of these routers. CORS is
//! a browser access policy and must not be treated as authentication.
use super::*;
use axum::{
Json, Router,
body::{Body, Bytes},
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State, rejection::BytesRejection},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
response::{IntoResponse, Response},
routing::{delete, get, post},
};
use serde_json::json;
use tower_http::{
cors::CorsLayer,
trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer},
};
use tracing::Level;
use wasmeld_package::wit_package::WitPackageError;
/// Builds the Axum management API around a shared [`Console`].
///
/// The returned router exposes Runtime control, component registration,
/// invocation, events, and WIT Registry endpoints.
///
/// `allowed_origins` only affects browser CORS responses. Passing an empty
/// vector does not make the API private; it merely omits allowed origins.
/// Multipart bodies receive one MiB of framing headroom above the larger
/// configured artifact limit.
pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
let max_body_bytes = console
.max_artifact_bytes()
.max(console.max_wit_package_bytes())
.saturating_add(1024 * 1024);
let mut cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_headers([header::CONTENT_TYPE]);
if !allowed_origins.is_empty() {
cors = cors.allow_origin(allowed_origins);
}
Router::new()
.route("/healthz", get(health))
.route("/api/v1/runtime", get(runtime_status))
.route("/api/v1/runtime/start", post(start_runtime))
.route("/api/v1/runtime/stop", post(stop_runtime))
.route("/api/v1/runtime/restart", post(restart_runtime))
.route("/api/v1/services", get(list_services).post(register))
.route(
"/api/v1/services/{id}/{revision}",
delete(unregister_service),
)
.route("/api/v1/deployments", get(list_deployments))
.route("/api/v1/deployments/{id}", get(get_deployment))
.route(
"/api/v1/deployments/{id}/activate",
post(activate_deployment),
)
.route(
"/api/v1/wit/packages",
get(list_wit_packages).post(publish_wit_package),
)
.route(
"/api/v1/wit/packages/{namespace}/{name}/{version}",
get(get_wit_package),
)
.route(
"/api/v1/wit/packages/{namespace}/{name}/{version}/content",
get(download_wit_package),
)
.route(
"/api/v1/services/{id}/{revision}/start",
post(start_service),
)
.route("/api/v1/services/{id}/{revision}/stop", post(stop_service))
.route(
"/api/v1/services/{id}/{revision}/restart",
post(restart_service),
)
.route(
"/api/v1/services/{id}/{revision}/invoke",
post(invoke_service),
)
.route("/api/v1/events", get(list_events))
.layer(DefaultBodyLimit::max(max_body_bytes))
.layer(cors)
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(Level::INFO))
.on_response(DefaultOnResponse::new().level(Level::INFO)),
)
.with_state(console)
.merge(web::router())
}
/// Builds the public data-plane API around the same resident Runtime.
///
/// This router intentionally contains no registration, lifecycle, Registry,
/// or deployment management routes. Callers must send
/// `Content-Type: application/octet-stream`; the stable service ID is resolved
/// to its active immutable revision at the start of each request.
///
/// # Wire example
///
/// ```text
/// POST /v1/services/image-resize/invoke
/// Content-Type: application/octet-stream
///
/// <component-specific bytes>
/// ```
pub fn gateway_app(console: Arc<Console>) -> Router {
let max_input_bytes = console.max_gateway_input_bytes();
Router::new()
.route("/healthz", get(health))
.route("/v1/services/{id}/invoke", post(gateway_invoke))
.layer(DefaultBodyLimit::max(max_input_bytes))
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(Level::INFO))
.on_response(DefaultOnResponse::new().level(Level::INFO)),
)
.with_state(console)
}
async fn health() -> Json<serde_json::Value> {
Json(json!({ "status": "ok" }))
}
async fn gateway_invoke(
State(console): State<Arc<Console>>,
AxumPath(service_id): AxumPath<String>,
headers: HeaderMap,
body: Result<Bytes, BytesRejection>,
) -> Result<Response, GatewayError> {
validate_path_segment("id", &service_id)?;
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.map(str::trim);
if !content_type.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream")) {
return Err(GatewayError::UnsupportedMediaType);
}
let input = body.map_err(|_| GatewayError::PayloadTooLarge {
limit: console.max_gateway_input_bytes(),
})?;
// Deployment resolution and Actor enqueue happen in the same blocking
// closure. The returned revision header therefore identifies the exact
// revision that received this request, even if deployment changes later.
let invocation = run_blocking(console, move |console| {
console.gateway_invoke(&service_id, input.to_vec())
})
.await?;
let mut response_headers = HeaderMap::new();
response_headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
response_headers.insert(
"x-wasmeld-revision",
HeaderValue::from_str(&invocation.revision).map_err(|_| {
ConsoleError::InvalidRequest("revision is not a valid header".to_owned())
})?,
);
response_headers.insert(
"x-wasmeld-latency-ms",
HeaderValue::from_str(&invocation.result.latency_ms.to_string()).map_err(|_| {
ConsoleError::InvalidRequest("latency is not a valid header".to_owned())
})?,
);
Ok((response_headers, invocation.result.output).into_response())
}
async fn runtime_status(
State(console): State<Arc<Console>>,
) -> Result<Json<RuntimeView>, ApiError> {
Ok(Json(console.runtime_view()?))
}
async fn start_runtime(State(console): State<Arc<Console>>) -> Result<Json<RuntimeView>, ApiError> {
Ok(Json(
run_blocking_and_persist(console, |console| console.start_runtime()).await?,
))
}
async fn stop_runtime(State(console): State<Arc<Console>>) -> Result<Json<RuntimeView>, ApiError> {
Ok(Json(
run_blocking_and_persist(console, |console| console.stop_runtime()).await?,
))
}
async fn restart_runtime(
State(console): State<Arc<Console>>,
) -> Result<Json<RuntimeView>, ApiError> {
Ok(Json(
run_blocking_and_persist(console, |console| console.restart_runtime()).await?,
))
}
async fn list_services(State(console): State<Arc<Console>>) -> Result<Json<ServiceList>, ApiError> {
Ok(Json(ServiceList {
services: console.services()?,
}))
}
async fn list_deployments(
State(console): State<Arc<Console>>,
) -> Result<Json<DeploymentList>, ApiError> {
Ok(Json(DeploymentList {
deployments: console.deployments()?,
}))
}
async fn get_deployment(
State(console): State<Arc<Console>>,
AxumPath(service_id): AxumPath<String>,
) -> Result<Json<DeploymentView>, ApiError> {
validate_path_segment("id", &service_id)?;
Ok(Json(console.deployment(&service_id)?))
}
async fn activate_deployment(
State(console): State<Arc<Console>>,
AxumPath(service_id): AxumPath<String>,
Json(request): Json<ActivateDeploymentRequest>,
) -> Result<Json<DeploymentView>, ApiError> {
validate_path_segment("id", &service_id)?;
validate_path_segment("revision", &request.revision)?;
let init_config = decode_optional_base64(request.init_config_base64.as_deref())?;
Ok(Json(
run_blocking_and_persist(console, move |console| {
console.activate_deployment(service_id, request.revision, init_config)
})
.await?,
))
}
async fn list_events(State(console): State<Arc<Console>>) -> Result<Json<EventList>, ApiError> {
Ok(Json(EventList {
events: console.events()?,
}))
}
async fn list_wit_packages(
State(console): State<Arc<Console>>,
) -> Result<Json<WitPackageList>, ApiError> {
Ok(Json(WitPackageList {
packages: console.wit_registry.list()?,
}))
}
async fn get_wit_package(
State(console): State<Arc<Console>>,
AxumPath((namespace, name, version)): AxumPath<(String, String, String)>,
) -> Result<Json<WitPackageMetadata>, ApiError> {
Ok(Json(
console
.wit_registry
.metadata(&format!("{namespace}:{name}"), &version)?,
))
}
async fn download_wit_package(
State(console): State<Arc<Console>>,
AxumPath((namespace, name, version)): AxumPath<(String, String, String)>,
) -> Result<Response, ApiError> {
let (metadata, bytes) = console
.wit_registry
.download(&format!("{namespace}:{name}"), &version)?;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/wasm")
.header(header::ETAG, format!("\"{}\"", metadata.sha256))
.body(Body::from(bytes))
.map_err(|error| {
ConsoleError::InvalidRequest(format!("failed to build package response: {error}"))
.into()
})
}
async fn publish_wit_package(
State(console): State<Arc<Console>>,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<WitPackageMetadata>), ApiError> {
// Axum materializes this one field in memory. DefaultBodyLimit above is
// only a transport guard; WitRegistry repeats the exact artifact limit and
// validates the package identity before committing it to disk.
let mut package = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|error| ConsoleError::InvalidRequest(format!("invalid multipart body: {error}")))?
{
match field.name() {
Some("package") => {
if package.is_some() {
return Err(ConsoleError::InvalidRequest(
"package field must occur exactly once".to_owned(),
)
.into());
}
package = Some(
field
.bytes()
.await
.map_err(|error| {
ConsoleError::InvalidRequest(format!(
"invalid WIT package field: {error}"
))
})?
.to_vec(),
);
}
Some(name) => {
return Err(ConsoleError::InvalidRequest(format!(
"unsupported multipart field {name}"
))
.into());
}
None => {}
}
}
let package = package
.ok_or_else(|| ConsoleError::InvalidRequest("package field is required".to_owned()))?;
if package.len() > console.max_wit_package_bytes() {
return Err(WitRegistryError::TooLarge {
limit: console.max_wit_package_bytes(),
}
.into());
}
let metadata = run_blocking(console, move |console| {
Ok(console.wit_registry.publish(&package)?)
})
.await?;
Ok((StatusCode::CREATED, Json(metadata)))
}
async fn register(
State(console): State<Arc<Console>>,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<ServiceView>), ApiError> {
let mut package = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|error| ConsoleError::InvalidRequest(format!("invalid multipart body: {error}")))?
{
match field.name() {
Some("package") => {
if package.is_some() {
return Err(ConsoleError::InvalidRequest(
"package field must occur exactly once".to_owned(),
)
.into());
}
package = Some(
field
.bytes()
.await
.map_err(|error| {
ConsoleError::InvalidRequest(format!("invalid package field: {error}"))
})?
.to_vec(),
);
}
Some(name) => {
return Err(ConsoleError::InvalidRequest(format!(
"unsupported multipart field {name}"
))
.into());
}
None => {}
}
}
let package = package
.ok_or_else(|| ConsoleError::InvalidRequest("package field is required".to_owned()))?;
let service =
run_blocking_and_persist(console, move |console| console.register_package(package)).await?;
Ok((StatusCode::CREATED, Json(service)))
}
async fn start_service(
State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>,
request: Option<Json<StartRequest>>,
) -> Result<Json<ServiceView>, ApiError> {
let key = api_key(id, revision)?;
let init_config = decode_optional_base64(
request
.as_ref()
.and_then(|Json(request)| request.init_config_base64.as_deref()),
)?;
Ok(Json(
run_blocking_and_persist(console, move |console| console.start(&key, init_config)).await?,
))
}
async fn stop_service(
State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>,
) -> Result<Json<ServiceView>, ApiError> {
let key = api_key(id, revision)?;
Ok(Json(
run_blocking_and_persist(console, move |console| console.stop(&key)).await?,
))
}
async fn unregister_service(
State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>,
) -> Result<Json<ServiceView>, ApiError> {
let key = api_key(id, revision)?;
Ok(Json(
run_blocking_and_persist(console, move |console| console.unregister(&key)).await?,
))
}
async fn restart_service(
State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>,
request: Option<Json<StartRequest>>,
) -> Result<Json<ServiceView>, ApiError> {
let key = api_key(id, revision)?;
let init_config = decode_optional_base64(
request
.as_ref()
.and_then(|Json(request)| request.init_config_base64.as_deref()),
)?;
Ok(Json(
run_blocking_and_persist(console, move |console| console.restart(&key, init_config))
.await?,
))
}
async fn invoke_service(
State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>,
Json(request): Json<InvokeRequest>,
) -> Result<Json<InvokeResponse>, ApiError> {
let key = api_key(id, revision)?;
let input = BASE64.decode(request.input_base64).map_err(|error| {
ConsoleError::InvalidRequest(format!("input_base64 is invalid: {error}"))
})?;
let result = run_blocking(console, move |console| console.invoke(&key, input)).await?;
Ok(Json(InvokeResponse {
output_base64: BASE64.encode(&result.output),
output_bytes: result.output.len(),
latency_ms: result.latency_ms,
}))
}
async fn run_blocking<T, F>(console: Arc<Console>, operation: F) -> Result<T, ConsoleError>
where
T: Send + 'static,
F: FnOnce(Arc<Console>) -> Result<T, ConsoleError> + Send + 'static,
{
// Wasmtime compilation and synchronous Actor calls must not occupy a Tokio
// async worker.
tokio::task::spawn_blocking(move || operation(console))
.await
.map_err(ConsoleError::from)
.and_then(|result| result)
}
async fn run_blocking_and_persist<T, F>(
console: Arc<Console>,
operation: F,
) -> Result<T, ConsoleError>
where
T: Send + 'static,
F: FnOnce(Arc<Console>) -> Result<T, ConsoleError> + Send + 'static,
{
// Control operations mutate durable state. Persist even on failure because
// an operation may have changed state before returning its error.
let operation_console = Arc::clone(&console);
let result = run_blocking(operation_console, operation).await;
// The flush marker orders previously accepted metric deltas before the
// full snapshot transaction. It means "attempted", not "durably written";
// the telemetry worker logs write failures independently.
console.flush_invocation_telemetry().await;
let persisted = console.persist_snapshot().await;
match result {
Ok(value) => {
persisted?;
Ok(value)
}
Err(error) => {
if let Err(persistence_error) = persisted {
tracing::error!(
error = %persistence_error,
"failed to persist console state after operation error"
);
}
Err(error)
}
}
}
enum GatewayError {
Console(ConsoleError),
UnsupportedMediaType,
PayloadTooLarge { limit: usize },
}
impl From<ConsoleError> for GatewayError {
fn from(error: ConsoleError) -> Self {
Self::Console(error)
}
}
impl IntoResponse for GatewayError {
fn into_response(self) -> Response {
let (status, code, message) = match self {
Self::UnsupportedMediaType => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"unsupported_media_type",
"Content-Type must be application/octet-stream".to_owned(),
),
Self::PayloadTooLarge { limit } => (
StatusCode::PAYLOAD_TOO_LARGE,
"payload_too_large",
format!("request body exceeds the {limit}-byte limit"),
),
Self::Console(error) => {
let (status, code, message) = match &error {
ConsoleError::InvalidRequest(_) => (
StatusCode::BAD_REQUEST,
"invalid_request",
error.to_string(),
),
ConsoleError::DeploymentNotFound(_)
| ConsoleError::ServiceNotFound(_)
| ConsoleError::Runtime(RuntimeError::ServiceNotRegistered(_)) => (
StatusCode::NOT_FOUND,
"service_not_found",
"service is not deployed".to_owned(),
),
ConsoleError::RuntimeNotRunning
| ConsoleError::Runtime(RuntimeError::ActorUnavailable(_))
| ConsoleError::Runtime(RuntimeError::ActorStopped(_)) => (
StatusCode::SERVICE_UNAVAILABLE,
"service_unavailable",
"service is temporarily unavailable".to_owned(),
),
ConsoleError::Runtime(RuntimeError::ActorOverloaded(_)) => (
StatusCode::TOO_MANY_REQUESTS,
"service_overloaded",
"service is overloaded".to_owned(),
),
ConsoleError::Runtime(RuntimeError::InputTooLarge { .. }) => (
StatusCode::PAYLOAD_TOO_LARGE,
"payload_too_large",
error.to_string(),
),
ConsoleError::Runtime(RuntimeError::DeadlineExceeded { .. }) => (
StatusCode::GATEWAY_TIMEOUT,
"deadline_exceeded",
"service execution deadline exceeded".to_owned(),
),
ConsoleError::Runtime(
RuntimeError::OutputTooLarge { .. }
| RuntimeError::ActorFault { .. }
| RuntimeError::ComponentError { .. },
) => (StatusCode::BAD_GATEWAY, "service_error", error.to_string()),
ConsoleError::ArtifactTooLarge { .. }
| ConsoleError::ActiveDeployment(_)
| ConsoleError::Package(_)
| ConsoleError::WitRegistry(_)
| ConsoleError::Storage { .. }
| ConsoleError::ManifestSerialize(_)
| ConsoleError::Database(_)
| ConsoleError::KvBackendStart(_)
| ConsoleError::InvalidDatabaseData(_)
| ConsoleError::Runtime(_)
| ConsoleError::Join(_)
| ConsoleError::LockPoisoned => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"internal gateway error".to_owned(),
),
};
(status, code, message)
}
};
(
status,
Json(json!({
"error": {
"code": code,
"message": message,
}
})),
)
.into_response()
}
}
struct ApiError(ConsoleError);
impl From<ConsoleError> for ApiError {
fn from(error: ConsoleError) -> Self {
Self(error)
}
}
impl From<WitRegistryError> for ApiError {
fn from(error: WitRegistryError) -> Self {
Self(error.into())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, code) = match &self.0 {
ConsoleError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
ConsoleError::ServiceNotFound(_) => (StatusCode::NOT_FOUND, "service_not_found"),
ConsoleError::DeploymentNotFound(_) => (StatusCode::NOT_FOUND, "deployment_not_found"),
ConsoleError::ActiveDeployment(_) => (StatusCode::CONFLICT, "active_deployment"),
ConsoleError::RuntimeNotRunning => (StatusCode::CONFLICT, "runtime_not_running"),
ConsoleError::ArtifactTooLarge { .. }
| ConsoleError::Package(PackageError::ComponentTooLarge { .. })
| ConsoleError::WitRegistry(WitRegistryError::TooLarge { .. })
| ConsoleError::Runtime(RuntimeError::InputTooLarge { .. })
| ConsoleError::Runtime(RuntimeError::OutputTooLarge { .. }) => {
(StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large")
}
ConsoleError::Runtime(RuntimeError::ServiceNotRegistered(_)) => {
(StatusCode::NOT_FOUND, "service_not_registered")
}
ConsoleError::Runtime(RuntimeError::ServiceAlreadyRegistered(_))
| ConsoleError::Runtime(RuntimeError::ActorUnavailable(_))
| ConsoleError::Runtime(RuntimeError::ActorOverloaded(_)) => {
(StatusCode::CONFLICT, "runtime_conflict")
}
ConsoleError::WitRegistry(WitRegistryError::AlreadyExists(_)) => {
(StatusCode::CONFLICT, "wit_package_exists")
}
ConsoleError::WitRegistry(WitRegistryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, "wit_package_not_found")
}
ConsoleError::Runtime(RuntimeError::DeadlineExceeded { .. }) => {
(StatusCode::REQUEST_TIMEOUT, "deadline_exceeded")
}
ConsoleError::Runtime(
RuntimeError::InvalidServiceKey
| RuntimeError::InvalidManifest(_)
| RuntimeError::ManifestParse(_)
| RuntimeError::ComponentCompilation(_)
| RuntimeError::UnsupportedImport(_)
| RuntimeError::ActorInitialization(_)
| RuntimeError::ComponentError { .. },
) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_component"),
ConsoleError::Package(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_package"),
ConsoleError::WitRegistry(WitRegistryError::InvalidPackage(
WitPackageError::Source { .. }
| WitPackageError::Encode(_)
| WitPackageError::Decode(_)
| WitPackageError::MissingVersion(_),
)) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_wit_package"),
ConsoleError::Storage { .. }
| ConsoleError::ManifestSerialize(_)
| ConsoleError::Database(_)
| ConsoleError::KvBackendStart(_)
| ConsoleError::InvalidDatabaseData(_)
| ConsoleError::Runtime(_)
| ConsoleError::WitRegistry(WitRegistryError::InvalidStorage(_))
| ConsoleError::WitRegistry(WitRegistryError::Storage { .. })
| ConsoleError::WitRegistry(WitRegistryError::LockPoisoned)
| ConsoleError::Join(_)
| ConsoleError::LockPoisoned => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
};
(
status,
Json(json!({
"error": {
"code": code,
"message": self.0.to_string(),
}
})),
)
.into_response()
}
}
@@ -1,95 +0,0 @@
//! Bounded, asynchronous persistence for data-plane invocation telemetry.
//!
//! Invocation responses must not wait for libSQL, so producers use
//! [`tokio::sync::mpsc::Sender::try_send`] and one task writes ordered batches.
//! When the queue is full or closed, the update is deliberately dropped and a
//! warning is emitted. These counters and events are operational telemetry;
//! they are not suitable as a billing ledger or audit log.
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc, oneshot};
use crate::persistence::{InvocationUpdate, Persistence};
const QUEUE_CAPACITY: usize = 1024;
const MAX_BATCH_SIZE: usize = 64;
/// Non-blocking producer used by Wasmtime invocation threads.
///
/// All producers share one FIFO queue. Queue order is preserved for accepted
/// records, but a process crash can lose records that have not yet reached
/// libSQL.
pub(crate) struct InvocationPersistence {
sender: mpsc::Sender<Command>,
}
enum Command {
Record(InvocationUpdate),
Flush(oneshot::Sender<()>),
}
impl InvocationPersistence {
/// Starts the single ordered writer on the current Tokio runtime.
///
/// `sync` is shared with full Console snapshot persistence. Taking it
/// around each batch prevents a snapshot transaction from racing a metric
/// delta transaction.
pub(crate) fn start(persistence: Persistence, sync: Arc<Mutex<()>>) -> Self {
let (sender, mut receiver) = mpsc::channel(QUEUE_CAPACITY);
tokio::spawn(async move {
while let Some(first) = receiver.recv().await {
let mut batch = Vec::with_capacity(MAX_BATCH_SIZE);
let mut flushes = Vec::new();
match first {
Command::Record(update) => batch.push(update),
Command::Flush(flush) => flushes.push(flush),
}
while batch.len() < MAX_BATCH_SIZE {
match receiver.try_recv() {
Ok(Command::Record(update)) => batch.push(update),
Ok(Command::Flush(flush)) => flushes.push(flush),
Err(_) => break,
}
}
if !batch.is_empty() {
let _sync = sync.lock().await;
if let Err(error) = persistence.record_invocations(batch).await {
tracing::error!(%error, "failed to persist invocation telemetry");
}
}
// Flush is an ordering barrier, not a durability receipt. A
// database error above is logged rather than returned, and a
// flush may wait for records dequeued after the marker when
// those records fit in the same batch.
for flush in flushes {
let _ = flush.send(());
}
}
});
Self { sender }
}
/// Enqueues telemetry without extending API response latency.
///
/// Saturation drops this update instead of applying backpressure to the
/// Component invocation path.
pub(crate) fn record(&self, update: InvocationUpdate) {
if let Err(error) = self.sender.try_send(Command::Record(update)) {
tracing::warn!(%error, "invocation telemetry queue is unavailable; update dropped");
}
}
/// Waits until all telemetry queued before this call has been attempted.
///
/// This method is used before a control-plane snapshot so the snapshot
/// reads the newest in-memory counters after older delta writes. It does
/// not report persistence failures; those are visible through tracing.
pub(crate) async fn flush(&self) {
let (sender, receiver) = oneshot::channel();
if self.sender.send(Command::Flush(sender)).await.is_ok() {
let _ = receiver.await;
}
}
}
-251
View File
@@ -1,251 +0,0 @@
//! Bounded bridge between synchronous Component Host calls and async Toasty.
//!
//! Wasmtime calls [`wasmeld_runtime::KvBackend`] synchronously on an Actor
//! thread, while Toasty and libSQL are asynchronous. This adapter moves every
//! database operation onto one dedicated current-thread Tokio runtime. The
//! bounded channel prevents a slow database from accumulating unbounded work
//! or blocking every Actor while waiting for queue capacity.
//!
//! # Timeout semantics
//!
//! A timeout only stops the caller from waiting. It does not remove a command
//! that is already queued or cancel a libSQL operation that has started.
//! Consequently, `set` and `delete` may commit after the Component observes
//! `KvBackendError::Timeout`. Components should make retries idempotent and
//! must not interpret a timeout as proof that no mutation occurred.
use std::{
fmt, io,
sync::{
Arc, Mutex,
mpsc::{self, Receiver, SyncSender, TrySendError},
},
thread,
time::Duration,
};
use wasmeld_runtime::{KvBackend, KvBackendError};
use crate::persistence::Persistence;
const COMMAND_CAPACITY: usize = 64;
// Keep Host calls responsive even when a Component requests a larger timeout.
// The Component-facing deadline is an upper bound, not permission to occupy an
// Actor thread indefinitely.
const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_millis(400);
pub(crate) struct ToastyKvBackend {
inner: Arc<BackendInner>,
}
struct BackendInner {
sender: SyncSender<KvCommand>,
worker: Mutex<Option<thread::JoinHandle<()>>>,
}
enum KvCommand {
Get {
service_id: String,
key: String,
response: SyncSender<Result<Option<Vec<u8>>, KvBackendError>>,
},
Set {
service_id: String,
key: String,
value: Vec<u8>,
response: SyncSender<Result<(), KvBackendError>>,
},
Delete {
service_id: String,
key: String,
response: SyncSender<Result<(), KvBackendError>>,
},
Shutdown,
}
impl ToastyKvBackend {
/// Starts the database worker and waits until its Tokio runtime is ready.
///
/// Waiting for the ready signal makes construction fail deterministically
/// instead of returning a backend whose first operation discovers that
/// the worker could not initialize.
pub(crate) fn start(persistence: Persistence) -> io::Result<Self> {
let (sender, receiver) = mpsc::sync_channel(COMMAND_CAPACITY);
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
let worker = thread::Builder::new()
.name("wasmeld-kv-backend".to_owned())
.spawn(move || run_worker(persistence, receiver, ready_sender))?;
match ready_receiver.recv() {
Ok(Ok(())) => Ok(Self {
inner: Arc::new(BackendInner {
sender,
worker: Mutex::new(Some(worker)),
}),
}),
Ok(Err(error)) => {
let _ = worker.join();
Err(error)
}
Err(_) => {
let _ = worker.join();
Err(io::Error::other("KV backend worker stopped during startup"))
}
}
}
fn request<T>(
&self,
timeout: Duration,
build: impl FnOnce(SyncSender<Result<T, KvBackendError>>) -> KvCommand,
) -> Result<T, KvBackendError> {
let (response, receiver) = mpsc::sync_channel(1);
// `try_send` is intentional: waiting for queue capacity here would
// stall the Actor and defeat the Runtime's invocation deadline.
match self.inner.sender.try_send(build(response)) {
Ok(()) => {}
Err(TrySendError::Full(_)) => return Err(KvBackendError::Busy),
Err(TrySendError::Disconnected(_)) => {
return Err(KvBackendError::Operation(
"backend worker is unavailable".to_owned(),
));
}
}
receiver
.recv_timeout(timeout.min(MAX_RESPONSE_TIMEOUT))
.map_err(|_| KvBackendError::Timeout)?
}
}
impl Clone for ToastyKvBackend {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
impl fmt::Debug for ToastyKvBackend {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ToastyKvBackend")
.finish_non_exhaustive()
}
}
impl KvBackend for ToastyKvBackend {
fn get(
&self,
service_id: &str,
key: &str,
timeout: Duration,
) -> Result<Option<Vec<u8>>, KvBackendError> {
self.request(timeout, |response| KvCommand::Get {
service_id: service_id.to_owned(),
key: key.to_owned(),
response,
})
}
fn set(
&self,
service_id: &str,
key: &str,
value: &[u8],
timeout: Duration,
) -> Result<(), KvBackendError> {
self.request(timeout, |response| KvCommand::Set {
service_id: service_id.to_owned(),
key: key.to_owned(),
value: value.to_vec(),
response,
})
}
fn delete(&self, service_id: &str, key: &str, timeout: Duration) -> Result<(), KvBackendError> {
self.request(timeout, |response| KvCommand::Delete {
service_id: service_id.to_owned(),
key: key.to_owned(),
response,
})
}
}
impl Drop for BackendInner {
fn drop(&mut self) {
// Only the final Arc owner reaches this Drop. Joining guarantees the
// worker no longer holds the Persistence handle when Console shuts
// down. If commands precede Shutdown, the FIFO channel drains them.
let _ = self.sender.send(KvCommand::Shutdown);
if let Ok(worker) = self.worker.get_mut()
&& let Some(worker) = worker.take()
{
let _ = worker.join();
}
}
}
fn run_worker(
persistence: Persistence,
receiver: Receiver<KvCommand>,
ready: SyncSender<io::Result<()>>,
) {
// Toasty is driven from one thread so synchronous Host calls never need to
// enter or block the Console server's multi-thread Tokio runtime.
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
let _ = ready.send(Err(error));
return;
}
};
let _ = ready.send(Ok(()));
// A single consumer also defines the ordering of mutations submitted to
// this backend. Ordering across different Wasmeld processes is delegated
// to libSQL and is not guaranteed by this queue.
while let Ok(command) = receiver.recv() {
match command {
KvCommand::Get {
service_id,
key,
response,
} => {
let result = runtime
.block_on(persistence.kv_get(&service_id, &key))
.map_err(operation_failed);
let _ = response.send(result);
}
KvCommand::Set {
service_id,
key,
value,
response,
} => {
let result = runtime
.block_on(persistence.kv_set(service_id, key, value))
.map_err(operation_failed);
let _ = response.send(result);
}
KvCommand::Delete {
service_id,
key,
response,
} => {
let result = runtime
.block_on(persistence.kv_delete(&service_id, &key))
.map_err(operation_failed);
let _ = response.send(result);
}
KvCommand::Shutdown => return,
}
}
}
fn operation_failed(error: toasty::Error) -> KvBackendError {
tracing::error!(%error, "persistent KV operation failed");
KvBackendError::Operation("persistent storage failure".to_owned())
}
File diff suppressed because it is too large Load Diff
+9 -60
View File
@@ -1,32 +1,11 @@
//! Standalone Wasmeld control-plane and data-plane server entry point. //! Standalone Wasmeld management server entry point.
//!
//! Configuration is supplied through environment variables:
//!
//! - `WASMELD_ADDR` binds the management API (default `127.0.0.1:8080`).
//! - `WASMELD_GATEWAY_ADDR` binds the public byte API (default `0.0.0.0:8081`).
//! - `WASMELD_ARTIFACT_DIR`, `WASMELD_WIT_REGISTRY_DIR`, and
//! `WASMELD_DATABASE_PATH` select durable local storage.
//! - `WASMELD_ALLOWED_ORIGINS` is a comma-separated management UI CORS list.
//! - `RUST_LOG` configures tracing.
//!
//! For example:
//!
//! ```text
//! WASMELD_ADDR=127.0.0.1:8080 \
//! WASMELD_GATEWAY_ADDR=0.0.0.0:8081 \
//! RUST_LOG=wasmeld_console=debug \
//! cargo run -p wasmeld-console
//! ```
//!
//! The defaults expose only the gateway on non-loopback interfaces.
//! Authentication and TLS must be supplied by the deployment environment.
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc}; use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
use axum::http::HeaderValue; use axum::http::HeaderValue;
use tracing::info; use tracing::info;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app}; use wasmeld_console::{Console, ConsoleConfig, app};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -37,12 +16,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
) )
.init(); .init();
let management_address = env::var("WASMELD_ADDR") let address = env::var("WASMELD_ADDR")
.unwrap_or_else(|_| "127.0.0.1:8080".to_owned()) .unwrap_or_else(|_| "127.0.0.1:8080".to_owned())
.parse::<SocketAddr>()?; .parse::<SocketAddr>()?;
let gateway_address = env::var("WASMELD_GATEWAY_ADDR")
.unwrap_or_else(|_| "0.0.0.0:8081".to_owned())
.parse::<SocketAddr>()?;
let artifact_dir = env::var_os("WASMELD_ARTIFACT_DIR") let artifact_dir = env::var_os("WASMELD_ARTIFACT_DIR")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("var/wasmeld/components")); .unwrap_or_else(|| PathBuf::from("var/wasmeld/components"));
@@ -62,32 +38,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}) })
.await?, .await?,
); );
let management = app(Arc::clone(&console), allowed_origins); let application = app(console, allowed_origins);
let gateway = gateway_app(Arc::clone(&console)); let listener = tokio::net::TcpListener::bind(address).await?;
let management_listener = tokio::net::TcpListener::bind(management_address).await?;
let gateway_listener = tokio::net::TcpListener::bind(gateway_address).await?;
// Both servers share one shutdown edge so neither listener remains alive
// after Ctrl+C has begun process termination.
let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false);
let signal_task = tokio::spawn(async move {
shutdown_signal().await;
let _ = shutdown_sender.send(true);
});
info!(%management_address, "Wasmeld management API listening"); info!(%address, "Wasmeld listening");
info!(%gateway_address, "Wasmeld Gateway listening"); axum::serve(listener, application)
let result = tokio::try_join!( .with_graceful_shutdown(shutdown_signal())
axum::serve(management_listener, management) .await?;
.with_graceful_shutdown(wait_for_shutdown(shutdown_receiver.clone())),
axum::serve(gateway_listener, gateway)
.with_graceful_shutdown(wait_for_shutdown(shutdown_receiver)),
);
signal_task.abort();
result?;
// Axum has stopped accepting requests at this point. Flush accepted
// telemetry before dropping Console; persistence failures are traced by
// the worker because telemetry is an operational, best-effort stream.
console.flush_invocation_telemetry().await;
Ok(()) Ok(())
} }
@@ -106,11 +63,3 @@ async fn shutdown_signal() {
tracing::warn!("failed to install Ctrl+C handler"); tracing::warn!("failed to install Ctrl+C handler");
} }
} }
async fn wait_for_shutdown(mut receiver: tokio::sync::watch::Receiver<bool>) {
while !*receiver.borrow() {
if receiver.changed().await.is_err() {
break;
}
}
}
+17 -254
View File
@@ -1,24 +1,9 @@
//! Toasty models and transactional libSQL snapshots for Console control data. //! Toasty models and transactional libSQL snapshots for Console control data.
//! //!
//! Component bytes and WIT packages remain filesystem artifacts. This module //! Component bytes and WIT packages remain filesystem artifacts. This module
//! persists service manifests, deployments, metrics, the bounded event //! persists only service manifests, metrics, and the bounded event history.
//! history, and service-scoped Host KV entries.
//!
//! There are two write paths:
//!
//! - [`Persistence::sync`] stores a complete control-plane snapshot.
//! - [`Persistence::record_invocations`] applies ordered metric deltas.
//!
//! Callers serialize these paths with the Console persistence mutex. Existing
//! service counters are intentionally owned by the delta path; a snapshot
//! updates their manifest and timestamp without replacing those counters.
use std::{ use std::path::Path;
collections::BTreeSet,
path::Path,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use toasty::Db; use toasty::Db;
use toasty_driver_turso::Turso; use toasty_driver_turso::Turso;
@@ -46,162 +31,53 @@ pub(crate) struct StoredEvent {
pub message: String, pub message: String,
} }
#[derive(Clone, Debug, toasty::Model)]
pub(crate) struct StoredDeployment {
#[key]
pub service_id: String,
pub active_revision: String,
pub updated_at_ms: u64,
}
#[derive(Clone, Debug, toasty::Model)]
#[key(service_id, entry_key)]
pub(crate) struct StoredKvEntry {
pub service_id: String,
pub entry_key: String,
pub value: Vec<u8>,
pub updated_at_ms: u64,
}
/// One invocation's durable metric delta and event.
///
/// The caller assigns the event ID before enqueueing the update. A missing
/// service row is tolerated because unregister may race queued telemetry; the
/// event is still retained for diagnostics.
pub(crate) struct InvocationUpdate {
pub service_key: String,
pub failed: bool,
pub updated_at_ms: u64,
pub latency_ms: u64,
pub event: StoredEvent,
}
/// Serialized access to the Toasty database connection. /// Serialized access to the Toasty database connection.
///
/// This mutex protects a single Toasty [`Db`] handle. It is separate from the
/// higher-level Console persistence mutex, which coordinates whole snapshot
/// and invocation-delta operations before they acquire this connection.
#[derive(Clone)]
pub(crate) struct Persistence { pub(crate) struct Persistence {
db: Arc<Mutex<Db>>, db: Mutex<Db>,
} }
impl Persistence { impl Persistence {
/// Opens the database and installs or advances its schema. /// Opens the database and installs the schema for a new file.
pub async fn open(path: &Path) -> toasty::Result<Self> { pub async fn open(path: &Path) -> toasty::Result<Self> {
let is_new_database = !path.exists(); let is_new_database = !path.exists();
let db = Db::builder() let db = Db::builder()
.models(toasty::models!( .models(toasty::models!(StoredService, StoredEvent))
StoredService,
StoredEvent,
StoredDeployment,
StoredKvEntry
))
.build(Turso::file(path)) .build(Turso::file(path))
.await?; .await?;
if is_new_database { if is_new_database {
db.push_schema().await?; db.push_schema().await?;
} else {
// This project predates Toasty's migration history. Keep the
// additive bootstrap explicit so existing databases can be opened
// without recreating the service and event tables.
let mut db = db;
toasty::sql::statement(
r#"CREATE TABLE IF NOT EXISTS "stored_deployments" (
"service_id" TEXT NOT NULL,
"active_revision" TEXT NOT NULL,
"updated_at_ms" INTEGER NOT NULL,
PRIMARY KEY ("service_id")
)"#,
)
.exec(&mut db)
.await?;
toasty::sql::statement(
r#"CREATE TABLE IF NOT EXISTS "stored_kv_entries" (
"service_id" TEXT NOT NULL,
"entry_key" TEXT NOT NULL,
"value" BLOB NOT NULL,
"updated_at_ms" INTEGER NOT NULL,
PRIMARY KEY ("service_id", "entry_key")
)"#,
)
.exec(&mut db)
.await?;
return Ok(Self {
db: Arc::new(Mutex::new(db)),
});
} }
Ok(Self { Ok(Self { db: Mutex::new(db) })
db: Arc::new(Mutex::new(db)),
})
} }
/// Loads the complete service and deployment sets plus retained events. /// Loads the complete service set and retained event history.
pub async fn load( pub async fn load(&self) -> toasty::Result<(Vec<StoredService>, Vec<StoredEvent>)> {
&self,
) -> toasty::Result<(Vec<StoredService>, Vec<StoredEvent>, Vec<StoredDeployment>)> {
let mut db = self.db.lock().await; let mut db = self.db.lock().await;
let services = StoredService::all().exec(&mut *db).await?; let services = StoredService::all().exec(&mut *db).await?;
let events = StoredEvent::all().exec(&mut *db).await?; let events = StoredEvent::all().exec(&mut *db).await?;
let deployments = StoredDeployment::all().exec(&mut *db).await?; Ok((services, events))
Ok((services, events, deployments))
} }
/// Atomically upserts a Console snapshot and prunes expired events. /// Atomically upserts a Console snapshot and prunes expired events.
///
/// The input is authoritative for services and deployments: rows omitted
/// from those collections are removed. Events older than the oldest
/// supplied retained event are pruned. Existing invocation counters are
/// preserved because [`Self::record_invocations`] owns counter changes.
pub async fn sync( pub async fn sync(
&self, &self,
services: Vec<StoredService>, services: Vec<StoredService>,
events: Vec<StoredEvent>, events: Vec<StoredEvent>,
deployments: Vec<StoredDeployment>,
) -> toasty::Result<()> { ) -> toasty::Result<()> {
let mut db = self.db.lock().await; let mut db = self.db.lock().await;
let mut tx = db.transaction().await?; let mut tx = db.transaction().await?;
let stored_services = StoredService::all().exec(&mut tx).await?;
let service_keys = services
.iter()
.map(|service| service.service_key.clone())
.collect::<BTreeSet<_>>();
let existing_service_keys = stored_services
.iter()
.map(|service| service.service_key.clone())
.collect::<BTreeSet<_>>();
for stored in stored_services {
if !service_keys.contains(&stored.service_key) {
StoredService::delete_by_service_key(&mut tx, &stored.service_key).await?;
}
}
for service in services { for service in services {
if existing_service_keys.contains(&service.service_key) { StoredService::upsert_by_service_key(service.service_key)
// Invocation metrics are written as ordered deltas by .manifest_toml(service.manifest_toml)
// `record_invocations`; control snapshots must not overwrite .updated_at_ms(service.updated_at_ms)
// or double-count those concurrent updates. .calls(service.calls)
StoredService::filter_by_service_key(&service.service_key) .errors(service.errors)
.update() .last_latency_ms(service.last_latency_ms)
.manifest_toml(service.manifest_toml) .exec(&mut tx)
.updated_at_ms(service.updated_at_ms) .await?;
.exec(&mut tx)
.await?;
} else {
StoredService::create()
.service_key(service.service_key)
.manifest_toml(service.manifest_toml)
.updated_at_ms(service.updated_at_ms)
.calls(0)
.errors(0)
.last_latency_ms(None)
.exec(&mut tx)
.await?;
}
} }
// Console stores events newest-first, so the last element is the
// oldest ID that should survive snapshot pruning.
if let Some(oldest_event) = events.last() { if let Some(oldest_event) = events.last() {
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id)) StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id))
.delete() .delete()
@@ -220,119 +96,6 @@ impl Persistence {
.await?; .await?;
} }
let deployment_ids = deployments
.iter()
.map(|deployment| deployment.service_id.clone())
.collect::<BTreeSet<_>>();
for stored in StoredDeployment::all().exec(&mut tx).await? {
if !deployment_ids.contains(&stored.service_id) {
StoredDeployment::delete_by_service_id(&mut tx, &stored.service_id).await?;
}
}
for deployment in deployments {
StoredDeployment::upsert_by_service_id(deployment.service_id)
.active_revision(deployment.active_revision)
.updated_at_ms(deployment.updated_at_ms)
.exec(&mut tx)
.await?;
}
tx.commit().await tx.commit().await
} }
/// Atomically records invocation deltas and their bounded event history.
///
/// All supplied updates commit together. Metrics use saturating arithmetic
/// so corrupted or extremely old databases cannot wrap counters.
pub(crate) async fn record_invocations(
&self,
updates: Vec<InvocationUpdate>,
) -> toasty::Result<()> {
let mut db = self.db.lock().await;
let mut tx = db.transaction().await?;
let mut newest_event_id = 0;
for update in updates {
if let Some(stored) = StoredService::filter_by_service_key(&update.service_key)
.first()
.exec(&mut tx)
.await?
{
StoredService::filter_by_service_key(&update.service_key)
.update()
.calls(stored.calls.saturating_add(1))
.errors(stored.errors.saturating_add(u64::from(update.failed)))
.updated_at_ms(update.updated_at_ms)
.last_latency_ms(Some(update.latency_ms))
.exec(&mut tx)
.await?;
}
newest_event_id = newest_event_id.max(update.event.id);
StoredEvent::upsert_by_id(update.event.id)
.timestamp_ms(update.event.timestamp_ms)
.kind(update.event.kind)
.service_id(update.event.service_id)
.revision(update.event.revision)
.message(update.event.message)
.exec(&mut tx)
.await?;
}
let oldest_retained_event_id = newest_event_id.saturating_sub(255);
if oldest_retained_event_id > 0 {
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_retained_event_id))
.delete()
.exec(&mut tx)
.await?;
}
tx.commit().await
}
pub(crate) async fn kv_get(
&self,
service_id: &str,
entry_key: &str,
) -> toasty::Result<Option<Vec<u8>>> {
// The namespace is the stable service ID rather than its revision.
// Deploying a new revision therefore preserves application state.
let mut db = self.db.lock().await;
Ok(
StoredKvEntry::filter_by_service_id_and_entry_key(service_id, entry_key)
.first()
.exec(&mut *db)
.await?
.map(|entry| entry.value),
)
}
pub(crate) async fn kv_set(
&self,
service_id: String,
entry_key: String,
value: Vec<u8>,
) -> toasty::Result<()> {
// Upsert makes a retry after an ambiguous Host timeout idempotent for
// the same `(service_id, entry_key, value)`.
let mut db = self.db.lock().await;
StoredKvEntry::upsert_by_service_id_and_entry_key(service_id, entry_key)
.value(value)
.updated_at_ms(unix_time_ms())
.exec(&mut *db)
.await?;
Ok(())
}
pub(crate) async fn kv_delete(&self, service_id: &str, entry_key: &str) -> toasty::Result<()> {
let mut db = self.db.lock().await;
StoredKvEntry::delete_by_service_id_and_entry_key(&mut *db, service_id, entry_key).await
}
}
fn unix_time_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
.unwrap_or_default()
} }
-108
View File
@@ -1,108 +0,0 @@
//! 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"));
}
}
+1 -13
View File
@@ -37,40 +37,28 @@ struct StoredWitPackage {
/// Errors produced by WIT Registry storage and integrity checks. /// Errors produced by WIT Registry storage and integrity checks.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum WitRegistryError { pub enum WitRegistryError {
/// Uploaded binary WIT exceeds the configured byte limit.
#[error("WIT package exceeds the {limit}-byte limit")] #[error("WIT package exceeds the {limit}-byte limit")]
TooLarge { TooLarge { limit: usize },
/// Maximum accepted artifact bytes.
limit: usize,
},
/// The immutable `name@version` already exists.
#[error("WIT package {0} is already published and cannot be overwritten")] #[error("WIT package {0} is already published and cannot be overwritten")]
AlreadyExists(String), AlreadyExists(String),
/// No indexed artifact matches the requested identity.
#[error("WIT package {0} was not found")] #[error("WIT package {0} was not found")]
NotFound(String), NotFound(String),
/// Uploaded or persisted bytes are not a valid versioned binary WIT package.
#[error(transparent)] #[error(transparent)]
InvalidPackage(#[from] WitPackageError), InvalidPackage(#[from] WitPackageError),
/// Directory layout and decoded artifact identity disagree.
#[error("invalid WIT registry data: {0}")] #[error("invalid WIT registry data: {0}")]
InvalidStorage(String), InvalidStorage(String),
/// Registry directory or artifact filesystem access failed.
#[error("failed to access {path}: {source}")] #[error("failed to access {path}: {source}")]
Storage { Storage {
/// Path involved in the failed operation.
path: PathBuf, path: PathBuf,
/// Underlying filesystem failure.
#[source] #[source]
source: io::Error, source: io::Error,
}, },
/// The process-local Registry index lock was poisoned.
#[error("WIT registry lock was poisoned")] #[error("WIT registry lock was poisoned")]
LockPoisoned, LockPoisoned,
} }
+3 -433
View File
@@ -15,7 +15,7 @@ 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 tower::ServiceExt; use tower::ServiceExt;
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app}; use wasmeld_console::{Console, ConsoleConfig, 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,
@@ -40,7 +40,6 @@ async fn manages_a_resident_component_over_http() {
assert_eq!(status, StatusCode::CREATED, "{registered}"); assert_eq!(status, StatusCode::CREATED, "{registered}");
assert_eq!(registered["id"], "echo"); assert_eq!(registered["id"], "echo");
assert_eq!(registered["status"], "stopped"); assert_eq!(registered["status"], "stopped");
assert_eq!(registered["capabilities"], json!([]));
let response = application let response = application
.clone() .clone()
@@ -90,302 +89,6 @@ 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 reports_exact_component_host_capabilities() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let application = test_app(artifact_dir.path()).await;
let component = fs::read(component_artifact("clock_probe_component.wasm"))
.expect("clock probe component should be readable");
let response = application
.clone()
.oneshot(package_request("clock-probe", "0.1.0", &component))
.await
.expect("register request should complete");
assert_eq!(response.status(), StatusCode::CREATED);
let registered = response_json(response).await;
assert_eq!(
registered["capabilities"],
json!([{
"interface": "wasmeld:clock/monotonic-clock@0.1.0",
"package": "wasmeld:clock",
"name": "monotonic-clock",
"version": "0.1.0"
}])
);
let response = application
.oneshot(get_request("/api/v1/services"))
.await
.expect("service list request should complete");
let services = response_json(response).await;
assert_eq!(
services["services"][0]["capabilities"],
registered["capabilities"]
);
}
#[tokio::test]
async fn routes_raw_gateway_calls_through_the_active_deployment() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let (management, gateway) = test_apps(artifact_dir.path()).await;
let component = fs::read(echo_component()).expect("echo component should be readable");
let response = gateway
.clone()
.oneshot(raw_request(
"/v1/services/echo/invoke",
"application/octet-stream",
b"not deployed",
))
.await
.expect("gateway request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
response_json(response).await["error"]["code"],
"service_not_found"
);
let response = gateway
.clone()
.oneshot(get_request("/api/v1/services"))
.await
.expect("gateway route isolation request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
for revision in ["0.1.0", "0.2.0"] {
let response = management
.clone()
.oneshot(package_request("echo", revision, &component))
.await
.expect("register request should complete");
assert_eq!(response.status(), StatusCode::CREATED);
}
let response = management
.clone()
.oneshot(json_request(
"/api/v1/deployments/echo/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("activation request should complete");
assert_eq!(response.status(), StatusCode::OK);
let deployment = response_json(response).await;
assert_eq!(deployment["active_revision"], "0.1.0");
assert_eq!(deployment["status"], "running");
let response = gateway
.clone()
.oneshot(raw_request(
"/v1/services/echo/invoke",
"application/octet-stream",
b"public bytes",
))
.await
.expect("gateway invocation should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()["content-type"],
"application/octet-stream"
);
assert_eq!(response.headers()["x-wasmeld-revision"], "0.1.0");
assert_eq!(
to_bytes(response.into_body(), usize::MAX).await.unwrap(),
b"public bytes".as_slice()
);
let response = management
.clone()
.oneshot(json_request(
"/api/v1/deployments/echo/activate",
json!({ "revision": "0.2.0" }),
))
.await
.expect("switch request should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = gateway
.clone()
.oneshot(raw_request(
"/v1/services/echo/invoke",
"application/octet-stream",
b"switched",
))
.await
.expect("gateway invocation should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["x-wasmeld-revision"], "0.2.0");
let response = gateway
.clone()
.oneshot(raw_request(
"/v1/services/echo/invoke",
"application/json",
b"{}",
))
.await
.expect("unsupported media request should complete");
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
assert_eq!(
response_json(response).await["error"]["code"],
"unsupported_media_type"
);
let response = management
.clone()
.oneshot(empty_post("/api/v1/services/echo/0.2.0/stop"))
.await
.expect("active actor stop request should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = gateway
.oneshot(raw_request(
"/v1/services/echo/invoke",
"application/octet-stream",
b"stopped",
))
.await
.expect("stopped deployment request should complete");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response_json(response).await["error"]["code"],
"service_unavailable"
);
let response = management
.oneshot(get_request("/api/v1/deployments"))
.await
.expect("deployment list request should complete");
let deployments = response_json(response).await;
assert_eq!(deployments["deployments"].as_array().unwrap().len(), 1);
assert_eq!(deployments["deployments"][0]["active_revision"], "0.2.0");
}
#[tokio::test]
async fn unregisters_only_inactive_service_revisions_and_prunes_persistence() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let component = fs::read(echo_component()).expect("echo component should be readable");
{
let application = test_app(artifact_dir.path()).await;
for revision in ["0.1.0", "0.2.0"] {
let response = application
.clone()
.oneshot(package_request("dev-echo", revision, &component))
.await
.expect("register request should complete");
assert_eq!(response.status(), StatusCode::CREATED);
}
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/dev-echo/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("activation request should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
.await
.expect("active unregister request should complete");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(
response_json(response).await["error"]["code"],
"active_deployment"
);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/dev-echo/activate",
json!({ "revision": "0.2.0" }),
))
.await
.expect("replacement activation should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
.await
.expect("inactive unregister request should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(!artifact_dir.path().join("dev-echo@0.1.0.wasm").exists());
assert!(!artifact_dir.path().join("dev-echo@0.1.0.toml").exists());
}
let application = test_app(artifact_dir.path()).await;
let response = application
.oneshot(get_request("/api/v1/services"))
.await
.expect("restored service list request should complete");
let body = response_json(response).await;
let services = body["services"].as_array().unwrap();
assert_eq!(services.len(), 1);
assert_eq!(services[0]["revision"], "0.2.0");
}
#[tokio::test]
async fn restores_the_active_deployment_with_a_fresh_actor() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let component = fs::read(echo_component()).expect("echo component should be readable");
{
let (management, gateway) = test_apps(artifact_dir.path()).await;
let response = management
.clone()
.oneshot(package_request("restored", "1.0.0", &component))
.await
.expect("register request should complete");
assert_eq!(response.status(), StatusCode::CREATED);
let response = management
.oneshot(json_request(
"/api/v1/deployments/restored/activate",
json!({ "revision": "1.0.0" }),
))
.await
.expect("activation request should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = gateway
.oneshot(raw_request(
"/v1/services/restored/invoke",
"application/octet-stream",
b"before restart",
))
.await
.expect("gateway invocation should complete");
assert_eq!(response.status(), StatusCode::OK);
}
let (management, gateway) = test_apps(artifact_dir.path()).await;
let response = management
.oneshot(get_request("/api/v1/deployments/restored"))
.await
.expect("restored deployment request should complete");
let deployment = response_json(response).await;
assert_eq!(deployment["active_revision"], "1.0.0");
assert_eq!(deployment["status"], "running");
let response = gateway
.oneshot(raw_request(
"/v1/services/restored/invoke",
"application/octet-stream",
b"after restart",
))
.await
.expect("restored gateway invocation should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
to_bytes(response.into_body(), usize::MAX).await.unwrap(),
b"after restart".as_slice()
);
}
#[tokio::test] #[tokio::test]
async fn reloads_manifests_without_restoring_actor_memory() { async fn reloads_manifests_without_restoring_actor_memory() {
let artifact_dir = TempDir::new().expect("temporary artifact directory"); let artifact_dir = TempDir::new().expect("temporary artifact directory");
@@ -408,7 +111,6 @@ async fn reloads_manifests_without_restoring_actor_memory() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let response = application let response = application
.clone()
.oneshot(json_request( .oneshot(json_request(
"/api/v1/services/persisted/0.1.0/invoke", "/api/v1/services/persisted/0.1.0/invoke",
json!({ "input_base64": BASE64.encode(b"persist metrics") }), json!({ "input_base64": BASE64.encode(b"persist metrics") }),
@@ -416,12 +118,6 @@ async fn reloads_manifests_without_restoring_actor_memory() {
.await .await
.expect("invoke request should complete"); .expect("invoke request should complete");
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let response = application
.oneshot(empty_post("/api/v1/services/persisted/0.1.0/stop"))
.await
.expect("stop request should flush control state");
assert_eq!(response.status(), StatusCode::OK);
} }
let application = test_app(artifact_dir.path()).await; let application = test_app(artifact_dir.path()).await;
@@ -444,80 +140,6 @@ async fn reloads_manifests_without_restoring_actor_memory() {
assert!(body["events"].as_array().unwrap().len() >= 3); assert!(body["events"].as_array().unwrap().len() >= 3);
} }
#[tokio::test]
async fn persists_service_scoped_kv_across_revisions_and_restarts() {
const KV_WORLD: &str = "component:kv-probe/kv-probe-component@0.1.0";
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let component = fs::read(component_artifact("kv_probe_component.wasm"))
.expect("KV probe component should be readable");
{
let application = test_app(artifact_dir.path()).await;
for (id, revision) in [
("shared-kv", "0.1.0"),
("shared-kv", "0.2.0"),
("isolated-kv", "0.1.0"),
] {
let response = application
.clone()
.oneshot(package_request_with_world(
id, revision, KV_WORLD, &component,
))
.await
.expect("KV component registration should complete");
assert_eq!(response.status(), StatusCode::CREATED);
let registered = response_json(response).await;
assert_eq!(
registered["capabilities"][0]["interface"],
"wasmeld:kv/store@0.1.0"
);
let response = application
.clone()
.oneshot(empty_post(&format!(
"/api/v1/services/{id}/{revision}/start"
)))
.await
.expect("KV actor start should complete");
assert_eq!(response.status(), StatusCode::OK);
}
assert_eq!(
invoke_service(&application, "shared-kv", "0.1.0", b"set:answer:forty-two").await,
b""
);
assert_eq!(
invoke_service(&application, "shared-kv", "0.2.0", b"get:answer").await,
b"forty-two"
);
assert_eq!(
invoke_service(&application, "isolated-kv", "0.1.0", b"get:answer").await,
b""
);
}
let application = test_app(artifact_dir.path()).await;
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/shared-kv/0.2.0/start"))
.await
.expect("restored KV actor start should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
invoke_service(&application, "shared-kv", "0.2.0", b"get:answer").await,
b"forty-two"
);
assert_eq!(
invoke_service(&application, "shared-kv", "0.2.0", b"delete:answer").await,
b""
);
assert_eq!(
invoke_service(&application, "shared-kv", "0.2.0", b"get:answer").await,
b""
);
}
#[tokio::test] #[tokio::test]
async fn manages_the_embedded_runtime_lifecycle() { async fn manages_the_embedded_runtime_lifecycle() {
let artifact_dir = TempDir::new().expect("temporary artifact directory"); let artifact_dir = TempDir::new().expect("temporary artifact directory");
@@ -851,10 +473,6 @@ async fn client_fetches_transitive_wit_dependencies_from_the_registry() {
} }
async fn test_app(artifact_dir: &Path) -> Router { async fn test_app(artifact_dir: &Path) -> Router {
test_apps(artifact_dir).await.0
}
async fn test_apps(artifact_dir: &Path) -> (Router, Router) {
let console = Console::new(ConsoleConfig { let console = Console::new(ConsoleConfig {
artifact_dir: artifact_dir.to_path_buf(), artifact_dir: artifact_dir.to_path_buf(),
wit_registry_dir: artifact_dir.join("wit-packages"), wit_registry_dir: artifact_dir.join("wit-packages"),
@@ -863,28 +481,16 @@ async fn test_apps(artifact_dir: &Path) -> (Router, Router) {
}) })
.await .await
.expect("console should start"); .expect("console should start");
let console = Arc::new(console); app(Arc::new(console), Vec::new())
(app(Arc::clone(&console), Vec::new()), gateway_app(console))
} }
fn package_request(id: &str, revision: &str, component: &[u8]) -> Request<Body> { fn package_request(id: &str, revision: &str, component: &[u8]) -> Request<Body> {
let mut package = Cursor::new(Vec::new());
let world = if id == "counter" { let world = if id == "counter" {
"component:counter/counter-component@0.1.0" "component:counter/counter-component@0.1.0"
} else if id == "clock-probe" {
"component:clock-probe/clock-probe-component@0.1.0"
} else { } else {
"component:echo/echo-component@0.1.0" "component:echo/echo-component@0.1.0"
}; };
package_request_with_world(id, revision, world, component)
}
fn package_request_with_world(
id: &str,
revision: &str,
world: &str,
component: &[u8],
) -> Request<Body> {
let mut package = Cursor::new(Vec::new());
write_package(&mut package, id, revision, world, component) write_package(&mut package, id, revision, world, component)
.expect("test package should be valid"); .expect("test package should be valid");
package_bytes_request(package.get_ref()) package_bytes_request(package.get_ref())
@@ -946,15 +552,6 @@ fn json_request(uri: &str, value: Value) -> Request<Body> {
.unwrap() .unwrap()
} }
fn raw_request(uri: &str, content_type: &str, body: &[u8]) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header(header::CONTENT_TYPE, content_type)
.body(Body::from(body.to_vec()))
.unwrap()
}
fn empty_post(uri: &str) -> Request<Body> { fn empty_post(uri: &str) -> Request<Body> {
Request::builder() Request::builder()
.method("POST") .method("POST")
@@ -963,14 +560,6 @@ fn empty_post(uri: &str) -> Request<Body> {
.unwrap() .unwrap()
} }
fn delete_request(uri: &str) -> Request<Body> {
Request::builder()
.method("DELETE")
.uri(uri)
.body(Body::empty())
.unwrap()
}
fn get_request(uri: &str) -> Request<Body> { fn get_request(uri: &str) -> Request<Body> {
Request::builder().uri(uri).body(Body::empty()).unwrap() Request::builder().uri(uri).body(Body::empty()).unwrap()
} }
@@ -982,23 +571,6 @@ async fn response_json(response: axum::response::Response) -> Value {
serde_json::from_slice(&bytes).expect("response should be JSON") serde_json::from_slice(&bytes).expect("response should be JSON")
} }
async fn invoke_service(application: &Router, id: &str, revision: &str, input: &[u8]) -> Vec<u8> {
let response = application
.clone()
.oneshot(json_request(
&format!("/api/v1/services/{id}/{revision}/invoke"),
json!({ "input_base64": BASE64.encode(input) }),
))
.await
.expect("component invocation should complete");
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK, "{body}");
BASE64
.decode(body["output_base64"].as_str().unwrap())
.unwrap()
}
fn echo_component() -> PathBuf { fn echo_component() -> PathBuf {
component_artifact("echo_component.wasm") component_artifact("echo_component.wasm")
} }
@@ -1008,8 +580,6 @@ fn component_artifact(name: &str) -> PathBuf {
for manifest in [ for manifest in [
"components/echo/Cargo.toml", "components/echo/Cargo.toml",
"components/counter/Cargo.toml", "components/counter/Cargo.toml",
"components/clock-probe/Cargo.toml",
"components/kv-probe/Cargo.toml",
] { ] {
let status = Command::new("rustup") let status = Command::new("rustup")
.args([ .args([
-8
View File
@@ -1,8 +0,0 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
-12
View File
@@ -1,12 +0,0 @@
{
"plugins": ["typescript"],
"categories": {
"correctness": "error",
"suspicious": "warn"
},
"rules": {
"typescript/no-explicit-any": "error",
"no-console": "warn"
},
"ignorePatterns": ["dist/**", "node_modules/**"]
}
-192
View File
@@ -1,192 +0,0 @@
# 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
@@ -1,16 +0,0 @@
<!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>
-35
View File
@@ -1,35 +0,0 @@
{
"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
@@ -1,13 +0,0 @@
<!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>
@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 306 B

@@ -1,18 +0,0 @@
{
"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"
}
]
}
@@ -1,13 +0,0 @@
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>
);
}
@@ -1,19 +0,0 @@
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>
);
}
@@ -1,18 +0,0 @@
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>
);
}
@@ -1,201 +0,0 @@
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>
);
}
@@ -1,23 +0,0 @@
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>
);
}
@@ -1,113 +0,0 @@
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>
);
}
@@ -1,68 +0,0 @@
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>
);
}
@@ -1,34 +0,0 @@
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>
);
}
@@ -1,33 +0,0 @@
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)}
/>
);
}
@@ -1,50 +0,0 @@
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>
);
}
@@ -1,35 +0,0 @@
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>
);
}
@@ -1,82 +0,0 @@
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>
);
}
@@ -1,43 +0,0 @@
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
@@ -1,577 +0,0 @@
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}
/>
);
}
@@ -1,368 +0,0 @@
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>
);
}
@@ -1,163 +0,0 @@
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();
}
}
@@ -1,13 +0,0 @@
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");
});
}
@@ -1,152 +0,0 @@
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;
}
-16
View File
@@ -1,16 +0,0 @@
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));
}
-164
View File
@@ -1,164 +0,0 @@
import type { BackendEvent, BackendService } from "./api";
export type ConnectionState = "connecting" | "online" | "offline";
export type ServiceStatus = "running" | "stopped" | "faulted";
export type EventTone = "success" | "warning" | "danger" | "neutral";
export type Service = {
id: string;
revision: string;
artifact: string;
world: string;
status: ServiceStatus;
updatedAt: string;
memoryMb: number;
fuel: string;
deadlineMs: number;
mailbox: number;
maxInputKb: number;
capabilities: BackendService["capabilities"];
calls: number;
errors: number;
latencyMs: number | null;
active: boolean;
};
export type RuntimeEvent = {
id: number;
time: string;
title: string;
detail: string;
tone: EventTone;
};
export type FormattedInvocationOutput = {
text: string;
format: "UTF-8" | "HEX" | "U64 LE";
automatic: boolean;
};
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}`;
}
export function toService(service: BackendService, activeRevision?: string): Service {
return {
id: service.id,
revision: service.revision,
artifact: service.artifact.split(/[\\/]/).pop() ?? service.artifact,
world: service.world,
status: service.status,
updatedAt: new Date(service.updated_at_ms).toLocaleString("zh-CN", { hour12: false }),
memoryMb: Math.round(service.limits.memory_bytes / 1024 / 1024),
fuel: compactNumber(service.limits.fuel_per_call),
deadlineMs: service.limits.deadline_ms,
mailbox: service.limits.mailbox_capacity,
maxInputKb: Math.round(service.limits.max_input_bytes / 1024),
capabilities: service.capabilities,
calls: service.calls,
errors: service.errors,
latencyMs: service.last_latency_ms,
active: service.revision === activeRevision,
};
}
export function toEvent(event: BackendEvent): RuntimeEvent {
const identity =
event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld";
const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = {
registered: { label: "已注册", tone: "success" },
unregistered: { label: "已注销", tone: "neutral" },
deployed: { label: "部署切换", tone: "success" },
started: { label: "已启动", tone: "success" },
stopped: { label: "已停止", tone: "neutral" },
invoked: { label: "调用完成", tone: "success" },
failed: { label: "操作失败", tone: "danger" },
};
return {
id: event.id,
time: new Date(event.timestamp_ms).toLocaleTimeString("zh-CN", { hour12: false }),
title: `${identity} ${meta[event.kind].label}`,
detail: event.message,
tone: meta[event.kind].tone,
};
}
export function compactNumber(value: number): string {
if (value >= 1_000_000) return `${value / 1_000_000}M`;
if (value >= 1_000) return `${value / 1_000}K`;
return value.toString();
}
export function formatDuration(milliseconds: number): string {
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m ${seconds}s`;
}
export function parseInvocationInput(value: string, format: "utf8" | "hex"): Uint8Array {
if (format === "utf8") return new TextEncoder().encode(value);
const compact = value.replace(/\s+/g, "");
if (compact.length % 2 !== 0 || /[^0-9a-f]/i.test(compact)) {
throw new Error("HEX 输入必须由成对的十六进制字符组成");
}
return Uint8Array.from(compact.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? []);
}
export function invocationInputSize(value: string, format: "utf8" | "hex"): number {
try {
return parseInvocationInput(value, format).byteLength;
} catch {
return 0;
}
}
export function formatInvocationOutput(
service: Service,
output: Uint8Array,
format: "utf8" | "hex",
): FormattedInvocationOutput {
if (service.id === "counter" && output.byteLength === 8) {
return {
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
.getBigUint64(0, true)
.toString(),
format: "U64 LE",
automatic: false,
};
}
if (format === "hex") {
return { text: formatHex(output) || "(empty)", format: "HEX", automatic: false };
}
const text = decodeReadableUtf8(output);
if (text !== null) {
return { text: text || "(empty)", format: "UTF-8", automatic: false };
}
return { text: formatHex(output), format: "HEX", automatic: true };
}
function formatHex(bytes: Uint8Array): string {
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;
}
@@ -1,17 +0,0 @@
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) ?? [];
}
@@ -1,73 +0,0 @@
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>
);
}
@@ -1,117 +0,0 @@
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>
);
}
@@ -1,53 +0,0 @@
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);
@@ -1,267 +0,0 @@
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>
);
}
@@ -1,106 +0,0 @@
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)!;
}
@@ -1,81 +0,0 @@
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>
);
}
@@ -1,92 +0,0 @@
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>
);
}
@@ -1,20 +0,0 @@
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;
};
@@ -1,114 +0,0 @@
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>
);
}
@@ -1,80 +0,0 @@
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";
}
}

Some files were not shown because too many files have changed in this diff Show More