diff --git a/frontend/bin/serve-widgets.mjs b/frontend/bin/serve-widgets.mjs new file mode 100644 index 000000000000..793cea0a8732 --- /dev/null +++ b/frontend/bin/serve-widgets.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Tiny static server for the built widgets bundle (dist-widgets/) with permissive +// CORS — module scripts and their chunks are CORS-gated cross-origin, and host +// apps (PostHog Code desktop, harness pages) load from a different origin. +// +// node bin/serve-widgets.mjs [port] (default 8124) +import * as fs from 'node:fs' +import * as http from 'node:http' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const distDir = path.resolve(__dirname, '..', 'dist-widgets') +const port = Number(process.argv[2] || 8124) + +const MIME = { + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.css': 'text/css', + '.html': 'text/html', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.map': 'application/json', + '.wasm': 'application/wasm', +} + +http.createServer((req, res) => { + const urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname) + const effectivePath = urlPath === '/' || urlPath === '/harness.html' ? '/harness.html' : urlPath + // The harness lives in src/widgets (it survives emptyOutDir); everything else in dist-widgets. + const baseDir = effectivePath === '/harness.html' ? path.resolve(__dirname, '..', 'src', 'widgets') : distDir + let filePath = path.normalize(path.join(baseDir, effectivePath)) + if (!filePath.startsWith(baseDir)) { + res.writeHead(403) + res.end() + return + } + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404, { 'Access-Control-Allow-Origin': '*' }) + res.end('not found: ' + urlPath) + return + } + res.writeHead(200, { + 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache', + }) + res.end(data) + }) +}).listen(port, () => { + console.log(`[serve-widgets] http://localhost:${port} -> ${distDir}`) +}) diff --git a/frontend/src/lib/oauth/oauthClient.ts b/frontend/src/lib/oauth/oauthClient.ts index c4c8896e2979..07b509cb3c27 100644 --- a/frontend/src/lib/oauth/oauthClient.ts +++ b/frontend/src/lib/oauth/oauthClient.ts @@ -80,7 +80,30 @@ export function isOAuthMode(): boolean { return !!getStoredSession() } +// --------------------------------------------------------------------------- +// Embedded-widget session override (see frontend/src/widgets/). +// +// When PostHog UI is mounted as a widget inside another app (e.g. PostHog Code), +// the host app owns the tokens. It seeds an in-memory session here — never +// localStorage, so nothing persists in the host origin — plus a refresh callback +// that asks the host for a fresh token on 401. +// --------------------------------------------------------------------------- + +let sessionOverride: OAuthSession | null = null +let refreshOverride: (() => Promise) | null = null + +export function setOAuthSessionOverride( + session: OAuthSession | null, + refresh?: () => Promise +): void { + sessionOverride = session + refreshOverride = session ? (refresh ?? null) : null +} + export function getStoredSession(): OAuthSession | null { + if (sessionOverride) { + return sessionOverride + } try { const raw = window.localStorage.getItem(SESSION_KEY) return raw ? (JSON.parse(raw) as OAuthSession) : null @@ -193,6 +216,15 @@ export function refreshAccessToken(): Promise { } async function doRefresh(): Promise { + // Widget mode: the host app owns token refresh. + if (sessionOverride && refreshOverride) { + const accessToken = await refreshOverride() + if (accessToken) { + sessionOverride = { ...sessionOverride, accessToken, expiresAt: Date.now() + 10 * 60 * 1000 } + return accessToken + } + return null + } const session = getStoredSession() if (!session) { return null diff --git a/frontend/src/widgets/QueryEditorWidget.tsx b/frontend/src/widgets/QueryEditorWidget.tsx new file mode 100644 index 000000000000..36c6448c702e --- /dev/null +++ b/frontend/src/widgets/QueryEditorWidget.tsx @@ -0,0 +1,120 @@ +import { useValues } from 'kea' +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' +import root from 'react-shadow' + +import { FloatingContainerContext } from 'lib/hooks/useFloatingContainerContext' +import { Spinner } from 'lib/lemon-ui/Spinner' +import { teamLogic } from 'scenes/teamLogic' +import { userLogic } from 'scenes/userLogic' + +import { Query } from '~/queries/Query/Query' +import { InsightVizNode, Node } from '~/queries/schema/schema-general' + +import { WidgetInstanceStore } from './widgetStore' + +type HTMLElementWithShadowRoot = HTMLElement & { shadowRoot: ShadowRoot } + +/** Where the widget bundle was loaded from — used to locate the sibling widgets.css. */ +let assetBaseUrl: string | null = null +export function setWidgetAssetBaseUrl(url: string): void { + assetBaseUrl = url +} + +function useShadowStyles(shadowHost: HTMLElementWithShadowRoot | null): boolean { + const [didLoadStyles, setDidLoadStyles] = useState(false) + + useEffect(() => { + // Runs once the shadow root is attached. Mirrors ToolbarApp.tsx: the app CSS is + // built as a sibling file next to the JS bundle and linked into the shadow root. + const shadowRoot = shadowHost?.shadowRoot + if (!shadowRoot) { + return + } + const styleLink = document.createElement('link') + styleLink.rel = 'stylesheet' + styleLink.type = 'text/css' + styleLink.href = assetBaseUrl ? `${assetBaseUrl}widgets.css` : 'widgets.css' + styleLink.onload = () => setDidLoadStyles(true) + styleLink.onerror = () => { + // Render anyway — unstyled beats invisible, and it surfaces the misconfiguration. + console.warn('[PostHogWidgets] Failed to load widgets.css from', styleLink.href) + setDidLoadStyles(true) + } + shadowRoot.appendChild(styleLink) + return () => { + styleLink.remove() + } + }, [shadowHost]) + + return didLoadStyles +} + +/** Renders the editable Query component once the global logics have hydrated. */ +function QueryEditorBody({ store }: { store: WidgetInstanceStore }): JSX.Element { + const state = useSyncExternalStore(store.subscribe, store.get) + const { user } = useValues(userLogic) + const { currentTeam } = useValues(teamLogic) + + const query = state.query as Node + + const setQuery = useMemo(() => { + return (nextQuery: Node) => { + // Round-trip through JSON so hosts always receive plain serializable data. + const plain = JSON.parse(JSON.stringify(nextQuery)) + store.setQuery(plain) + store.get().onQueryChange?.(plain) + } + }, [store]) + + if (!user || !currentTeam) { + return ( +
+ + Connecting to PostHog… +
+ ) + } + + return ( + + query={query as InsightVizNode} + setQuery={setQuery as (query: InsightVizNode, isSourceUpdate?: boolean) => void} + readOnly={false} + editMode + /> + ) +} + +export function QueryEditorWidget({ store }: { store: WidgetInstanceStore }): JSX.Element { + const state = useSyncExternalStore(store.subscribe, store.get) + const [shadowHost, setShadowHost] = useState(null) + const didLoadStyles = useShadowStyles(shadowHost) + const [floatingContainer, setFloatingContainer] = useState(null) + + // The `theme` attribute drives PostHog's dark-mode CSS ([theme='dark'] selectors). + // It must live INSIDE the shadow root — an attribute on the outer document does not + // penetrate the shadow boundary. + const themeProps = { theme: state.theme } + + return ( + + {shadowHost && didLoadStyles ? ( + +
+ + {/* Popovers, tooltips and modals portal here so they stay inside the shadow root. */} +
+
+ + ) : null} + + ) +} diff --git a/frontend/src/widgets/harness.html b/frontend/src/widgets/harness.html new file mode 100644 index 000000000000..4f7eed5115d9 --- /dev/null +++ b/frontend/src/widgets/harness.html @@ -0,0 +1,105 @@ + + + + + PostHog Widgets harness + + + +

