The frontend is not a loose collection of packages. It is organized around deep modules: a few packages own a wide surface of complexity and expose a narrow public API. Apps and pages import only from barrels, so the hard parts — the request pipeline, the auth session, the composite UI glue — have exactly one home.
1. What “deep module” means here
A deep module hides a broad implementation behind a small interface. In this codebase that is a literal choice: package barrels (src/index.ts) are the only sanctioned import path, and the index re-exports a deliberately short list.
| Package | Hides | Exposes |
|---|---|---|
@bitz/platform-sdk | request pipeline, auth session, ProblemDetails parsing, tenant/permission context, generated OpenAPI contract | createPlatformClient, PlatformProvider, usePlatform, PermissionGate, FeatureGate, createPlatformApi, config + error helpers |
@bitz/widgets | pagination, loading, empty, error, and permission glue; the PlatformResult mapping | ServerDataTable, AppShell, SidebarNav, HeaderBar, FormField, ConfirmAction, QueryState, EmptyState, ErrorState, PermissionGate |
@bitz/components | shadcn primitives and the cn helper | a shadcn primitive barrel (dozens of UI re-exports) (button, sidebar, table, dialog, …) |
@bitz/editor | Tiptap setup | a useBitzEditor wrapper over StarterKit |
@bitz/i18n | React Context + typed t() | a swap-safe i18n seam (I18nProvider, useI18n, useT, tRaw, zhCN) |
A page that imports ServerDataTable from @bitz/widgets does not know whether the table fetches via React Query, how it maps a ProblemDetails error to a retry state, or which gate hides the delete column. That is the point.
2. Dependency direction
The arrow direction is fixed: apps depend on packages, packages depend only on lower-level packages, and nothing reaches into a package’s deep paths. @bitz/platform-sdk is the only package that talks to the backend; @bitz/widgets is the only package that composes data-fetching with UI states (and it depends on both the SDK and i18n, which the previous diagram omitted).
3. The request path through the web app
There is exactly one request pipeline instance, created in PlatformProvider via useMemo and shared through React context (usePlatform().client). Individual pages never construct a client and never reimplement retry, refresh coordination, timeout, or ProblemDetails mapping — those all live in the one pipeline. See Platform contract layer for the field-level detail.
4. The auth-session state machine
AuthSession is the single owner of credential state and refresh coordination. Its transitions are an architectural contract, not an implementation detail:
The load-bearing invariant is the singleton refresh: concurrent 401s share one in-flight refreshOnce() promise. If the retried request is still a 401, the session calls forceRefreshFailed() and emits a refresh-failed event — this prevents a refresh storm. Listeners are isolated (each is try/catch’d) so a throwing listener cannot break the session.
5. Red lines
These are non-negotiable. Each one exists because its failure mode is a security or correctness regression, not a style issue, and each is implemented.
| # | Red line | Status | Why |
|---|---|---|---|
| 1 | Generated client is never hand-edited | Current | generated.d.ts mirrors the backend OpenAPI artifact; edits drift and are overwritten by regeneration. |
| 2 | Frontend permission checks are UX only | Current | The backend authorization pipeline is the single permission truth. |
| 3 | No refresh token in localStorage | Current | Refresh is HttpOnly-cookie only; access token is a module-private field so no script can exfiltrate it. |
| 4 | No permission fact in localStorage | Current | TenantId / UserId / permissions come from /api/auth/me or JWT claims, never self-declared. |
| 5 | No hand-written backend DTO | Current | src/contracts/ may alias generated schemas but must not copy their fields. |
| 6 | No scattered fetch in pages | Current | All HTTP goes through usePlatform().api. |
| 7 | One request pipeline instance | Current | PlatformProvider memoizes one client; no per-page HTTP adapter. |
6. Why the structure is worth it
The cost of a deep-module, single-pipeline layout is upfront discipline: you cannot drop a fetch into a page, hand-copy a DTO, or read the refresh token. The payoff is that the system’s hard invariants hold by construction:
- Security invariants live in one place. Token storage, refresh coordination, and ProblemDetails handling are all in
@bitz/platform-sdk. A review of that one package audits the whole web app’s auth posture. - Backend changes propagate through regeneration. When a backend DTO changes, regenerating the client turns a silent runtime mismatch into a compile error.
- Composite UI states are consistent. Every data-driven screen shares the same loading/empty/error/permission shapes because they come from
@bitz/widgets, not from each page’s ad-hoc JSX.
7. Reviewing an architecture change
- Name the package the change lives in, and confirm it is a barrel import, not a deep-path import.
- If a new HTTP call is introduced, confirm it goes through
usePlatform().apiand not a page-levelfetch. - If a new permission check is introduced, confirm the backend enforces the same capability.
- If a backend contract changes, export OpenAPI and regenerate the client; only adjust schema aliases under
src/contracts/. - Run the workspace checks (
yarn lint && yarn typecheck && yarn build) before commit — Husky and lint-staged enforce formatting and lint on staged files.
8. Source review
# Confirm barrels are the import surface and apps/app does not consume deep paths.rg -n "from '@bitz/platform-sdk'|from '@bitz/widgets'" frontend/apps/app/srcrg -n "from '@bitz/widgets/src/|from '@bitz/platform-sdk/src/" frontend/apps/app/src# Expected: the first command returns many matches; the second returns none.
# Confirm the single request-pipeline and auth-session home.rg -n "createPlatformClient|class AuthSession|refreshOnce|forceRefreshFailed" \ frontend/packages/platform-sdk/src/client frontend/packages/platform-sdk/src/auth