Full-stack pnpm + Turborepo monorepo skill, independent of the separate backend and frontend monorepo skills it draws on. Repository shape is split four ways at the top level: applications-backend, applications-frontend, packages-backend, packages-frontend — so a path states which runtime a file ends up in. Defaults: NestJS for backend services, React with IBM Carbon Design for browser applications; the project's stated architecture overrides both. Covers workspace globs, the Turborepo pipeline, filtered validation, blast-radius checks, Module Federation, turbo prune image builds, and Helm. Documents one sanctioned cross-boundary dependency: API contract types owned by the backend, imported type-only by the frontend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
203 lines
7.7 KiB
Markdown
203 lines
7.7 KiB
Markdown
# Frontend Applications
|
|
|
|
Everything under `applications-frontend/*`: React application layout, IBM Carbon Design setup,
|
|
Module Federation wiring, and the nginx container image.
|
|
|
|
Read this when adding an application, a page, a component or a hook; theming or styling with
|
|
Carbon; wiring a host to a remote; or debugging a runtime module-loading failure.
|
|
|
|
Default stack: **React** with **IBM Carbon Design** (`@carbon/react`). If the project
|
|
architecture specifies otherwise, follow the architecture — the layout, federation, and image
|
|
rules below remain framework-neutral.
|
|
|
|
---
|
|
|
|
## 1. Application workspace layout
|
|
|
|
```text
|
|
applications-frontend/<app-name>/
|
|
├── src/
|
|
│ ├── index.tsx # entry — mounts React, imports Carbon styles
|
|
│ ├── bootstrap.tsx # async boundary, required when federated (§4)
|
|
│ ├── App.tsx
|
|
│ ├── pages/<Page>/
|
|
│ ├── components/<Component>/ # app-local components only
|
|
│ ├── hooks/
|
|
│ ├── api/ # typed clients; imports contract types type-only
|
|
│ └── styles/
|
|
│ └── _carbon.scss # single Carbon import + theme configuration
|
|
├── public/
|
|
├── Dockerfile
|
|
├── nginx.conf
|
|
├── package.json
|
|
├── tsconfig.json
|
|
└── vite.config.ts | webpack.config.cjs # webpack when Module Federation is used
|
|
```
|
|
|
|
A component belongs in `packages-frontend/*` only when a **second** application needs it and
|
|
its API has settled. Until then it stays app-local — a shared component that is still changing
|
|
imposes its churn on every consumer.
|
|
|
|
## 2. IBM Carbon Design
|
|
|
|
Install `@carbon/react` in the application workspace, never at the root.
|
|
|
|
Import Carbon styles **once**, in one SCSS entry point. Importing them per-component
|
|
multiplies bundle size and produces unpredictable cascade order:
|
|
|
|
```scss
|
|
// src/styles/_carbon.scss
|
|
@use "@carbon/react" with (
|
|
$font-path: "@ibm/plex"
|
|
);
|
|
```
|
|
|
|
Rules that keep a Carbon application coherent:
|
|
|
|
- **Use Carbon components before writing your own.** A hand-rolled button or modal loses
|
|
Carbon's keyboard handling, focus management, and ARIA wiring — accessibility work that is
|
|
already done and easy to get wrong.
|
|
- **Theme with Carbon tokens, never raw hex.** `$background`, `$text-primary`, `$layer-01`.
|
|
A raw colour is invisible to theme switching and breaks the moment someone enables dark mode.
|
|
- **Use the Carbon grid** (`Grid`, `Column`) rather than ad-hoc flexbox for page structure, so
|
|
breakpoints match the rest of the system.
|
|
- **Never mix in a second component library.** Two design systems in one app means two type
|
|
scales, two grids, and tokens that fight each other.
|
|
- Follow Carbon's spacing scale (`$spacing-01` … `$spacing-13`) instead of arbitrary pixels.
|
|
|
|
Theme selection belongs at the application root via Carbon's `Theme` component, so a switch
|
|
propagates to every component without per-component overrides.
|
|
|
|
## 3. Talking to backend services
|
|
|
|
API clients live in `src/api/`. Import contract types **type-only** from the owning backend
|
|
package — a value import pulls server code into the browser bundle and nothing in the build
|
|
will flag it (SKILL.md §3):
|
|
|
|
```ts
|
|
import type { UserDto } from "@<scope>/user-contracts";
|
|
|
|
export async function fetchUser(id: string): Promise<UserDto> {
|
|
const res = await fetch(`${BASE_PATH}/users/${id}`);
|
|
if (!res.ok) throw new Error(`fetchUser failed: ${res.status}`);
|
|
return res.json() as Promise<UserDto>;
|
|
}
|
|
```
|
|
|
|
Base paths come from configuration, never hardcoded per call site — when a service's base path
|
|
changes, one constant should move, not twenty fetch calls.
|
|
|
|
## 4. Module Federation
|
|
|
|
Used when applications are composed at runtime: a **host** loads **remotes** over HTTP. Both
|
|
are built and deployed independently, so **nothing catches a mismatch at compile time**. The
|
|
contract has three parts, and all three must agree:
|
|
|
|
1. the remote's URL and its `remoteEntry.js` filename,
|
|
2. the exact keys in the remote's `exposes`, matched by the host's imports,
|
|
3. the shared-dependency singleton settings on **both** sides.
|
|
|
|
```js
|
|
// remote — webpack.config.cjs
|
|
new ModuleFederationPlugin({
|
|
name: "remoteApp",
|
|
filename: "remoteEntry.js",
|
|
exposes: { "./Widget": "./src/components/Widget" },
|
|
shared: { react: { singleton: true, requiredVersion: deps.react },
|
|
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] } },
|
|
});
|
|
|
|
// host — webpack.config.cjs
|
|
new ModuleFederationPlugin({
|
|
name: "hostApp",
|
|
remotes: { remoteApp: "remoteApp@https://<host>/remoteEntry.js" },
|
|
shared: { react: { singleton: true, requiredVersion: deps.react },
|
|
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] } },
|
|
});
|
|
```
|
|
|
|
**The async bootstrap is mandatory.** `src/index.tsx` must do nothing but
|
|
`import("./bootstrap")`. Federated modules resolve asynchronously; importing React directly in
|
|
the entry file evaluates it before the shared scope is initialised, and you get two React
|
|
copies with hook errors that look nothing like a configuration problem.
|
|
|
|
Failure modes and what they actually mean:
|
|
|
|
| Symptom | Cause |
|
|
|---|---|
|
|
| `Shared module is not available for eager consumption` | missing async bootstrap |
|
|
| `Invalid hook call` / two React copies | `singleton: true` missing on one side |
|
|
| `ScriptExternalLoadError` | wrong remote URL, or the remote is not deployed |
|
|
| Host builds, blank page at runtime | `exposes` key renamed on the remote only |
|
|
|
|
Changing any part of the contract is a breaking change for the other side (SKILL.md §8).
|
|
|
|
## 5. Container image
|
|
|
|
Build with `turbo prune`, then serve the static output from nginx:
|
|
|
|
```dockerfile
|
|
FROM node:22-alpine AS pruner
|
|
WORKDIR /app
|
|
RUN corepack enable
|
|
COPY . .
|
|
RUN pnpm dlx turbo prune --scope=@<scope>/<app> --docker
|
|
|
|
FROM node:22-alpine AS builder
|
|
WORKDIR /app
|
|
RUN corepack enable
|
|
COPY --from=pruner /app/out/json/ .
|
|
RUN pnpm install --frozen-lockfile
|
|
COPY --from=pruner /app/out/full/ .
|
|
RUN pnpm turbo run build --filter=@<scope>/<app>
|
|
|
|
FROM nginx:alpine AS runner
|
|
COPY --from=builder /app/applications-frontend/<app>/dist /usr/share/nginx/html
|
|
COPY applications-frontend/<app>/nginx.conf /etc/nginx/conf.d/default.conf
|
|
EXPOSE 8080
|
|
```
|
|
|
|
`nginx.conf` must route unknown paths back to `index.html`, or a client-side route will 404 on
|
|
refresh and deep links will not work:
|
|
|
|
```nginx
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
```
|
|
|
|
Anything baked in at build time is public. Build-time environment variables are compiled into
|
|
the bundle — never put a secret in one.
|
|
|
|
## 6. Validation
|
|
|
|
```bash
|
|
pnpm check-types --filter="@<scope>/<app>"
|
|
pnpm lint --filter="@<scope>/<app>"
|
|
pnpm build --filter="@<scope>/<app>"
|
|
```
|
|
|
|
Federated applications additionally require host and remote **running together** — separate
|
|
green builds prove nothing about a runtime contract:
|
|
|
|
```bash
|
|
pnpm dev --filter="@<scope>/<remote>"
|
|
pnpm dev --filter="@<scope>/<host>"
|
|
```
|
|
|
|
Then load the host and confirm the remote's component actually renders, with a clean console.
|
|
|
|
## 7. Mistakes to avoid
|
|
|
|
- Importing backend contract types with a value import instead of `import type`.
|
|
- Importing Carbon styles in more than one place.
|
|
- Raw hex colours or arbitrary pixel spacing instead of Carbon tokens and the spacing scale.
|
|
- Hand-rolling a component Carbon already provides.
|
|
- Mixing a second component library into a Carbon application.
|
|
- Omitting the async bootstrap in a federated application.
|
|
- Changing an `exposes` key, remote name, or remote URL on one side only.
|
|
- Setting `singleton: true` on one side of a shared dependency but not the other.
|
|
- Missing the `try_files` fallback in `nginx.conf`.
|
|
- Baking a secret into a build-time environment variable.
|
|
- Promoting an app-local component into `packages-frontend/*` before a second consumer exists.
|