Compare commits
2 Commits
93f4a505ba
...
391901a71a
| Author | SHA1 | Date | |
|---|---|---|---|
| 391901a71a | |||
| 88a7fba0b0 |
@@ -67,6 +67,30 @@ Host 会继续向隐藏的保活页面发送只读状态快照,但拒绝其管
|
||||
立即显示最新 Runtime 状态,同时停用后的定时器不能意外触发注册、启停或 Deployment
|
||||
操作。
|
||||
|
||||
## 浏览器 Tab 状态
|
||||
|
||||
同源普通 Tab 和已安装 PWA 窗口通过 `BroadcastChannel` 共享 Host 全局状态。状态按
|
||||
所有权分为三层:
|
||||
|
||||
| 状态 | 跨 Tab | Host 到 Page | 生命周期 |
|
||||
| ------------------------------------------ | -------- | --------------------- | --------------- |
|
||||
| Runtime 快照、连接、API 地址、控制操作状态 | 始终共享 | 分发给已存在的 iframe | Host Tab |
|
||||
| 当前路由、搜索、弹窗、toast、iframe LRU | 不共享 | 当前 Tab 自己管理 | Browser Tab |
|
||||
| 页面筛选、表单、滚动位置、页面资源 | 不共享 | 不进入 Host | iframe document |
|
||||
|
||||
`src/host/tab-sync.ts` 使用独立版本的 Host Tab 协议:
|
||||
|
||||
- 每个 Host Tab 有随机 `sender` 和单调递增 `sequence`。
|
||||
- 新 Tab 发送 `hello`,已打开 Tab 立即返回当前 Host 全局状态。
|
||||
- 所有 Host Tab 都可以发布,不依赖可能失效的 leader。
|
||||
- 接收方拒绝自身消息、旧序列和字段不合法的消息。
|
||||
- 应用远端状态时抑制本地广播 effect,避免 Tab 间回声循环。
|
||||
- 浏览器不支持 `BroadcastChannel` 时退化为单 Tab,不影响页面和 API 操作。
|
||||
|
||||
Host 收到本地或远端全局状态后,只遍历当前 Tab 的 FrameCache。活动 iframe 和隐藏的
|
||||
keep-alive iframe 会收到最新快照;从未打开或已被销毁的 Page 没有同步目标,创建并
|
||||
发送 `ready` 后才取得当时的最新状态。Page 私有信号永远不会上传到 Host Tab channel。
|
||||
|
||||
## 开发
|
||||
|
||||
先在仓库根目录启动 Rust 后端:
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
WifiOff,
|
||||
} from "lucide-solid";
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
@@ -39,12 +41,14 @@ import { servicesFrom } from "../lib/view-state";
|
||||
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
|
||||
import {
|
||||
isPageMessage,
|
||||
type HostGlobalState,
|
||||
type HostSharedState,
|
||||
type PageCommand,
|
||||
type RuntimeAction,
|
||||
} from "../sdk/protocol";
|
||||
import { DialogLayer, type DialogState } from "./dialogs";
|
||||
import { FrameCache, type FrameEntry } from "./frame-cache";
|
||||
import { createBrowserHostTabSync, type HostTabSync } from "./tab-sync";
|
||||
|
||||
const NAVIGATION: Array<{
|
||||
id: View;
|
||||
@@ -83,19 +87,24 @@ export default function HostApp() {
|
||||
const [dialogBusy, setDialogBusy] = createSignal(false);
|
||||
const [toast, setToast] = createSignal<string | null>(null);
|
||||
const frameElements = new Map<View, HTMLIFrameElement>();
|
||||
let tabSync: HostTabSync | null = null;
|
||||
let applyingSharedHostState = false;
|
||||
let toastTimer: number | undefined;
|
||||
let refreshGeneration = 0;
|
||||
|
||||
const state = createMemo<HostSharedState>(() => ({
|
||||
view: view(),
|
||||
const globalState = createMemo<HostGlobalState>(() => ({
|
||||
snapshot: snapshot(),
|
||||
connection: connection(),
|
||||
query: query(),
|
||||
apiBase: apiBase(),
|
||||
runtimeAction: runtimeAction(),
|
||||
serviceAction: serviceAction(),
|
||||
deploymentAction: deploymentAction(),
|
||||
}));
|
||||
const state = createMemo<HostSharedState>(() => ({
|
||||
...globalState(),
|
||||
view: view(),
|
||||
query: query(),
|
||||
}));
|
||||
const activeServices = createMemo(
|
||||
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
||||
);
|
||||
@@ -108,17 +117,35 @@ export default function HostApp() {
|
||||
return { ...state(), view: targetView };
|
||||
}
|
||||
|
||||
function applySharedHostState(next: HostGlobalState) {
|
||||
applyingSharedHostState = true;
|
||||
refreshGeneration += 1;
|
||||
batch(() => {
|
||||
setSnapshot(next.snapshot);
|
||||
setConnection(next.connection);
|
||||
setApiBase(next.apiBase);
|
||||
setRuntimeAction(next.runtimeAction);
|
||||
setServiceAction(next.serviceAction);
|
||||
setDeploymentAction(next.deploymentAction);
|
||||
});
|
||||
applyingSharedHostState = false;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const generation = ++refreshGeneration;
|
||||
try {
|
||||
const next = await fetchSnapshot(apiBase());
|
||||
if (generation !== refreshGeneration) return;
|
||||
batch(() => {
|
||||
setSnapshot(next);
|
||||
setConnection("online");
|
||||
});
|
||||
} catch {
|
||||
if (generation !== refreshGeneration) return;
|
||||
batch(() => {
|
||||
setSnapshot(null);
|
||||
setConnection("offline");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +317,10 @@ export default function HostApp() {
|
||||
onMount(() => {
|
||||
window.addEventListener("message", receive);
|
||||
window.addEventListener("popstate", popState);
|
||||
tabSync = createBrowserHostTabSync({
|
||||
getState: globalState,
|
||||
applyState: applySharedHostState,
|
||||
});
|
||||
void refresh();
|
||||
const interval = window.setInterval(() => void refresh(), 5000);
|
||||
onCleanup(() => window.clearInterval(interval));
|
||||
@@ -298,6 +329,7 @@ export default function HostApp() {
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("message", receive);
|
||||
window.removeEventListener("popstate", popState);
|
||||
tabSync?.close();
|
||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
|
||||
});
|
||||
@@ -310,6 +342,16 @@ export default function HostApp() {
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
globalState,
|
||||
(next) => {
|
||||
if (!applyingSharedHostState) tabSync?.publish(next);
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
);
|
||||
|
||||
async function register(file: File) {
|
||||
setDialogBusy(true);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { HostGlobalState, RuntimeAction } from "../sdk/protocol";
|
||||
|
||||
const HOST_TAB_CHANNEL = "wasmeld-console:host-state";
|
||||
const HOST_TAB_PROTOCOL_VERSION = 1;
|
||||
|
||||
type HostTabMessage =
|
||||
| {
|
||||
channel: typeof HOST_TAB_CHANNEL;
|
||||
version: typeof HOST_TAB_PROTOCOL_VERSION;
|
||||
type: "hello";
|
||||
sender: string;
|
||||
}
|
||||
| {
|
||||
channel: typeof HOST_TAB_CHANNEL;
|
||||
version: typeof HOST_TAB_PROTOCOL_VERSION;
|
||||
type: "state";
|
||||
sender: string;
|
||||
sequence: number;
|
||||
state: HostGlobalState;
|
||||
};
|
||||
|
||||
export type TabChannel = {
|
||||
postMessage: (message: unknown) => void;
|
||||
addEventListener: (type: "message", listener: (event: MessageEvent<unknown>) => void) => void;
|
||||
removeEventListener: (type: "message", listener: (event: MessageEvent<unknown>) => void) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export type HostTabSyncOptions = {
|
||||
getState: () => HostGlobalState;
|
||||
applyState: (state: HostGlobalState) => void;
|
||||
channel?: TabChannel;
|
||||
tabId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replicates Host-owned control-plane state between same-origin browser tabs.
|
||||
*
|
||||
* Every tab may publish; there is deliberately no leader election. Sequence
|
||||
* numbers are scoped to a sender and suppress stale delivery, while consumers
|
||||
* suppress their own Solid broadcast effect when applying a remote snapshot.
|
||||
* Page-local state never enters this channel.
|
||||
*/
|
||||
export class HostTabSync {
|
||||
readonly #channel: TabChannel;
|
||||
readonly #tabId: string;
|
||||
readonly #getState: () => HostGlobalState;
|
||||
readonly #applyState: (state: HostGlobalState) => void;
|
||||
readonly #latestSequence = new Map<string, number>();
|
||||
#sequence = 0;
|
||||
#closed = false;
|
||||
|
||||
constructor(options: HostTabSyncOptions) {
|
||||
this.#channel = options.channel ?? new BroadcastChannel(HOST_TAB_CHANNEL);
|
||||
this.#tabId = options.tabId ?? crypto.randomUUID();
|
||||
this.#getState = options.getState;
|
||||
this.#applyState = options.applyState;
|
||||
this.#channel.addEventListener("message", this.#receive);
|
||||
this.#send({
|
||||
channel: HOST_TAB_CHANNEL,
|
||||
version: HOST_TAB_PROTOCOL_VERSION,
|
||||
type: "hello",
|
||||
sender: this.#tabId,
|
||||
});
|
||||
}
|
||||
|
||||
publish(state: HostGlobalState): void {
|
||||
this.#send({
|
||||
channel: HOST_TAB_CHANNEL,
|
||||
version: HOST_TAB_PROTOCOL_VERSION,
|
||||
type: "state",
|
||||
sender: this.#tabId,
|
||||
sequence: ++this.#sequence,
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#channel.removeEventListener("message", this.#receive);
|
||||
this.#channel.close();
|
||||
}
|
||||
|
||||
readonly #receive = (event: MessageEvent<unknown>) => {
|
||||
const message = event.data;
|
||||
if (!isHostTabMessage(message) || message.sender === this.#tabId) return;
|
||||
|
||||
if (message.type === "hello") {
|
||||
this.publish(this.#getState());
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = this.#latestSequence.get(message.sender) ?? 0;
|
||||
if (message.sequence <= latest) return;
|
||||
this.#latestSequence.set(message.sender, message.sequence);
|
||||
this.#applyState(message.state);
|
||||
};
|
||||
|
||||
#send(message: HostTabMessage): void {
|
||||
if (!this.#closed) this.#channel.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserHostTabSync(
|
||||
options: Omit<HostTabSyncOptions, "channel">,
|
||||
): HostTabSync | null {
|
||||
if (typeof BroadcastChannel === "undefined") return null;
|
||||
return new HostTabSync(options);
|
||||
}
|
||||
|
||||
function isHostTabMessage(value: unknown): value is HostTabMessage {
|
||||
if (!isRecord(value)) return false;
|
||||
if (
|
||||
value.channel !== HOST_TAB_CHANNEL ||
|
||||
value.version !== HOST_TAB_PROTOCOL_VERSION ||
|
||||
typeof value.sender !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (value.type === "hello") return true;
|
||||
return (
|
||||
value.type === "state" &&
|
||||
Number.isSafeInteger(value.sequence) &&
|
||||
(value.sequence as number) > 0 &&
|
||||
isHostGlobalState(value.state)
|
||||
);
|
||||
}
|
||||
|
||||
function isHostGlobalState(value: unknown): value is HostGlobalState {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
(value.snapshot === null || isRecord(value.snapshot)) &&
|
||||
isConnection(value.connection) &&
|
||||
typeof value.apiBase === "string" &&
|
||||
(value.runtimeAction === null || isRuntimeAction(value.runtimeAction)) &&
|
||||
(value.serviceAction === null || typeof value.serviceAction === "string") &&
|
||||
(value.deploymentAction === null || typeof value.deploymentAction === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isConnection(value: unknown): value is HostGlobalState["connection"] {
|
||||
return value === "connecting" || value === "online" || value === "offline";
|
||||
}
|
||||
|
||||
function isRuntimeAction(value: unknown): value is RuntimeAction {
|
||||
return value === "start" || value === "stop" || value === "restart";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -16,18 +16,27 @@ export type RuntimeAction = "start" | "stop" | "restart";
|
||||
export type ServiceAction = "start" | "stop" | "restart";
|
||||
export type PageLifecyclePhase = "activate" | "deactivate" | "dispose";
|
||||
|
||||
export type HostSharedState = {
|
||||
// This is a serializable snapshot, never a Solid signal crossing realms.
|
||||
view: View;
|
||||
/**
|
||||
* Control-plane state shared unconditionally between same-origin Host tabs.
|
||||
*
|
||||
* Navigation, search, dialogs, notifications and iframe caches are excluded:
|
||||
* those values describe one browser tab rather than the Wasmeld runtime.
|
||||
*/
|
||||
export type HostGlobalState = {
|
||||
snapshot: BackendSnapshot | null;
|
||||
connection: ConnectionState;
|
||||
query: string;
|
||||
apiBase: string;
|
||||
runtimeAction: RuntimeAction | null;
|
||||
serviceAction: string | null;
|
||||
deploymentAction: string | null;
|
||||
};
|
||||
|
||||
export type HostSharedState = HostGlobalState & {
|
||||
// This is a serializable snapshot, never a Solid signal crossing realms.
|
||||
view: View;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type PageCommand =
|
||||
| { type: "refresh" }
|
||||
| { type: "navigate"; view: View }
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { HostTabSync } from "../src/host/tab-sync.ts";
|
||||
|
||||
class FakeBus {
|
||||
channels = new Set();
|
||||
messages = [];
|
||||
|
||||
createChannel() {
|
||||
const channel = new FakeChannel(this);
|
||||
this.channels.add(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
deliver(sender, message) {
|
||||
const copy = structuredClone(message);
|
||||
this.messages.push(copy);
|
||||
for (const channel of this.channels) {
|
||||
if (channel !== sender) channel.receive(copy);
|
||||
}
|
||||
}
|
||||
|
||||
replay(message) {
|
||||
for (const channel of this.channels) channel.receive(structuredClone(message));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeChannel {
|
||||
listeners = new Set();
|
||||
|
||||
constructor(bus) {
|
||||
this.bus = bus;
|
||||
}
|
||||
|
||||
postMessage(message) {
|
||||
this.bus.deliver(this, message);
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
if (type === "message") this.listeners.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener) {
|
||||
if (type === "message") this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
receive(data) {
|
||||
for (const listener of this.listeners) listener({ data });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.bus.channels.delete(this);
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
test("new tabs request and receive the current Host state", () => {
|
||||
const bus = new FakeBus();
|
||||
const firstState = hostState("http://runtime-a.test");
|
||||
const received = [];
|
||||
const first = new HostTabSync({
|
||||
tabId: "tab-a",
|
||||
channel: bus.createChannel(),
|
||||
getState: () => firstState,
|
||||
applyState: () => {},
|
||||
});
|
||||
const second = new HostTabSync({
|
||||
tabId: "tab-b",
|
||||
channel: bus.createChannel(),
|
||||
getState: () => hostState("http://runtime-b.test"),
|
||||
applyState: (state) => received.push(state),
|
||||
});
|
||||
|
||||
assert.deepEqual(received, [firstState]);
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
test("publishes Host state but rejects self, stale and malformed messages", () => {
|
||||
const bus = new FakeBus();
|
||||
const received = [];
|
||||
const first = new HostTabSync({
|
||||
tabId: "tab-a",
|
||||
channel: bus.createChannel(),
|
||||
getState: () => hostState(""),
|
||||
applyState: (state) => received.push(state),
|
||||
});
|
||||
const second = new HostTabSync({
|
||||
tabId: "tab-b",
|
||||
channel: bus.createChannel(),
|
||||
getState: () => hostState(""),
|
||||
applyState: () => {},
|
||||
});
|
||||
received.length = 0;
|
||||
|
||||
const update = hostState("http://shared.test");
|
||||
second.publish(update);
|
||||
assert.deepEqual(received, [update]);
|
||||
|
||||
const stateMessage = bus.messages.findLast(
|
||||
(message) => message.type === "state" && message.sender === "tab-b",
|
||||
);
|
||||
bus.replay(stateMessage);
|
||||
bus.replay({
|
||||
...stateMessage,
|
||||
sequence: stateMessage.sequence + 1,
|
||||
state: { ...stateMessage.state, connection: "invalid" },
|
||||
});
|
||||
assert.deepEqual(received, [update]);
|
||||
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
function hostState(apiBase) {
|
||||
return {
|
||||
snapshot: null,
|
||||
connection: "online",
|
||||
apiBase,
|
||||
runtimeAction: null,
|
||||
serviceAction: null,
|
||||
deploymentAction: null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user