UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

384 lines (289 loc) 11.4 kB
--- name: ui-integration description: Route creation, API client usage, auth client, SSR hydration, sidebar system, and the @/app module surface. Use when adding new UI routes, fetching data from the API, implementing auth flows, or customizing sidebar navigation. metadata: sources: "ui/src/app.ts,ui/src/lib/api.ts,ui/src/lib/auth.ts,ui/src/router.tsx,ui/src/router.server.tsx,ui/src/hydrate.tsx,ui/src/routes/__root.tsx,ui/src/routes/_layout.tsx,ui/src/routes/_layout/_authenticated.tsx,ui/src/lib/plugin-sidebar.gen.ts" --- # UI Integration ## File-based Routing Routes are defined as files in `ui/src/routes/`. TanStack Router auto-generates the route tree. ### Route File Convention ``` ui/src/routes/ ├── __root.tsx # Root layout (HTML shell, head, scripts) ├── index.tsx # / ├── _layout.tsx # Shell layout (sidebar, header, footer) ├── _layout/ ├── index.tsx # / (inside shell) ├── _authenticated.tsx # Auth guard layout (redirects to /login) └── _authenticated/ ├── index.tsx # / (authenticated) ├── settings.tsx # /settings └── your-plugin/ └── index.tsx # /your-plugin ├── login.tsx # /login └── about.tsx # /about ``` - Files starting with `_` are **layout** routes (parent components with `<Outlet />`) - Files starting with `_` followed by a path segment are **nested layouts** - Regular files become path segments (e.g., `settings.tsx` `/settings`) - Directories create nested paths (e.g., `_authenticated/settings.tsx` → `/settings` inside the auth guard) ### Basic Route ```tsx import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/about")({ component: AboutPage, }); function AboutPage() { return <div>About</div>; } ``` ### Route with Loader ```tsx import { createFileRoute } from "@tanstack/react-router"; import { useApiClient } from "@/app"; export const Route = createFileRoute("/projects/$id")({ loader: async ({ context, params }) => { const { apiClient } = context; const project = await apiClient.projects.getProject({ id: params.id }); return { project }; }, component: ProjectPage, }); function ProjectPage() { const { project } = Route.useLoaderData(); return <div>{project.name}</div>; } ``` ### Route with Head/Sidebar Metadata ```tsx export const Route = createFileRoute("/_layout/settings")({ component: SettingsPage, head: () => ({ meta: [{ title: "Settings" }], }), }); ``` ## The `@/app` Module Surface `ui/src/app.ts` exports everything UI route code needs: ```ts // Runtime config helpers import { getRuntimeConfig, getAccount, getAppName, getActiveRuntime, getRepository, getCspNonce } from "@/app"; // API client import { createApiClient, useApiClient, useOrpc, type ApiClient } from "@/app"; // Auth client import { createAuthClient, useAuthClient, sessionQueryOptions, useRelayHistory, type AuthClient, type SessionData } from "@/app"; // Types import type { ClientRuntimeConfig, RouterContext, CreateRouterOptions, RenderOptions } from "@/app"; ``` ### Runtime Helpers ```ts const config = getRuntimeConfig(); // Full window.__RUNTIME_CONFIG__ const account = getAccount(config); // NEAR account const appName = getAppName(config); // Title or account const activeRuntime = getActiveRuntime(config); // { accountId, gatewayId, title } const repo = getRepository(config); // Repository URL const nonce = getCspNonce(); // CSP nonce for inline scripts ``` These are SSR-safe — they accept an optional `RuntimeConfigInput` to work with config from loader data. ## API Client ### Creation The client is created once in `hydrate.tsx` and stored in the Router context: ```ts const apiClient = createApiClient({ hostUrl: runtimeConfig.hostUrl, rpcBase: runtimeConfig.rpcBase, // "/api/rpc" }); ``` It uses `RPCLink` with `credentials: "include"` and a global error interceptor that shows a toast on network failures. ### In Route Components ```ts import { useApiClient, useOrpc } from "@/app"; function Component() { // Direct usage const apiClient = useApiClient(); const { data } = await apiClient.registry.listRegistryApps({ limit: 24 }); // With TanStack Query utils (caching, refetch, mutations) const orpc = useOrpc(); const { data, isLoading } = orpc.registry.listRegistryApps.useQuery({ limit: 24 }); const mutation = orpc.registry.listRegistryApps.useMutation(); // In route loaders (from context, no hooks): const { apiClient } = context; const result = await apiClient.ping(); } ``` ### Typed API Calls Every procedure from every plugin is available on `apiClient` with full TypeScript types: ```ts apiClient.ping() // → { status, timestamp } apiClient.registry.listRegistryApps({ limit: 24 }) // → { apps, meta } apiClient.projects.getProject({ id: "proj_123" }) // → Project apiClient.authHealth() // → { status, emailConfigured, ... } ``` The types come from the auto-generated `api-types.gen.ts`, which merges every plugin's contract into a single `ApiContract` type. ### Error Handling Network/fetch errors are automatically caught by the `RPCLink` interceptor and shown as a toast: ``` "Unable to connect to API"The API is currently unavailable. ``` Procedure-level errors (like `UNAUTHORIZED`, `NOT_FOUND`) are thrown as `ORPCError` instances and should be caught inline: ```ts try { await apiClient.authHealth(); } catch (error) { if (error instanceof ORPCError) { // Handle specific error } } ``` ## Auth Client ### Creation The auth client is also created once in `hydrate.tsx`: ```ts const authClient = createAuthClient(runtimeConfig); ``` Configured with Better-Auth plugins: SIWN (NEAR), passkey, organization, admin, API key, anonymous, phone. ### In Route Components ```ts import { useAuthClient, sessionQueryOptions } from "@/app"; function LoginPage() { const authClient = useAuthClient(); // Sign in with email await authClient.signIn.email({ email, password }); // Sign in with NEAR (SIWN) await authClient.signIn.siwn({ networkId: "mainnet" }); // Sign out await authClient.signOut(); // Organization switching await authClient.organization.setActive({ organizationId: "org_123" }); } ``` ### Session Query Pattern Use `sessionQueryOptions()` for standardized session fetching with TanStack Query: ```ts // In a route loader: const session = await queryClient.ensureQueryData( sessionQueryOptions(authClient, context.session), ); // In a component: const { data: session } = useQuery(sessionQueryOptions(authClient)); ``` Returns `SessionData` with `user`, `session`, and typed auth context. ## Auth Route Guard The `_authenticated.tsx` layout protects routes that require a session: ```ts export const Route = createFileRoute("/_layout/_authenticated")({ beforeLoad: async ({ context, location }) => { const { queryClient, authClient } = context; const session = await queryClient.ensureQueryData( sessionQueryOptions(authClient, context.session), ); if (!session?.user) { throw redirect({ to: "/login", search: { redirect: location.href }, }); } if (session.user.banned) { throw redirect({ to: "/login", hash: "banned" }); } return { auth: { isAuthenticated: true, user: session.user, session: session.session, activeOrganizationId: session.session?.activeOrganizationId || null, isAnonymous: session.user.isAnonymous || false, isAdmin: session.user.role === "admin", isBanned: session.user.banned || false, }, }; }, component: AuthenticatedLayout, }); ``` Nest routes under `_layout/_authenticated/` to inherit this guard. Unauthenticated users are redirected to `/login?redirect=<current-path>`. ## Sidebar System Sidebar items are auto-generated by `bos types gen` into `ui/src/lib/plugin-sidebar.gen.ts` from `bos.config.json`. ### Plugin sidebar config: ```json { "plugins": { "your-plugin": { "sidebar": [ { "to": "/your-plugin", "label": "Your Plugin", "icon": "Activity", "roleRequired": "member" } ] } } } ``` ### Role filtering in `_layout.tsx`: ```ts import { pluginSidebarItems, type SidebarItem, type SidebarRole } from "@/lib/plugin-sidebar.gen"; function filterSidebarByRole(items: SidebarItem[], userRole: SidebarRole): SidebarItem[] { return items.filter((item) => { if (item.roleRequired === "anon") return true; if (item.roleRequired === "member" && userRole !== "anon") return true; if (item.roleRequired === "admin" && userRole === "admin") return true; return false; }); } const userRole = isAdmin ? "admin" : isAuthenticated ? "member" : "anon"; const visibleItems = filterSidebarByRole(pluginSidebarItems, userRole); ``` Available icons: any `lucide-react` icon name as a string. ## SSR Architecture ### Client Bootstrap (`hydrate.tsx`) 1. Reads `window.__RUNTIME_CONFIG__` 2. Creates `QueryClient`, `ApiClient`, `AuthClient` 3. Creates TanStack Router with browser history 4. Hydrates React DOM into the HTML shell ### Server-Side (`router.server.tsx`) 1. Creates request-scoped router with memory history 2. Creates per-request `apiClient` and `authClient` 3. Renders to stream via TanStack Router SSR's `createRequestHandler` 4. Host calls `loadRouterModule()` to dynamically load the SSR bundle ### Route Loading for SSR Loaders run on both server and client. Use `loader` for data needed at render time, `beforeLoad` for auth checks and redirects: ```ts export const Route = createFileRoute("/projects")({ beforeLoad: async ({ context }) => { // Runs on server and client — good for auth }, loader: async ({ context }) => { // Runs on server and client — good for data fetching const { apiClient } = context; return await apiClient.projects.listProjects(); }, component: ProjectsPage, }); ``` The `RouterContext` type: ```ts interface RouterContext extends BaseRouterContextWithApi<ApiClient, SessionData> { apiClient: ApiClient; authClient: AuthClient; } ``` ## Component Patterns ### Semantic Tailwind Classes Use theme-aware classes — never hardcoded colors: ```tsx <div className="bg-background text-foreground"> // ✅ correct <div className="bg-blue-600 text-white"> // ❌ wrong <div className="text-muted-foreground"> // ✅ muted text <div className="bg-card border border-border"> // ✅ card surface ``` ### SSR-Safe Rendering For values that only exist on the client: ```tsx import { useClientValue } from "@/hooks"; function Component() { const appName = useClientValue(() => getAppName(), "app"); return <h1>{appName}</h1>; } ``` ### Component Location - Shared UI components: `ui/src/components/ui/` — semantic, reusable primitives - Feature components: colocated with the route that uses them - Exports from `ui/src/components/index.ts` for shared components