Skip to content
Merged
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
183 changes: 183 additions & 0 deletions docs/frontend-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Frontend architecture — the shared `src/` skeleton

**This is the source of truth for how a pathscale frontend app is laid out**, for humans
and agents alike. Seven apps share it: `24x.ai`, `honey.id`, `pathscale.com`,
`pays.online`, `support.cafe`, `web3.trading`, `nofilter.io`. They link here rather than
each keeping a copy — one skeleton, one description, no drift.

Scope: **where code goes and why**. Two neighbours cover the rest:

- [`ui-usage.md`](ui-usage.md) — how to use `@pathscale/ui` (components, theming, forms).
- [`frontend-services-contract.md`](frontend-services-contract.md) — how a backend
endpoint is wired from contract to hook.

Anything a single app does differently belongs in **that app's**
`docs/frontend-conventions.md`, under its "Deviations" section — not here.

## The skeleton

Present in **all seven** apps, without exception:

```
src/
api/ config/ features/ layouts/ models/ services/ styles/
assets/ constants/ hooks/ lib/ scripts/ stores/ utils/
```

Plus five root files, also in all seven: `App.tsx`, `config.ts`, `env.d.ts`,
`index.css`, `index.tsx`.

Optional, present in some:

| directory | in |
|---|---|
| `pages/` | honey.id, pathscale.com, pays.online, web3.trading, nofilter.io |
| `routes.ts` | same five |
| `types/` | same five |
| `schemas/` | honey.id, web3.trading, nofilter.io |
| `routing/` (dir) | pathscale.com, nofilter.io — `pays.online` has `routing.ts` as a file |
| `test/` | pathscale.com, nofilter.io |
| `contexts/` | honey.id only |
| `callapp/`, `realtime/`, `webrtc.d.ts` | nofilter.io only |

`24x.ai` and `support.cafe` are the lean end — skeleton only, no `pages/`, no route table.

Note `config.ts` (file) and `config/` (directory) coexist in every app. They are not
duplicates: see below.

## What belongs where

### `api/` — transport wiring, nothing else

`configure.ts` builds the `@pathscale/wss-adapter` configuration — remote URLs, the
method map, `onError`, `onDisconnect` — and calls `wssAdapter.configure()`. `index.ts`
exports the ready-to-call session objects (`authApi`, `apiApi`, …) that hooks import.

`api/services/` is **generated output**, not hand-written wiring: one JSON method map per
service, emitted by `bun run schema`. Do not edit it, and do not add code files to it.

**Does not belong:** business logic, retries with domain meaning, anything importing from
`features/`.

### `models/` — generated DTOs, never hand-edited

Every type describing a backend payload. Generated from the services JSON; edits are
silently destroyed on the next `bun run schema`.

Two layouts are in the wild. Newer apps group by contract first
(`src/models/api/userApi/GetUsersDto.ts` in `honey.id`); older ones are flat by service
(`src/models/paymentApi/` in `pays.online`). Both are generated; follow whichever your
repo already uses.

**The exception worth knowing:** a few hand-written files sit at the *top level* of
`models/` — `honey.id` has `src/models/roles.ts` and
`src/models/supportCafeChatMessage.ts`. The never-hand-edit rule applies to the generated
*subdirectories*, not to these. Generated files carry a "DO NOT MODIFY IT BY HAND" banner;
if a file has no banner, it is hand-written.

### `services/` — business logic and error normalisation

Where an API call becomes a domain operation: sequencing multiple calls, normalising
errors into something a component can act on, owning connection lifecycle.

Two roles live here in practice:

- **`serviceStore.ts`** — connection and session lifecycle. Present in all seven. Hooks
import it for the `servicesInitialized` readiness gate.
- **`authServices.ts` / `authErrorNormalize.ts`** — domain logic proper.

**Not every call passes through here.** A plain read often goes hook → `api/` directly.
`services/` is entered when there is logic to own, not as a mandatory relay layer.

**Does not belong:** JSX, component imports, route knowledge.

### `hooks/` — what components actually consume

