Skip to content

Commit 1fc9b2f

Browse files
feat(web): cloud-tasks browser host (#3447)
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com>
1 parent f2b86c6 commit 1fc9b2f

43 files changed

Lines changed: 4187 additions & 576 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,32 @@ jobs:
177177
path: apps/code/playwright-report/
178178
retention-days: 7
179179

180+
# Web host e2e: a plain SPA over the Vite dev server (Playwright starts it),
181+
# no Electron packaging. Reuses the cached Chromium above. Runs even if the
182+
# desktop suite failed so both hosts report independently.
183+
- name: Run web E2E smoke tests
184+
if: ${{ !cancelled() }}
185+
run: pnpm run test:e2e:web
186+
env:
187+
CI: true
188+
189+
- name: Upload web test results to Trunk
190+
if: ${{ !cancelled() }}
191+
continue-on-error: true
192+
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
193+
with:
194+
junit-paths: "apps/web/tests/e2e/junit.xml"
195+
org-slug: posthog-inc
196+
token: ${{ secrets.TRUNK_API_TOKEN }}
197+
198+
- name: Upload web Playwright report
199+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
200+
if: failure()
201+
with:
202+
name: web-playwright-report
203+
path: apps/web/playwright-report/
204+
retention-days: 7
205+
180206
e2e:
181207
# Live-model e2e for the @posthog/agent adapters (claude + codex). Runs only
182208
# after the unit + integration jobs pass — a red tree never reaches the

apps/web/README.md

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# @posthog/web
2+
3+
Browser host for PostHog Code. Boots the same `@posthog/ui` shell and
4+
`@posthog/core` services as the desktop app, with web platform adapters —
5+
no Electron, no local workspace-server. Scope today: auth + cloud tasks
6+
(local workspaces, terminal, and local git need a workspace backend and are
7+
out of scope for the web host's first iteration).
8+
9+
## Run
10+
11+
```bash
12+
pnpm --filter @posthog/web dev # Vite on http://localhost:5273
13+
```
14+
15+
No separate backend process: the host router slice runs in the browser
16+
(`web-host-router.ts`), served over tRPC's `unstable_localLink`
17+
(`web-trpc.ts`). `auth`, `cloudTask`, and `analytics` are the real routers
18+
backed by in-browser `AuthService` / `CloudTaskService` (both are
19+
host-agnostic core code); the rest are stubs that return benign empties.
20+
Procedures outside the slice fail with NOT_FOUND at call time — that is the
21+
to-do list for widening the web surface.
22+
23+
## Testing
24+
25+
```bash
26+
pnpm --filter @posthog/web test:e2e # Playwright: happy-path browser e2e
27+
```
28+
29+
- **`tests/e2e/`** drives stock Chromium against the Vite dev server (Playwright
30+
starts it). Scope is the hermetic happy path up to the OAuth wall — boot,
31+
container wiring, onboarding → sign-in card, and the `/callback` relay — since
32+
real login needs PostHog cloud and a popup IdP. Runs in CI (`test.yml`),
33+
reusing the desktop suite's cached Chromium.
34+
- The boot spec doubles as the host-capability guard: `web-container.ts` runs
35+
`assertHostCapabilities(REQUIRED_HOST_CAPABILITIES)` at container load, so an
36+
unbound capability throws before the app mounts and fails the boot spec. (A
37+
jsdom unit test that imported the whole composition root was tried but dropped
38+
— evaluating the entire app graph took ~30s+ per run, and the e2e already
39+
covers it.) The capability *mechanism* is unit-tested in
40+
`@posthog/di` (`hostCapabilities.test.ts`).
41+
42+
## Auth
43+
44+
`WebOAuthFlowService` (`web-oauth-flow.ts`) implements the core
45+
`IAuthOAuthFlowService` with a browser PKCE flow: popup to
46+
`{cloud}/oauth/authorize`, redirect back to `{origin}/callback`, code relayed
47+
to the opener tab over a BroadcastChannel, token exchange via fetch. Session
48+
persistence is localStorage (`web-auth-adapters.ts`); the refresh token is
49+
encrypted at rest with AES-GCM under a **non-extractable** Web Crypto key held
50+
in IndexedDB (`webAuthTokenCipher`). The key round-trips through structured
51+
clone but its raw bytes are never exposed to JS, so a stolen localStorage dump
52+
can't be decrypted offline — matching the bar the desktop host's machine-bound
53+
cipher sets. A live XSS payload can still ask the key to decrypt while it runs
54+
on the page (httpOnly cookies would need server-side sessions the cloud host
55+
doesn't have). Tokens written by the earlier plaintext build fail to decrypt
56+
and are cleared, forcing a clean re-auth.
57+
58+
## Hosting
59+
60+
Build a static bundle and serve it:
61+
62+
```bash
63+
pnpm --filter @posthog/web build # Vite output: apps/web/dist
64+
```
65+
66+
Serve `dist/` as a single-page app with a fallback to `index.html` for unknown
67+
paths. The OAuth popup lands on the real path `/callback` (`OAUTH_CALLBACK_PATH`
68+
in `web-oauth-flow.ts`, dispatched in `main.tsx`), which must load the SPA to
69+
relay the code back to the opener tab. The app's own routes use hash history, so
70+
`/callback` is the only real path that needs the fallback.
71+
72+
The build is code-split: vendor libraries into cacheable groups (`manualChunks`
73+
in `vite.config.ts`) and each route's component into its own lazy chunk (the
74+
TanStack Router plugin's `autoCodeSplitting`, mirroring `apps/code`), so a screen
75+
downloads only when navigated to. All emitted assets are content-hashed — serve
76+
`dist/assets/` with a long-lived immutable `Cache-Control` and only `index.html`
77+
short-lived, so returning users re-download just the chunks that changed.
78+
79+
The steps below are one-time setup for a deployed origin. None block the app
80+
from booting, but auth, attachments, and integrations each need one.
81+
82+
### Environment variables
83+
84+
All are Vite build-time vars (`import.meta.env.*`), baked in at build time.
85+
86+
| Var | Required | Purpose |
87+
| --- | --- | --- |
88+
| `VITE_POSTHOG_API_KEY` | Recommended | Real `phc_…` project key. Enables posthog-js analytics, error/rejection capture, session recording, and real feature flags. The guard in `main.tsx` requires the `phc_` prefix; without it posthog stays uninitialized and the tracker/analytics service no-op, leaving only the host-forced `SYNC_CLOUD_TASKS_FLAG` on (every other flag reads `false`). |
89+
| `VITE_POSTHOG_API_HOST` | No | posthog-js ingestion host. Default `https://internal-c.posthog.com`. |
90+
| `VITE_POSTHOG_UI_HOST` | No | posthog-js UI host. Default `https://us.i.posthog.com`. |
91+
| `VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE` | No | Dev/test only: a static access token that bypasses the OAuth flow (`AUTH_TOKEN_OVERRIDE`). Leave unset in production. |
92+
93+
Session recording turns on whenever posthog-js initializes (any build with a
94+
real key); automatic unhandled-error/rejection/console capture is additionally
95+
gated to non-dev (production) builds (`capture_exceptions` in
96+
`posthogAnalyticsImpl.ts`).
97+
98+
### OAuth redirect URI registration — required for sign-in
99+
100+
The web host reuses the Code ("Array") OAuth application client ids
101+
(`packages/shared/src/oauth.ts`). Each region stores its app's `redirect_uris`
102+
as database rows (Django admin → OAuth applications), and they must include:
103+
104+
- `https://<web-origin>/callback` for the deployed host. `http` is rejected for
105+
non-loopback hosts by `OAuthApplication` redirect validation, so the origin
106+
must be HTTPS.
107+
- `http://localhost/callback` (portless) for local dev — PostHog's authorize
108+
view extends RFC 8252 §7.3 loopback port flexibility to `localhost`
109+
(`posthog/api/oauth/views.py: validate_redirect_uri`), so the portless form
110+
matches the Vite dev server on any port. If desktop dev builds can already
111+
sign in to the region, check whether the registered localhost URI is portless
112+
or pinned to `:8237`.
113+
114+
A CIMD client (the `raycast_metadata.py` / `wizard_metadata.py` pattern in
115+
`posthog/api/oauth/`) is NOT suitable: CIMD registrations are capped to
116+
unprivileged scopes, and Code requires `scope=*` like the desktop app.
117+
118+
### S3 artifact-bucket CORS — required for attachment uploads
119+
120+
Composer attachments upload straight from the browser via an S3 presigned POST
121+
(`.../artifacts/prepare_upload/` returns the presigned post, then a `POST` to
122+
`s3.<region>.amazonaws.com/<bucket>`). The bucket
123+
(`posthog-cloud-prod-us-east-1-app-assets` for US) must return
124+
`Access-Control-Allow-Origin` for the web origin or the browser blocks the
125+
response — the POST itself returns `204` (it succeeds server-side) but `fetch`
126+
rejects with a bare `NetworkError`. Desktop (Electron/Node `fetch`) is not
127+
subject to CORS, so this is web-only.
128+
129+
Add the deployed web origin (and `http://localhost:5273` for dev) with the
130+
`POST` method to the bucket's CORS config. Until then, attaching + preview work
131+
but sending a task with an attachment fails at the upload step.
132+
133+
### `posthog_web` integration connect origin — required for Slack / GitHub connect
134+
135+
PostHog brokers the Slack/GitHub OAuth server-side and, on completion, redirects
136+
to a target chosen by the `connect_from` value (`posthog_code`
137+
`posthog-code://…`, `posthog_mobile``posthog://…`) — a per-known-client
138+
mapping, not an open redirect. The web host's `startFlow` opens
139+
`.../integrations/authorize/?kind={slack|github}&next=…&connect_from=posthog_web`
140+
in a tab; the backend needs a `posthog_web` mapping for the flow to complete. No
141+
callback relay is required because the integration is created server-side and the
142+
connect hooks refetch `getIntegrations()` on window-focus. See Known gaps for
143+
what remains once the mapping exists.
144+
145+
### CORS — no action needed
146+
147+
Verified against `us.posthog.com`: `/oauth/token` answers preflight with the
148+
request origin allowed, and `/api/*` responds `access-control-allow-origin: *`
149+
with `authorization` in the allowed headers. The agent-proxy stream service has
150+
a CORS origin allowlist (`TASKS_AGENT_PROXY_CORS_ORIGINS` in
151+
`PostHog/posthog/services/agent-proxy`), and `CloudTaskService` falls back to
152+
the CORS-open Django stream leg regardless.
153+
154+
## Known gaps
155+
156+
Limitations that remain even after the hosting setup above — each needs a code
157+
or backend change, not just configuration.
158+
159+
- **Slack / GitHub connect** is client-wired (`startFlow` opens the authorize
160+
URL; the connection is detected via a window-focus refetch of
161+
`getIntegrations()`, not a callback relay) but gated on backend support for the
162+
`posthog_web` connect origin (see Hosting). Even with that mapping, the GitHub
163+
*user* flow (`POST /api/users/@me/integrations/github/start/`) hardcodes
164+
`connect_from: "posthog_code"` in `@posthog/api-client` and returns **400** on
165+
web — it needs a host-parameterized `connect_from`. Where a project already has
166+
the integration connected, the settings pages render the connected/manage state
167+
correctly.
168+
- **Per-device stores** (cloud workspaces, archive, pins, browser tabs) are
169+
localStorage-only — not durable across devices or a site-data clear. Desktop
170+
persists these in SQLite; the browser host needs server-side state to match.
171+
- **Skill dependency expansion** is a passthrough: a skill that declares
172+
`dependencies:` on other skills won't pull them in automatically (pick them
173+
explicitly). This is a pipeline gap, not just a web gap — `exportSkill` strips
174+
SKILL.md frontmatter and the team-skills API has no `dependencies` field, so
175+
the dependency list never reaches any client (desktop only expands local
176+
on-disk skills). Needs `dependencies` carried end-to-end through
177+
export → publish → the LlmSkill API (backend) → `fetchSkillForInstall`.

apps/web/dev-server.mjs

Lines changed: 0 additions & 68 deletions
This file was deleted.

apps/web/package.json

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,38 @@
77
"scripts": {
88
"dev": "vite",
99
"build": "vite build",
10-
"typecheck": "tsc --noEmit"
10+
"typecheck": "tsc --noEmit",
11+
"test:e2e": "playwright test --config=tests/e2e/playwright.config.ts",
12+
"test:e2e:ui": "playwright test --config=tests/e2e/playwright.config.ts --ui"
1113
},
1214
"dependencies": {
15+
"@agentclientprotocol/sdk": "0.22.1",
1316
"@inversifyjs/strongly-typed": "2.2.0",
17+
"@pierre/diffs": "^1.2.10",
18+
"@posthog/agent": "workspace:*",
1419
"@posthog/core": "workspace:*",
1520
"@posthog/di": "workspace:*",
1621
"@posthog/host-router": "workspace:*",
22+
"@posthog/host-trpc": "workspace:*",
1723
"@posthog/platform": "workspace:*",
1824
"@posthog/shared": "workspace:*",
1925
"@posthog/ui": "workspace:*",
2026
"@posthog/workspace-client": "workspace:*",
2127
"@tanstack/react-query": "^5.100.14",
2228
"@trpc/client": "^11.17.0",
23-
"superjson": "catalog:",
29+
"@trpc/server": "^11.17.0",
2430
"@trpc/tanstack-react-query": "^11.17.0",
2531
"inversify": "^7.10.6",
2632
"react": "19.2.6",
2733
"react-dom": "19.2.6",
28-
"reflect-metadata": "^0.2.2"
34+
"reflect-metadata": "^0.2.2",
35+
"superjson": "catalog:",
36+
"zod": "^4.4.3"
2937
},
3038
"devDependencies": {
39+
"@playwright/test": "^1.60.0",
3140
"@tailwindcss/vite": "^4.2.2",
41+
"@tanstack/router-plugin": "catalog:",
3242
"@types/react": "^19.1.0",
3343
"@types/react-dom": "^19.1.0",
3444
"@vitejs/plugin-react": "^4.2.1",

apps/web/src/Providers.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import { HostTRPCProvider } from "@posthog/host-router/react";
22
import { ThemeWrapper } from "@posthog/ui/primitives/ThemeWrapper";
3+
import { WorkspaceClientProvider } from "@posthog/workspace-client/provider";
34
import { QueryClientProvider } from "@tanstack/react-query";
45
import type React from "react";
56
import { HotkeysProvider } from "react-hotkeys-hook";
67
import { queryClient } from "./web-container";
78
import { hostTrpcClient } from "./web-trpc";
89

910
// Web transport wiring — the per-host counterpart of apps/code's Providers.tsx.
10-
// @posthog/ui consumes the HOST router context (useHostTRPCClient), so web only
11-
// needs HostTRPCProvider over the HTTP client. No electron TrpcRouter context.
11+
// @posthog/ui consumes the HOST router context (useHostTRPCClient), so web needs
12+
// HostTRPCProvider over the in-process client. No electron TrpcRouter context.
13+
//
14+
// It also mounts WorkspaceClientProvider with connection={null}: several
15+
// task-detail components (useTaskData, FileTreePanel, staging) call
16+
// useWorkspaceTRPC() unconditionally, so the context must exist or they throw.
17+
// There is no workspace-server on the web host, so the provider points at its
18+
// UNAVAILABLE dead URL — those local-only calls reject as failed queries (and
19+
// are gated on a repoPath that cloud tasks never have, so they don't fire).
1220

1321
export const Providers: React.FC<{ children: React.ReactNode }> = ({
1422
children,
@@ -17,7 +25,9 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({
1725
<HotkeysProvider>
1826
<QueryClientProvider client={queryClient}>
1927
<HostTRPCProvider trpcClient={hostTrpcClient} queryClient={queryClient}>
20-
<ThemeWrapper>{children}</ThemeWrapper>
28+
<WorkspaceClientProvider connection={null} queryClient={queryClient}>
29+
<ThemeWrapper>{children}</ThemeWrapper>
30+
</WorkspaceClientProvider>
2131
</HostTRPCProvider>
2232
</QueryClientProvider>
2333
</HotkeysProvider>

0 commit comments

Comments
 (0)