PostHog Widgets harness — mountQueryEditor

+
+ + + + + + +
+
+

onQueryChange output

+ + + + diff --git a/frontend/src/widgets/index.tsx b/frontend/src/widgets/index.tsx new file mode 100644 index 000000000000..1b534fecd8bf --- /dev/null +++ b/frontend/src/widgets/index.tsx @@ -0,0 +1,142 @@ +// PostHog embeddable widgets entry. +// +// Loads as a standalone ESM bundle in a FOREIGN document (e.g. the PostHog Code +// desktop app, which runs React 19 — a separate React copy and kea context live +// inside this bundle, rendered into a shadow root, exactly like the toolbar). +// +// const { mountQueryEditor } = window.PostHogWidgets +// const handle = mountQueryEditor(el, { query, onQueryChange, apiHost, getAccessToken, theme }) +// handle.update({ query, theme }) +// handle.unmount() +import '~/styles' +import './widgets.scss' + +import { createRoot, Root } from 'react-dom/client' + +import { setOAuthSessionOverride } from 'lib/oauth/oauthClient' +import { teamLogic } from 'scenes/teamLogic' +import { userLogic } from 'scenes/userLogic' + +import { initKea } from '~/initKea' +import { ErrorBoundary } from '~/layout/ErrorBoundary' + +import { QueryEditorWidget, setWidgetAssetBaseUrl } from './QueryEditorWidget' +import { MountQueryEditorOptions, QueryEditorWidgetHandle } from './types' +import { WidgetInstanceStore } from './widgetStore' + +// Never send telemetry from embedded widgets: posthog-js is not initialized here, +// and `posthog.capture`/`captureException` calls downstream become no-ops. +;(window as any).JS_POSTHOG_API_KEY = undefined + +// The CSS sibling (widgets.css) is resolved relative to this module's URL. +// NOTE: deliberately NOT `new URL('./', import.meta.url)` — Vite statically +// rewrites that pattern into an emitted-asset URL at build time. +const moduleUrl: string = import.meta.url +setWidgetAssetBaseUrl(moduleUrl.slice(0, moduleUrl.lastIndexOf('/') + 1)) + +let keaInitialized = false + +/** + * One-time bootstrap shared by all widget mounts in this document: + * - seed the in-memory OAuth session so lib/api targets `apiHost` with a bearer token + * - initialize the kea context (kea is a singleton per JS realm — one context, keyed logics) + */ +function ensureBooted(options: MountQueryEditorOptions): void { + const initialToken = options.personalApiKey ?? null + + setOAuthSessionOverride( + { + backendHost: options.apiHost.replace(/\/+$/, ''), + clientId: 'posthog-widgets-embedded', + accessToken: initialToken ?? '', + refreshToken: '', + expiresAt: Date.now() + 10 * 60 * 1000, + }, + options.getAccessToken + ) + + if (options.getAccessToken && !initialToken) { + // Fetch the real token ASAP; requests racing ahead of it will 401 once and + // then be retried through the refresh path (which calls getAccessToken). + void options.getAccessToken().then((token) => { + if (token) { + setOAuthSessionOverride( + { + backendHost: options.apiHost.replace(/\/+$/, ''), + clientId: 'posthog-widgets-embedded', + accessToken: token, + refreshToken: '', + expiresAt: Date.now() + 10 * 60 * 1000, + }, + options.getAccessToken + ) + } + }) + } + + if (!keaInitialized) { + // replaceInitialPathInWindow: false — never touch the host app's URL. + initKea({ replaceInitialPathInWindow: false }) + keaInitialized = true + } + + if (options.__unsafeMockContext) { + const mock = options.__unsafeMockContext + userLogic.mount() + teamLogic.mount() + // afterMount kicks off loadUser()/loadCurrentTeam(), which fail without + // credentials and overwrite values with null — re-assert the mocks until + // those in-flight loads have settled. Harness-only, so a timer is fine. + const assertMocks = (): void => { + if (!userLogic.values.user) { + userLogic.actions.loadUserSuccess(mock.user as any) + } + if (!teamLogic.values.currentTeam) { + teamLogic.actions.loadCurrentTeamSuccess(mock.team as any) + } + } + assertMocks() + setTimeout(assertMocks, 1000) + setTimeout(assertMocks, 3000) + } +} + +export function mountQueryEditor(el: HTMLElement, options: MountQueryEditorOptions): QueryEditorWidgetHandle { + ensureBooted(options) + + const store = new WidgetInstanceStore(options) + const container = document.createElement('div') + container.style.display = 'contents' + el.appendChild(container) + + const reactRoot: Root = createRoot(container) + reactRoot.render( + + + + ) + + return { + update(partial) { + store.update({ + ...(partial.query !== undefined ? { query: partial.query } : {}), + ...(partial.onQueryChange !== undefined ? { onQueryChange: partial.onQueryChange } : {}), + ...(partial.theme !== undefined ? { theme: partial.theme } : {}), + }) + }, + unmount() { + reactRoot.unmount() + container.remove() + }, + } +} + +declare global { + interface Window { + PostHogWidgets?: { + mountQueryEditor: typeof mountQueryEditor + } + } +} + +window.PostHogWidgets = { mountQueryEditor } diff --git a/frontend/src/widgets/types.ts b/frontend/src/widgets/types.ts new file mode 100644 index 000000000000..4bc4992294c6 --- /dev/null +++ b/frontend/src/widgets/types.ts @@ -0,0 +1,37 @@ +import { Node } from '~/queries/schema/schema-general' + +/** Options accepted by `window.PostHogWidgets.mountQueryEditor`. */ +export interface MountQueryEditorOptions { + /** The query node to edit (e.g. an InsightVizNode, or a bare source node). Plain JSON. */ + query: Node | Record + /** Called with the full updated query node (plain JSON) on every edit. */ + onQueryChange?: (query: Node) => void + /** Absolute API host, e.g. "https://us.posthog.com". */ + apiHost: string + /** + * Async access-token source. Called once at mount for the initial token and again + * whenever a request 401s (the host should refresh and return a new token). + * Preferred over `personalApiKey`. + */ + getAccessToken?: () => Promise + /** Static token fallback (personal API key or OAuth access token). */ + personalApiKey?: string + /** Theme; defaults to "light". */ + theme?: 'light' | 'dark' + /** Optional callback for a "close" affordance rendered by the widget shell. */ + onClose?: () => void + /** + * HARNESS/TEST ONLY: seed userLogic/teamLogic with fixed data instead of loading + * them from the API. Lets the editor UI render without credentials (all taxonomy / + * query requests will still fail). Never use in production hosts. + */ + __unsafeMockContext?: { + user: Record + team: Record + } +} + +export interface QueryEditorWidgetHandle { + update(props: Partial>): void + unmount(): void +} diff --git a/frontend/src/widgets/widgetStore.ts b/frontend/src/widgets/widgetStore.ts new file mode 100644 index 000000000000..b493c5c72c93 --- /dev/null +++ b/frontend/src/widgets/widgetStore.ts @@ -0,0 +1,45 @@ +import { Node } from '~/queries/schema/schema-general' + +import { MountQueryEditorOptions } from './types' + +export interface WidgetInstanceState { + query: Node | Record + onQueryChange?: (query: Node) => void + theme: 'light' | 'dark' + onClose?: () => void +} + +/** Tiny external store so `handle.update(props)` re-renders without recreating the React root. */ +export class WidgetInstanceStore { + private state: WidgetInstanceState + private listeners = new Set<() => void>() + + constructor(options: MountQueryEditorOptions) { + this.state = { + query: options.query, + onQueryChange: options.onQueryChange, + theme: options.theme ?? 'light', + onClose: options.onClose, + } + } + + get = (): WidgetInstanceState => this.state + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + setQuery(query: Node): void { + // The Query component is controlled (we pass setQuery), so edits must flow + // back down through a re-render with the new query prop. + this.update({ query }) + } + + update(partial: Partial): void { + this.state = { ...this.state, ...partial } + for (const listener of this.listeners) { + listener() + } + } +} diff --git a/frontend/src/widgets/widgets.scss b/frontend/src/widgets/widgets.scss new file mode 100644 index 000000000000..c979cfa2d398 --- /dev/null +++ b/frontend/src/widgets/widgets.scss @@ -0,0 +1,33 @@ +// Shadow-root skin for embedded widgets — mirrors toolbar/styles.scss. +// base.scss declares the design tokens on `:root, :host`, so importing it here +// makes every --color-* / semantic var resolve INSIDE the shadow root. +@import '../styles/base'; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +:host { + // Isolate from the host page's cascade, then re-establish our own baseline. + all: initial; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + color: var(--color-text-primary); + display: block; +} + +// Re-evaluate text color within themed containers so dark mode descendants +// inherit the correct color instead of the light-mode value computed on :host. +// No background here: the [theme] attribute also sits on the full-viewport +// floating (popover) container, which must stay transparent. +[theme] { + color: var(--color-text-primary); +} + +.posthog-widget-frame { + // The InsightViz layout expects a bounded flex column. + max-width: 100%; +} diff --git a/frontend/vite.config.mts b/frontend/vite.config.mts index 6f46c4bb9d8f..3e5f001b8b4b 100644 --- a/frontend/vite.config.mts +++ b/frontend/vite.config.mts @@ -86,6 +86,11 @@ export default defineConfig(({ mode }) => { exporter: resolve(__dirname, 'src/exporter/index.tsx'), render_query: resolve(__dirname, 'src/render-query/index.tsx'), toolbar: resolve(__dirname, 'src/toolbar/index.tsx'), + // Embeddable widgets (shadow-root, own React+kea) for host apps like + // PostHog Code. The self-contained bundle builds via + // vite.widgets.config.mts; this entry keeps the module in the main + // graph for typechecking/linting. + widgets: resolve(__dirname, 'src/widgets/index.tsx'), }, output: { entryFileNames: isDev ? '[name].js' : '[name]-[hash].js', diff --git a/frontend/vite.widgets.config.mts b/frontend/vite.widgets.config.mts new file mode 100644 index 000000000000..990662e46dc9 --- /dev/null +++ b/frontend/vite.widgets.config.mts @@ -0,0 +1,43 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +import baseConfigFactory from './vite.config.mts' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +// Standalone build for the embeddable widgets bundle (src/widgets/index.tsx). +// +// Produces dist-widgets/widgets.js (ESM entry, code-split chunks alongside) and +// dist-widgets/widgets.css (single stylesheet the widget links into its shadow +// root). Serve the directory statically (bin/serve-widgets.mjs) and load +// widgets.js as a module script from any host app. +// +// pnpm --filter=@posthog/frontend exec vite build --config vite.widgets.config.mts +export default defineConfig((env) => { + const base = baseConfigFactory(env) as Record + + return { + ...base, + plugins: base.plugins, + build: { + outDir: 'dist-widgets', + emptyOutDir: true, + manifest: false, + sourcemap: false, + // One stylesheet for the whole bundle — the shadow root links it once. + cssCodeSplit: false, + rollupOptions: { + input: { widgets: resolve(__dirname, 'src/widgets/index.tsx') }, + output: { + entryFileNames: 'widgets.js', + chunkFileNames: 'chunks/[name]-[hash].js', + assetFileNames: (assetInfo: { names?: string[] }) => { + const name = assetInfo.names?.[0] ?? '' + return name.endsWith('.css') ? 'widgets.css' : 'assets/[name]-[hash][extname]' + }, + }, + }, + }, + } +})