One hook per endpoint or per interaction, wrapping `@tanstack/solid-query` and returning
reactive state. Subdirectory grouping differs by app and both are fine: by domain
(`src/hooks/users/`, `src/hooks/metrics/` in `honey.id`) or by service
(`src/hooks/userApi/`, `src/hooks/adminApi/` in `support.cafe`).

Query keys are centralised in `src/constants/queryKeys.ts` — never inline a key string.

**Does not belong:** JSX, direct DOM work, endpoints that aren't in the services JSON.

### `components/` vs `features/` — the split that matters most

- **`components/`** — reusable and domain-agnostic. If it would still make sense in a
different app, it goes here. Grouped by kind (`components/buttons/`,
`components/tables/`, `components/skeletons/`).
- **`features/`** — one directory per product area, owning its own slice end to end. The
convention inside is `components/`, `hooks/`, `pages/`, plus whatever that feature needs
(`honey.id` has `features/auth/utils/`, `features/auth/schema/`,
`features/admin/skeletons/`).

The test: **would this make sense outside this product area?** Yes → `components/`.
No → that feature's folder. A feature may import from `components/`; `components/` must
never import from `features/`.

### `stores/` vs `contexts/` — global state vs tree-scoped values

- **`stores/`** — module-level `createSignal` singletons, imported directly, no provider.
This is the default and covers nearly everything: `authStore.ts`, `connectionStore.ts`,
`i18nStore.ts` recur across apps.
- **`contexts/`** — Solid `createContext`, only for values that *cannot* be global because
they belong to one subtree. The single example across all seven is `honey.id`'s
`src/contexts/DashboardNavContext.tsx`, which passes a DOM element reference down one
layout.

Reach for a store first. A context is justified only when a value is genuinely per-subtree.

### The small ones

- **`config/`** (directory) — app configuration modules: `routes.ts` (path constants),
`featureFlags.ts`, `i18n.ts`. **`config.ts`** (root file) is the environment/runtime
config entry — different thing, both exist everywhere.
- **`constants/`** — fixed values, no logic: `queryKeys.ts`, `connectionSettings.ts`.
- **`lib/`** — self-contained utilities with real internal complexity, often a
subdirectory of their own (`lib/secureStorage/`, `lib/theme.ts`).
- **`utils/`** — small stateless helpers, one concern per file (`formatTimestamp.ts`,
`roleUtils.ts`). If it grows a subdirectory and internal state, it belongs in `lib/`.
- **`layouts/`** — page shells composed by the router (`AppLayout.tsx`, `AuthLayout.tsx`,
`RootLayout.tsx`), exported through an `index.ts`.
- **`styles/`** — global CSS and theme files. Component styling is Tailwind utilities plus
`@pathscale/ui` tokens; see [`ui-usage.md`](ui-usage.md).
- **`scripts/`** — build and codegen scripts run through `package.json`, notably
`schema.js`. Not application code; never imported by `src/`.
- **`assets/`** — static files (images, icons).

## Routing

Three layers. They stack — a later one never replaces an earlier one.

**1. Path constants — `src/config/routes.ts`. Present in all seven, always.** Exports a
`ROUTES` object and any path helpers. Every path string in the app comes from here; never
hard-code a route string in a component.

**2. The route table — `src/routes.ts`.** Exports `routes: RouteConfig[]`, mapping paths to
components, layouts and guards. Five apps have it; `24x.ai` and `support.cafe` instead
declare `<Route>` elements inline in `App.tsx`, with guards as wrapper components
(`RequireAuth`, `RedirectIfAuth`, `RequireRole`).

**3. Derived groupings — `src/routing/`.** Filters the route table into groups the shell
renders (`routeGroups.ts`), and in `nofilter.io` also access policy
(`src/routing/accessPolicy.ts`). This layer **imports** `~/routes` — it is a consumer of
layer 2, not an alternative to it. `pays.online` does the same thing in a single
`src/routing.ts` file.

So "`routes.ts` or `routing/`?" is a false choice: if an app has `routing/`, it has
`routes.ts` too, and both sit on `config/routes.ts`.

