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:
Maofeng
2026-07-30 09:57:06 +08:00
parent 93f4a505ba
commit 88a7fba0b0
4 changed files with 338 additions and 11 deletions
@@ -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,
};
}