feat(console-web): synchronize Host state across tabs
- separate global Host state from tab-local shell and Page state - replicate control-plane snapshots through a versioned BroadcastChannel - handshake new tabs without publishing empty startup state - reject self, stale, and malformed cross-tab messages - suppress broadcast echoes while applying remote snapshots - publish API snapshots and connection state atomically - keep Page state private and update only mounted iframe documents - test initial synchronization and protocol rejection paths
This commit is contained in:
@@ -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;
|
||||
setSnapshot(next);
|
||||
setConnection("online");
|
||||
batch(() => {
|
||||
setSnapshot(next);
|
||||
setConnection("online");
|
||||
});
|
||||
} catch {
|
||||
if (generation !== refreshGeneration) return;
|
||||
setSnapshot(null);
|
||||
setConnection("offline");
|
||||
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