## Adding something new — where does it go?

| you are adding | it goes in |
|---|---|
| a backend call | `hooks/` (see [`frontend-services-contract.md`](frontend-services-contract.md)) |
| a screen | that feature's `pages/`, registered in `routes.ts` / `App.tsx` |
| a widget used by one feature | that feature's `components/` |
| a widget used by three features | `components/` |
| global state | `stores/` |
| a value scoped to one subtree | `contexts/` |
| a pure helper | `utils/`, or `lib/` if it needs its own directory |
| a path | `config/routes.ts`, always |
| a type describing backend data | nowhere by hand — regenerate `models/` |
148 changes: 148 additions & 0 deletions docs/frontend-services-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Frontend services contract — contract to hook

**This is the source of truth for how a backend endpoint reaches a component**, for humans
and agents alike, across `24x.ai`, `honey.id`, `pathscale.com`, `pays.online`,
`support.cafe`, `web3.trading` and `nofilter.io`. They link here rather than each keeping
a copy.

This documents the **wiring pattern**. It is deliberately not an endpoint list — the
endpoints are already machine-readable in each app's `docs/*.services.json`, and any list
here would be stale within a week.

Neighbours: [`frontend-architecture.md`](frontend-architecture.md) for where code lives,
[`ui-usage.md`](ui-usage.md) for `@pathscale/ui`.

## The rule that comes before everything

**`docs/*.services.json` is the contract, and it is authoritative.** Endpoints,
parameters, returns, error variants, roles — if it is not in there, it does not exist as
far as the frontend is concerned.

Do not invent an endpoint, add a parameter the contract doesn't declare, or assume a
return shape because it would be convenient. When something you need is missing, **say so
and stop** — that is a backend conversation, not a frontend workaround. Guessing produces
code that typechecks, passes review, and fails against a real server.

Apps carry one or more contracts. `honey.id` has three — `docs/api.services.json`,
`docs/auth.services.json`, `docs/support.services.json` — which is why its `src/models/`
has three subtrees. Most apps have one.

## The chain

```
docs/<service>.services.json the contract — authoritative, hand-maintained
├─(bun run schema)─> src/models/<contract>/… generated DTOs + enums + errorCatalog
└─(bun run schema)─> src/api/services/<contract>/… generated method maps (JSON)

src/api/configure.ts reads docs/*.services.json directly, configures @pathscale/wss-adapter
└─> src/api/index.ts exports callable sessions (authApi, apiApi, …)
└─> src/hooks/<group>/useX.ts solid-query wrapper — what components consume
└─> src/services/… only where there is domain logic to own
```

Two things about this diagram are easy to get wrong, so they are worth stating plainly:

**`src/api/services/` is generated, and `configure.ts` does not read it.** The method map
`configure.ts` passes to the adapter is built in-process from `docs/*.services.json`
(`buildMethods()`). The JSON under `src/api/services/` is a *separate emission* of the same
information, and is imported by hand in only a couple of places — in `honey.id`, just
`src/features/auth/reauth/reauthSession.ts`. Do not treat that directory as the wiring.

**`src/services/` is not a mandatory hop.** A plain read goes hook → `api/`. The
`services/` layer is entered when there is real logic — multi-step flows, error
normalisation, connection lifecycle. Routing every call through it "for consistency" adds
a pass-through file that does nothing.

## Worked trace — `GetUsers` in `honey.id`

**1. The contract.** `docs/api.services.json` declares endpoint code `10000`, name
`GetUsers`, parameters `appPublicId`, `page`, `pageSize`.

**2. Generation.** `bun run schema` (which is `bun run src/scripts/schema.js` in every one
of the seven apps) reads the contract and writes:

- `src/models/api/userApi/GetUsersDto.ts` — `GetUsersParams` and `GetUsersResponse`,
carrying a "DO NOT MODIFY IT BY HAND" banner
- `src/models/api/userApi/index.ts` — barrel re-export
- `src/models/api/enums/` — the contract's enums, one file each, plus an `index.ts`
- `src/api/services/api/userApi.json` — `{"10000": {"name": "GetUsers", "parameters": [...]}}`

