Skip to content

Commit fd8883a

Browse files
refactor: split electron app into several packages (#2442)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent 2105492 commit fd8883a

2,828 files changed

Lines changed: 122658 additions & 89019 deletions

File tree

Some content is hidden

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

.github/workflows/code-quality.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,11 @@ jobs:
2323

2424
- name: Run Biome
2525
run: biome ci .
26+
27+
- name: Setup Node
28+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
29+
with:
30+
node-version: 22
31+
32+
- name: Check host boundaries (apps/code must stay a thin Electron host)
33+
run: node scripts/check-host-boundaries.mjs

.github/workflows/test.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ jobs:
3030
- name: Install dependencies
3131
run: pnpm install --frozen-lockfile
3232

33+
- name: Rebuild native modules for Node
34+
run: node scripts/rebuild-better-sqlite3-node.mjs
35+
3336
- name: Build dependencies
3437
run: pnpm --filter @posthog/electron-trpc build
3538

@@ -91,7 +94,7 @@ jobs:
9194
- name: Package Electron app
9295
run: pnpm --filter code run package
9396
env:
94-
NODE_OPTIONS: "--max-old-space-size=4096"
97+
NODE_OPTIONS: "--max-old-space-size=6144"
9598

9699
- name: Install Playwright
97100
if: steps.playwright-cache.outputs.cache-hit != 'true'

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ playwright-report/
5353
test-results/
5454
*storybook.log
5555
.session-store.json
56+
.playwright-mcp
5657

5758
# Downloaded binaries
5859
apps/code/resources/codex-acp/

AGENTS.md

Lines changed: 234 additions & 201 deletions
Large diffs are not rendered by default.

MIGRATION.md

Lines changed: 1501 additions & 0 deletions
Large diffs are not rendered by default.

PORTING.md

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# PORTING.md — thin UI, thick core
2+
3+
The playbook for porting a feature so its **business logic is portable** (runs on
4+
web/mobile, not just Electron) and its **UI is a thin shell**. Patterns validated on
5+
`connectivity` and matched against the existing `git` / `focus` / `sessions` / `billing` code.
6+
7+
If anything contradicts [AGENTS.md](./AGENTS.md) / [CLAUDE.md](./CLAUDE.md), those win on
8+
layering. For multi-agent coordination use [REFACTOR.md](./REFACTOR.md) + `REFACTOR_SLICES.json`.
9+
10+
---
11+
12+
## Port a feature by answering three questions
13+
14+
Most mistakes come from skipping these or conflating them (we built `connectivity` ~3 ways
15+
before getting it right). Answer them in order.
16+
17+
### Q1 — Where does the data / host access come from? *(picks the wiring)*
18+
19+
- **a. ws-server backend** — git, fs, process, the connectivity probe.
20+
→ A **core service that injects the workspace client** and calls ws-server; bound in the
21+
**main process**, reached from the renderer over tRPC. *(see `git`, `focus`)*
22+
- **b. PostHog cloud API** — tasks, billing, projects, anything on the Django API.
23+
→ A **core service / functions using `@posthog/api-client`**. Portable anywhere there's an
24+
HTTP client; no host capability needed. *(see `billing`, `projects`)*
25+
- **c. Client-local host capability** — clipboard, dialog, OS notifications, `navigator.onLine`.
26+
→ A **`@posthog/platform` interface + per-host adapter**. *(see `clipboard`)*
27+
28+
> ws-client injection (a) is **main-process only** — the renderer's ws-client is built inside
29+
> React (`Providers.tsx`), connection-dependent, and can drop. Don't inject it into a
30+
> renderer-resident service.
31+
32+
### Q2 — Is there real logic? → **make a service.** *(this is the "thick core")*
33+
34+
**The logic of a feature lives in a service — an `@injectable` class in `@posthog/core`.**
35+
Not in components, not in hooks, not in stores.
36+
37+
A service:
38+
- holds business logic — orchestration, retries, dedup, rules, transforms, sagas;
39+
- **injects** its dependencies (workspace client, `api-client`, platform interfaces, other services);
40+
- has **no React, no JSX**; it may read/write a store but **is not** a store;
41+
- is the thing web/mobile reuse unchanged.
42+
43+
When **not** to make one: a feature with no real logic — a value streamed from the backend
44+
into a store, a one-line passthrough — does **not** get a service. `connectivity` is one
45+
boolean fed by a subscription, so it's a store + host glue, **no service**. Don't manufacture a
46+
`FooService` for a 1-field feature; that was the connectivity over-engineering.
47+
48+
> Rule of thumb: if you can't name an algorithm/decision the service makes, you don't need one.
49+
50+
### Q3 — Where does the state live? → **a store, on the correct side.**
51+
52+
A store is a **state cell** (zustand): holds state, **no logic / async / `trpcClient`**.
53+
54+
- **Domain state of record** — a *fact* business logic reads (`isOnline` drives `sessions`
55+
retries) → **`@posthog/core`, `zustand/vanilla`**. Fed by a service or host glue; observed
56+
by UI and core.
57+
- **Pure view state** — scroll position, open panel, draft text, selection → **`@posthog/ui`,
58+
`zustand`** (`create`).
59+
60+
Components read via selectors; a hook re-bundles for ergonomics (`createSelectors`
61+
`store.use.field()`).
62+
63+
---
64+
65+
## Layers
66+
67+
| Package | Owns | Never contains |
68+
|---|---|---|
69+
| `@posthog/platform` | Host-capability **interfaces** + tokens. Host-neutral. | Implementations, Node, DOM, tRPC, Electron |
70+
| `@posthog/workspace-server` | Node backend services + their tRPC. | UI, core, Electron |
71+
| `@posthog/api-client` | PostHog/Django HTTP client. | UI, Node-only host syscalls |
72+
| `@posthog/core` | Portable **services** + domain types + **domain stores**. Injects workspace client / api-client / platform interfaces. | React, `trpcClient`, Node syscalls, Electron, host-router types |
73+
| `@posthog/ui` | React glue: components, hooks, contributions, **view-state stores**. | Business logic, `trpcClient`, Node |
74+
| `apps/code` | Electron lifecycle + **platform adapters** + tRPC routers + DI wiring. | Business logic |
75+
76+
`apps/code/src/main/platform-adapters/` — capabilities **main** consumes.
77+
`apps/code/src/renderer/platform-adapters/` — capabilities the **renderer** consumes (wrap `trpcClient`).
78+
79+
---
80+
81+
## Skeletons
82+
83+
### Service (Q2) — the logic, injectable, in core
84+
85+
```ts
86+
// @posthog/core/<feature>/<feature>.ts
87+
@injectable()
88+
export class FeatureService {
89+
constructor(
90+
@inject(FEATURE_WORKSPACE_CLIENT) private readonly ws: FeatureWorkspaceClient, // Q1a
91+
// or @inject(API_CLIENT) private readonly api: PostHogApiClient, // Q1b
92+
// or @inject(THING_SERVICE) private readonly thing: IThing, // Q1c
93+
) {}
94+
async doThing() { /* orchestration, rules, retries — the actual logic */ }
95+
}
96+
```
97+
98+
### Q1a — ws-server backend (`git` / `focus`)
99+
100+
Core declares a **narrow slice** of the workspace client and injects it; bound in main with the
101+
real client; exposed to the renderer via a host-router tRPC router.
102+
103+
```ts
104+
// @posthog/core/<feature>/identifiers.ts
105+
import type { WorkspaceClient } from "@posthog/workspace-client/client";
106+
export interface FeatureWorkspaceClient { feature: WorkspaceClient["feature"]; }
107+
export const FEATURE_SERVICE = Symbol.for("posthog.core.featureService");
108+
export const FEATURE_WORKSPACE_CLIENT = Symbol.for("posthog.core.featureWorkspaceClient");
109+
```
110+
```ts
111+
// apps/code/src/main/index.ts (composition — the real client; cloud client on web)
112+
container.bind(MAIN_TOKENS.FeatureService).toConstantValue(new FeatureService(workspaceClient));
113+
container.bind(FEATURE_SERVICE).toService(MAIN_TOKENS.FeatureService);
114+
```
115+
116+
### Q1c — client-local capability (`clipboard`)
117+
118+
```ts
119+
// @posthog/platform/src/<cap>.ts host-neutral: onDidChange(listener): () => void
120+
export interface IThing { read(): Promise<T>; onDidChange(l: (v: T) => void): () => void; }
121+
export const THING_SERVICE = Symbol.for("posthog.platform.thing");
122+
```
123+
```ts
124+
// apps/code/src/{main,renderer}/platform-adapters/<cap>.ts
125+
container.bind(THING_SERVICE).toConstantValue(electronImpl);
126+
```
127+
**Never** put tRPC `{ onData, onError }` / `{ unsubscribe }` shapes in the interface — translate
128+
them in the adapter. Platform ships built `dist/`: a new file needs `src/<cap>.ts` + a
129+
`tsup.config.ts` entry + a `package.json` export + `pnpm --filter @posthog/platform build`.
130+
131+
### Streamed state, no service (`connectivity`) — Q1a data + Q3 domain store
132+
133+
```ts
134+
// @posthog/core/<feature>/<feature>Store.ts (domain fact → core, vanilla)
135+
import { createStore } from "zustand/vanilla";
136+
export const featureStore = createStore<{ value: T; setValue: (v: T) => void }>((set) => ({
137+
value: initial, setValue: (value) => set({ value }),
138+
}));
139+
export const getValue = () => featureStore.getState().value;
140+
```
141+
```ts
142+
// apps/code/src/renderer/platform-adapters/<feature>.ts (host glue — the ONLY trpcClient touch)
143+
import { featureStore } from "@posthog/core/<feature>/<feature>Store";
144+
import { trpcClient } from "@renderer/trpc/client";
145+
const { setValue } = featureStore.getState();
146+
void trpcClient.feature.get.query().then(setValue).catch(() => undefined);
147+
trpcClient.feature.onChange.subscribe(undefined, { onData: setValue });
148+
```
149+
```ts
150+
// @posthog/ui/hooks/useFeature.ts (read via auto-selectors)
151+
import { featureStore } from "@posthog/core/<feature>/<feature>Store";
152+
import { createSelectors } from "./createSelectors";
153+
const feature = createSelectors(featureStore);
154+
export const useFeature = () => ({ value: feature.use.value() });
155+
```
156+
A core consumer (e.g. `sessions`' `getIsOnline`) imports the store's `getValue` getter directly.
157+
158+
---
159+
160+
## DI
161+
162+
- **Plain Inversify.** Interface + `Symbol.for` token in the owning package; constructor `@inject(TOKEN)`; bind in the feature's `.module.ts` (ui/core) or `index.ts` composition (main).
163+
- **Never call them "ports."** These are **interfaces** — name them as such (`IThing` / `FeatureWorkspaceClient`), not `FooPort` / `FOO_PORT` / `ports.ts`. The existing `*_PORT` tokens, `*Port` types, and `ports.ts` files are legacy; new code uses "interface", and rename old ones when you touch them.
164+
- **Do NOT use `@inversifyjs/binding-decorators` (`@provide`) or `@inversifyjs/strongly-typed`.** Tried both on `connectivity`, removed them — `@provide`'s side-effect-import is a footgun, `strongly-typed`'s binding-map is pure tax.
165+
- **`resolveService` is a service-locator smell.** Constructor-inject in services; `useService(TOKEN)` at the React boundary only. `resolveService` is tolerated only in host composition seams (`apps/`).
166+
167+
---
168+
169+
## Anti-patterns (removed this session — do not reintroduce)
170+
171+
| Anti-pattern | Fix |
172+
|---|---|
173+
| A platform interface for **backend** data | Q1a/Q1b — workspace client / api-client |
174+
| A **service** for a trivial passthrough (1 field, no logic) | Q3 store + host glue, no service |
175+
| A **domain** store in `@posthog/ui` | Domain facts → core (`zustand/vanilla`) |
176+
| A renderer-resident core service injecting the workspace client | ws-client is React-bound/fragile; stream into a core store |
177+
| Bespoke `IFeatureClient` that 1:1 wraps `trpcClient.x` | Use the real client / `HOST_TRPC_CLIENT` |
178+
| Per-feature `FeatureLogger` interface + token | Generic `logger.scope` (UI) / shared logger (core) |
179+
| `@inversifyjs/strongly-typed` + a `Deps` map | `@inject(TOKEN)` |
180+
| Separate `IFeatureService` interface for one impl | Inject the concrete class |
181+
| `{ onData, onError }` / `{ unsubscribe }` in a platform interface | `onDidChange(listener): () => void` |
182+
| Logic / async in a Zustand store action | A service (Q2); the store does only `set` |
183+
| `trpcClient` imported in `@posthog/ui` | host glue in `apps/` feeds the store |
184+
| Adapter in a `features/<x>/` folder | `apps/code/.../platform-adapters/<x>.ts` |
185+
| A bridge mirroring a service that mirrors another service | collapse it; each consumer caches what it needs |
186+
187+
---
188+
189+
## Validation gates
190+
191+
```sh
192+
pnpm typecheck # all packages green
193+
pnpm exec biome lint packages/core/src/<feature> # core purity: zero noRestrictedImports
194+
pnpm --filter <pkg> exec vitest run src/<feature> # unit tests (services test with fake deps)
195+
pnpm biome check --write <touched paths> # format
196+
```
197+
- Touched a `@posthog/platform` interface (Q1c)? **rebuild platform dist** or typecheck lies.
198+
- Moved a service/store to core? the **core purity gate** must be clean (no Node/Electron/React/`trpcClient`).
199+
- Repoint test mocks to the new import specifier (a shim hides the break from typecheck).
200+
- Renderer `vite build` is the cheap runtime smoke when DI/boot wiring changed.
201+
202+
---
203+
204+
## Reference: `connectivity` (streamed domain state, no service)
205+
206+
```
207+
@posthog/core/connectivity/connectivityStore.ts domain store { isOnline } + getIsOnline (vanilla)
208+
@posthog/ui/features/connectivity/connectivityToast.ts subscribes the core store → offline toast
209+
@posthog/ui/hooks/useConnectivity.ts reads the core store via createSelectors
210+
apps/code/src/renderer/platform-adapters/connectivity.ts host glue: trpc subscription → core store + toast
211+
```
212+
`sessions` imports `getIsOnline` from the core store. The probe lives in `workspace-server`'s
213+
connectivity service, served over tRPC. No core service (nothing to orchestrate), no platform
214+
interface, no per-feature DI — the store *is* in core because `isOnline` is a domain fact.

0 commit comments

Comments
 (0)