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,
|
WifiOff,
|
||||||
} from "lucide-solid";
|
} from "lucide-solid";
|
||||||
import {
|
import {
|
||||||
|
batch,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
|
on,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
@@ -39,12 +41,14 @@ import { servicesFrom } from "../lib/view-state";
|
|||||||
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
|
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
|
||||||
import {
|
import {
|
||||||
isPageMessage,
|
isPageMessage,
|
||||||
|
type HostGlobalState,
|
||||||
type HostSharedState,
|
type HostSharedState,
|
||||||
type PageCommand,
|
type PageCommand,
|
||||||
type RuntimeAction,
|
type RuntimeAction,
|
||||||
} from "../sdk/protocol";
|
} from "../sdk/protocol";
|
||||||
import { DialogLayer, type DialogState } from "./dialogs";
|
import { DialogLayer, type DialogState } from "./dialogs";
|
||||||
import { FrameCache, type FrameEntry } from "./frame-cache";
|
import { FrameCache, type FrameEntry } from "./frame-cache";
|
||||||
|
import { createBrowserHostTabSync, type HostTabSync } from "./tab-sync";
|
||||||
|
|
||||||
const NAVIGATION: Array<{
|
const NAVIGATION: Array<{
|
||||||
id: View;
|
id: View;
|
||||||
@@ -83,19 +87,24 @@ export default function HostApp() {
|
|||||||
const [dialogBusy, setDialogBusy] = createSignal(false);
|
const [dialogBusy, setDialogBusy] = createSignal(false);
|
||||||
const [toast, setToast] = createSignal<string | null>(null);
|
const [toast, setToast] = createSignal<string | null>(null);
|
||||||
const frameElements = new Map<View, HTMLIFrameElement>();
|
const frameElements = new Map<View, HTMLIFrameElement>();
|
||||||
|
let tabSync: HostTabSync | null = null;
|
||||||
|
let applyingSharedHostState = false;
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
let refreshGeneration = 0;
|
let refreshGeneration = 0;
|
||||||
|
|
||||||
const state = createMemo<HostSharedState>(() => ({
|
const globalState = createMemo<HostGlobalState>(() => ({
|
||||||
view: view(),
|
|
||||||
snapshot: snapshot(),
|
snapshot: snapshot(),
|
||||||
connection: connection(),
|
connection: connection(),
|
||||||
query: query(),
|
|
||||||
apiBase: apiBase(),
|
apiBase: apiBase(),
|
||||||
runtimeAction: runtimeAction(),
|
runtimeAction: runtimeAction(),
|
||||||
serviceAction: serviceAction(),
|
serviceAction: serviceAction(),
|
||||||
deploymentAction: deploymentAction(),
|
deploymentAction: deploymentAction(),
|
||||||
}));
|
}));
|
||||||
|
const state = createMemo<HostSharedState>(() => ({
|
||||||
|
...globalState(),
|
||||||
|
view: view(),
|
||||||
|
query: query(),
|
||||||
|
}));
|
||||||
const activeServices = createMemo(
|
const activeServices = createMemo(
|
||||||
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
||||||
);
|
);
|
||||||
@@ -108,17 +117,35 @@ export default function HostApp() {
|
|||||||
return { ...state(), view: targetView };
|
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() {
|
async function refresh() {
|
||||||
const generation = ++refreshGeneration;
|
const generation = ++refreshGeneration;
|
||||||
try {
|
try {
|
||||||
const next = await fetchSnapshot(apiBase());
|
const next = await fetchSnapshot(apiBase());
|
||||||
if (generation !== refreshGeneration) return;
|
if (generation !== refreshGeneration) return;
|
||||||
|
batch(() => {
|
||||||
setSnapshot(next);
|
setSnapshot(next);
|
||||||
setConnection("online");
|
setConnection("online");
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (generation !== refreshGeneration) return;
|
if (generation !== refreshGeneration) return;
|
||||||
|
batch(() => {
|
||||||
setSnapshot(null);
|
setSnapshot(null);
|
||||||
setConnection("offline");
|
setConnection("offline");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +317,10 @@ export default function HostApp() {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener("message", receive);
|
window.addEventListener("message", receive);
|
||||||
window.addEventListener("popstate", popState);
|
window.addEventListener("popstate", popState);
|
||||||
|
tabSync = createBrowserHostTabSync({
|
||||||
|
getState: globalState,
|
||||||
|
applyState: applySharedHostState,
|
||||||
|
});
|
||||||
void refresh();
|
void refresh();
|
||||||
const interval = window.setInterval(() => void refresh(), 5000);
|
const interval = window.setInterval(() => void refresh(), 5000);
|
||||||
onCleanup(() => window.clearInterval(interval));
|
onCleanup(() => window.clearInterval(interval));
|
||||||
@@ -298,6 +329,7 @@ export default function HostApp() {
|
|||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("message", receive);
|
window.removeEventListener("message", receive);
|
||||||
window.removeEventListener("popstate", popState);
|
window.removeEventListener("popstate", popState);
|
||||||
|
tabSync?.close();
|
||||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||||
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
|
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) {
|
async function register(file: File) {
|
||||||
setDialogBusy(true);
|
setDialogBusy(true);
|
||||||
try {
|
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 ServiceAction = "start" | "stop" | "restart";
|
||||||
export type PageLifecyclePhase = "activate" | "deactivate" | "dispose";
|
export type PageLifecyclePhase = "activate" | "deactivate" | "dispose";
|
||||||
|
|
||||||
export type HostSharedState = {
|
/**
|
||||||
// This is a serializable snapshot, never a Solid signal crossing realms.
|
* Control-plane state shared unconditionally between same-origin Host tabs.
|
||||||
view: View;
|
*
|
||||||
|
* 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;
|
snapshot: BackendSnapshot | null;
|
||||||
connection: ConnectionState;
|
connection: ConnectionState;
|
||||||
query: string;
|
|
||||||
apiBase: string;
|
apiBase: string;
|
||||||
runtimeAction: RuntimeAction | null;
|
runtimeAction: RuntimeAction | null;
|
||||||
serviceAction: string | null;
|
serviceAction: string | null;
|
||||||
deploymentAction: 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 =
|
export type PageCommand =
|
||||||
| { type: "refresh" }
|
| { type: "refresh" }
|
||||||
| { type: "navigate"; view: View }
|
| { 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