**3. Transport.** `src/api/configure.ts` builds the adapter configuration — remote URL per
service, the method map, `timeout`, an `onError` handler and per-service `onDisconnect` —
and calls `wssAdapter.configure()`. `src/api/index.ts` exports the resulting session
objects.

**4. The hook.** `src/hooks/users/useGetUsers.ts` is the whole consumer-facing surface:

```ts
export const useGetUsers = (params: GetUsersParams, options?: { enabled?: boolean }) =>
useQuery<NormalizedGetUsersResponse>(() => ({
queryKey: userApiKeys.users(params.appPublicId, keyParams),
enabled: (options?.enabled ?? true) && serviceStore.servicesInitialized && !!params.appPublicId,
queryFn: async () => {
const res = await apiApi.GetUsers(params);
const payload = (res as { params?: GetUsersResponse }).params ?? res;
return { ...payload, data: (payload.data ?? []) as UserListItem[] };
},
}));
```

Four conventions are visible in those few lines, and all four are load-bearing:

- **Types come from `models/`**, never hand-declared alongside the hook.
- **The key comes from `src/constants/queryKeys.ts`** (`userApiKeys.users(...)`), never
inlined.
- **`enabled` gates on `serviceStore.servicesInitialized`.** Without it the query fires
before the socket is up and fails on first paint.
- **The response is unwrapped defensively** — `res.params ?? res` — because the adapter
may hand back either shape.

**5. Components** call `useGetUsers(...)` and read `.data` / `.isLoading`. They never touch
`apiApi`.

## Adding an endpoint

1. **Confirm it exists in `docs/*.services.json`.** If not, stop — nothing below is valid.
2. **Regenerate:** `bun run schema`. Never hand-write the DTO.
3. **Commit the generated output** alongside your change; it is checked in.
4. **Add a query key** to `src/constants/queryKeys.ts`.
5. **Write the hook** in `src/hooks/<group>/`, mirroring the nearest existing one —
same unwrapping, same `enabled` gate.
6. **Add a `services/` function only if there is logic to own.** Otherwise skip it.
7. **Consume the hook** from the component.

Regenerated, never hand-edited: `src/models/**` and `src/api/services/**`.
Hand-written: everything in `hooks/`, `services/`, `constants/queryKeys.ts`, and
`api/configure.ts`.

## How errors surface

`src/services/authErrorNormalize.ts` in `honey.id` is the worked example, and it closes the
loop back to the contract: it imports `AUTH_ERROR_CATALOG` and `ERROR_CODE_NAME_BY_VALUE`
from `src/models/auth/errorCatalog.ts` — themselves generated from the `errors` blocks in
`docs/auth.services.json`. Backend error variants are therefore typed, not guessed at from
strings.

Four rules it encodes, all worth copying:

- **Never branch on a message string.** Control flow keys off `kind` and `code`; the
`NormalizedAuthError.message` field is commented "never use for control flow" precisely
because messages are display text and will change.
- **Allowlist what reaches the UI.** `SAFE_PARAM_KEYS` passes through timing and count
fields (`retryAfter`, `attemptsRemaining`, …) and drops everything else, so a backend
that starts attaching a token to an error payload cannot leak it into a component.
- **Never surface the raw error.** It is kept on `raw` for logging and not rendered.
- **Distinguish known from unknown.** `isKnownServiceError` separates a declared contract
variant from anything else, so unknown failures get a generic message rather than an
unhandled branch.

Transport-level failures are handled once, centrally, in `configure.ts`'s `onError`:
auth failures invalidate the session through `authStore`, and everything else is logged.
Individual hooks do not re-implement that.

## When the contract and the code disagree

The contract wins. If `src/models/` looks wrong, regenerate before you debug — the most
common cause is a contract that moved and generated files that didn't. If the contract
itself looks wrong, that is a backend conversation. Do not paper over it in a hook.
Loading