From 024209c44948f91d4d46f0d37d3f242f409c562b Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 15 Jul 2026 14:28:53 +0200 Subject: [PATCH 1/3] feat(embedded-app): render the real PostHog webapp in-app via local auth proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EXPERIMENT: renders the cloud webapp inside an iframe, isolated from the React 19 host tree. - EmbeddedAppProxyService (workspace-server, bound in main like AuthProxy): serves an HTML shell booting the webapp's `embed` vite entry, and proxies /api, /_preflight, /uploaded_media, /static to PostHog cloud with a fresh OAuth Bearer token from AuthService — the iframe sees a same-origin API, no CORS, no credentials in the renderer. - host-router embeddedApp.getUrl starts the proxy lazily and returns its URL. - packages/ui PostHogApp component + /embedded-app route: iframe plus postMessage bridge (navigate, setTheme, ready, routeChanged) synced to the host theme store. - bootstrap: POSTHOG_CODE_APP_NAME env (dev only) isolates userData and the single-instance lock so a second dev instance can run beside another checkout's app. Co-Authored-By: Claude Fable 5 --- apps/code/src/main/bootstrap.ts | 6 +- apps/code/src/main/di/bindings.ts | 6 + apps/code/src/main/di/container.ts | 15 + apps/code/src/main/trpc/router.ts | 2 + packages/host-router/src/router.ts | 2 + .../src/routers/embedded-app.router.ts | 15 + .../features/embedded-app/EmbeddedAppView.tsx | 139 ++++++++ packages/ui/src/router/routeTree.gen.ts | 21 ++ .../ui/src/router/routes/embedded-app.tsx | 11 + .../embedded-app-proxy.module.ts | 9 + .../embedded-app-proxy/embedded-app-proxy.ts | 326 ++++++++++++++++++ .../embedded-app-proxy/identifiers.ts | 13 + 12 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 packages/host-router/src/routers/embedded-app.router.ts create mode 100644 packages/ui/src/features/embedded-app/EmbeddedAppView.tsx create mode 100644 packages/ui/src/router/routes/embedded-app.tsx create mode 100644 packages/workspace-server/src/services/embedded-app-proxy/embedded-app-proxy.module.ts create mode 100644 packages/workspace-server/src/services/embedded-app-proxy/embedded-app-proxy.ts create mode 100644 packages/workspace-server/src/services/embedded-app-proxy/identifiers.ts diff --git a/apps/code/src/main/bootstrap.ts b/apps/code/src/main/bootstrap.ts index 87f850cb82..f9c7ec5cca 100644 --- a/apps/code/src/main/bootstrap.ts +++ b/apps/code/src/main/bootstrap.ts @@ -37,7 +37,11 @@ if (shouldRefuseInternalChildBoot(app.isPackaged, process.env)) { const isDev = !app.isPackaged; // Set app name for single-instance lock, crashReporter, etc -const appName = isDev ? "posthog-code-dev" : "posthog-code"; +// POSTHOG_CODE_APP_NAME (dev only) isolates userData + the single-instance +// lock so a second dev instance can run beside another checkout's app. +const appName = isDev + ? (process.env.POSTHOG_CODE_APP_NAME ?? "posthog-code-dev") + : "posthog-code"; app.setName(isDev ? "PostHog Code (Development)" : "PostHog Code"); // Set userData path for @posthog/code diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index 1c3db763fa..5632515d8b 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -149,6 +149,10 @@ import type { } from "@posthog/workspace-server/services/archive/ports"; import type { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers"; import type { AuthProxyAuth } from "@posthog/workspace-server/services/auth-proxy/ports"; +import type { + EMBEDDED_APP_PROXY_AUTH, + EmbeddedAppProxyAuth, +} from "@posthog/workspace-server/services/embedded-app-proxy/identifiers"; import type { ENRICHMENT_AUTH, ENRICHMENT_FILE_READER, @@ -361,6 +365,8 @@ export interface MainBindings { // Auth proxy / mcp proxy [AUTH_PROXY_AUTH]: AuthProxyAuth; [MCP_PROXY_AUTH]: McpProxyAuth; + // EXPERIMENT (embedded webapp) + [EMBEDDED_APP_PROXY_AUTH]: EmbeddedAppProxyAuth; // Archive / suspension host ports [ARCHIVE_SESSION_CANCELLER]: SessionCanceller; diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index e620dce39d..e4c6c337ed 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -119,6 +119,7 @@ import { STORAGE_PATHS_SERVICE } from "@posthog/platform/storage-paths"; import { UPDATER_SERVICE } from "@posthog/platform/updater"; import { URL_LAUNCHER_SERVICE } from "@posthog/platform/url-launcher"; import { WORKSPACE_SETTINGS_SERVICE } from "@posthog/platform/workspace-settings"; +import { getCloudUrlFromRegion } from "@posthog/shared"; import type { WorkspaceClient } from "@posthog/workspace-client/client"; import { databaseModule } from "@posthog/workspace-server/db/db.module"; import { @@ -155,6 +156,8 @@ import { authProxyModule } from "@posthog/workspace-server/services/auth-proxy/a import { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers"; import { browserTabsModule } from "@posthog/workspace-server/services/browser-tabs/browser-tabs.module"; import { claudeCliSessionsModule } from "@posthog/workspace-server/services/claude-cli-sessions/claude-cli-sessions.module"; +import { embeddedAppProxyModule } from "@posthog/workspace-server/services/embedded-app-proxy/embedded-app-proxy.module"; +import { EMBEDDED_APP_PROXY_AUTH } from "@posthog/workspace-server/services/embedded-app-proxy/identifiers"; import { enrichmentModule } from "@posthog/workspace-server/services/enrichment/enrichment.module"; import { ENRICHMENT_AUTH, @@ -380,6 +383,18 @@ container.bind(AUTH_PROXY_AUTH).toDynamicValue((ctx) => ({ .get(MAIN_AUTH_SERVICE) .authenticatedFetch(fetch, url, init), })); +// EXPERIMENT (embedded webapp): local same-origin proxy for the iframe'd app +container.load(embeddedAppProxyModule); +container.bind(EMBEDDED_APP_PROXY_AUTH).toDynamicValue((ctx) => ({ + authenticatedFetch: (url: string, init?: RequestInit) => + ctx + .get(MAIN_AUTH_SERVICE) + .authenticatedFetch(fetch, url, init), + getUpstreamUrl: async () => { + const state = ctx.get(MAIN_AUTH_SERVICE).getState(); + return getCloudUrlFromRegion(state.cloudRegion ?? "us"); + }, +})); container.load(mcpProxyModule); container.bind(MCP_PROXY_AUTH).toDynamicValue((ctx) => { const auth = () => ctx.get(MAIN_AUTH_SERVICE); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index c6bd789ffe..6d963c38c8 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -15,6 +15,7 @@ import { connectivityRouter } from "@posthog/host-router/routers/connectivity.ro import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.router"; import { dashboardsRouter } from "@posthog/host-router/routers/dashboards.router"; import { deepLinkRouter } from "@posthog/host-router/routers/deep-link.router"; +import { embeddedAppRouter } from "@posthog/host-router/routers/embedded-app.router"; import { enrichmentRouter } from "@posthog/host-router/routers/enrichment.router"; import { environmentRouter } from "@posthog/host-router/routers/environment.router"; import { externalAppsRouter } from "@posthog/host-router/routers/external-apps.router"; @@ -72,6 +73,7 @@ export const trpcRouter = router({ contextMenu: contextMenuRouter, dev: devRouter, discordPresence: discordPresenceRouter, + embeddedApp: embeddedAppRouter, enrichment: enrichmentRouter, environment: environmentRouter, encryption: encryptionRouter, diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index 71f787c4c2..577dcf7dda 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -14,6 +14,7 @@ import { connectivityRouter } from "./routers/connectivity.router"; import { contextMenuRouter } from "./routers/context-menu.router"; import { dashboardsRouter } from "./routers/dashboards.router"; import { deepLinkRouter } from "./routers/deep-link.router"; +import { embeddedAppRouter } from "./routers/embedded-app.router"; import { enrichmentRouter } from "./routers/enrichment.router"; import { environmentRouter } from "./routers/environment.router"; import { externalAppsRouter } from "./routers/external-apps.router"; @@ -64,6 +65,7 @@ export const hostRouter = router({ contextMenu: contextMenuRouter, dashboards: dashboardsRouter, deepLink: deepLinkRouter, + embeddedApp: embeddedAppRouter, enrichment: enrichmentRouter, environment: environmentRouter, externalApps: externalAppsRouter, diff --git a/packages/host-router/src/routers/embedded-app.router.ts b/packages/host-router/src/routers/embedded-app.router.ts new file mode 100644 index 0000000000..529068324e --- /dev/null +++ b/packages/host-router/src/routers/embedded-app.router.ts @@ -0,0 +1,15 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { EmbeddedAppProxyService } from "@posthog/workspace-server/services/embedded-app-proxy/embedded-app-proxy"; +import { EMBEDDED_APP_PROXY_SERVICE } from "@posthog/workspace-server/services/embedded-app-proxy/identifiers"; +import { z } from "zod"; + +/** EXPERIMENT (embedded webapp): expose the local proxy URL to the renderer. */ +export const embeddedAppRouter = router({ + getUrl: publicProcedure + .output(z.object({ url: z.string() })) + .query(async ({ ctx }) => ({ + url: await ctx.container + .get(EMBEDDED_APP_PROXY_SERVICE) + .ensureStarted(), + })), +}); diff --git a/packages/ui/src/features/embedded-app/EmbeddedAppView.tsx b/packages/ui/src/features/embedded-app/EmbeddedAppView.tsx new file mode 100644 index 0000000000..b48e1fef6f --- /dev/null +++ b/packages/ui/src/features/embedded-app/EmbeddedAppView.tsx @@ -0,0 +1,139 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { Button } from "@posthog/quill"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useThemeStore } from "../../shell/themeStore"; + +/** + * EXPERIMENT (embedded webapp): renders the real PostHog webapp in an iframe. + * + * The iframe points at a local main-process proxy that serves the webapp + * shell and forwards /api to PostHog cloud with the app's OAuth token, so + * the webapp sees a same-origin cookie-less API. A postMessage bridge keeps + * navigation and theme in sync (see frontend/src/embed/bridge.ts in the + * posthog repo). + */ + +const FROM_EMBED = "posthog-embed"; +const FROM_HOST = "posthog-embed-host"; + +const QUICK_LINKS: Array<{ label: string; path: string }> = [ + { label: "Notebooks", path: "/notebooks" }, + { label: "Feature flags", path: "/feature_flags" }, + { label: "Dashboards", path: "/dashboard" }, + { label: "Activity", path: "/activity/explore" }, +]; + +interface PostHogAppProps { + /** Initial webapp path, e.g. "/notebooks" */ + url: string; +} + +export function PostHogApp({ url: initialPath }: PostHogAppProps) { + const trpc = useHostTRPC(); + const { data, error, isLoading } = useQuery( + trpc.embeddedApp.getUrl.queryOptions(undefined, { + staleTime: Number.POSITIVE_INFINITY, + retry: 1, + }), + ); + const isDarkMode = useThemeStore((s) => s.isDarkMode); + const iframeRef = useRef(null); + const [embedRoute, setEmbedRoute] = useState(null); + const [ready, setReady] = useState(false); + + // Compute the iframe src once per proxy URL: theme changes and navigation + // afterwards go over the bridge instead of reloading the iframe. + const initialThemeRef = useRef(isDarkMode ? "dark" : "light"); + const iframeSrc = useMemo(() => { + if (!data?.url) return null; + return `${data.url}${initialPath}?__posthog_embed_theme=${initialThemeRef.current}`; + }, [data?.url, initialPath]); + + const postToEmbed = useCallback((message: Record) => { + iframeRef.current?.contentWindow?.postMessage( + { source: FROM_HOST, ...message }, + "*", + ); + }, []); + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const data: unknown = event.data; + if ( + !data || + typeof data !== "object" || + (data as Record).source !== FROM_EMBED || + event.source !== iframeRef.current?.contentWindow + ) { + return; + } + const message = data as { type?: string; url?: unknown }; + if (message.type === "ready") { + setReady(true); + if (typeof message.url === "string") setEmbedRoute(message.url); + } else if ( + message.type === "routeChanged" && + typeof message.url === "string" + ) { + setEmbedRoute(message.url); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + // Host theme wins: push it whenever it changes (and once the embed is ready). + useEffect(() => { + if (ready) { + postToEmbed({ type: "setTheme", theme: isDarkMode ? "dark" : "light" }); + } + }, [ready, isDarkMode, postToEmbed]); + + const navigate = useCallback( + (path: string) => postToEmbed({ type: "navigate", url: path }), + [postToEmbed], + ); + + if (isLoading) { + return ( +
+ Starting embedded PostHog… +
+ ); + } + if (error || !iframeSrc) { + return ( +
+ Could not start the embedded PostHog proxy + {error ? `: ${String(error)}` : ""} +
+ ); + } + + return ( +
+
+ {QUICK_LINKS.map((link) => ( + + ))} + + {ready ? (embedRoute ?? "") : "connecting…"} + +
+