diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md new file mode 100644 index 0000000..01ed8e3 --- /dev/null +++ b/docs/frontend-architecture.md @@ -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 `` 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/` | diff --git a/docs/frontend-services-contract.md b/docs/frontend-services-contract.md new file mode 100644 index 0000000..c97cd1b --- /dev/null +++ b/docs/frontend-services-contract.md @@ -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/.services.json the contract — authoritative, hand-maintained + │ + ├─(bun run schema)─> src/models//… generated DTOs + enums + errorCatalog + └─(bun run schema)─> src/api/services//… 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//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(() => ({ + 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//`, 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.