Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions frontend/bin/serve-widgets.mjs
Original file line number Diff line number Diff line change
@@ -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}`)
})
32 changes: 32 additions & 0 deletions frontend/src/lib/oauth/oauthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>) | null = null

export function setOAuthSessionOverride(
session: OAuthSession | null,
refresh?: () => Promise<string | null>
): 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
Expand Down Expand Up @@ -193,6 +216,15 @@ export function refreshAccessToken(): Promise<string | null> {
}

async function doRefresh(): Promise<string | null> {
// 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
Expand Down
120 changes: 120 additions & 0 deletions frontend/src/widgets/QueryEditorWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-center p-8 gap-2 text-secondary">
<Spinner />
<span>Connecting to PostHog…</span>
</div>
)
}

return (
<Query<InsightVizNode>
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<HTMLElementWithShadowRoot | null>(null)
const didLoadStyles = useShadowStyles(shadowHost)
const [floatingContainer, setFloatingContainer] = useState<HTMLDivElement | null>(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 (
<root.div id="posthog-widgets-query-editor" ref={setShadowHost as any} mode="open">
{shadowHost && didLoadStyles ? (
<FloatingContainerContext.Provider value={floatingContainer}>
<div
{...themeProps}
className="posthog-widget-frame bg-primary text-primary"
style={{ display: 'flex', flexDirection: 'column', minHeight: 0, height: '100%' }}
>
<QueryEditorBody store={store} />
{/* Popovers, tooltips and modals portal here so they stay inside the shadow root. */}
<div
ref={setFloatingContainer}
{...themeProps}
className="fixed inset-0 pointer-events-none z-[2147483000] [&>*]:pointer-events-auto"
/>
</div>
</FloatingContainerContext.Provider>
) : null}
</root.div>
)
}
105 changes: 105 additions & 0 deletions frontend/src/widgets/harness.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>PostHog Widgets harness</title>
<style>
body { font-family: sans-serif; margin: 0; padding: 16px; background: #f3f4ef; }
#controls { margin-bottom: 12px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
#mount { border: 1px dashed #999; min-height: 400px; background: white; }
#query-out { width: 100%; height: 160px; font-family: monospace; font-size: 11px; }
input { width: 340px; }
</style>
</head>
<body>
<h3>PostHog Widgets harness — mountQueryEditor</h3>
<div id="controls">
<label>API host <input id="host" value="https://us.posthog.com" /></label>
<label>Token (personal API key or OAuth access token) <input id="token" type="password" /></label>
<label>Theme
<select id="theme"><option>light</option><option>dark</option></select>
</label>
<button id="mount-btn">Mount</button>
<button id="mount-mock-btn">Mount (mock context)</button>
<button id="unmount-btn">Unmount</button>
</div>
<div id="mount"></div>
<h4>onQueryChange output</h4>
<textarea id="query-out" readonly></textarea>
<script type="module">
import './widgets.js'

const q = new URLSearchParams(location.hash.slice(1))
if (q.get('token')) document.getElementById('token').value = q.get('token')
if (q.get('host')) document.getElementById('host').value = q.get('host')

const initialQuery = {
kind: 'InsightVizNode',
source: {
kind: 'TrendsQuery',
series: [{ kind: 'EventsNode', event: '$pageview', name: '$pageview', math: 'total' }],
dateRange: { date_from: '-7d' },
interval: 'day',
trendsFilter: { display: 'ActionsLineGraph' },
},
}

// Record every API request the editor makes (endpoint catalogue deliverable).
window.__apiLog = []
const origFetch = window.fetch
window.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.url
const response = await origFetch(input, init)
if (url.includes('/api/') || url.includes('posthog.com')) {
window.__apiLog.push({ method: init?.method || 'GET', url, status: response.status })
}
return response
}

const mockContext = {
user: {
uuid: 'mock-user', distinct_id: 'mock', first_name: 'Mock', email: 'mock@example.com',
anonymize_data: true, realm: 'cloud', is_staff: false,
organization: {
id: 'mock-org', name: 'Mock Org', available_product_features: [],
teams: [{ id: 2, name: 'Mock Team' }], membership_level: 15,
},
team: { id: 2, project_id: 2, name: 'Mock Team', api_token: 'phc_mock' },
},
team: {
id: 2, project_id: 2, uuid: 'mock-team', name: 'Mock Team', api_token: 'phc_mock',
timezone: 'UTC', week_start_day: 0,
test_account_filters: [], test_account_filters_default_checked: false,
group_types: [], has_group_types: false,
person_display_name_properties: [], live_events_token: '',
completed_snippet_onboarding: true, ingested_event: true,
},
}

let handle = null
const doMount = (mock) => {
if (handle) return
handle = window.PostHogWidgets.mountQueryEditor(document.getElementById('mount'), {
query: initialQuery,
apiHost: document.getElementById('host').value,
personalApiKey: document.getElementById('token').value || undefined,
theme: document.getElementById('theme').value,
onQueryChange: (query) => {
document.getElementById('query-out').value = JSON.stringify(query, null, 2)
window.__lastQuery = query
console.log('[harness] onQueryChange', query)
},
...(mock ? { __unsafeMockContext: mockContext } : {}),
})
window.__handle = handle
}
document.getElementById('mount-btn').onclick = () => doMount(false)
document.getElementById('mount-mock-btn').onclick = () => doMount(true)
document.getElementById('unmount-btn').onclick = () => {
handle?.unmount()
handle = null
}
document.getElementById('theme').onchange = (e) => handle?.update({ theme: e.target.value })
</script>
</body>
</html>
Loading
Loading