Compare commits

..

8 Commits

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

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

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

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

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

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

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

Use clsx and tailwind-merge through a shared cn helper so callers can override component defaults without relying on global semantic CSS. Control sizes define minimum heights while icon-only actions retain stable square dimensions.
2026-07-30 15:16:02 +08:00
55 changed files with 3577 additions and 1017 deletions
+26
View File
@@ -0,0 +1,26 @@
{
"lsp": {
"tailwindcss-language-server": {
"settings": {
"classFunctions": [
"cva",
"cx",
"cn"
],
"experimental": {
"classRegex": [
"[cls|className]\\s\\:\\=\\s\"([^\"]*)"
]
}
}
}
},
"languages": {
"CSS": {
"language_servers": [
"tailwindcss-intellisense-css",
"!vscode-css-language-server"
]
}
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ sha2 = "0.10.9"
thiserror = "2.0.17"
toasty = "=0.9.0"
toasty-driver-turso = "=0.9.0"
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] }
tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
toml = "0.9.8"
tower-http = { version = "0.7.0", features = ["cors", "trace"] }
tracing = "0.1.44"
+6
View File
@@ -29,6 +29,12 @@ Host driver <- validated operation <- raw effect <-
线程外;协议级驱动以及文件监听、串口、系统信号等其它来源通过独立版本的 WIT 能力演进,
不需要扩大基础 service world。
Console 已接入 Timer 与 TCP Host Driver:平台配置的 Timer、Listener 和 Stream 在
Tokio task 中等待,并通过有界队列交给专用 `ResidentSupervisor` 线程串行进入
`ResidentSession`。TCP 地址执行默认拒绝的 Host policy,字节流支持有界读写、半关闭和
pause/resume;服务停止、重启和 Runtime Deployment 恢复会按 Revision 创建或释放 Host
资源。UDP 和 Unix Driver 仍待接入。
## 结构
```text
+4 -1
View File
@@ -70,9 +70,12 @@ impl ResidentGuest for ResidentProbe {
command: source.kind,
payload: source.payload,
})],
// A TCP FIN is a read-half close. This fixture has no additional
// response to send, so it explicitly asks the Host to close the
// remaining write half and release the stream resource.
Event::StreamHalfClosed(stream_id) => vec![Effect::CloseStream(stream_id)],
Event::StreamOpened(_)
| Event::StreamWritable(_)
| Event::StreamHalfClosed(_)
| Event::StreamClosed(_)
| Event::Shutdown => Vec::new(),
})
+65
View File
@@ -30,6 +30,71 @@ Host KV 使用 `(service_id, key)` 复合主键存储在该数据库中:同一
编译后的本地机器码默认缓存在 `var/wasmeld/component-cache`,相同 Component 在
Console 或 Runtime 重启后可由 Wasmtime 直接复用;缓存不包含 Actor 内存。
## 常驻 Host Driver
导出 `wasmeld:resident/actor@0.1.0` 的 Component 启动时,Console 会为它创建独立的
`ResidentSupervisor`。Supervisor 在专用阻塞线程上独占 `ResidentSession`,避免同步
Actor mailbox 阻塞 Tokio executor。Timer、TCP Listener 和每条 TCP Stream 由 Tokio
task 等待外部事件,再通过容量受限的 channel 汇聚到同一个 Supervisor。
Host 资源属于平台配置,不由 Component 创建,也不写入 `.wasmpkg`。当前可通过
`ConsoleConfig::resident_services` 为稳定服务 ID 配置 TCP Listener 和 Timer
```rust
use std::collections::BTreeMap;
use wasmeld_console::{
ConsoleConfig, NetworkScope, ResidentPolicy, ResidentServiceConfig,
ResidentTcpListenerConfig, ResidentTimerConfig,
};
let mut resident_services = BTreeMap::new();
resident_services.insert(
"scheduler".to_owned(),
ResidentServiceConfig {
policy: ResidentPolicy {
tcp_listen: NetworkScope::Loopback,
..ResidentPolicy::default()
},
tcp_listeners: vec![ResidentTcpListenerConfig {
name: "internal-api".to_owned(),
bind: "127.0.0.1:9000".parse().unwrap(),
}],
timers: vec![ResidentTimerConfig {
name: "heartbeat".to_owned(),
initial_delay_ms: 1_000,
interval_ms: Some(30_000),
}],
..ResidentServiceConfig::default()
},
);
let config = ConsoleConfig {
resident_services,
..ConsoleConfig::default()
};
```
`ResidentPolicy` 默认拒绝全部网络地址;配置 Listener 时还必须显式选择 `Loopback`
`Any`。Listener 在 Actor 启动过程中完成策略校验和 bind,任何失败都会回滚刚启动的
Actor,避免出现“Actor 正在运行但端口未监听”的半启动状态。Listener、Timer 和其它
顶层资源名称在同一服务内必须唯一,每个 Revision 都会获得独立的 Host handle 和不复用
资源 ID。
TCP Driver 每次最多读取 `limits.resident.max_stream_chunk_bytes`,不会把 socket handle
交给 Wasm。TCP 字节在 Supervisor 队列满时等待并把背压传递到内核接收缓冲区,不能像
Timer occurrence 一样丢弃。Component 可通过 WIT 返回 `write-stream``close-stream`
`pause-stream``resume-stream`;每条连接的写入/控制队列也是有界的,慢客户端填满
队列时只关闭该连接,不阻塞同一 Actor 的其它连接。
Timer task 只负责等待,队列满时丢弃该次 occurrence,而不是创建无界积压。Component
返回 `arm-timer` 会替换 Host 初始日程,`cancel-timer` 会取消它;generation 使取消前
已经排队的迟到事件失效。
停止顺序固定为:停止接收新 Driver 事件、停止 accept、投递 `shutdown`、关闭 Stream
和 Timer task、先释放子 Stream 再释放 Listener、最后停止 Actor。服务重启和 Runtime
Deployment 恢复都会重新 bind 并创建新 Session,不会复用旧 Revision 的 socket、Timer
或 Component 内存。UDP 和 Unix Driver 尚未接入,后续继续复用相同的 Supervisor
序列化边界。
## 启动
`wasmeld-console` 需要 Rust 1.95 或更高版本;Wasm 组件继续使用项目约定的 Rust 1.90
+6 -2
View File
@@ -585,7 +585,8 @@ impl IntoResponse for GatewayError {
| ConsoleError::InvalidDatabaseData(_)
| ConsoleError::Runtime(_)
| ConsoleError::Join(_)
| ConsoleError::LockPoisoned => (
| ConsoleError::LockPoisoned
| ConsoleError::ResidentSupervisor(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"internal gateway error".to_owned(),
@@ -680,7 +681,10 @@ impl IntoResponse for ApiError {
| ConsoleError::WitRegistry(WitRegistryError::Storage { .. })
| ConsoleError::WitRegistry(WitRegistryError::LockPoisoned)
| ConsoleError::Join(_)
| ConsoleError::LockPoisoned => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
| ConsoleError::LockPoisoned
| ConsoleError::ResidentSupervisor(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
}
};
(
+185 -22
View File
@@ -36,11 +36,12 @@ mod api;
mod invocation_persistence;
mod kv_backend;
mod persistence;
mod resident_supervisor;
mod web;
mod wit_registry;
use std::{
collections::{BTreeMap, VecDeque},
collections::{BTreeMap, HashMap, VecDeque},
fs, io,
path::{Path, PathBuf},
sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard},
@@ -54,14 +55,19 @@ use wasmeld_package::{
ComponentPackage, PackageError, read_package, wit_package::WitPackageMetadata,
};
use wasmeld_runtime::{
CapabilityDescriptor, ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceKey,
ServiceManifest,
ActorHandle, CapabilityDescriptor, ComponentExecution, ResourceLimits, Runtime, RuntimeConfig,
RuntimeError, ServiceKey, ServiceManifest,
};
pub use api::{app, gateway_app};
use invocation_persistence::InvocationPersistence;
use kv_backend::ToastyKvBackend;
use persistence::{InvocationUpdate, Persistence, StoredDeployment, StoredEvent, StoredService};
pub use resident_supervisor::{
ResidentServiceConfig, ResidentTcpListenerConfig, ResidentTimerConfig,
};
use resident_supervisor::{ResidentSupervisor, ResidentSupervisorError};
pub use wasmeld_runtime::{NetworkScope, ResidentPolicy};
use wit_registry::WitRegistry;
pub use wit_registry::WitRegistryError;
@@ -87,6 +93,12 @@ pub struct ConsoleConfig {
pub registration_limits: ResourceLimits,
/// Wasmtime Engine and epoch settings.
pub runtime: RuntimeConfig,
/// Host-owned resident resources keyed by stable service ID.
///
/// Each started revision receives an independent copy of this
/// configuration and therefore never shares resource IDs, socket handles,
/// or driver tasks with another revision.
pub resident_services: BTreeMap<String, ResidentServiceConfig>,
}
impl Default for ConsoleConfig {
@@ -99,6 +111,7 @@ impl Default for ConsoleConfig {
max_wit_package_bytes: DEFAULT_MAX_WIT_PACKAGE_BYTES,
registration_limits: ResourceLimits::default(),
runtime: RuntimeConfig::default(),
resident_services: BTreeMap::new(),
}
}
}
@@ -115,6 +128,9 @@ pub struct Console {
// to avoid deadlocks between HTTP control operations.
runtime: RwLock<Option<Runtime>>,
runtime_config: RuntimeConfig,
async_runtime: tokio::runtime::Handle,
resident_services: BTreeMap<String, ResidentServiceConfig>,
resident_supervisors: Mutex<HashMap<ServiceKey, ResidentSupervisor>>,
artifact_dir: PathBuf,
wit_registry: WitRegistry,
max_artifact_bytes: usize,
@@ -486,6 +502,10 @@ pub enum ConsoleError {
/// An internal lifecycle, state, or Runtime lock was poisoned.
#[error("console state lock was poisoned")]
LockPoisoned,
/// A resident Host driver could not start, dispatch, or drain.
#[error("resident supervisor failed: {0}")]
ResidentSupervisor(String),
}
impl Console {
@@ -495,6 +515,12 @@ impl Console {
/// configuration. Their previous Store and linear memory are not restored.
pub async fn new(config: ConsoleConfig) -> Result<Self, ConsoleError> {
config.registration_limits.validate()?;
for (service_id, resident) in &config.resident_services {
validate_path_segment("resident service id", service_id)?;
resident
.validate()
.map_err(ConsoleError::ResidentSupervisor)?;
}
fs::create_dir_all(&config.artifact_dir).map_err(|source| ConsoleError::Storage {
path: config.artifact_dir.clone(),
source,
@@ -547,6 +573,9 @@ impl Console {
let console = Self {
runtime: RwLock::new(Some(runtime)),
runtime_config,
async_runtime: tokio::runtime::Handle::current(),
resident_services: config.resident_services,
resident_supervisors: Mutex::new(HashMap::new()),
artifact_dir,
wit_registry,
max_artifact_bytes: config.max_artifact_bytes,
@@ -671,6 +700,16 @@ impl Console {
Ok(started) => {
*runtime = Some(started);
drop(runtime);
if let Err(error) = self.restore_deployments() {
self.rollback_runtime_start();
self.mark_runtime_stopped()?;
self.push_event(
EventKind::Failed,
None,
format!("Wasmeld Runtime failed to restore deployments: {error}"),
)?;
return Err(error);
}
self.mark_runtime_started()?;
self.push_event(
EventKind::Started,
@@ -704,6 +743,7 @@ impl Console {
drop(runtime);
return self.runtime_view();
};
self.shutdown_all_resident_supervisors();
let stopped = running.stop_all();
drop(runtime);
self.mark_runtime_stopped()?;
@@ -738,9 +778,9 @@ impl Console {
.write()
.map_err(|_| ConsoleError::LockPoisoned)?;
if let Some(running) = runtime.take()
&& let Err(error) = running.stop_all()
{
if let Some(running) = runtime.take() {
self.shutdown_all_resident_supervisors();
if let Err(error) = running.stop_all() {
drop(runtime);
self.mark_runtime_stopped()?;
self.push_event(
@@ -750,11 +790,22 @@ impl Console {
)?;
return Err(error.into());
}
}
match self.build_runtime() {
Ok(started) => {
*runtime = Some(started);
drop(runtime);
if let Err(error) = self.restore_deployments() {
self.rollback_runtime_start();
self.mark_runtime_stopped()?;
self.push_event(
EventKind::Failed,
None,
format!("Wasmeld Runtime failed to restore deployments: {error}"),
)?;
return Err(error);
}
self.mark_runtime_started()?;
self.push_event(
EventKind::Started,
@@ -783,25 +834,120 @@ impl Console {
.values()
.map(|service| service.manifest.clone())
.collect::<Vec<_>>();
let deployments = state
.deployments
.iter()
.map(|(service_id, deployment)| {
ServiceKey::new(service_id.clone(), deployment.active_revision.clone())
})
.collect::<Result<Vec<_>, _>>()?;
drop(state);
let runtime = Runtime::new(self.runtime_config.clone())?;
for manifest in manifests {
runtime.register_from_file(manifest)?;
}
for key in deployments {
runtime.start(&key, Vec::new())?;
}
Ok(runtime)
}
fn start_actor(
&self,
runtime: &Runtime,
key: &ServiceKey,
init_config: Vec<u8>,
) -> Result<ActorHandle, ConsoleError> {
let actor = runtime.start(key, init_config)?;
if let Err(error) = self.attach_resident_supervisor(actor.clone()) {
// A resident Actor without its Host driver would be running in a
// misleading half-state. Roll it back before surfacing the error.
let _ = runtime.stop(key);
return Err(error);
}
Ok(actor)
}
fn attach_resident_supervisor(&self, actor: ActorHandle) -> Result<(), ConsoleError> {
if actor.execution() != ComponentExecution::Resident {
return Ok(());
}
let key = actor.key().clone();
let mut supervisors = self
.resident_supervisors
.lock()
.map_err(|_| ConsoleError::LockPoisoned)?;
if supervisors
.get(&key)
.is_some_and(|supervisor| supervisor.owns_actor(&actor))
{
return Ok(());
}
if let Some(mut stale) = supervisors.remove(&key)
&& let Err(error) = stale.shutdown()
{
tracing::warn!(
service = %key,
%error,
"stale resident supervisor did not drain cleanly before Actor replacement"
);
}
let config = self
.resident_services
.get(key.id())
.cloned()
.unwrap_or_default();
let command_capacity = actor.mailbox_capacity();
let supervisor =
ResidentSupervisor::start(actor, config, self.async_runtime.clone(), command_capacity)
.map_err(supervisor_error)?;
supervisors.insert(key, supervisor);
Ok(())
}
fn shutdown_resident_supervisor(&self, key: &ServiceKey) {
let supervisor = self
.resident_supervisors
.lock()
.map(|mut supervisors| supervisors.remove(key));
match supervisor {
Ok(Some(mut supervisor)) => {
if let Err(error) = supervisor.shutdown() {
tracing::warn!(service = %key, %error, "resident supervisor did not drain cleanly");
}
}
Ok(None) => {}
Err(_) => tracing::warn!(service = %key, "resident supervisor lock was poisoned"),
}
}
fn shutdown_all_resident_supervisors(&self) {
let supervisors = self
.resident_supervisors
.lock()
.map(|mut supervisors| std::mem::take(&mut *supervisors));
match supervisors {
Ok(supervisors) => {
for (key, mut supervisor) in supervisors {
if let Err(error) = supervisor.shutdown() {
tracing::warn!(service = %key, %error, "resident supervisor did not drain cleanly");
}
}
}
Err(_) => tracing::warn!("resident supervisor lock was poisoned"),
}
}
fn stop_actor(&self, runtime: &Runtime, key: &ServiceKey) -> Result<(), RuntimeError> {
self.shutdown_resident_supervisor(key);
runtime.stop(key)
}
fn rollback_runtime_start(&self) {
self.shutdown_all_resident_supervisors();
match self.runtime.write() {
Ok(mut runtime) => {
if let Some(runtime) = runtime.take()
&& let Err(error) = runtime.stop_all()
{
tracing::warn!(%error, "failed to stop Runtime during start rollback");
}
}
Err(_) => tracing::warn!("Runtime lock was poisoned during start rollback"),
}
}
fn mark_runtime_started(&self) -> Result<(), ConsoleError> {
let mut state = self.state()?;
let deployed = state
@@ -931,6 +1077,7 @@ impl Console {
let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
self.shutdown_resident_supervisor(key);
runtime.unregister(key)?;
self.state()?.services.remove(key);
@@ -956,7 +1103,7 @@ impl Console {
let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
self.ensure_managed(key)?;
runtime.start(key, init_config)?;
self.start_actor(runtime, key, init_config)?;
let view = self.update_status(key, ServiceStatus::Running)?;
self.push_event(EventKind::Started, Some(key), "actor started".to_owned())?;
Ok(view)
@@ -966,7 +1113,7 @@ impl Console {
let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
self.ensure_managed(key)?;
runtime.stop(key)?;
self.stop_actor(runtime, key)?;
let view = self.update_status(key, ServiceStatus::Stopped)?;
self.push_event(EventKind::Stopped, Some(key), "actor stopped".to_owned())?;
Ok(view)
@@ -976,11 +1123,11 @@ impl Console {
let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
self.ensure_managed(key)?;
match runtime.stop(key) {
match self.stop_actor(runtime, key) {
Ok(()) | Err(RuntimeError::ActorUnavailable(_)) => {}
Err(error) => return Err(error.into()),
}
runtime.start(key, init_config)?;
self.start_actor(runtime, key, init_config)?;
let view = self.update_status(key, ServiceStatus::Running)?;
self.push_event(EventKind::Started, Some(key), "actor restarted".to_owned())?;
Ok(view)
@@ -1006,7 +1153,7 @@ impl Console {
// Starting is idempotent. The old revision stays resident so calls
// that resolved immediately before this switch can finish safely and
// rollback does not require recompilation.
runtime.start(&key, init_config)?;
self.start_actor(runtime, &key, init_config)?;
let updated_at_ms = unix_time_ms();
let view = {
@@ -1221,7 +1368,7 @@ impl Console {
let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
for key in &deployments {
runtime.start(key, Vec::new())?;
self.start_actor(runtime, key, Vec::new())?;
}
}
@@ -1362,6 +1509,22 @@ impl Console {
}
}
impl Drop for Console {
fn drop(&mut self) {
self.shutdown_all_resident_supervisors();
if let Ok(runtime) = self.runtime.get_mut()
&& let Some(runtime) = runtime.take()
&& let Err(error) = runtime.stop_all()
{
tracing::warn!(%error, "failed to stop Runtime while dropping Console");
}
}
}
fn supervisor_error(error: ResidentSupervisorError) -> ConsoleError {
ConsoleError::ResidentSupervisor(error.to_string())
}
fn runtime_capabilities(
runtime: &Runtime,
key: &ServiceKey,
@@ -0,0 +1,877 @@
//! Revision-scoped execution of Host-owned resident event sources.
//!
//! The Wasmtime Actor API is intentionally synchronous: every call waits for a
//! bounded mailbox response. Running [`ResidentSession`] directly from a Tokio
//! task would therefore block an async worker for up to the Component deadline.
//! This module keeps one session on a dedicated supervisor thread. Async
//! drivers only wait for external activity and use a bounded channel to submit
//! facts such as a timer deadline, accepted TCP connection, or stream chunk.
//!
//! ```text
//! timer / listener / stream task --> bounded queue --> supervisor thread
//! ^ |
//! |-------- validated operations --------|
//! ```
//!
//! The supervisor is scoped to one immutable service revision. Shutdown first
//! prevents new driver events, then delivers the resident `shutdown` callback,
//! aborts every Host task, closes socket handles, releases Host resource IDs,
//! and only then allows the Console to stop the Actor.
mod tcp;
use std::{
collections::{HashMap, VecDeque},
io,
net::SocketAddr,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, SyncSender, TrySendError},
},
thread::{self, JoinHandle as ThreadJoinHandle},
time::{Duration, Instant},
};
use thiserror::Error;
use tokio::{net::TcpStream, runtime::Handle, task::JoinHandle as TaskJoinHandle};
use wasmeld_runtime::{
ActorHandle, ResidentEndpoint, ResidentHostError, ResidentOperation, ResidentPolicy,
ResidentSession, ResourceId, ServiceKey, StreamCloseReason,
};
pub use tcp::ResidentTcpListenerConfig;
use tcp::{TcpDriverError, TcpEndpointDriver, TcpStreamCommand, TcpStreamDriver};
/// Platform configuration for Host resources attached to one service ID.
///
/// A configuration is inherited by every started revision of that service, but
/// each revision receives independent Host handles and resource IDs.
#[derive(Clone, Debug, Default)]
pub struct ResidentServiceConfig {
/// Defense-in-depth policy checked before any Host endpoint is bound.
///
/// The default denies every network endpoint. Adding a TCP listener also
/// requires setting `tcp_listen` to `loopback` or `any`.
pub policy: ResidentPolicy,
/// Host-owned TCP listeners registered when a resident Actor starts.
pub tcp_listeners: Vec<ResidentTcpListenerConfig>,
/// Host-owned timers registered when a resident Actor starts.
pub timers: Vec<ResidentTimerConfig>,
}
impl ResidentServiceConfig {
pub(crate) fn validate(&self) -> Result<(), String> {
let mut names = std::collections::BTreeSet::new();
for listener in &self.tcp_listeners {
listener.validate()?;
self.policy
.validate_endpoint(&ResidentEndpoint::Tcp {
name: listener.name.clone(),
bind: listener.bind,
})
.map_err(|error| error.to_string())?;
if !names.insert(listener.name.as_str()) {
return Err(format!(
"resident resource name {:?} is duplicated",
listener.name
));
}
}
for timer in &self.timers {
timer.validate()?;
if !names.insert(timer.name.as_str()) {
return Err(format!(
"resident resource name {:?} is duplicated",
timer.name
));
}
}
Ok(())
}
}
/// One management-owned timer source for a resident Component.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResidentTimerConfig {
/// Stable operator-facing name, unique within one service configuration.
pub name: String,
/// Delay before the first event after the Actor starts.
pub initial_delay_ms: u64,
/// Optional Host-managed repeat period.
///
/// A Component may replace this schedule by returning `arm-timer` or stop
/// it with `cancel-timer`.
pub interval_ms: Option<u64>,
}
impl ResidentTimerConfig {
fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("resident timer name must not be empty".to_owned());
}
if self.initial_delay_ms == 0 {
return Err(format!(
"resident timer {:?} initial_delay_ms must be greater than zero",
self.name
));
}
if self.interval_ms == Some(0) {
return Err(format!(
"resident timer {:?} interval_ms must be greater than zero",
self.name
));
}
Ok(())
}
}
/// Failure while creating, driving, or draining a resident session.
#[derive(Debug, Error)]
pub(crate) enum ResidentSupervisorError {
#[error(transparent)]
Host(#[from] ResidentHostError),
#[error("resident driver queue capacity must be greater than zero")]
EmptyQueue,
#[error("failed to start resident supervisor thread: {0}")]
ThreadStart(#[source] io::Error),
#[error("resident supervisor for {0} stopped accepting commands")]
ChannelClosed(ServiceKey),
#[error("resident supervisor for {0} panicked")]
WorkerPanicked(ServiceKey),
#[error("resident timer operation referenced unknown resource {0}")]
UnknownTimer(u64),
#[error("resident stream operation referenced unknown resource {0}")]
UnknownStream(u64),
#[error(transparent)]
Tcp(#[from] TcpDriverError),
#[error("resident driver cannot apply {0} before its driver is installed")]
UnsupportedOperation(&'static str),
}
/// Clone-free owner for one revision's resident session and driver tasks.
pub(crate) struct ResidentSupervisor {
key: ServiceKey,
actor: ActorHandle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
worker: Option<ThreadJoinHandle<()>>,
}
impl ResidentSupervisor {
pub(crate) fn start(
actor: ActorHandle,
config: ResidentServiceConfig,
async_runtime: Handle,
command_capacity: usize,
) -> Result<Self, ResidentSupervisorError> {
if command_capacity == 0 {
return Err(ResidentSupervisorError::EmptyQueue);
}
let key = actor.key().clone();
let session = ResidentSession::new(actor.clone(), config.policy.clone())?;
let (sender, receiver) = mpsc::sync_channel(command_capacity);
let accepting_events = Arc::new(AtomicBool::new(true));
let core = SupervisorCore::new(
session,
config,
async_runtime,
sender.clone(),
accepting_events.clone(),
command_capacity,
Instant::now(),
)?;
let thread_name = format!("wasmeld-resident-{key}");
let worker_key = key.clone();
let worker = thread::Builder::new()
.name(thread_name)
.spawn(move || run_supervisor(worker_key, core, receiver))
.map_err(ResidentSupervisorError::ThreadStart)?;
Ok(Self {
key,
actor,
sender,
accepting_events,
worker: Some(worker),
})
}
pub(crate) fn owns_actor(&self, actor: &ActorHandle) -> bool {
self.actor.is_same_instance(actor)
}
/// Drains this revision and cancels all Host listener, stream, and timer tasks.
pub(crate) fn shutdown(&mut self) -> Result<(), ResidentSupervisorError> {
let Some(worker) = self.worker.take() else {
return Ok(());
};
// Async event producers observe this before retrying a full bounded
// queue, so the control-plane shutdown command cannot be starved by
// network traffic.
self.accepting_events.store(false, Ordering::Release);
let (response_sender, response_receiver) = mpsc::sync_channel(1);
let send_result = self
.sender
.send(SupervisorCommand::Shutdown { response_sender });
let response = match send_result {
Ok(()) => response_receiver
.recv()
.map_err(|_| ResidentSupervisorError::ChannelClosed(self.key.clone()))?,
Err(_) => Err(ResidentSupervisorError::ChannelClosed(self.key.clone())),
};
worker
.join()
.map_err(|_| ResidentSupervisorError::WorkerPanicked(self.key.clone()))?;
response
}
}
impl Drop for ResidentSupervisor {
fn drop(&mut self) {
if let Err(error) = self.shutdown() {
tracing::warn!(service = %self.key, %error, "resident supervisor drop could not drain cleanly");
}
}
}
enum SupervisorCommand {
TcpAccepted {
endpoint_id: ResourceId,
stream: TcpStream,
peer: SocketAddr,
},
StreamData {
stream_id: ResourceId,
bytes: Vec<u8>,
},
StreamHalfClosed {
stream_id: ResourceId,
},
StreamClosed {
stream_id: ResourceId,
reason: StreamCloseReason,
},
TimerFired {
timer_id: ResourceId,
generation: u64,
scheduled_at_ns: u64,
},
Shutdown {
response_sender: SyncSender<Result<(), ResidentSupervisorError>>,
},
}
struct SupervisorCore {
session: ResidentSession,
async_runtime: Handle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
stream_command_capacity: usize,
stream_read_bytes: usize,
origin: Instant,
tcp_endpoints: HashMap<ResourceId, TcpEndpointDriver>,
streams: HashMap<ResourceId, TcpStreamDriver>,
timers: HashMap<ResourceId, TimerDriver>,
}
impl SupervisorCore {
fn new(
session: ResidentSession,
config: ResidentServiceConfig,
async_runtime: Handle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
stream_command_capacity: usize,
origin: Instant,
) -> Result<Self, ResidentSupervisorError> {
let stream_read_bytes = session.max_stream_chunk_bytes();
let mut core = Self {
session,
async_runtime,
sender,
accepting_events,
stream_command_capacity,
stream_read_bytes,
origin,
tcp_endpoints: HashMap::new(),
streams: HashMap::new(),
timers: HashMap::new(),
};
for listener in config.tcp_listeners {
core.register_tcp_listener(listener)?;
}
for timer in config.timers {
let timer_id = core.session.register_timer(timer.name)?;
core.timers.insert(timer_id, TimerDriver::default());
core.arm_timer(timer_id, timer.initial_delay_ms, timer.interval_ms)?;
}
Ok(core)
}
fn register_tcp_listener(
&mut self,
config: ResidentTcpListenerConfig,
) -> Result<(), ResidentSupervisorError> {
let endpoint_id = self.session.register_endpoint(ResidentEndpoint::Tcp {
name: config.name.clone(),
bind: config.bind,
})?;
let driver = match TcpEndpointDriver::start(
&config,
self.session.owner().clone(),
endpoint_id,
&self.async_runtime,
self.sender.clone(),
self.accepting_events.clone(),
) {
Ok(driver) => driver,
Err(error) => {
let _ = self.session.release_resource(endpoint_id);
return Err(error.into());
}
};
self.tcp_endpoints.insert(endpoint_id, driver);
Ok(())
}
fn tcp_accepted(
&mut self,
endpoint_id: ResourceId,
stream: TcpStream,
peer: SocketAddr,
) -> Result<(), ResidentSupervisorError> {
let (stream_id, operations) = self
.session
.accept_stream(endpoint_id, Some(peer.to_string()))?;
let driver = TcpStreamDriver::start(
self.session.owner().clone(),
stream_id,
stream,
self.stream_read_bytes,
self.stream_command_capacity,
&self.async_runtime,
self.sender.clone(),
self.accepting_events.clone(),
);
self.streams.insert(stream_id, driver);
if let Err(error) = self.apply_operations(operations) {
self.discard_stream(stream_id);
return Err(error);
}
Ok(())
}
fn stream_data(
&mut self,
stream_id: ResourceId,
bytes: Vec<u8>,
) -> Result<(), ResidentSupervisorError> {
if !self.streams.contains_key(&stream_id) {
return Ok(());
}
let result = self
.session
.stream_data(stream_id, bytes)
.map_err(ResidentSupervisorError::from)
.and_then(|operations| self.apply_operations(operations));
if result.is_err() {
self.discard_stream(stream_id);
}
result
}
fn stream_half_closed(&mut self, stream_id: ResourceId) -> Result<(), ResidentSupervisorError> {
if !self.streams.contains_key(&stream_id) {
return Ok(());
}
let result = self
.session
.stream_half_closed(stream_id)
.map_err(ResidentSupervisorError::from)
.and_then(|operations| self.apply_operations(operations));
if result.is_err() {
self.discard_stream(stream_id);
}
result
}
fn stream_closed(
&mut self,
stream_id: ResourceId,
reason: StreamCloseReason,
) -> Result<(), ResidentSupervisorError> {
let Some(driver) = self.streams.remove(&stream_id) else {
// A task can race with Host-initiated termination. Resource IDs are
// never reused, so a late terminal fact is safe to ignore.
return Ok(());
};
drop(driver);
let operations = self.session.stream_closed(stream_id, reason)?;
self.apply_operations(operations)
}
fn discard_stream(&mut self, stream_id: ResourceId) {
self.streams.remove(&stream_id);
let _ = self.session.release_resource(stream_id);
}
fn timer_fired(
&mut self,
timer_id: ResourceId,
generation: u64,
scheduled_at_ns: u64,
) -> Result<(), ResidentSupervisorError> {
let interval_ms = {
let timer = self
.timers
.get_mut(&timer_id)
.ok_or(ResidentSupervisorError::UnknownTimer(timer_id.get()))?;
if timer.generation != generation {
return Ok(());
}
timer.task = None;
timer.interval_ms
};
let operations = self.session.timer_fired(timer_id, scheduled_at_ns)?;
let schedule_replaced = operations.iter().any(|operation| {
matches!(
operation,
ResidentOperation::ArmTimer {
timer_id: operation_timer,
..
} | ResidentOperation::CancelTimer {
timer_id: operation_timer
} if *operation_timer == timer_id
)
});
self.apply_operations(operations)?;
if !schedule_replaced && let Some(interval_ms) = interval_ms {
self.arm_timer(timer_id, interval_ms, Some(interval_ms))?;
}
Ok(())
}
fn apply_operations(
&mut self,
operations: Vec<ResidentOperation>,
) -> Result<(), ResidentSupervisorError> {
let mut pending = VecDeque::from(operations);
while let Some(operation) = pending.pop_front() {
match operation {
ResidentOperation::ArmTimer {
timer_id,
delay_ms,
interval_ms,
} => self.arm_timer(timer_id, delay_ms, interval_ms)?,
ResidentOperation::CancelTimer { timer_id } => self.cancel_timer(timer_id)?,
ResidentOperation::WriteStream { stream_id, bytes } => {
if !self.queue_stream_command(stream_id, TcpStreamCommand::Write(bytes))? {
pending.extend(
self.terminate_stream(stream_id, StreamCloseReason::TransportError)?,
);
}
}
ResidentOperation::CloseStream { stream_id } => {
if !self.queue_stream_command(stream_id, TcpStreamCommand::Close)? {
pending.extend(
self.terminate_stream(stream_id, StreamCloseReason::HostClosed)?,
);
}
}
ResidentOperation::PauseStream { stream_id } => {
if !self.queue_stream_command(stream_id, TcpStreamCommand::Pause)? {
pending.extend(
self.terminate_stream(stream_id, StreamCloseReason::TransportError)?,
);
}
}
ResidentOperation::ResumeStream { stream_id } => {
if !self.queue_stream_command(stream_id, TcpStreamCommand::Resume)? {
pending.extend(
self.terminate_stream(stream_id, StreamCloseReason::TransportError)?,
);
}
}
ResidentOperation::SendDatagram { .. } => {
return Err(ResidentSupervisorError::UnsupportedOperation(
"send-datagram",
));
}
ResidentOperation::AcknowledgeMessage { .. } => {
return Err(ResidentSupervisorError::UnsupportedOperation(
"acknowledge-message",
));
}
ResidentOperation::SourceCommand { .. } => {
return Err(ResidentSupervisorError::UnsupportedOperation(
"source-command",
));
}
}
}
Ok(())
}
/// Queues one bounded operation without ever blocking the supervisor.
///
/// A full per-stream queue means the peer is not consuming output quickly
/// enough. The caller terminates that stream instead of stalling unrelated
/// connections or allowing an unbounded Host buffer.
fn queue_stream_command(
&self,
stream_id: ResourceId,
command: TcpStreamCommand,
) -> Result<bool, ResidentSupervisorError> {
let driver = self
.streams
.get(&stream_id)
.ok_or(ResidentSupervisorError::UnknownStream(stream_id.get()))?;
match driver.try_send(command) {
Ok(()) => Ok(true),
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
tracing::warn!(
service = %self.session.owner(),
resource_id = stream_id.get(),
"resident TCP stream closed because its write/control queue is full"
);
Ok(false)
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => Ok(false),
}
}
fn terminate_stream(
&mut self,
stream_id: ResourceId,
reason: StreamCloseReason,
) -> Result<Vec<ResidentOperation>, ResidentSupervisorError> {
let driver = self
.streams
.remove(&stream_id)
.ok_or(ResidentSupervisorError::UnknownStream(stream_id.get()))?;
drop(driver);
Ok(self.session.stream_closed(stream_id, reason)?)
}
fn arm_timer(
&mut self,
timer_id: ResourceId,
delay_ms: u64,
interval_ms: Option<u64>,
) -> Result<(), ResidentSupervisorError> {
let timer = self
.timers
.get_mut(&timer_id)
.ok_or(ResidentSupervisorError::UnknownTimer(timer_id.get()))?;
timer.cancel_task();
timer.generation = next_generation(timer.generation);
timer.interval_ms = interval_ms;
let generation = timer.generation;
let delay = Duration::from_millis(delay_ms);
let scheduled_at_ns = u64::try_from(self.origin.elapsed().saturating_add(delay).as_nanos())
.unwrap_or(u64::MAX);
let sender = self.sender.clone();
let accepting_events = self.accepting_events.clone();
let owner = self.session.owner().clone();
timer.task = Some(self.async_runtime.spawn(async move {
tokio::time::sleep(delay).await;
if !accepting_events.load(Ordering::Acquire) {
return;
}
match sender.try_send(SupervisorCommand::TimerFired {
timer_id,
generation,
scheduled_at_ns,
}) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
tracing::warn!(
service = %owner,
resource_id = timer_id.get(),
"resident timer occurrence dropped because the supervisor queue is full"
);
}
Err(TrySendError::Disconnected(_)) => {}
}
}));
Ok(())
}
fn cancel_timer(&mut self, timer_id: ResourceId) -> Result<(), ResidentSupervisorError> {
let timer = self
.timers
.get_mut(&timer_id)
.ok_or(ResidentSupervisorError::UnknownTimer(timer_id.get()))?;
timer.cancel_task();
timer.generation = next_generation(timer.generation);
timer.interval_ms = None;
Ok(())
}
fn shutdown(&mut self) -> Result<(), ResidentSupervisorError> {
self.accepting_events.store(false, Ordering::Release);
for endpoint in self.tcp_endpoints.values_mut() {
endpoint.abort();
}
for timer in self.timers.values_mut() {
timer.cancel_task();
}
let mut first_error = match self.session.shutdown() {
Ok(operations) => self.apply_operations(operations).err(),
Err(error) => Some(error.into()),
};
// A shutdown callback may return stream or timer operations. They have
// been validated, but no source may remain active beyond this edge.
let stream_ids = self.streams.keys().copied().collect::<Vec<_>>();
for stream_id in stream_ids {
self.streams.remove(&stream_id);
if let Err(error) = self.session.release_resource(stream_id)
&& first_error.is_none()
{
first_error = Some(error.into());
}
}
self.streams.clear();
let endpoint_ids = self.tcp_endpoints.keys().copied().collect::<Vec<_>>();
for endpoint_id in endpoint_ids {
self.tcp_endpoints.remove(&endpoint_id);
if let Err(error) = self.session.release_resource(endpoint_id)
&& first_error.is_none()
{
first_error = Some(error.into());
}
}
self.tcp_endpoints.clear();
for timer in self.timers.values_mut() {
timer.cancel_task();
}
let timer_ids = self.timers.keys().copied().collect::<Vec<_>>();
for timer_id in timer_ids {
if let Err(error) = self.session.release_resource(timer_id)
&& first_error.is_none()
{
first_error = Some(error.into());
}
}
self.timers.clear();
match first_error {
Some(error) => Err(error),
None => Ok(()),
}
}
}
fn run_supervisor(
key: ServiceKey,
mut core: SupervisorCore,
receiver: Receiver<SupervisorCommand>,
) {
while let Ok(command) = receiver.recv() {
match command {
SupervisorCommand::TcpAccepted {
endpoint_id,
stream,
peer,
} => {
if let Err(error) = core.tcp_accepted(endpoint_id, stream, peer) {
tracing::warn!(
service = %key,
resource_id = endpoint_id.get(),
%peer,
%error,
"resident TCP connection was rejected"
);
}
}
SupervisorCommand::StreamData { stream_id, bytes } => {
if let Err(error) = core.stream_data(stream_id, bytes) {
tracing::warn!(
service = %key,
resource_id = stream_id.get(),
%error,
"resident TCP stream data failed and the connection was closed"
);
}
}
SupervisorCommand::StreamHalfClosed { stream_id } => {
if let Err(error) = core.stream_half_closed(stream_id) {
tracing::warn!(
service = %key,
resource_id = stream_id.get(),
%error,
"resident TCP half-close event failed and the connection was closed"
);
}
}
SupervisorCommand::StreamClosed { stream_id, reason } => {
if let Err(error) = core.stream_closed(stream_id, reason) {
tracing::warn!(
service = %key,
resource_id = stream_id.get(),
%error,
"resident TCP terminal event failed"
);
}
}
SupervisorCommand::TimerFired {
timer_id,
generation,
scheduled_at_ns,
} => {
if let Err(error) = core.timer_fired(timer_id, generation, scheduled_at_ns) {
let _ = core.cancel_timer(timer_id);
tracing::warn!(
service = %key,
resource_id = timer_id.get(),
%error,
"resident timer event failed and was cancelled"
);
}
}
SupervisorCommand::Shutdown { response_sender } => {
let result = core.shutdown();
let _ = response_sender.send(result);
return;
}
}
}
if let Err(error) = core.shutdown() {
tracing::warn!(service = %key, %error, "resident supervisor channel closed during drain");
}
}
#[derive(Default)]
struct TimerDriver {
generation: u64,
interval_ms: Option<u64>,
task: Option<TaskJoinHandle<()>>,
}
impl TimerDriver {
fn cancel_task(&mut self) {
if let Some(task) = self.task.take() {
task.abort();
}
}
}
impl Drop for TimerDriver {
fn drop(&mut self) {
self.cancel_task();
}
}
fn next_generation(current: u64) -> u64 {
let next = current.wrapping_add(1);
if next == 0 { 1 } else { next }
}
#[cfg(test)]
mod tests {
use super::{
ResidentServiceConfig, ResidentTcpListenerConfig, ResidentTimerConfig, next_generation,
};
#[test]
fn rejects_invalid_timer_configuration() {
for timer in [
ResidentTimerConfig {
name: String::new(),
initial_delay_ms: 1,
interval_ms: None,
},
ResidentTimerConfig {
name: "zero-delay".to_owned(),
initial_delay_ms: 0,
interval_ms: None,
},
ResidentTimerConfig {
name: "zero-interval".to_owned(),
initial_delay_ms: 1,
interval_ms: Some(0),
},
] {
assert!(
ResidentServiceConfig {
timers: vec![timer],
..ResidentServiceConfig::default()
}
.validate()
.is_err()
);
}
}
#[test]
fn rejects_duplicate_timer_names() {
let timer = ResidentTimerConfig {
name: "heartbeat".to_owned(),
initial_delay_ms: 1,
interval_ms: None,
};
assert!(
ResidentServiceConfig {
timers: vec![timer.clone(), timer],
..ResidentServiceConfig::default()
}
.validate()
.is_err()
);
}
#[test]
fn rejects_names_shared_by_listener_and_timer() {
assert!(
ResidentServiceConfig {
tcp_listeners: vec![ResidentTcpListenerConfig {
name: "events".to_owned(),
bind: "127.0.0.1:9000".parse().unwrap(),
}],
timers: vec![ResidentTimerConfig {
name: "events".to_owned(),
initial_delay_ms: 1,
interval_ms: None,
}],
..ResidentServiceConfig::default()
}
.validate()
.is_err()
);
}
#[test]
fn rejects_tcp_listener_outside_default_policy() {
assert!(
ResidentServiceConfig {
tcp_listeners: vec![ResidentTcpListenerConfig {
name: "denied".to_owned(),
bind: "127.0.0.1:9000".parse().unwrap(),
}],
..ResidentServiceConfig::default()
}
.validate()
.is_err()
);
}
#[test]
fn timer_generation_never_uses_zero() {
assert_eq!(next_generation(0), 1);
assert_eq!(next_generation(u64::MAX), 1);
}
}
@@ -0,0 +1,402 @@
//! Tokio TCP transport owned by one resident supervisor.
//!
//! This module owns operating-system socket handles and knows nothing about
//! Wasm bindings or Component effects. It reports accepted connections and
//! bounded stream facts through [`SupervisorCommand`], while the parent
//! supervisor validates resource identity and translates Component operations
//! back into [`TcpStreamCommand`] values.
use std::{
io,
net::SocketAddr,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{SyncSender, TrySendError},
},
time::Duration,
};
use thiserror::Error;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpSocket, TcpStream},
runtime::Handle,
sync::mpsc,
task::JoinHandle,
};
use wasmeld_runtime::{ResourceId, ServiceKey, StreamCloseReason};
use super::SupervisorCommand;
/// One management-owned TCP listener for a resident Component.
///
/// The address must be numeric; DNS and socket handles are never exposed to
/// the Component. Port zero is accepted for tests and private embedding, but a
/// fixed configured port is required when external callers must discover it.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResidentTcpListenerConfig {
/// Stable operator-facing name, unique among all resources for the service.
pub name: String,
/// Numeric address bound by the Host after policy validation.
pub bind: SocketAddr,
}
impl ResidentTcpListenerConfig {
pub(super) fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("resident TCP listener name must not be empty".to_owned());
}
Ok(())
}
}
#[derive(Debug, Error)]
pub(crate) enum TcpDriverError {
#[error("failed to {action} resident TCP listener {name:?} at {bind}: {source}")]
Listener {
name: String,
bind: SocketAddr,
action: &'static str,
#[source]
source: io::Error,
},
}
pub(super) struct TcpEndpointDriver {
task: JoinHandle<()>,
}
impl TcpEndpointDriver {
pub(super) fn start(
config: &ResidentTcpListenerConfig,
owner: ServiceKey,
endpoint_id: ResourceId,
async_runtime: &Handle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
) -> Result<Self, TcpDriverError> {
let listener = bind_listener(config, async_runtime)?;
let name = config.name.clone();
let task = async_runtime.spawn(async move {
run_listener(owner, endpoint_id, name, listener, sender, accepting_events).await;
});
Ok(Self { task })
}
pub(super) fn abort(&mut self) {
self.task.abort();
}
}
impl Drop for TcpEndpointDriver {
fn drop(&mut self) {
self.abort();
}
}
pub(super) struct TcpStreamDriver {
commands: mpsc::Sender<TcpStreamCommand>,
task: JoinHandle<()>,
}
impl TcpStreamDriver {
#[allow(clippy::too_many_arguments)]
pub(super) fn start(
owner: ServiceKey,
stream_id: ResourceId,
stream: TcpStream,
read_bytes: usize,
command_capacity: usize,
async_runtime: &Handle,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
) -> Self {
let (commands, command_receiver) = mpsc::channel(command_capacity);
let task = async_runtime.spawn(async move {
run_stream(
owner,
stream_id,
stream,
read_bytes,
command_receiver,
sender,
accepting_events,
)
.await;
});
Self { commands, task }
}
pub(super) fn try_send(
&self,
command: TcpStreamCommand,
) -> Result<(), mpsc::error::TrySendError<TcpStreamCommand>> {
self.commands.try_send(command)
}
}
impl Drop for TcpStreamDriver {
fn drop(&mut self) {
// Aborting the task drops both owned socket halves. The session owner
// removes the resource identity separately and in deterministic order.
self.task.abort();
}
}
pub(super) enum TcpStreamCommand {
Write(Vec<u8>),
Close,
Pause,
Resume,
}
fn bind_listener(
config: &ResidentTcpListenerConfig,
async_runtime: &Handle,
) -> Result<TcpListener, TcpDriverError> {
// TcpSocket gives the platform an explicit backlog and SO_REUSEADDR, which
// makes an immediate service restart able to reclaim its stable address
// after existing connections are closed.
let _runtime_context = async_runtime.enter();
let socket = if config.bind.is_ipv4() {
TcpSocket::new_v4()
} else {
TcpSocket::new_v6()
}
.map_err(|source| listener_error(config, "create socket for", source))?;
socket
.set_reuseaddr(true)
.map_err(|source| listener_error(config, "configure", source))?;
socket
.bind(config.bind)
.map_err(|source| listener_error(config, "bind", source))?;
socket
.listen(1024)
.map_err(|source| listener_error(config, "listen on", source))
}
async fn run_listener(
owner: ServiceKey,
endpoint_id: ResourceId,
name: String,
listener: TcpListener,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
) {
while accepting_events.load(Ordering::Acquire) {
match listener.accept().await {
Ok((stream, peer)) => {
if let Err(error) = stream.set_nodelay(true) {
tracing::warn!(
service = %owner,
endpoint = %name,
%peer,
%error,
"resident TCP connection rejected because TCP_NODELAY could not be set"
);
continue;
}
if !send_stream_event(
&sender,
&accepting_events,
SupervisorCommand::TcpAccepted {
endpoint_id,
stream,
peer,
},
)
.await
{
return;
}
}
Err(error) => {
if !accepting_events.load(Ordering::Acquire) {
return;
}
tracing::warn!(
service = %owner,
endpoint = %name,
%error,
"resident TCP accept failed; listener will retry"
);
// Persistent descriptor exhaustion must not turn into a hot
// retry loop. A healthy listener resumes after the backoff.
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
}
async fn run_stream(
owner: ServiceKey,
stream_id: ResourceId,
stream: TcpStream,
read_bytes: usize,
mut commands: mpsc::Receiver<TcpStreamCommand>,
sender: SyncSender<SupervisorCommand>,
accepting_events: Arc<AtomicBool>,
) {
let (mut reader, mut writer) = stream.into_split();
let mut buffer = vec![0_u8; read_bytes];
let mut read_paused = false;
let mut peer_half_closed = false;
loop {
if !accepting_events.load(Ordering::Acquire) {
return;
}
if read_paused || peer_half_closed {
let Some(command) = commands.recv().await else {
return;
};
if let Some(reason) = apply_stream_command(command, &mut writer, &mut read_paused).await
{
let _ = send_stream_terminal(&sender, &accepting_events, stream_id, reason).await;
return;
}
continue;
}
tokio::select! {
biased;
command = commands.recv() => {
let Some(command) = command else {
return;
};
if let Some(reason) =
apply_stream_command(command, &mut writer, &mut read_paused).await
{
let _ =
send_stream_terminal(&sender, &accepting_events, stream_id, reason).await;
return;
}
}
read = reader.read(&mut buffer) => {
match read {
Ok(0) => {
peer_half_closed = true;
if !send_stream_event(
&sender,
&accepting_events,
SupervisorCommand::StreamHalfClosed { stream_id },
)
.await
{
return;
}
}
Ok(read) => {
if !send_stream_event(
&sender,
&accepting_events,
SupervisorCommand::StreamData {
stream_id,
bytes: buffer[..read].to_vec(),
},
)
.await
{
return;
}
}
Err(error) => {
tracing::debug!(
service = %owner,
resource_id = stream_id.get(),
%error,
"resident TCP read failed"
);
let _ = send_stream_terminal(
&sender,
&accepting_events,
stream_id,
StreamCloseReason::TransportError,
)
.await;
return;
}
}
}
}
}
}
async fn apply_stream_command(
command: TcpStreamCommand,
writer: &mut tokio::net::tcp::OwnedWriteHalf,
read_paused: &mut bool,
) -> Option<StreamCloseReason> {
match command {
TcpStreamCommand::Write(bytes) => writer
.write_all(&bytes)
.await
.err()
.map(|_| StreamCloseReason::TransportError),
TcpStreamCommand::Close => {
let _ = writer.shutdown().await;
Some(StreamCloseReason::HostClosed)
}
TcpStreamCommand::Pause => {
*read_paused = true;
None
}
TcpStreamCommand::Resume => {
*read_paused = false;
None
}
}
}
async fn send_stream_terminal(
sender: &SyncSender<SupervisorCommand>,
accepting_events: &AtomicBool,
stream_id: ResourceId,
reason: StreamCloseReason,
) -> bool {
send_stream_event(
sender,
accepting_events,
SupervisorCommand::StreamClosed { stream_id, reason },
)
.await
}
/// Sends a non-lossy stream fact through the bounded supervisor queue.
///
/// Unlike timer ticks, TCP chunks cannot be dropped. Waiting here naturally
/// stops the connection task from reading more bytes and propagates
/// backpressure into the kernel receive buffer. Shutdown flips
/// `accepting_events`, which cancels the retry without blocking Actor drain.
async fn send_stream_event(
sender: &SyncSender<SupervisorCommand>,
accepting_events: &AtomicBool,
mut command: SupervisorCommand,
) -> bool {
while accepting_events.load(Ordering::Acquire) {
match sender.try_send(command) {
Ok(()) => return true,
Err(TrySendError::Full(returned)) => {
command = returned;
tokio::time::sleep(Duration::from_millis(1)).await;
}
Err(TrySendError::Disconnected(_)) => return false,
}
}
false
}
fn listener_error(
config: &ResidentTcpListenerConfig,
action: &'static str,
source: io::Error,
) -> TcpDriverError {
TcpDriverError::Listener {
name: config.name.clone(),
bind: config.bind,
action,
source,
}
}
+245 -1
View File
@@ -1,9 +1,12 @@
use std::{
collections::BTreeMap,
fs,
io::Cursor,
net::{SocketAddr, TcpListener as StdTcpListener},
path::{Path, PathBuf},
process::Command,
sync::{Arc, OnceLock},
time::Duration,
};
use axum::{
@@ -14,8 +17,15 @@ use axum::{
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde_json::{Value, json};
use tempfile::TempDir;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
use tower::ServiceExt;
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
use wasmeld_console::{
Console, ConsoleConfig, NetworkScope, ResidentPolicy, ResidentServiceConfig,
ResidentTcpListenerConfig, ResidentTimerConfig, app, gateway_app,
};
use wasmeld_package::{
module::{ModuleLock, sync_dependencies},
wit_package::build_wit_package,
@@ -90,6 +100,192 @@ async fn manages_a_resident_component_over_http() {
assert!(events["events"].as_array().unwrap().len() >= 5);
}
#[tokio::test]
async fn drives_resident_timers_across_service_and_runtime_restarts() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let mut resident_services = BTreeMap::new();
resident_services.insert(
"resident-probe".to_owned(),
ResidentServiceConfig {
timers: vec![ResidentTimerConfig {
name: "heartbeat".to_owned(),
initial_delay_ms: 5,
interval_ms: None,
}],
..ResidentServiceConfig::default()
},
);
let console = Arc::new(
Console::new(ConsoleConfig {
artifact_dir: artifact_dir.path().to_path_buf(),
wit_registry_dir: artifact_dir.path().join("wit-packages"),
database_path: artifact_dir.path().join("console.db"),
resident_services,
..ConsoleConfig::default()
})
.await
.expect("console should start"),
);
let application = app(console, Vec::new());
let component = fs::read(component_artifact("resident_probe_component.wasm"))
.expect("resident probe component should be readable");
let response = application
.clone()
.oneshot(package_request_with_world(
"resident-probe",
"0.1.0",
"component:resident-probe/resident-probe-component@0.1.0",
&component,
))
.await
.expect("resident package registration should complete");
assert_eq!(response.status(), StatusCode::CREATED);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/resident-probe/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("resident deployment activation should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(
wait_for_resident_count(&application).await > 0,
"configured timer should reach the resident Component"
);
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/stop"))
.await
.expect("resident service stop should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/restart"))
.await
.expect("resident service restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(
wait_for_resident_count(&application).await > 0,
"service restart should create a new timer session"
);
let response = application
.clone()
.oneshot(empty_post("/api/v1/runtime/restart"))
.await
.expect("Runtime restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(
wait_for_resident_count(&application).await > 0,
"deployment restore should recreate the timer supervisor"
);
}
#[tokio::test]
async fn drives_tcp_streams_across_service_and_runtime_restarts() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let occupied_listener =
StdTcpListener::bind("127.0.0.1:0").expect("occupied test listener should bind");
let address = occupied_listener
.local_addr()
.expect("occupied test listener should have an address");
let mut resident_services = BTreeMap::new();
resident_services.insert(
"resident-probe".to_owned(),
ResidentServiceConfig {
policy: ResidentPolicy {
tcp_listen: NetworkScope::Loopback,
..ResidentPolicy::default()
},
tcp_listeners: vec![ResidentTcpListenerConfig {
name: "echo".to_owned(),
bind: address,
}],
..ResidentServiceConfig::default()
},
);
let console = Arc::new(
Console::new(ConsoleConfig {
artifact_dir: artifact_dir.path().to_path_buf(),
wit_registry_dir: artifact_dir.path().join("wit-packages"),
database_path: artifact_dir.path().join("console.db"),
resident_services,
..ConsoleConfig::default()
})
.await
.expect("console should start"),
);
let application = app(console, Vec::new());
let component = fs::read(component_artifact("resident_probe_component.wasm"))
.expect("resident probe component should be readable");
let response = application
.clone()
.oneshot(package_request_with_world(
"resident-probe",
"0.1.0",
"component:resident-probe/resident-probe-component@0.1.0",
&component,
))
.await
.expect("resident package registration should complete");
assert_eq!(response.status(), StatusCode::CREATED);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/resident-probe/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("conflicting resident deployment activation should complete");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
// Driver startup failure must stop the Actor and leave deployment state
// untouched. Once the external conflict is gone, the same revision can
// start normally without repairing Runtime state manually.
drop(occupied_listener);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/resident-probe/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("resident deployment activation should complete");
assert_eq!(response.status(), StatusCode::OK);
let large_payload = vec![0xA5; 2 * 64 * 1024 + 17];
assert_tcp_echo(address, &large_payload).await;
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/stop"))
.await
.expect("resident service stop should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(empty_post("/api/v1/services/resident-probe/0.1.0/restart"))
.await
.expect("resident service restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_tcp_echo(address, b"service restart").await;
let response = application
.clone()
.oneshot(empty_post("/api/v1/runtime/restart"))
.await
.expect("Runtime restart should complete");
assert_eq!(response.status(), StatusCode::OK);
assert_tcp_echo(address, b"runtime restart").await;
}
#[tokio::test]
async fn reports_exact_component_host_capabilities() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
@@ -999,6 +1195,53 @@ async fn invoke_service(application: &Router, id: &str, revision: &str, input: &
.unwrap()
}
async fn wait_for_resident_count(application: &Router) -> u64 {
for _ in 0..100 {
let output = invoke_service(application, "resident-probe", "0.1.0", &[]).await;
let count = u64::from_le_bytes(
output
.try_into()
.expect("resident probe response should be a u64"),
);
if count > 0 {
return count;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
0
}
async fn assert_tcp_echo(address: SocketAddr, payload: &[u8]) {
let mut stream = connect_tcp(address).await;
stream
.write_all(payload)
.await
.expect("TCP request should be written");
let mut echoed = vec![0_u8; payload.len()];
tokio::time::timeout(Duration::from_secs(2), stream.read_exact(&mut echoed))
.await
.expect("resident TCP echo should not time out")
.expect("resident TCP echo should be readable");
assert_eq!(echoed, payload);
}
async fn connect_tcp(address: SocketAddr) -> TcpStream {
let mut last_error = None;
for _ in 0..100 {
match TcpStream::connect(address).await {
Ok(stream) => return stream,
Err(error) => {
last_error = Some(error);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
panic!(
"resident TCP listener {address} did not accept connections: {}",
last_error.expect("at least one connection should have been attempted")
);
}
fn echo_component() -> PathBuf {
component_artifact("echo_component.wasm")
}
@@ -1010,6 +1253,7 @@ fn component_artifact(name: &str) -> PathBuf {
"components/counter/Cargo.toml",
"components/clock-probe/Cargo.toml",
"components/kv-probe/Cargo.toml",
"components/resident-probe/Cargo.toml",
] {
let status = Command::new("rustup")
.args([
+57 -15
View File
@@ -15,22 +15,37 @@ index.html / src/host
Solid reactive owner、事件监听器、第三方 UI 库和页面局部缓存会随文档一起释放,避免
长期导航后把所有页面资源都留在同一个 JavaScript realm 中。
需要保留表单、筛选器或昂贵页面状态时,可以在 `src/host/app.tsx``NAVIGATION`
配置中设置 `keepAlive: true`。当前服务版本和运行设置启用保活。Host 会隐藏而不是销毁
这些 iframe,并保持其 DOM、JavaScript realm 和 Solid 状态:
页面 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` 不变。
- LRU 淘汰、关闭 Host 或普通页面离开时发送 `dispose`,随后销毁文档。
- `MAX_KEEP_ALIVE_IFRAMES` 限制保活文档数量;活动的非保活页面最多临时多占一个 iframe
- 页面连续隐藏 30 分钟后失效,Host 发送 `dispose` 并销毁 iframe;再次访问会创建新文档。
- 失效时间从 `deactivate` 开始计算,页面在前台显示的时间不计入;到期前返回会重置计时
- 关闭 Host 或离开非保活页面时同样发送 `dispose`,随后销毁文档。
页面可以使用 `PageProps.active` 响应生命周期,也可以监听
`wasmeld:activate``wasmeld:deactivate``wasmeld:dispose` 或统一的
`wasmeld:lifecycle` 事件。页面自身不应通过 `display: none` 判断状态,因为 Host
可能改变具体的隐藏实现。
这里不使用按页面数量淘汰的 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();
@@ -38,11 +53,18 @@ export default function StreamPage(props: PageProps) {
pauseStream();
}
});
onCleanup(closeStream);
// ...
}
```
`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 中。
@@ -50,15 +72,35 @@ Registry 中。
## 目录
```text
src/host/ 常驻 Host Shell、iframe LRU 和 Host-owned dialogs
src/components/ui/ 跨页面复用的无业务 UI
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/pages/ 集中式页面注册表;每个 iframe 页面一个目录和独立懒加载 chunk
src/sdk/ Host/Page postMessage 协议与两侧 client
src/styles/ Tailwind v4 theme 和共享组件样式
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,不接收任意窗口的控制命令。
@@ -75,7 +117,7 @@ Host 会继续向隐藏的保活页面发送只读状态快照,但拒绝其管
| 状态 | 跨 Tab | Host 到 Page | 生命周期 |
| ------------------------------------------ | -------- | --------------------- | --------------- |
| Runtime 快照、连接、API 地址、控制操作状态 | 始终共享 | 分发给已存在的 iframe | Host Tab |
| 当前路由、搜索、弹窗、toast、iframe LRU | 不共享 | 当前 Tab 自己管理 | Browser Tab |
| 当前路由、搜索、弹窗、toast、iframe 保活集 | 不共享 | 当前 Tab 自己管理 | Browser Tab |
| 页面筛选、表单、滚动位置、页面资源 | 不共享 | 不进入 Host | iframe document |
`src/host/tab-sync.ts` 使用独立版本的 Host Tab 协议:
+35 -91
View File
@@ -8,8 +8,11 @@
"name": "wasmeld-console-web",
"version": "0.1.0",
"dependencies": {
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-solid": "1.27.0",
"solid-js": "1.9.14"
"solid-js": "1.9.14",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
@@ -545,9 +548,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -565,9 +565,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -585,9 +582,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -605,9 +599,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -625,9 +616,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -645,9 +633,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -665,9 +650,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -685,9 +667,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -892,9 +871,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -912,9 +888,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -932,9 +905,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -952,9 +922,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -972,9 +939,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -992,9 +956,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1012,9 +973,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1032,9 +990,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1205,9 +1160,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1225,9 +1177,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1245,9 +1194,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1265,9 +1211,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1285,9 +1228,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1305,9 +1245,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1550,9 +1487,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1570,9 +1504,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1590,9 +1521,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1610,9 +1538,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2224,6 +2149,27 @@
],
"license": "CC-BY-4.0"
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2565,9 +2511,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2589,9 +2532,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2613,9 +2553,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2637,9 +2574,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3040,6 +2974,16 @@
"node": ">=0.10.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+4 -1
View File
@@ -13,8 +13,11 @@
"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"
"solid-js": "1.9.14",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
@@ -4,10 +4,10 @@ import type { LucideProps } from "lucide-solid";
export function EmptyState(props: { icon: Component<LucideProps>; title: string; detail: string }) {
const Icon = props.icon;
return (
<div class="empty-state">
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted">
<Icon size={22} />
<strong>{props.title}</strong>
<span>{props.detail}</span>
<strong class="text-sm text-ink">{props.title}</strong>
<span class="text-xs">{props.detail}</span>
</div>
);
}
@@ -0,0 +1,19 @@
import type { JSXElement } from "solid-js";
export function PageHeading(props: {
eyebrow: string;
title: string;
description: string;
actions?: JSXElement;
}) {
return (
<header class="mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
<span class="text-[11px] font-bold uppercase text-cyan-strong">{props.eyebrow}</span>
<h1 class="mt-1 text-2xl font-bold text-ink">{props.title}</h1>
<p class="mt-1 max-w-3xl text-sm text-muted">{props.description}</p>
</div>
{props.actions && <div class="flex shrink-0 items-center gap-2">{props.actions}</div>}
</header>
);
}
@@ -0,0 +1,18 @@
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export type PageProps = JSX.HTMLAttributes<HTMLElement>;
/** Standard constrained content area rendered inside a Page iframe. */
export function Page(props: PageProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<main
{...rest}
data-slot="page"
class={cn("mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7", local.class)}
>
{local.children}
</main>
);
}
@@ -0,0 +1,201 @@
import {
Box,
Braces,
CircleStop,
Play,
RadioTower,
RefreshCw,
RotateCcw,
Search,
SquareTerminal,
} from "lucide-solid";
import { For, Show } from "solid-js";
import { serviceKey, type Service } from "../../lib/model";
import type { HostSharedState, PageCommand } from "../../sdk/protocol";
import { EmptyState } from "../feedback/empty-state";
import { IconButton } from "../ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
import { StatusBadge } from "./status-badge";
export function ServiceTable(props: {
services: Service[];
state: HostSharedState;
command: (command: PageCommand) => void;
compact?: boolean;
}) {
return (
<Show
when={props.services.length > 0}
fallback={
<EmptyState icon={Search} title="没有匹配的服务" detail="调整搜索关键词或状态筛选。" />
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<Show when={!props.compact}>
<TableHead></TableHead>
<TableHead>Host </TableHead>
</Show>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>
<span class="sr-only"></span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={props.services}>
{(service) => {
const key = serviceKey(service);
return (
<TableRow>
<TableCell>
<div class="flex min-w-48 items-center gap-3">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
<Box size={15} />
</span>
<span class="min-w-0">
<span class="flex items-center gap-2">
<strong class="truncate">{service.id}</strong>
<Show when={service.active}>
<span class="inline-flex items-center gap-1 rounded-sm bg-cyan-soft px-1.5 py-0.5 text-[10px] font-semibold text-cyan-strong">
<RadioTower size={10} />
</span>
</Show>
</span>
<small class="mt-0.5 block text-xs text-muted">{service.revision}</small>
</span>
</div>
</TableCell>
<TableCell>
<StatusBadge status={service.status} />
</TableCell>
<Show when={!props.compact}>
<TableCell
class="max-w-48 truncate font-mono text-xs text-muted"
title={service.artifact}
>
{service.artifact}
</TableCell>
<TableCell>
<Show
when={service.capabilities.length > 0}
fallback={<span class="text-xs text-muted"></span>}
>
<span
class="inline-flex max-w-56 items-center gap-1.5 text-xs text-muted"
title={service.capabilities
.map((capability) => capability.interface)
.join("\n")}
>
<Braces size={12} />
<span class="truncate">
{service.capabilities[0]?.package}/{service.capabilities[0]?.name}
</span>
<Show when={service.capabilities.length > 1}>
<small>+{service.capabilities.length - 1}</small>
</Show>
</span>
</Show>
</TableCell>
</Show>
<TableCell class="font-mono text-xs">
{service.calls.toLocaleString("zh-CN")}
</TableCell>
<TableCell class="font-mono text-xs">
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
</TableCell>
<TableCell>
<div class="flex items-center justify-end gap-1">
<IconButton
size="small"
aria-label="设为对外版本"
title={service.active ? "当前对外版本" : "设为对外版本"}
disabled={
service.active ||
props.state.snapshot?.runtime.status !== "running" ||
props.state.deploymentAction !== null
}
onClick={() => props.command({ type: "open-activate", serviceKey: key })}
>
{props.state.deploymentAction === key ? (
<RefreshCw class="animate-spin" size={15} />
) : (
<RadioTower size={15} />
)}
</IconButton>
<Show
when={service.status === "running"}
fallback={
<IconButton
size="small"
aria-label={`启动 ${service.id}`}
title="启动"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "start",
})
}
>
<Play size={15} />
</IconButton>
}
>
<IconButton
size="small"
aria-label={`调用 ${service.id}`}
title="测试调用"
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
>
<SquareTerminal size={15} />
</IconButton>
<IconButton
size="small"
aria-label={`重启 ${service.id}`}
title="重启"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "restart",
})
}
>
<RotateCcw size={15} />
</IconButton>
<IconButton
size="small"
tone="danger"
aria-label={`停止 ${service.id}`}
title="停止"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "stop",
})
}
>
<CircleStop size={15} />
</IconButton>
</Show>
</div>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</Show>
);
}
@@ -0,0 +1,23 @@
import { cva } from "class-variance-authority";
import { STATUS_META, type ServiceStatus } from "../../lib/model";
const statusBadgeVariants = cva(
"inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold before:size-1.5 before:rounded-full before:bg-current before:content-['']",
{
variants: {
status: {
running: "text-brand",
stopped: "text-muted",
faulted: "text-coral-strong",
},
},
},
);
export function StatusBadge(props: { status: ServiceStatus }) {
return (
<span data-slot="service-status" class={statusBadgeVariants({ status: props.status })}>
{STATUS_META[props.status].label}
</span>
);
}
@@ -0,0 +1,113 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md border text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45",
{
variants: {
variant: {
primary: "border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong",
secondary: "border-line bg-white text-ink hover:bg-canvas",
danger: "border-coral-strong bg-white text-coral-strong hover:bg-coral-soft",
},
size: {
default: "min-h-10 px-4 py-2",
small: "min-h-9 px-4 py-1.5",
},
},
defaultVariants: {
variant: "secondary",
size: "default",
},
},
);
export type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
/**
* Standard text or icon-and-text command button.
*
* Size variants define a minimum target height instead of fixing the block
* height, so wrapped labels and enlarged user fonts can grow without clipping.
* The component defaults to `type="button"` so using it inside a form never
* submits accidentally. Set `type="submit"` explicitly for form actions.
*/
export function Button(props: ButtonProps) {
const [local, rest] = splitProps(props, ["variant", "size", "class", "children", "type"]);
return (
<button
{...rest}
data-slot="button"
type={local.type ?? "button"}
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
>
{local.children}
</button>
);
}
export const iconButtonVariants = cva(
"inline-flex shrink-0 items-center justify-center rounded-md border border-line bg-white text-muted transition-colors hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-45",
{
variants: {
tone: {
default: "",
danger: "text-coral-strong",
},
size: {
default: "size-9",
small: "size-8",
},
},
defaultVariants: {
tone: "default",
size: "default",
},
},
);
export type IconButtonProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> &
VariantProps<typeof iconButtonVariants> & {
"aria-label": string;
};
/**
* Square icon-only button for familiar toolbar and row actions.
*
* Callers must provide an accessible `aria-label`; `title` is recommended when
* the icon's meaning benefits from a visible hover explanation.
*/
export function IconButton(props: IconButtonProps) {
const [local, rest] = splitProps(props, ["tone", "size", "class", "children", "type"]);
return (
<button
{...rest}
data-slot="icon-button"
type={local.type ?? "button"}
class={cn(iconButtonVariants({ tone: local.tone, size: local.size }), local.class)}
>
{local.children}
</button>
);
}
export type IconLinkProps = Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "aria-label"> &
Pick<VariantProps<typeof iconButtonVariants>, "size"> & {
"aria-label": string;
};
/** Anchor counterpart to IconButton for downloads and external navigation. */
export function IconLink(props: IconLinkProps) {
const [local, rest] = splitProps(props, ["size", "class", "children"]);
return (
<a
{...rest}
data-slot="icon-link"
class={cn(iconButtonVariants({ size: local.size }), local.class)}
>
{local.children}
</a>
);
}
@@ -0,0 +1,68 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { Dynamic } from "solid-js/web";
import { cn } from "../../lib/cn";
type CardElement = "article" | "aside" | "div" | "section";
export const cardVariants = cva("rounded-md border border-line bg-surface", {
variants: {
padding: {
none: "",
small: "p-4",
medium: "p-5",
},
elevation: {
flat: "",
raised: "shadow-sm",
},
},
defaultVariants: {
padding: "none",
elevation: "flat",
},
});
export type CardProps = JSX.HTMLAttributes<HTMLElement> &
VariantProps<typeof cardVariants> & {
as?: CardElement;
};
/**
* Generic bordered surface.
*
* `as` preserves document semantics at the call site; Card only owns visual
* framing and must not encode page or domain behavior.
*/
export function Card(props: CardProps) {
const [local, rest] = splitProps(props, ["as", "padding", "elevation", "class", "children"]);
return (
<Dynamic
component={local.as ?? "div"}
{...rest}
data-slot="card"
class={cn(cardVariants({ padding: local.padding, elevation: local.elevation }), local.class)}
>
{local.children}
</Dynamic>
);
}
export type CardHeaderProps = JSX.HTMLAttributes<HTMLDivElement>;
/** Standard Card heading/action row. */
export function CardHeader(props: CardHeaderProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<div
{...rest}
data-slot="card-header"
class={cn(
"flex min-h-16 items-center justify-between gap-4 border-b border-line px-5 py-3 [&_h2]:text-base [&_h2]:font-semibold [&_p]:mt-0.5 [&_p]:text-xs [&_p]:text-muted",
local.class,
)}
>
{local.children}
</div>
);
}
@@ -0,0 +1,34 @@
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export type FieldProps = JSX.LabelHTMLAttributes<HTMLLabelElement>;
/**
* Associates a label and form control while keeping field layout consistent.
*
* Keep validation and help text at the call site: those messages are domain
* content, while Field only owns spacing and label typography.
*/
export function Field(props: FieldProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<label {...rest} data-slot="field" class={cn("block", local.class)}>
{local.children}
</label>
);
}
export type FieldLabelProps = JSX.HTMLAttributes<HTMLSpanElement>;
export function FieldLabel(props: FieldLabelProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<span
{...rest}
data-slot="field-label"
class={cn("mb-1.5 block text-xs font-semibold text-muted", local.class)}
>
{local.children}
</span>
);
}
@@ -0,0 +1,33 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const inputVariants = cva(
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
{
variants: {
density: {
default: "min-h-10 py-2",
compact: "min-h-9 py-1.5",
},
},
defaultVariants: {
density: "default",
},
},
);
export type InputProps = JSX.InputHTMLAttributes<HTMLInputElement> &
VariantProps<typeof inputVariants>;
/** Native single-line input with a minimum, rather than fixed, control height. */
export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["density", "class"]);
return (
<input
{...rest}
data-slot="input"
class={cn(inputVariants({ density: local.density }), local.class)}
/>
);
}
@@ -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="page-heading">
<div>
<span class="eyebrow">{props.eyebrow}</span>
<h1>{props.title}</h1>
<p>{props.description}</p>
</div>
{props.actions && <div class="heading-actions">{props.actions}</div>}
</header>
);
}
@@ -0,0 +1,50 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export function SegmentedControl(props: JSX.HTMLAttributes<HTMLDivElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<div
{...rest}
data-slot="segmented-control"
class={cn("inline-flex rounded-md border border-line bg-canvas p-1", local.class)}
>
{local.children}
</div>
);
}
export const segmentVariants = cva(
"min-h-8 rounded-sm px-3 py-1 text-xs font-semibold text-muted transition-colors",
{
variants: {
active: {
true: "bg-white text-ink shadow-sm",
false: "hover:text-ink",
},
},
defaultVariants: {
active: false,
},
},
);
export type SegmentProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof segmentVariants>;
/** One mutually exclusive option inside a SegmentedControl. */
export function Segment(props: SegmentProps) {
const [local, rest] = splitProps(props, ["active", "class", "children", "type"]);
return (
<button
{...rest}
data-slot="segment"
type={local.type ?? "button"}
aria-pressed={local.active === true}
class={cn(segmentVariants({ active: local.active }), local.class)}
>
{local.children}
</button>
);
}
@@ -0,0 +1,35 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const selectVariants = cva(
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
{
variants: {
density: {
default: "min-h-10 py-2",
compact: "min-h-9 py-1.5",
},
},
defaultVariants: {
density: "default",
},
},
);
export type SelectProps = JSX.SelectHTMLAttributes<HTMLSelectElement> &
VariantProps<typeof selectVariants>;
/** Native select with browser behavior preserved and a content-growable height. */
export function Select(props: SelectProps) {
const [local, rest] = splitProps(props, ["density", "class", "children"]);
return (
<select
{...rest}
data-slot="select"
class={cn(selectVariants({ density: local.density }), local.class)}
>
{local.children}
</select>
);
}
@@ -1,195 +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 "./empty-state";
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="调整搜索关键词或状态筛选。" />
}
>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th></th>
<th></th>
<Show when={!props.compact}>
<th></th>
<th>Host </th>
</Show>
<th></th>
<th></th>
<th>
<span class="sr-only"></span>
</th>
</tr>
</thead>
<tbody>
<For each={props.services}>
{(service) => {
const key = serviceKey(service);
return (
<tr>
<td>
<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>
</td>
<td>
<StatusBadge status={service.status} />
</td>
<Show when={!props.compact}>
<td class="code max-w-48 truncate text-muted" title={service.artifact}>
{service.artifact}
</td>
<td>
<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>
</td>
</Show>
<td class="font-mono text-xs">{service.calls.toLocaleString("zh-CN")}</td>
<td class="font-mono text-xs">
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
</td>
<td>
<div class="row-actions">
<button
type="button"
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="spin" size={15} />
) : (
<RadioTower size={15} />
)}
</button>
<Show
when={service.status === "running"}
fallback={
<button
type="button"
aria-label={`启动 ${service.id}`}
title="启动"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "start",
})
}
>
<Play size={15} />
</button>
}
>
<button
type="button"
aria-label={`调用 ${service.id}`}
title="测试调用"
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
>
<SquareTerminal size={15} />
</button>
<button
type="button"
aria-label={`重启 ${service.id}`}
title="重启"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "restart",
})
}
>
<RotateCcw size={15} />
</button>
<button
type="button"
aria-label={`停止 ${service.id}`}
title="停止"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "stop",
})
}
>
<CircleStop size={15} />
</button>
</Show>
</div>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</Show>
);
}
@@ -1,6 +0,0 @@
import { STATUS_META, type ServiceStatus } from "../../lib/model";
export function StatusBadge(props: { status: ServiceStatus }) {
const meta = () => STATUS_META[props.status];
return <span class={`status-badge ${meta().className}`}>{meta().label}</span>;
}
@@ -0,0 +1,82 @@
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export type TableProps = JSX.HTMLAttributes<HTMLTableElement> & {
containerClass?: string;
};
/** Responsive native table with a horizontal overflow boundary. */
export function Table(props: TableProps) {
const [local, rest] = splitProps(props, ["class", "containerClass", "children"]);
return (
<div data-slot="table-container" class={cn("w-full overflow-x-auto", local.containerClass)}>
<table
{...rest}
data-slot="table"
class={cn("w-full min-w-[760px] border-collapse text-left text-sm", local.class)}
>
{local.children}
</table>
</div>
);
}
export function TableHeader(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<thead {...rest} data-slot="table-header" class={cn(local.class)}>
{local.children}
</thead>
);
}
export function TableBody(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<tbody {...rest} data-slot="table-body" class={cn(local.class)}>
{local.children}
</tbody>
);
}
export function TableRow(props: JSX.HTMLAttributes<HTMLTableRowElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<tr
{...rest}
data-slot="table-row"
class={cn("transition-colors hover:bg-[#f8faf9] [&:last-child_td]:border-b-0", local.class)}
>
{local.children}
</tr>
);
}
export function TableHead(props: JSX.ThHTMLAttributes<HTMLTableCellElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<th
{...rest}
data-slot="table-head"
class={cn(
"border-b border-line bg-canvas px-4 py-2.5 text-[11px] font-semibold uppercase text-muted",
local.class,
)}
>
{local.children}
</th>
);
}
export function TableCell(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<td
{...rest}
data-slot="table-cell"
class={cn("border-b border-line px-4 py-3 align-middle", local.class)}
>
{local.children}
</td>
);
}
@@ -0,0 +1,43 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const textareaVariants = cva(
"min-h-36 w-full rounded-md border border-line bg-white px-3 py-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
{
variants: {
resize: {
vertical: "resize-y",
none: "resize-none",
},
typography: {
mono: "font-mono",
sans: "font-sans",
},
},
defaultVariants: {
resize: "vertical",
typography: "mono",
},
},
);
export type TextareaProps = JSX.TextareaHTMLAttributes<HTMLTextAreaElement> &
VariantProps<typeof textareaVariants>;
/** Standard multiline input with explicit resize and typography variants. */
export function Textarea(props: TextareaProps) {
const [local, rest] = splitProps(props, ["resize", "typography", "class", "children"]);
return (
<textarea
{...rest}
data-slot="textarea"
class={cn(
textareaVariants({ resize: local.resize, typography: local.typography }),
local.class,
)}
>
{local.children}
</textarea>
);
}
+47 -51
View File
@@ -1,15 +1,4 @@
import {
Boxes,
FileCode2,
History,
LayoutDashboard,
Package,
Search,
Server,
Settings,
ShieldCheck,
WifiOff,
} from "lucide-solid";
import { Boxes, Search, ShieldCheck, WifiOff } from "lucide-solid";
import {
batch,
createEffect,
@@ -20,9 +9,7 @@ import {
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import type { LucideProps } from "lucide-solid";
import {
activateDeployment,
changeRuntimeState,
@@ -36,8 +23,10 @@ import {
type BackendSnapshot,
type InvokeResult,
} from "../lib/api";
import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model";
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,
@@ -50,28 +39,15 @@ import { DialogLayer, type DialogState } from "./dialogs";
import { FrameCache, type FrameEntry } from "./frame-cache";
import { createBrowserHostTabSync, type HostTabSync } from "./tab-sync";
const NAVIGATION: Array<{
id: View;
label: string;
icon: Component<LucideProps>;
keepAlive?: boolean;
}> = [
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
{ id: "services", label: "服务版本", icon: Package, keepAlive: true },
{ id: "instances", label: "运行实例", icon: Server },
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
{ id: "activity", label: "调用记录", icon: History },
{ id: "settings", label: "运行设置", icon: Settings, keepAlive: true },
];
const MAX_KEEP_ALIVE_IFRAMES = 3;
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, {
maxKeepAliveEntries: MAX_KEEP_ALIVE_IFRAMES,
shouldKeepAlive: keepsFrameAlive,
inactiveTtlMs: KEEP_ALIVE_INACTIVE_TTL_MS,
shouldKeepAlive: (view) => pageDefinition(view).keepAlive,
});
const [view, setView] = createSignal(initial);
const [frames, setFrames] = createSignal(frameCache.snapshot());
@@ -90,6 +66,7 @@ export default function HostApp() {
let tabSync: HostTabSync | null = null;
let applyingSharedHostState = false;
let toastTimer: number | undefined;
let frameExpirationTimer: number | undefined;
let refreshGeneration = 0;
const globalState = createMemo<HostGlobalState>(() => ({
@@ -109,9 +86,7 @@ export default function HostApp() {
() => servicesFrom(state()).filter((service) => service.status === "running").length,
);
const frameReady = createMemo(() => readyViews().has(view()));
const title = createMemo(
() => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览",
);
const title = createMemo(() => pageDefinition(view()).label);
function stateFor(targetView: View): HostSharedState {
return { ...state(), view: targetView };
@@ -162,7 +137,7 @@ export default function HostApp() {
const transition = frameCache.activate(next);
const removedViews = new Set(transition.removed.map((entry) => entry.view));
// Retained frames are paused before being hidden. Transient and LRU-evicted
// 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");
@@ -185,6 +160,7 @@ export default function HostApp() {
url.searchParams.set("view", next);
window.history.pushState({ view: next }, "", url);
}
scheduleFrameExpiration();
}
function findService(key: string): Service | null {
@@ -309,6 +285,32 @@ export default function HostApp() {
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);
@@ -331,6 +333,7 @@ export default function HostApp() {
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");
});
@@ -400,7 +403,7 @@ export default function HostApp() {
<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-[#155a46]">
<span class="flex size-9 items-center justify-center rounded-md bg-[#e5f4ef] text-brand-strong">
<Boxes size={19} />
</span>
<div>
@@ -409,7 +412,7 @@ export default function HostApp() {
</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={NAVIGATION}>
<For each={PAGE_DEFINITIONS}>
{(item) => {
const Icon = item.icon;
return (
@@ -456,8 +459,9 @@ export default function HostApp() {
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
size={15}
/>
<input
class="input h-9 pl-9"
<Input
density="compact"
class="pl-9"
value={query()}
aria-label="搜索"
placeholder="搜索服务、制品或事件"
@@ -472,7 +476,7 @@ export default function HostApp() {
<PageFrame
frame={frame}
active={view() === frame.view}
title={titleFor(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);
@@ -488,11 +492,11 @@ export default function HostApp() {
{title()}
</div>
<div
class="loading-progress-track"
class="h-1 w-40 overflow-hidden rounded-sm bg-line"
role="progressbar"
aria-label={`${title()}加载进度`}
>
<span class="loading-progress-indicator" />
<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>
@@ -529,7 +533,7 @@ export default function HostApp() {
<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">
<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>
)}
@@ -543,14 +547,6 @@ function initialView(): View {
return isView(requested) ? requested : "overview";
}
function keepsFrameAlive(view: View): boolean {
return NAVIGATION.some((item) => item.id === view && item.keepAlive === true);
}
function titleFor(view: View): string {
return NAVIGATION.find((item) => item.id === view)?.label ?? view;
}
function PageFrame(props: {
frame: FrameEntry;
active: boolean;
+68 -57
View File
@@ -12,6 +12,10 @@ import {
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,
@@ -88,29 +92,38 @@ function DialogFrame(props: {
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
return (
<div class="dialog-backdrop" role="presentation">
<dialog open class="dialog" aria-label={props.title}>
<header class="dialog-header">
<div class="dialog-title">
<span>{props.icon}</span>
<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>{props.title}</h2>
<p>{props.description}</p>
<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>
<button
class="icon-btn"
type="button"
<IconButton
aria-label="关闭"
title="关闭"
disabled={props.closeDisabled}
onClick={props.onClose}
>
<X size={18} />
</button>
</IconButton>
</header>
<div class="dialog-body">{props.children}</div>
<footer class="dialog-footer">{props.footer}</footer>
<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>
);
@@ -159,28 +172,19 @@ function UploadDialog(props: {
onClose={props.onClose}
footer={
<>
<button
class="btn btn-secondary"
type="button"
disabled={props.busy}
onClick={props.onClose}
>
<Button disabled={props.busy} onClick={props.onClose}>
</button>
<button
class="btn btn-primary"
type="button"
disabled={props.busy || !file()}
onClick={() => void submit()}
>
{props.busy ? <RefreshCw class="spin" size={16} /> : <ShieldCheck size={16} />}
</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>
</Button>
</>
}
>
<label class="upload-zone">
<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) => {
@@ -197,15 +201,19 @@ function UploadDialog(props: {
) : (
<CloudUpload size={28} />
)}
<strong>{file()?.name ?? `选择 ${isWit() ? "WIT Package" : "组件包"}`}</strong>
<span>
<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="form-error">{error()}</div>
<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>
);
@@ -226,29 +234,33 @@ function ActivateDialog(props: {
onClose={props.onClose}
footer={
<>
<button class="btn btn-secondary" disabled={props.busy} onClick={props.onClose}>
<Button disabled={props.busy} onClick={props.onClose}>
</button>
<button
class="btn btn-primary"
</Button>
<Button
variant="primary"
disabled={props.busy}
onClick={() => void props.onActivate(props.dialog.service)}
>
{props.busy ? <RefreshCw class="spin" size={16} /> : <RadioTower size={16} />}
{props.busy ? <RefreshCw class="animate-spin" size={16} /> : <RadioTower size={16} />}
{props.busy ? "正在切换" : "确认切换"}
</button>
</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="code mt-1 block">{props.dialog.currentRevision ?? "尚未部署"}</strong>
<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="code mt-1 block text-brand">{props.dialog.service.revision}</strong>
<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">
@@ -301,37 +313,36 @@ function InvokeDialog(props: {
onClose={props.onClose}
footer={
<>
<button class="btn btn-secondary" disabled={running()} onClick={props.onClose}>
<Button disabled={running()} onClick={props.onClose}>
</button>
<button class="btn btn-primary" disabled={running()} onClick={() => void invoke()}>
{running() ? <RefreshCw class="spin" size={16} /> : <Play size={16} />}
</Button>
<Button variant="primary" disabled={running()} onClick={() => void invoke()}>
{running() ? <RefreshCw class="animate-spin" size={16} /> : <Play size={16} />}
</button>
</Button>
</>
}
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="segmented">
<button classList={{ active: format() === "utf8" }} onClick={() => setFormat("utf8")}>
<SegmentedControl aria-label="输入格式">
<Segment active={format() === "utf8"} onClick={() => setFormat("utf8")}>
UTF-8
</button>
<button classList={{ active: format() === "hex" }} onClick={() => setFormat("hex")}>
</Segment>
<Segment active={format() === "hex"} onClick={() => setFormat("hex")}>
HEX
</button>
</Segment>
</SegmentedControl>
<span class="font-mono text-xs text-muted">{inputBytes()} B</span>
</div>
<span class="code text-muted">{inputBytes()} B</span>
</div>
<label class="field">
<span></span>
<textarea
class="textarea"
<Field>
<FieldLabel></FieldLabel>
<Textarea
value={input()}
spellcheck={false}
placeholder={format() === "utf8" ? "输入请求内容" : "00 ff a1"}
onInput={(event) => setInput(event.currentTarget.value)}
/>
</label>
</Field>
<div class="mt-4">
<div class="mb-1.5 flex items-center justify-between">
<span class="text-xs font-semibold text-muted"></span>
@@ -1,22 +1,27 @@
import type { View } from "../lib/model";
import type { View } from "../pages/registry";
export type FrameEntry = {
view: View;
instance: number;
keepAlive: boolean;
lastUsed: number;
/**
* 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 = {
/**
* Maximum number of keep-alive documents, including the active document.
*
* A non-keep-alive active document may temporarily exist in addition to this
* limit. The active document is never evicted; inactive entries are removed
* from least to most recently used.
* How long a keep-alive document may remain continuously hidden.
*/
maxKeepAliveEntries: number;
inactiveTtlMs: number;
shouldKeepAlive: (view: View) => boolean;
/** Injectable monotonic clock for deterministic tests. */
now?: () => number;
};
export type FrameTransition = {
@@ -25,24 +30,30 @@ export type FrameTransition = {
activated: FrameEntry;
};
export type FrameExpiration = Omit<FrameTransition, "activated">;
/**
* Owns the bounded set of iframe document identities.
* 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;
#clock = 0;
constructor(initialView: View, options: FrameCacheOptions) {
if (!Number.isInteger(options.maxKeepAliveEntries) || options.maxKeepAliveEntries < 0) {
throw new RangeError("maxKeepAliveEntries must be a non-negative integer");
if (!Number.isFinite(options.inactiveTtlMs) || options.inactiveTtlMs <= 0) {
throw new RangeError("inactiveTtlMs must be a positive finite number");
}
this.#options = options;
this.#active = initialView;
@@ -60,27 +71,29 @@ export class FrameCache {
activate(view: View): FrameTransition {
const removed: FrameEntry[] = [];
const previous = this.#entries.get(this.#active);
const now = this.#now();
if (previous && previous.view !== view && !previous.keepAlive) {
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.lastUsed = ++this.#clock;
activated.inactiveSince = null;
this.#active = view;
while (this.#keepAliveCount() > this.#options.maxKeepAliveEntries) {
const candidate = this.#oldestInactiveKeepAlive();
if (!candidate) break;
this.#entries.delete(candidate.view);
removed.push(candidate);
}
return {
entries: this.snapshot(),
removed,
@@ -88,29 +101,63 @@ export class FrameCache {
};
}
/**
* 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),
lastUsed: ++this.#clock,
inactiveSince: null,
};
}
#keepAliveCount(): number {
let count = 0;
#removeExpired(now: number): FrameEntry[] {
const removed: FrameEntry[] = [];
for (const entry of this.#entries.values()) {
if (entry.keepAlive) count += 1;
if (
entry.view === this.#active ||
!entry.keepAlive ||
entry.inactiveSince === null ||
now - entry.inactiveSince < this.#options.inactiveTtlMs
) {
continue;
}
return count;
this.#entries.delete(entry.view);
removed.push(entry);
}
return removed;
}
#oldestInactiveKeepAlive(): FrameEntry | undefined {
let oldest: FrameEntry | undefined;
for (const entry of this.#entries.values()) {
if (!entry.keepAlive || entry.view === this.#active) continue;
if (!oldest || entry.lastUsed < oldest.lastUsed) oldest = entry;
}
return oldest;
#now(): number {
return this.#options.now?.() ?? performance.now();
}
}
+16
View File
@@ -0,0 +1,16 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merges conditional Tailwind classes using the same pattern as shadcn.
*
* `clsx` evaluates conditional input while `tailwind-merge` lets a caller's
* class override a component default from the same Tailwind class group:
*
* ```ts
* cn("h-10 px-4", compact && "h-8", props.class)
* ```
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+4 -18
View File
@@ -1,15 +1,5 @@
import type { BackendEvent, BackendService } from "./api";
export const VIEWS = [
"overview",
"services",
"instances",
"wit-packages",
"activity",
"settings",
] as const;
export type View = (typeof VIEWS)[number];
export type ConnectionState = "connecting" | "online" | "offline";
export type ServiceStatus = "running" | "stopped" | "faulted";
export type EventTone = "success" | "warning" | "danger" | "neutral";
@@ -47,16 +37,12 @@ export type FormattedInvocationOutput = {
automatic: boolean;
};
export const STATUS_META: Record<ServiceStatus, { label: string; className: string }> = {
running: { label: "运行中", className: "status-running" },
stopped: { label: "已停止", className: "status-stopped" },
faulted: { label: "故障", className: "status-faulted" },
export const STATUS_META: Record<ServiceStatus, { label: string }> = {
running: { label: "运行中" },
stopped: { label: "已停止" },
faulted: { label: "故障" },
};
export function isView(value: string | null): value is View {
return VIEWS.includes(value as View);
}
export function serviceKey(service: Pick<Service, "id" | "revision">): string {
return `${service.id}@${service.revision}`;
}
@@ -1,7 +1,10 @@
import { History, RefreshCw } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/ui/empty-state";
import { PageHeading } from "../../components/ui/page-heading";
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";
@@ -13,6 +16,11 @@ const toneClass = {
};
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(
@@ -24,18 +32,22 @@ export default function ActivityPage(props: PageProps) {
});
return (
<main class="page">
<Page>
<PageHeading
eyebrow="EVENTS"
title="调用记录"
description="最近 256 条持久化生命周期与调用事件。"
actions={
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</button>
</IconButton>
}
/>
<section class="panel">
<Card as="section">
<Show
when={events().length > 0}
fallback={
@@ -55,7 +67,7 @@ export default function ActivityPage(props: PageProps) {
</For>
</div>
</Show>
</section>
</main>
</Card>
</Page>
);
}
@@ -1,8 +1,11 @@
import { Cpu, RefreshCw, Server, SquareTerminal } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/ui/empty-state";
import { PageHeading } from "../../components/ui/page-heading";
import { StatusBadge } from "../../components/ui/status-badge";
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";
@@ -14,29 +17,33 @@ export default function InstancesPage(props: PageProps) {
);
return (
<main class="page">
<Page>
<PageHeading
eyebrow="ACTORS"
title="运行实例"
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
actions={
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</button>
</IconButton>
}
/>
<Show
when={services().length > 0}
fallback={
<div class="panel">
<Card>
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
</div>
</Card>
}
>
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<For each={services()}>
{(service) => (
<article class="panel p-5">
<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">
@@ -44,7 +51,9 @@ export default function InstancesPage(props: PageProps) {
</span>
<div class="min-w-0">
<strong class="block truncate">{service.id}</strong>
<span class="code mt-1 block truncate text-muted">{service.revision}</span>
<span class="mt-1 block truncate font-mono text-xs text-muted">
{service.revision}
</span>
</div>
</div>
<StatusBadge status={service.status} />
@@ -71,8 +80,8 @@ export default function InstancesPage(props: PageProps) {
</dl>
<div class="mt-5 flex justify-end gap-2">
<Show when={service.status === "running"}>
<button
class="btn btn-secondary h-9"
<Button
size="small"
onClick={() =>
props.command({
type: "open-invoke",
@@ -82,10 +91,10 @@ export default function InstancesPage(props: PageProps) {
>
<SquareTerminal size={15} />
</button>
</Button>
</Show>
<button
class="btn btn-secondary h-9"
<Button
size="small"
disabled={props.state()?.serviceAction !== null}
onClick={() =>
props.command({
@@ -96,13 +105,13 @@ export default function InstancesPage(props: PageProps) {
}
>
{service.status === "running" ? "停止" : "启动"}
</button>
</Button>
</div>
</article>
</Card>
)}
</For>
</section>
</Show>
</main>
</Page>
);
}
+12 -18
View File
@@ -1,21 +1,9 @@
import { AlertTriangle, LoaderCircle } from "lucide-solid";
import { lazy, Show, Suspense, type Component } from "solid-js";
import { lazy, Show, Suspense } from "solid-js";
import { render } from "solid-js/web";
import { isView, type View } from "../lib/model";
import { createFrameBridge } from "../primitives/create-frame-bridge";
import "../styles/app.css";
import type { PageProps } from "./types";
// Each view is a dynamic import so a freshly-created iframe evaluates only the
// page it owns. Destroying that iframe releases the chunk's runtime objects.
const pages: Record<View, Component<PageProps>> = {
overview: lazy(() => import("./overview")),
services: lazy(() => import("./services")),
instances: lazy(() => import("./instances")),
"wit-packages": lazy(() => import("./wit-packages")),
activity: lazy(() => import("./activity")),
settings: lazy(() => import("./settings")),
};
import { isView, pageDefinition } from "./registry";
function FramePage() {
const requested = new URLSearchParams(window.location.search).get("view");
@@ -36,17 +24,23 @@ function FramePage() {
>
{(activeView) => {
const bridge = createFrameBridge(activeView());
const Page = pages[activeView()];
document.title = `${activeView()} · Wasmeld`;
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="spin" size={24} />
<LoaderCircle class="animate-spin" size={24} />
</main>
}
>
<Page state={bridge.state} active={bridge.active} command={bridge.command} />
<Page
state={bridge.state}
active={bridge.active}
lifecycle={bridge.lifecycle}
command={bridge.command}
/>
</Suspense>
);
}}
@@ -11,8 +11,11 @@ import {
Zap,
} from "lucide-solid";
import { createMemo, For, Show, type JSXElement } from "solid-js";
import { PageHeading } from "../../components/ui/page-heading";
import { ServiceTable } from "../../components/ui/service-table";
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";
@@ -29,7 +32,7 @@ export default function OverviewPage(props: PageProps) {
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
return (
<main class="page">
<Page>
<Show when={props.state()} fallback={<OverviewSkeleton />}>
{(state) => (
<>
@@ -39,28 +42,28 @@ export default function OverviewPage(props: PageProps) {
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
actions={
<>
<button
class="icon-btn"
type="button"
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</button>
<button
class="btn btn-primary"
type="button"
</IconButton>
<Button
variant="primary"
onClick={() => props.command({ type: "open-register" })}
>
<CloudUpload size={16} />
</button>
</Button>
</>
}
/>
<section class="metric-grid" aria-label="关键指标">
<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"
@@ -91,7 +94,10 @@ export default function OverviewPage(props: PageProps) {
/>
</section>
<section class="panel mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]">
<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} />
@@ -108,32 +114,33 @@ export default function OverviewPage(props: PageProps) {
<Show
when={state().snapshot?.runtime.status === "running"}
fallback={
<button
class="icon-btn"
<IconButton
aria-label="启动 Runtime"
title="启动 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "start" })}
>
<Play size={15} />
</button>
</IconButton>
}
>
<button
class="icon-btn"
<IconButton
aria-label="重启 Runtime"
title="重启 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "restart" })}
>
<RotateCcw size={15} />
</button>
<button
class="icon-btn text-coral-strong"
</IconButton>
<IconButton
tone="danger"
aria-label="停止 Runtime"
title="停止 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
>
<CircleStop size={15} />
</button>
</IconButton>
</Show>
</div>
</div>
@@ -156,32 +163,32 @@ export default function OverviewPage(props: PageProps) {
}
detail="当前 Runtime"
/>
</section>
</Card>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,0.8fr)]">
<section class="panel min-w-0">
<div class="panel-header">
<Card as="section" class="min-w-0">
<CardHeader>
<div>
<h2></h2>
<p> Actor </p>
</div>
<button
class="btn btn-secondary h-9"
<Button
size="small"
onClick={() => props.command({ type: "navigate", view: "services" })}
>
</button>
</div>
</Button>
</CardHeader>
<ServiceTable
services={services().slice(0, 5)}
state={state()}
command={props.command}
compact
/>
</section>
</Card>
<aside class="panel">
<div class="panel-header">
<Card as="aside">
<CardHeader>
<div>
<h2></h2>
<p></p>
@@ -190,7 +197,7 @@ export default function OverviewPage(props: PageProps) {
<span class="size-1.5 rounded-full bg-brand" />
LIVE
</span>
</div>
</CardHeader>
<div class="divide-y divide-line">
<For
each={events().slice(0, 6)}
@@ -207,12 +214,12 @@ export default function OverviewPage(props: PageProps) {
)}
</For>
</div>
</aside>
</Card>
</div>
</>
)}
</Show>
</main>
</Page>
);
}
@@ -224,13 +231,15 @@ function Metric(props: {
note: string;
}) {
return (
<article class="metric">
<div class={`metric-icon ${props.tone}`}>{props.icon}</div>
<div class="metric-copy">
<span>{props.label}</span>
<strong>{props.value}</strong>
<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>
<small>{props.note}</small>
<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>
);
}
@@ -248,8 +257,8 @@ function RuntimeStat(props: { label: string; value: string; detail: string }) {
function OverviewSkeleton() {
return (
<div class="space-y-5 py-5">
<div class="skeleton-line w-36" />
<div class="skeleton-line h-8 w-64" />
<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>
@@ -0,0 +1,106 @@
import {
FileCode2,
History,
LayoutDashboard,
Package,
Server,
Settings,
type LucideProps,
} from "lucide-solid";
import type { Component } from "solid-js";
import type { PageProps } from "./types";
type PageModule = {
default: Component<PageProps>;
};
type PageDefinition = {
id: string;
label: string;
icon: Component<LucideProps>;
/**
* Requests that Host retain this page's iframe after navigation.
*
* Primary navigation pages normally opt in; detail and short-lived workflow
* pages should opt out. This is still a preference rather than ownership of
* the iframe lifetime: Host expires documents that remain hidden beyond its
* inactivity TTL. A retained page must pause background work while
* `active()` is false and release all resources when disposed.
*/
keepAlive: boolean;
/**
* Loads only this page's code inside the iframe document.
*
* Keep this as a dynamic import. Turning it into a static import would make
* every iframe evaluate every page and weaken the document lifecycle
* boundary used to release page-owned memory.
*/
load: () => Promise<PageModule>;
};
/**
* The single source of truth for Wasmeld Console pages.
*
* Add a page here after creating `src/pages/<id>/index.tsx`. Host navigation,
* route validation, iframe titles, keep-alive policy and page module loading
* are all derived from this registry, so they cannot drift independently.
*/
export const PAGE_DEFINITIONS = [
{
id: "overview",
label: "运行概览",
icon: LayoutDashboard,
keepAlive: true,
load: () => import("./overview"),
},
{
id: "services",
label: "服务版本",
icon: Package,
keepAlive: true,
load: () => import("./services"),
},
{
id: "instances",
label: "运行实例",
icon: Server,
keepAlive: true,
load: () => import("./instances"),
},
{
id: "wit-packages",
label: "WIT 包",
icon: FileCode2,
keepAlive: true,
load: () => import("./wit-packages"),
},
{
id: "activity",
label: "调用记录",
icon: History,
keepAlive: true,
load: () => import("./activity"),
},
{
id: "settings",
label: "运行设置",
icon: Settings,
keepAlive: true,
load: () => import("./settings"),
},
] as const satisfies readonly PageDefinition[];
export type View = (typeof PAGE_DEFINITIONS)[number]["id"];
export const VIEWS: readonly View[] = PAGE_DEFINITIONS.map((page) => page.id);
const PAGE_BY_ID = new Map<View, PageDefinition>(PAGE_DEFINITIONS.map((page) => [page.id, page]));
export function isView(value: string | null): value is View {
return value !== null && PAGE_BY_ID.has(value as View);
}
export function pageDefinition(view: View): PageDefinition {
// `View` is derived from PAGE_DEFINITIONS, so every valid value has an entry.
return PAGE_BY_ID.get(view)!;
}
@@ -1,7 +1,11 @@
import { CloudUpload, Filter, RefreshCw } from "lucide-solid";
import { createMemo, createSignal, Show } from "solid-js";
import { PageHeading } from "../../components/ui/page-heading";
import { ServiceTable } from "../../components/ui/service-table";
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";
@@ -22,7 +26,7 @@ export default function ServicesPage(props: PageProps) {
});
return (
<main class="page">
<Page>
<PageHeading
eyebrow="COMPONENTS"
title="服务版本"
@@ -34,8 +38,8 @@ export default function ServicesPage(props: PageProps) {
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
size={14}
/>
<select
class="select w-36 pl-9"
<Select
class="w-36 pl-9"
value={status()}
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
>
@@ -43,34 +47,35 @@ export default function ServicesPage(props: PageProps) {
<option value="running"></option>
<option value="stopped"></option>
<option value="faulted"></option>
</select>
</Select>
</label>
<button
class="btn btn-primary"
onClick={() => props.command({ type: "open-register" })}
>
<Button variant="primary" onClick={() => props.command({ type: "open-register" })}>
<CloudUpload size={16} />
</button>
</Button>
</>
}
/>
<section class="panel">
<div class="panel-header">
<Card as="section">
<CardHeader>
<div>
<h2></h2>
<p>{filtered().length} </p>
</div>
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</button>
</div>
</IconButton>
</CardHeader>
<Show when={props.state()}>
{(state) => (
<ServiceTable services={filtered()} state={state()} command={props.command} />
)}
</Show>
</section>
</main>
</Card>
</Page>
);
}
@@ -1,7 +1,12 @@
import { Database, Globe2, Save, ShieldCheck } from "lucide-solid";
import { createEffect, createSignal } from "solid-js";
import { displayApiBase } from "../../lib/api";
import { PageHeading } from "../../components/ui/page-heading";
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) {
@@ -16,21 +21,21 @@ export default function SettingsPage(props: PageProps) {
});
return (
<main class="page">
<Page>
<PageHeading
eyebrow="CONFIGURATION"
title="运行设置"
description="配置管理 API 连接;运行边界由后端策略统一控制。"
/>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.7fr)]">
<section class="panel">
<div class="panel-header">
<Card as="section">
<CardHeader>
<div>
<h2> API</h2>
<p>使</p>
</div>
<Globe2 size={18} class="text-cyan-strong" />
</div>
</CardHeader>
<form
class="p-5"
onSubmit={(event) => {
@@ -38,27 +43,26 @@ export default function SettingsPage(props: PageProps) {
props.command({ type: "save-api-base", value: endpoint() });
}}
>
<label class="field">
<span>Endpoint</span>
<input
class="input"
<Field>
<FieldLabel>Endpoint</FieldLabel>
<Input
type="url"
value={endpoint()}
placeholder={window.location.origin}
onInput={(event) => setEndpoint(event.currentTarget.value)}
/>
</label>
</Field>
<p class="mt-2 text-xs text-muted">
{displayApiBase(props.state()?.apiBase ?? "")}
</p>
<div class="mt-5 flex justify-end">
<button class="btn btn-primary" type="submit">
<Button variant="primary" type="submit">
<Save size={15} />
</button>
</Button>
</div>
</form>
</section>
</Card>
<aside class="space-y-3">
<Info
icon={ShieldCheck}
@@ -68,14 +72,14 @@ export default function SettingsPage(props: PageProps) {
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
</aside>
</div>
</main>
</Page>
);
}
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
const Icon = props.icon;
return (
<article class="panel flex gap-3 p-4">
<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>
@@ -83,6 +87,6 @@ function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }
<strong class="text-sm">{props.title}</strong>
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
</div>
</article>
</Card>
);
}
@@ -1,4 +1,5 @@
import type { Accessor } from "solid-js";
import type { PageLifecycle } from "../sdk/lifecycle";
import type { HostSharedState, PageCommand } from "../sdk/protocol";
export type PageProps = {
@@ -8,5 +9,12 @@ export type PageProps = {
* 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,7 +1,18 @@
import { CloudUpload, Download, FileCode2, Search } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/ui/empty-state";
import { PageHeading } from "../../components/ui/page-heading";
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";
@@ -21,85 +32,83 @@ export default function WitPackagesPage(props: PageProps) {
});
return (
<main class="page">
<Page>
<PageHeading
eyebrow="INTERFACES"
title="WIT 包"
description="管理不可变、版本化的 Component 接口依赖。"
actions={
<button
class="btn btn-primary"
onClick={() => props.command({ type: "open-wit-publish" })}
>
<Button variant="primary" onClick={() => props.command({ type: "open-wit-publish" })}>
<CloudUpload size={16} />
WIT
</button>
</Button>
}
/>
<section class="panel">
<div class="panel-header">
<Card as="section">
<CardHeader>
<div>
<h2>Registry</h2>
<p>{packages().length} </p>
</div>
</div>
</CardHeader>
<Show
when={packages().length > 0}
fallback={
<EmptyState icon={Search} title="没有匹配的 WIT 包" detail="发布或调整搜索关键词。" />
}
>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>SHA-256</th>
<th>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>SHA-256</TableHead>
<TableHead>
<span class="sr-only"></span>
</th>
</tr>
</thead>
<tbody>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={packages()}>
{(item) => (
<tr>
<td>
<TableRow>
<TableCell>
<span class="flex items-center gap-2">
<FileCode2 size={15} class="text-cyan-strong" />
<strong>{item.name}</strong>
</span>
</td>
<td class="code">{item.version}</td>
<td class="text-xs text-muted">
</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(", ")
: "无"}
</td>
<td class="code max-w-64 truncate text-muted" title={item.sha256}>
</TableCell>
<TableCell
class="max-w-64 truncate font-mono text-xs text-muted"
title={item.sha256}
>
{item.sha256}
</td>
<td class="text-right">
<a
class="icon-btn"
</TableCell>
<TableCell class="text-right">
<IconLink
aria-label={`下载 ${item.name}@${item.version}`}
title="下载"
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
>
<Download size={15} />
</a>
</td>
</tr>
</IconLink>
</TableCell>
</TableRow>
)}
</For>
</tbody>
</table>
</div>
</TableBody>
</Table>
</Show>
</section>
</main>
</Card>
</Page>
);
}
@@ -1,5 +1,6 @@
import { createSignal, onCleanup, onMount } from "solid-js";
import type { View } from "../lib/model";
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";
@@ -7,12 +8,14 @@ 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`. Removing the iframe still runs `onCleanup`, so an evicted
* document cannot continue to receive Host snapshots.
* 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;
@@ -22,7 +25,12 @@ export function createFrameBridge(view: View) {
}
if (event.data.type === "lifecycle") {
const phase = event.data.phase;
setActive(phase === "activate");
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 },
@@ -34,16 +42,39 @@ export function createFrameBridge(view: View) {
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));
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";
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { View } from "../lib/model";
import type { View } from "../pages/registry";
import {
SDK_CHANNEL,
SDK_VERSION,
@@ -0,0 +1,124 @@
export type PageLifecycleEventType = "show" | "hide" | "unload";
export type PageLifecycleState = "initializing" | "visible" | "hidden" | "unloaded";
export type PageLifecycleSource = "host" | "document";
export type PageLifecycleEvent = {
type: PageLifecycleEventType;
previous: PageLifecycleState;
current: PageLifecycleState;
/**
* `host` means the event came from iframe navigation policy. `document`
* means the browser is discarding the document and acts as an unload
* fallback when a Host `dispose` message cannot be delivered first.
*/
source: PageLifecycleSource;
};
export type PageLifecycleListener = (event: PageLifecycleEvent) => void;
/**
* Framework-independent lifecycle exposed to every Page.
*
* Event subscriptions are best for imperative work at a transition boundary:
* refresh data on show, pause a stream on hide, and close external resources
* on unload. Each method returns an idempotent unsubscribe function that a UI
* framework should call during component cleanup.
*/
export type PageLifecycle = {
state: () => PageLifecycleState;
/** Subscribes only to future transitions of the selected type. */
on: (type: PageLifecycleEventType, listener: PageLifecycleListener) => () => void;
/**
* Subscribes to show and immediately replays it when already visible.
*
* Replay prevents a lazy-loaded Page from missing the initial Host event.
*/
onShow: (listener: PageLifecycleListener) => () => void;
/** Subscribes to hide and immediately replays it when already hidden. */
onHide: (listener: PageLifecycleListener) => () => void;
onUnload: (listener: PageLifecycleListener) => () => void;
};
export type PageLifecycleDispatcher = {
lifecycle: PageLifecycle;
/**
* Applies one lifecycle transition. Repeated and post-unload transitions are
* ignored, so Host `dispose` followed by browser `pagehide` emits one unload.
*/
dispatch: (
type: PageLifecycleEventType,
source: PageLifecycleSource,
) => PageLifecycleEvent | null;
};
/**
* Creates the lifecycle state machine for one iframe document.
*
* This module deliberately has no Solid or DOM dependency. A React, Vue or
* other Page adapter can expose the same object without changing SDK behavior.
*/
export function createPageLifecycleDispatcher(): PageLifecycleDispatcher {
let state: PageLifecycleState = "initializing";
let lastEvent: PageLifecycleEvent | null = null;
const listeners: Record<PageLifecycleEventType, Set<PageLifecycleListener>> = {
show: new Set(),
hide: new Set(),
unload: new Set(),
};
function on(type: PageLifecycleEventType, listener: PageLifecycleListener): () => void {
if (state === "unloaded") return () => {};
listeners[type].add(listener);
return () => listeners[type].delete(listener);
}
function onCurrent(type: "show" | "hide", listener: PageLifecycleListener): () => void {
const unsubscribe = on(type, listener);
if (lastEvent?.type === type) listener(lastEvent);
return unsubscribe;
}
const lifecycle: PageLifecycle = {
state: () => state,
on,
onShow: (listener) => onCurrent("show", listener),
onHide: (listener) => onCurrent("hide", listener),
onUnload: (listener) => on("unload", listener),
};
function dispatch(
type: PageLifecycleEventType,
source: PageLifecycleSource,
): PageLifecycleEvent | null {
if (state === "unloaded") return null;
const current = stateFor(type);
if (current === state) return null;
const event: PageLifecycleEvent = {
type,
previous: state,
current,
source,
};
state = current;
lastEvent = event;
for (const listener of [...listeners[type]]) listener(event);
if (current === "unloaded") {
for (const subscriptions of Object.values(listeners)) subscriptions.clear();
}
return event;
}
return { lifecycle, dispatch };
}
function stateFor(type: PageLifecycleEventType): PageLifecycleState {
switch (type) {
case "show":
return "visible";
case "hide":
return "hidden";
case "unload":
return "unloaded";
}
}
+10 -1
View File
@@ -1,6 +1,15 @@
import type { View } from "../lib/model";
import type { View } from "../pages/registry";
import { SDK_CHANNEL, SDK_VERSION, type PageCommand, type PageMessage } from "./protocol";
export type {
PageLifecycle,
PageLifecycleEvent,
PageLifecycleEventType,
PageLifecycleListener,
PageLifecycleSource,
PageLifecycleState,
} from "./lifecycle";
export function notifyReady(view: View): void {
send({
channel: SDK_CHANNEL,
@@ -1,5 +1,6 @@
import type { BackendSnapshot } from "../lib/api";
import type { ConnectionState, View } from "../lib/model";
import type { ConnectionState } from "../lib/model";
import type { View } from "../pages/registry";
export const SDK_CHANNEL = "wasmeld-console";
@@ -64,273 +64,6 @@
}
}
@layer components {
.btn {
@apply inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45;
}
.btn-primary {
@apply border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong;
}
.btn-secondary {
@apply border-line bg-white text-ink hover:bg-canvas;
}
.btn-danger {
@apply border-coral-strong bg-white text-coral-strong hover:bg-coral-soft;
}
.icon-btn {
@apply inline-flex size-9 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;
}
.panel {
@apply border-line bg-surface rounded-md border;
}
.panel-header {
@apply border-line flex min-h-16 items-center justify-between gap-4 border-b px-5 py-3;
}
.panel-header h2 {
@apply text-base font-semibold;
}
.panel-header p {
@apply mt-0.5 text-xs text-muted;
}
.page {
@apply mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7;
}
.page-heading {
@apply mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end;
}
.page-heading h1 {
@apply mt-1 text-2xl font-bold text-ink;
}
.page-heading p {
@apply mt-1 max-w-3xl text-sm text-muted;
}
.eyebrow {
@apply text-cyan-strong text-[11px] font-bold uppercase;
}
.heading-actions {
@apply flex shrink-0 items-center gap-2;
}
.metric-grid {
@apply mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4;
}
.metric {
@apply flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3;
}
.metric-icon {
@apply flex size-10 shrink-0 items-center justify-center rounded-md;
}
.metric-copy {
@apply min-w-0 flex-1;
}
.metric-copy span {
@apply block text-xs text-muted;
}
.metric-copy strong {
@apply mt-1 block text-2xl font-bold;
}
.metric > small {
@apply self-end whitespace-nowrap pb-1 text-[11px] text-muted;
}
.table-wrap {
@apply w-full overflow-x-auto;
}
.data-table {
@apply w-full min-w-[760px] border-collapse text-left text-sm;
}
.data-table th {
@apply border-line bg-canvas border-b px-4 py-2.5 text-[11px] font-semibold uppercase text-muted;
}
.data-table td {
@apply border-line border-b px-4 py-3 align-middle;
}
.data-table tbody tr {
@apply transition-colors hover:bg-[#f8faf9];
}
.data-table tbody tr:last-child td {
@apply border-b-0;
}
.status-badge {
@apply inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold;
}
.status-badge::before {
content: "";
@apply size-1.5 rounded-full bg-current;
}
.status-running {
@apply text-brand;
}
.status-stopped {
@apply text-muted;
}
.status-faulted {
@apply text-coral-strong;
}
.row-actions {
@apply flex items-center justify-end gap-1;
}
.row-actions button {
@apply inline-flex size-8 items-center justify-center rounded-md text-muted hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-35;
}
.empty-state {
@apply flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted;
}
.empty-state strong {
@apply text-sm text-ink;
}
.empty-state span {
@apply text-xs;
}
.code {
@apply font-mono text-xs;
}
.field {
@apply block;
}
.field > span {
@apply mb-1.5 block text-xs font-semibold text-muted;
}
.input,
.select,
.textarea {
@apply border-line w-full rounded-md border bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand;
}
.input,
.select {
@apply h-10;
}
.textarea {
@apply min-h-36 resize-y py-3 font-mono;
}
.dialog-backdrop {
@apply fixed inset-0 z-50 flex items-center justify-center bg-[#0d1715]/55 p-3 backdrop-blur-[1px];
}
.dialog {
@apply border-line max-h-[calc(100dvh-24px)] w-full max-w-3xl overflow-auto rounded-md border bg-white p-0 shadow-2xl;
}
.dialog-header {
@apply border-line flex items-start justify-between gap-4 border-b px-5 py-4 sm:px-7 sm:py-5;
}
.dialog-title {
@apply flex min-w-0 items-start gap-3;
}
.dialog-title > span {
@apply bg-brand-soft text-brand flex size-10 shrink-0 items-center justify-center rounded-md;
}
.dialog-title h2 {
@apply text-lg font-bold sm:text-xl;
}
.dialog-title p {
@apply mt-1 text-sm text-muted;
}
.dialog-body {
@apply px-5 py-5 sm:px-7;
}
.dialog-footer {
@apply border-line flex items-center justify-end gap-2 border-t bg-[#fafbfb] px-5 py-4 sm:px-7;
}
.upload-zone {
@apply border-line flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-[#fbfcfc] px-5 text-center text-muted transition-colors hover:border-brand hover:bg-brand-soft;
}
.upload-zone input {
@apply sr-only;
}
.upload-zone strong {
@apply text-sm text-ink;
}
.upload-zone span {
@apply text-xs;
}
.form-error {
@apply border-coral-strong/30 bg-coral-soft text-coral-strong mt-3 rounded-md border px-3 py-2 text-sm;
}
.segmented {
@apply border-line inline-flex rounded-md border bg-canvas p-1;
}
.segmented button {
@apply h-8 rounded-sm px-3 text-xs font-semibold text-muted;
}
.segmented button.active {
@apply bg-white text-ink shadow-sm;
}
.skeleton-line {
@apply h-3 animate-pulse rounded-sm bg-[#dfe6e4];
}
.loading-progress-track {
@apply h-1 w-40 overflow-hidden rounded-sm bg-line;
}
.loading-progress-indicator {
@apply block h-full w-[36%] rounded-sm bg-brand;
animation: loading-progress-swing 1s ease-in-out infinite alternate;
}
.spin {
animation: spin 0.8s linear infinite;
}
}
@keyframes loading-progress-swing {
from {
transform: translateX(0);
@@ -340,16 +73,3 @@
transform: translateX(178%);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.loading-progress-indicator {
animation: none;
transform: translateX(89%);
}
}
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import test from "node:test";
const uiDirectory = new URL("../src/components/ui/", import.meta.url);
const appStylesheet = new URL("../src/styles/app.css", import.meta.url);
test("UI primitives do not depend on page, Host or domain modules", async () => {
const files = (await readdir(uiDirectory)).filter(
(name) => name.endsWith(".ts") || name.endsWith(".tsx"),
);
for (const file of files) {
const source = await readFile(new URL(file, uiDirectory), "utf8");
const imports = [...source.matchAll(/from\s+["']([^"']+)["']/g)].map((match) => match[1]);
for (const specifier of imports) {
assert.doesNotMatch(
specifier,
/(?:^|\/)(?:feedback|host|layout|pages|sdk|services)(?:\/|$)|\/lib\/(?:api|model|view-state)$/,
`${file} must remain business-independent: ${specifier}`,
);
}
}
});
test("global stylesheet does not own component variants", async () => {
const source = await readFile(appStylesheet, "utf8");
assert.doesNotMatch(
source,
/@layer\s+components/,
"component classes and variants belong beside their Solid components",
);
});
@@ -4,7 +4,7 @@ import { FrameCache } from "../src/host/frame-cache.ts";
test("retains opted-in iframe identities across navigation", () => {
const cache = new FrameCache("services", {
maxKeepAliveEntries: 2,
inactiveTtlMs: 1_000,
shouldKeepAlive: (view) => view === "services" || view === "settings",
});
const services = cache.snapshot()[0];
@@ -26,7 +26,7 @@ test("retains opted-in iframe identities across navigation", () => {
test("recreates non-keep-alive documents after leaving them", () => {
const cache = new FrameCache("overview", {
maxKeepAliveEntries: 2,
inactiveTtlMs: 1_000,
shouldKeepAlive: () => false,
});
const firstInstance = cache.snapshot()[0].instance;
@@ -41,40 +41,72 @@ test("recreates non-keep-alive documents after leaving them", () => {
);
});
test("evicts inactive keep-alive documents in LRU order", () => {
test("expires a keep-alive document after its continuous hidden TTL", () => {
let now = 5_000;
const cache = new FrameCache("services", {
maxKeepAliveEntries: 2,
inactiveTtlMs: 1_000,
shouldKeepAlive: () => true,
now: () => now,
});
const servicesInstance = cache.snapshot()[0].instance;
// Time spent visible does not consume the inactive TTL.
now = 50_000;
cache.activate("settings");
const overflow = cache.activate("instances");
now = 50_999;
assert.equal(cache.timeUntilExpiration(), 1);
assert.deepEqual(cache.expireInactive().removed, []);
now = 51_000;
const expiration = cache.expireInactive();
assert.deepEqual(
overflow.removed.map((entry) => entry.view),
expiration.removed.map((entry) => entry.view),
["services"],
);
assert.deepEqual(
overflow.entries.map((entry) => entry.view),
["settings", "instances"],
expiration.entries.map((entry) => entry.view),
["settings"],
);
const recreated = cache.activate("services");
assert.notEqual(recreated.activated.instance, servicesInstance);
});
test("revisiting a hidden page before its TTL preserves and resets it", () => {
let now = 0;
const cache = new FrameCache("services", {
inactiveTtlMs: 1_000,
shouldKeepAlive: () => true,
now: () => now,
});
const services = cache.snapshot()[0];
cache.activate("settings");
now = 999;
const revisited = cache.activate("services");
assert.equal(revisited.activated, services);
now = 1_500;
assert.deepEqual(cache.expireInactive().removed, []);
assert.equal(cache.timeUntilExpiration(), 499);
now = 1_999;
const expiration = cache.expireInactive();
assert.deepEqual(
recreated.removed.map((entry) => entry.view),
expiration.removed.map((entry) => entry.view),
["settings"],
);
});
test("rejects invalid keep-alive limits", () => {
test("rejects invalid inactive TTL values", () => {
for (const inactiveTtlMs of [0, -1, Number.POSITIVE_INFINITY, Number.NaN]) {
assert.throws(
() =>
new FrameCache("overview", {
maxKeepAliveEntries: -1,
inactiveTtlMs,
shouldKeepAlive: () => true,
}),
RangeError,
);
}
});
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createPageLifecycleDispatcher } from "../src/sdk/lifecycle.ts";
test("maps page visibility transitions to show, hide and unload events", () => {
const dispatcher = createPageLifecycleDispatcher();
const events = [];
dispatcher.lifecycle.onShow((event) => events.push(event));
dispatcher.lifecycle.onHide((event) => events.push(event));
dispatcher.lifecycle.onUnload((event) => events.push(event));
assert.equal(dispatcher.lifecycle.state(), "initializing");
dispatcher.dispatch("show", "host");
dispatcher.dispatch("hide", "host");
dispatcher.dispatch("show", "host");
dispatcher.dispatch("unload", "host");
assert.equal(dispatcher.lifecycle.state(), "unloaded");
assert.deepEqual(
events.map(({ type, previous, current, source }) => ({
type,
previous,
current,
source,
})),
[
{ type: "show", previous: "initializing", current: "visible", source: "host" },
{ type: "hide", previous: "visible", current: "hidden", source: "host" },
{ type: "show", previous: "hidden", current: "visible", source: "host" },
{ type: "unload", previous: "visible", current: "unloaded", source: "host" },
],
);
});
test("deduplicates lifecycle events and ignores transitions after unload", () => {
const dispatcher = createPageLifecycleDispatcher();
const events = [];
dispatcher.lifecycle.on("show", (event) => events.push(event.type));
dispatcher.lifecycle.on("unload", (event) => events.push(event.type));
assert.equal(dispatcher.dispatch("show", "host")?.type, "show");
assert.equal(dispatcher.dispatch("show", "host"), null);
assert.equal(dispatcher.dispatch("unload", "host")?.type, "unload");
assert.equal(dispatcher.dispatch("unload", "document"), null);
assert.equal(dispatcher.dispatch("show", "host"), null);
assert.deepEqual(events, ["show", "unload"]);
});
test("unsubscribe removes a page lifecycle listener", () => {
const dispatcher = createPageLifecycleDispatcher();
let calls = 0;
const unsubscribe = dispatcher.lifecycle.onHide(() => {
calls += 1;
});
unsubscribe();
unsubscribe();
dispatcher.dispatch("hide", "host");
assert.equal(calls, 0);
});
test("show and hide helpers replay current state for lazy pages", () => {
const dispatcher = createPageLifecycleDispatcher();
dispatcher.dispatch("show", "host");
const replayed = [];
dispatcher.lifecycle.onShow((event) => replayed.push(event.type));
dispatcher.lifecycle.onHide((event) => replayed.push(event.type));
dispatcher.lifecycle.on("show", (event) => replayed.push(`future:${event.type}`));
assert.deepEqual(replayed, ["show"]);
dispatcher.dispatch("hide", "host");
dispatcher.lifecycle.onHide((event) => replayed.push(`late:${event.type}`));
assert.deepEqual(replayed, ["show", "hide", "late:hide"]);
});
+17 -1
View File
@@ -515,6 +515,15 @@ impl ResidentSession {
self.actor.key()
}
/// Returns the largest stream chunk that a driver may deliver.
///
/// A stream driver should size its read buffer to this value or a smaller
/// protocol-specific limit. The Runtime applies the same limit to both
/// incoming `stream-data` events and Component `write-stream` effects.
pub fn max_stream_chunk_bytes(&self) -> usize {
self.actor.resident_stream_chunk_limit()
}
/// Registers a configured listener or datagram endpoint after policy checks.
///
/// Registration does not bind an operating-system socket. The driver binds
@@ -664,6 +673,7 @@ impl ResidentSession {
reason: StreamCloseReason,
) -> Result<Vec<ResidentOperation>, ResidentHostError> {
self.ensure_kind(stream_id, ResidentResourceKind::Stream, "stream")?;
*self.stream_state_mut(stream_id)? = StreamState::Closing;
let result = self.dispatch(ResidentEvent::StreamClosed { stream_id, reason });
self.resources.remove(&stream_id);
result
@@ -1056,7 +1066,13 @@ impl ResidentSession {
}
impl ResidentPolicy {
fn validate_endpoint(&self, endpoint: &ResidentEndpoint) -> Result<(), ResidentHostError> {
/// Validates one endpoint without registering or binding it.
///
/// Management layers can call this while loading static configuration so
/// an address outside policy fails before any Actor is started. Drivers
/// must still register the endpoint immediately before bind because policy
/// is also a runtime ownership boundary.
pub fn validate_endpoint(&self, endpoint: &ResidentEndpoint) -> Result<(), ResidentHostError> {
match endpoint {
ResidentEndpoint::Tcp { bind, .. } if !self.tcp_listen.allows(*bind) => {
Err(ResidentHostError::EndpointDenied(format!(
+23
View File
@@ -615,10 +615,33 @@ impl ActorHandle {
self.execution
}
/// Returns whether two handles address the exact same Actor instance.
///
/// Service keys are revision identities and therefore survive an Actor
/// fault/restart. Host resource supervisors use this stronger check to
/// retain themselves for an idempotent start but replace stale resources
/// when the Runtime created a fresh Store for the same revision.
pub fn is_same_instance(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.status, &other.status)
}
/// Returns the immutable mailbox capacity configured for this Actor.
///
/// Host supervisors use the same bound for their external event queue so a
/// restored revision retains the limits stored in its own manifest instead
/// of inheriting newer process defaults.
pub fn mailbox_capacity(&self) -> usize {
self.limits.mailbox_capacity
}
pub(crate) fn resident_resource_limit(&self) -> usize {
self.limits.resident.max_resources
}
pub(crate) fn resident_stream_chunk_limit(&self) -> usize {
self.limits.resident.max_stream_chunk_bytes
}
/// Sends an invocation and waits up to the remaining service deadline.
///
/// Cloned handles compete for the same bounded mailbox. A Wasm trap is
@@ -96,12 +96,16 @@ fn faulted_actor_can_be_started_again() {
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
let (key, actor) = start_component(&runtime, "fault", "fault_component.wasm");
let already_running = runtime.start(&key, Vec::new()).unwrap();
assert!(actor.is_same_instance(&already_running));
assert!(matches!(
actor.invoke(b"fault".to_vec()),
Err(RuntimeError::ActorFault { .. })
));
let restarted = runtime.start(&key, Vec::new()).unwrap();
assert!(!actor.is_same_instance(&restarted));
assert_eq!(restarted.invoke(b"ready".to_vec()).unwrap(), b"ready");
runtime.stop(&key).unwrap();