loopable / web /src /customer-grid /hostBridge.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
092334a verified
Raw
History Blame Contribute Delete
4.37 kB
import type { HostEvent } from "./types";
const READY = "streamlit:componentReady";
const RENDER = "streamlit:render";
const SET_VALUE = "streamlit:setComponentValue";
const SET_HEIGHT = "streamlit:setFrameHeight";
const HOST_RENDER_EVENT = "aios:grid-host-render";
type HostWindow = Window & {
__AIOS_STREAMLIT_ARGS__?: Record<string, unknown>;
};
function post(type: string, extra: Record<string, unknown>): void {
window.parent.postMessage(
{ isStreamlitMessage: true, type, ...extra },
"*"
);
}
export function isStreamlitComponent(): boolean {
if (typeof window === "undefined" || window.parent === window) return false;
return new URLSearchParams(window.location.search).has("streamlitUrl");
}
/**
* Minimal Streamlit Components v1 protocol. Keeping the adapter isolated makes
* the grid host-neutral and disposable when the surrounding Streamlit shell is.
*/
export function initializeHostBridge(): void {
if (!isStreamlitComponent()) return;
window.addEventListener("message", (event: MessageEvent) => {
const data = event.data as
| { type?: string; args?: Record<string, unknown> }
| undefined;
if (!data || data.type !== RENDER) return;
(window as HostWindow).__AIOS_STREAMLIT_ARGS__ = data.args ?? {};
window.dispatchEvent(new CustomEvent(HOST_RENDER_EVENT));
const height = Number(data.args?.height ?? 760);
post(SET_HEIGHT, { height: Number.isFinite(height) ? height : 760 });
});
post(READY, { apiVersion: 1 });
}
export function readHostArgs(): Record<string, unknown> | null {
return (window as HostWindow).__AIOS_STREAMLIT_ARGS__ ?? null;
}
export function subscribeHostRender(listener: () => void): () => void {
window.addEventListener(HOST_RENDER_EVENT, listener);
return () => window.removeEventListener(HOST_RENDER_EVENT, listener);
}
/**
* ⚠ The component value is an EVENT LOG, not the last event (2026-07-27).
*
* Streamlit gives a component exactly ONE value slot, and a rerun reads whatever is in it at
* that moment. Creating a field emits `field_upsert` and, 420ms later, the insertColumn
* autosave emits `view_upsert` β€” and when the server was still busy with the first rerun, the
* second write CLOBBERED the slot before anything read it. The field creation simply vanished:
* no error, menu closed, column gone on the next payload. (Measured live 2026-07-27; the same
* race sat latent under overlay-field creation, masked by localStorage keeping a local copy.)
*
* So every emit sends the RECENT WINDOW of events; the host processes each exactly once,
* keyed by the event's id (which is why every HostEvent carries one). The window is bounded β€”
* an id-deduped log needs only to be longer than the longest burst, and 24 is far past any
* real interaction burst.
*/
const PENDING: HostEvent[] = [];
/**
* EXIT wave 1 (X2): where an event goes when there is no Streamlit host.
*
* ⚠ A REGISTRATION HOOK, NOT AN IMPORT, and the direction is the point. This
* module is the STREAMLIT adapter β€” "disposable when the surrounding Streamlit
* shell is". Importing `apiBridge` from here would make the successor path
* reachable only THROUGH the doomed module and put a cycle between the two
* (apiBridge needs `isStreamlitComponent`). So the successor installs itself:
* `main.tsx` calls `installStandaloneBridge()` in standalone only, the embed
* never does, and when hostBridge is finally deleted the sink is simply what is
* left. Null until installed β€” an un-installed standalone drops events exactly
* as it always did, which is what keeps this change inert in the embed.
*/
type EventSink = (event: HostEvent) => boolean;
let standaloneSink: EventSink | null = null;
export function setStandaloneSink(sink: EventSink | null): void {
standaloneSink = sink;
}
export function emitHostEvent(event: HostEvent): boolean {
if (!isStreamlitComponent()) return standaloneSink ? standaloneSink(event) : false;
PENDING.push(event);
if (PENDING.length > 24) PENDING.shift();
post(SET_VALUE, { value: { events: [...PENDING] }, dataType: "json" });
return true;
}
export function eventId(prefix: string): string {
const cryptoId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
return `${prefix}:${cryptoId}`;
}