commit ff69bcca2fb3b673297230710a74c70410fd1996 Author: Oleg Lukasonok Date: Wed Aug 12 23:49:02 2026 +0300 development-monorepo-pnpm skill: initial import 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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..2321cfa --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# development-monorepo-pnpm + +Agent Skill for full-stack monorepos built on **pnpm workspaces** and **Turborepo**, where the +front/back split is expressed in the top-level directory names: + +```text +applications-backend/ # deployable services — NestJS by default +applications-frontend/ # deployable browser apps — React + IBM Carbon by default +packages-backend/ # libraries consumed by services +packages-frontend/ # libraries consumed by browser apps +``` + +Framework defaults are defaults, not mandates — a project's stated architecture always wins. + +The skill is self-contained: it covers both halves of the stack, from workspace globs and the +Turborepo pipeline through to container images and Helm charts. + +## Layout + +```text +development-monorepo-pnpm/ +├── SKILL.md # Entry point — repo shape, defaults, shared rules +├── references/ +│ ├── applications-backend.md # NestJS layout, config, ports, health, service image +│ ├── applications-frontend.md # React + Carbon, Module Federation, nginx image +│ ├── packages.md # Which root, package tiers, public API, changesets +│ ├── workspace-and-tooling.md # Globs, turbo.json, root scripts, overrides +│ └── containers-and-helm.md # turbo prune images, charts, values, secrets +└── assets/ + └── example-shared-package/ # Minimal shared TypeScript package +``` + +## The rule worth knowing up front + +Frontend workspaces must not import from `packages-backend/*` and vice versa — with one +exception: **API contract types**. Contracts are owned by the service that defines them, so +they live in `packages-backend/-contracts`, export types only, and are imported by +frontend consumers with `import type`. A value import from the same package pulls server code +into the browser bundle, and nothing in the build will flag it. + +See [SKILL.md](SKILL.md). diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..2fb8987 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,287 @@ +--- +name: development-monorepo-pnpm +description: > + Create, review, refactor, debug, and maintain full-stack monorepos built on pnpm workspaces + and Turborepo, split four ways — applications-backend, applications-frontend, + packages-backend, packages-frontend. Backend services default to NestJS; browser + applications default to React with IBM Carbon Design, unless the project architecture states + otherwise. Use when adding or changing a service, controller, module, React page, component + or hook; assigning ports and API base paths; sharing API contract types across the + front/back boundary; wiring Module Federation; editing shared eslint or typescript presets; + cutting changesets; editing Dockerfiles, Helm charts, or Turborepo tasks; running filtered + validation or blast-radius checks; or fixing workspace, dependency, lockfile, or + build-pipeline problems — even when the user only names their repository instead of saying + "monorepo". +license: Proprietary +metadata: + author: workspace-skills-code-agent + version: "1.0" + spec: agentskills.io/specification +compatibility: Requires pnpm (via corepack) and Node. Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, and other compatible agent environments +--- + +# Full-Stack pnpm + Turborepo Monorepo + +Work rules for monorepos holding **both** backend services and browser applications, using +**pnpm workspaces** for dependency resolution and **Turborepo** for task orchestration. + +The defining choice in this layout is that the front/back split is expressed in the +**directory names themselves**, not in a nested convention. Four top-level workspace roots, +each with one job. + +## Scope routing + +Pick the reference that matches the change; do not load all of them. + +| The change touches | Read | +|---|---| +| A service under `applications-backend/*` — modules, controllers, providers, config | `references/applications-backend.md` | +| An app under `applications-frontend/*` — components, routing, Carbon, Module Federation | `references/applications-frontend.md` | +| Anything under `packages-backend/*` or `packages-frontend/*` — exports, presets, changesets | `references/packages.md` | +| `pnpm-workspace.yaml`, `turbo.json`, root scripts, formatting, dependency overrides | `references/workspace-and-tooling.md` | +| Dockerfiles, images, Helm charts, values, deploy automation | `references/containers-and-helm.md` | + +## 1. Canonical repository shape + +```text +/ +├── applications-backend/ # deployable services — NestJS by default +│ └── / +├── applications-frontend/ # deployable browser apps — React + Carbon by default +│ └── / +├── packages-backend/ # libraries consumed by services +│ └── / +├── packages-frontend/ # libraries consumed by browser apps +│ └── / +├── helm-charts// # deployment assets +├── .scripts/ # shell modules: docker, images, helm, pipelines +├── package.json # root scripts + packageManager + engines + overrides +├── pnpm-workspace.yaml # the four globs below +├── turbo.json # task pipeline +├── pnpm-lock.yaml +├── .prettierrc # single formatting authority +└── Taskfile.yml # thin delegator — includes .scripts/ modules only +``` + +```yaml +# pnpm-workspace.yaml +packages: + - "applications-backend/*" + - "applications-frontend/*" + - "packages-backend/*" + - "packages-frontend/*" +``` + +Applications sit **directly** under their root — `applications-backend/`, not +`applications-backend//core`. The four-way split already gives each side its own +namespace, so no extra nesting layer is needed. + +**A workspace exists only if a glob matches it.** Creating a directory is not enough: confirm +the glob, then run `pnpm install`. A directory outside these four roots is not part of the +build graph, and Turborepo will silently ignore it. + +### Why the split is in the directory name + +One glance at a path tells you which runtime a file ends up in. That matters because the two +sides have genuinely different constraints — a browser bundle ships to untrusted clients and +pays for every kilobyte, a service does not; a service holds credentials, a bundle must never. +When both live under one `packages/` root, the only thing standing between a secret-handling +utility and a browser bundle is someone reading the import path carefully. + +## 2. Default stacks + +| Layer | Default | Applies unless | +|---|---|---| +| `applications-backend/*` | **NestJS** — modules, controllers, providers, DI | the project architecture specifies another framework | +| `applications-frontend/*` | **React** with **IBM Carbon Design** (`@carbon/react`) | the project architecture specifies another framework or design system | + +These are defaults, not mandates. **The project's stated architecture always wins** — read the +paired code-agent repository's `memory-bank/` (§9) and any architecture decision record +*before* scaffolding. If the architecture is silent, use the defaults above and record the +choice; if it disagrees, follow it and do not "correct" it toward these defaults. + +Do not mix design systems inside one frontend application. Carbon components and a second +component library in the same app produce two type scales, two grid systems, and a token set +that fights itself. + +## 3. The one sanctioned cross-boundary dependency + +Frontend workspaces must not import from `packages-backend/*`, and backend workspaces must not +import from `packages-frontend/*` — with exactly one exception: + +**API contract types.** The service that defines an endpoint owns its contract, so contract +packages live under `packages-backend/-contracts` and expose **types only** — no +runtime code, no dependencies, no side effects. Frontend consumers import them type-only: + +```ts +import type { UserDto } from "@/user-contracts"; +``` + +`import type` is erased at compile time, so nothing from the backend package reaches the +browser bundle. A plain `import` from the same package does reach it — that is the failure +this rule exists to prevent, and it will not show up as a type error. + +A contract package that acquires a runtime dependency has stopped being a contract package. +Split it before it ships anything into a bundle. + +## 4. Tooling baseline + +Root `package.json` pins the contract for every contributor and CI runner: `packageManager` +(exact pnpm version, read by corepack), `engines.node`, `pnpm.overrides` for security pins, +and root scripts that always delegate to Turborepo — never to a single workspace. + +```bash +pnpm build # turbo run build +pnpm dev # turbo run dev +pnpm lint # turbo run lint +pnpm check-types # turbo run check-types +pnpm format # prettier --write +pnpm publish-packages # build:ci + lint, then changeset version && changeset publish +pnpm prepush # turbo build --filter=...[HEAD^] — only what the branch touched +``` + +`build` depends on `^build`, `check-types`, and `lint`, so a type error or lint error is a +build failure for every downstream workspace, not a warning. Full pipeline, cache outputs, and +override rules → `references/workspace-and-tooling.md`. + +## 5. Before making changes + +1. Identify the exact workspace path, and therefore which of the four roots you are in. +2. Read that workspace's `package.json` — scripts, dependencies, whether it is publishable. +3. Inspect its `tsconfig.json`, lint config, build config, and source layout before adding. +4. Map what it consumes from shared packages, and what consumes it. +5. For Module Federation changes, map the host↔remote contract before editing either side. +6. Read the paired code-agent repository's `rules/` and `memory-bank/` when one exists (§9) — + it carries architecture decisions the code does not. + +```bash +find applications-backend applications-frontend -maxdepth 2 -name package.json -print +find packages-backend packages-frontend -maxdepth 2 -name package.json -print +pnpm list --depth 0 +task --list +``` + +## 6. Implementation rules + +### Workspace boundaries + +- Application logic stays inside its own workspace. +- Promote into a shared package only when **more than one** application needs it *and* the + abstraction has stopped changing. Config presets are the exception — shared from day one. + Premature promotion is the most expensive mistake here: every consumer inherits the churn. +- A shared package goes in the root matching its **consumers**, never its author. +- Never cross a workspace boundary with a relative import (`../../other-app/src`). Depend on + the workspace package, or the Module Federation contract, or not at all. + +### TypeScript + +- Every workspace extends a shared config from the `typescript-config` preset. Do not restate + `compilerOptions` locally beyond `outDir`, `rootDir`, `include`, `exclude`. +- `check-types` is `tsc --noEmit`, named identically in every workspace so Turborepo can run + it across the graph. +- Keep types explicit at public boundaries; avoid broad `any`. + +### Dependencies + +- Add a dependency to the **smallest** workspace that needs it. +- Root dependencies are monorepo tooling only — turbo, prettier, husky, changesets, + typescript. Nothing an application imports at runtime. +- Inter-workspace dependencies use `workspace:*`. +- Run `pnpm install` after every `package.json` edit and commit the lockfile change with the + edit that caused it. +- Never remove a `pnpm.overrides` entry without a security review — removing one silently + reintroduces a vulnerable transitive version and nothing in the build complains. + +### Formatting + +Root `.prettierrc` is the single authority. Do not add per-package Prettier config; a second +config produces churn diffs every time someone formats a file from the other side. + +## 7. Validation + +Validate the narrowest scope covering the change, then widen if it touched shared code. + +```bash +# Single workspace +pnpm check-types --filter="@/" +pnpm lint --filter="@/" +pnpm build --filter="@/" + +# Shared package or cross-workspace change — unfiltered, plus each consumer +pnpm check-types && pnpm lint && pnpm build +pnpm build --filter="@/" + +# Only what the branch changed +pnpm prepush +``` + +Module Federation changes additionally require **host and remote running together** — a green +build on each side separately proves nothing about a runtime contract: + +```bash +pnpm dev --filter="@/" +pnpm dev --filter="@/" +``` + +Record the commands run and their outcome in the task record (§9). "It builds", without the +filter you used, is not a validation result. + +## 8. Blast-radius checks + +Before changing anything another workspace can observe — an exported symbol, a REST base path, +a port, an environment variable name, an MFE `exposes` key: + +1. Enumerate the surface. +2. Search every consumer across all four roots. +3. Decide explicitly whether the change is backward-compatible. +4. If it is not: update consumers in the same change, or ship a compatible wrapper first. +5. Validate each affected consumer with its own `--filter` build. + +```bash +grep -Rn "from '@/'" applications-backend applications-frontend \ + packages-backend packages-frontend +grep -Rn "api/" applications-frontend # base-path callers +``` + +A port or base path change is never local: it also lands in the Helm chart values, the gateway +routing, and every frontend caller. See `references/containers-and-helm.md`. + +## 9. Durable records + +Monorepos in this family pair with a `-code-agent/` repository holding `rules/`, +`memory-bank/`, and `tasks/`. Update `memory-bank/` when durable facts change — application +inventory, ports, base paths, MFE topology, chosen frameworks and design system, chart +structure. Create a task record under `tasks/` for substantial work. + +Never place task records inside the target repository unless explicitly asked. + +## 10. Common mistakes to avoid + +- Importing a `packages-backend/*` package from a frontend workspace with a value import + instead of `import type` — it silently pulls server code into the browser bundle. +- Putting a shared package in the root that matches its author rather than its consumers. +- Adding a workspace outside the four roots, or without updating `pnpm-workspace.yaml`. +- Re-introducing a `core/` nesting layer under an application directory. +- Changing a service port or base path without updating Helm values, gateway routing, and + every caller. +- Changing an MFE `exposes` key, remote name, or remote URL on one side only. +- Removing a `pnpm.overrides` entry without a security review. +- Adding a root-level dependency for a need local to one workspace. +- Skipping `--filter` validation for the exact workspace you edited. +- Mixing a second component library into a Carbon application. +- Overriding the project's stated architecture because it differs from the defaults in §2. +- Adding tasks directly to root `Taskfile.yml` instead of a `.scripts/` module. +- Editing generated artifacts (`dist/`, rendered manifests, lockfiles) by hand. +- Committing `.env`, `key.pem`, `cert.pem`, registry tokens, or `.npmrc` secrets — including + into memory-bank or task documentation. + +## Reference files + +| File | Contents | Read when | +|---|---|---| +| `references/applications-backend.md` | NestJS service layout, modules/controllers/providers, config and env, ports and base paths, health checks, service Dockerfile | Touching `applications-backend/*` | +| `references/applications-frontend.md` | React app layout, IBM Carbon setup and theming, routing, state, Module Federation wiring and failure modes, nginx image | Touching `applications-frontend/*` | +| `references/packages.md` | Package tiers per root, contract packages, public-API safety, export and dependency rules, changesets and publishing | Touching `packages-backend/*` or `packages-frontend/*` | +| `references/workspace-and-tooling.md` | Workspace globs, `turbo.json` pipeline and caching, root scripts, Prettier, `pnpm.overrides`, lockfile discipline | Touching root config or the task pipeline | +| `references/containers-and-helm.md` | `turbo prune` image builds for both sides, chart layout, values and secrets, port/base-path propagation | Touching Dockerfiles, images, or charts | +| `assets/example-shared-package/` | Minimal shared TypeScript package — ESM, barrel export, shared tsconfig and eslint | Scaffolding a package under either packages root | diff --git a/assets/example-shared-package/.eslintrc.cjs b/assets/example-shared-package/.eslintrc.cjs new file mode 100644 index 0000000..f8919d0 --- /dev/null +++ b/assets/example-shared-package/.eslintrc.cjs @@ -0,0 +1,5 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@ca/eslint-config/base"], +}; diff --git a/assets/example-shared-package/README.md b/assets/example-shared-package/README.md new file mode 100644 index 0000000..c30cc7e --- /dev/null +++ b/assets/example-shared-package/README.md @@ -0,0 +1,52 @@ +# example-shared-package — `@ca/example-utils` + +Complete minimal shared TypeScript package for a pnpm + Turborepo monorepo. Copy it as the +starting point for a new package and rename the scope to match your repository. + +It goes under **`packages-backend/`** or **`packages-frontend/`** depending on who consumes +it — the root matches the consumers, never the author. See `references/packages.md` §1. + +The package itself is environment-neutral: no Node built-ins, no DOM APIs. That is what makes +it a safe template for either root. Adding `fs`, `path`, or `crypto` makes it backend-only; +adding DOM APIs makes it frontend-only. + +## Structure + +```text +packages-backend/example-utils/ # or packages-frontend/example-utils/ +├── .eslintrc.cjs +├── package.json +├── tsconfig.json +└── src/ + ├── index.ts # Public barrel — re-exports types and utils + ├── types.ts # Shared TypeScript types + └── example-utils.ts # Utility functions +``` + +## Key conventions + +- `"type": "module"` with ESM output under `dist/`. +- `exports` maps the package root to `dist/index.js` and `dist/index.d.ts`. +- `tsconfig.json` extends the shared `typescript-config` preset — only `outDir` and `rootDir` + are local. +- `.eslintrc.cjs` extends the shared `eslint-config` preset. +- Internal imports carry the `.js` extension — required for ESM TypeScript. +- `files: ["dist"]` keeps sources out of the published payload. +- Workspace dependencies use `workspace:*`. +- `src/index.ts` is the entire public API; nothing else is importable by consumers. +- Script names (`build`, `check-types`, `lint`) match the tasks in `turbo.json`, or Turborepo + skips the workspace silently. + +## Contract packages + +A package that shares API types across the front/back boundary is a variant of this template: +name it `-contracts`, place it under `packages-backend/`, export **types only**, and +keep it free of runtime dependencies. Frontend consumers import it with `import type`. + +## Validation commands + +```bash +pnpm check-types --filter="@ca/example-utils" +pnpm lint --filter="@ca/example-utils" +pnpm build --filter="@ca/example-utils" +``` diff --git a/assets/example-shared-package/package.json b/assets/example-shared-package/package.json new file mode 100644 index 0000000..844fabe --- /dev/null +++ b/assets/example-shared-package/package.json @@ -0,0 +1,34 @@ +{ + "name": "@ca/example-utils", + "version": "1.0.0", + "description": "Generic example shared utility package for CogArchHub", + "author": "IBM", + "license": "IBM copyright", + "private": false, + "publishConfig": { + "registry": "https://nexus.edst.ibm.com/repository/Cognitive_Architect/" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "check-types": "tsc --noEmit", + "lint": "eslint src --ext .ts", + "dev": "tsc --watch" + }, + "devDependencies": { + "@ca/eslint-config": "workspace:*", + "@ca/typescript-config": "workspace:*", + "typescript": "^5.4.5" + } +} diff --git a/assets/example-shared-package/src/example-utils.ts b/assets/example-shared-package/src/example-utils.ts new file mode 100644 index 0000000..066aca5 --- /dev/null +++ b/assets/example-shared-package/src/example-utils.ts @@ -0,0 +1,24 @@ +import type { ExampleItem, ExampleResult } from "./types.js"; + +/** + * Wraps a value in an ExampleResult with an ISO timestamp. + */ +export const wrapResult = (data: T): ExampleResult => ({ + data, + timestamp: new Date().toISOString(), +}); + +/** + * Finds an item by id from a list. + * Returns undefined when not found. + */ +export const findById = ( + items: ExampleItem[], + id: string, +): ExampleItem | undefined => items.find((item) => item.id === id); + +/** + * Converts an ExampleItem to a display string. + */ +export const toDisplayString = (item: ExampleItem): string => + `[${item.id}] ${item.name}: ${item.value}`; diff --git a/assets/example-shared-package/src/index.ts b/assets/example-shared-package/src/index.ts new file mode 100644 index 0000000..34f2278 --- /dev/null +++ b/assets/example-shared-package/src/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./example-utils.js"; diff --git a/assets/example-shared-package/src/types.ts b/assets/example-shared-package/src/types.ts new file mode 100644 index 0000000..5d45a99 --- /dev/null +++ b/assets/example-shared-package/src/types.ts @@ -0,0 +1,10 @@ +export type ExampleItem = { + id: string; + name: string; + value: string; +}; + +export type ExampleResult = { + data: T; + timestamp: string; +}; diff --git a/assets/example-shared-package/tsconfig.json b/assets/example-shared-package/tsconfig.json new file mode 100644 index 0000000..ff633ec --- /dev/null +++ b/assets/example-shared-package/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@ca/typescript-config/base", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/references/applications-backend.md b/references/applications-backend.md new file mode 100644 index 0000000..03b5ce8 --- /dev/null +++ b/references/applications-backend.md @@ -0,0 +1,176 @@ +# Backend Applications + +Everything under `applications-backend/*`: NestJS service layout, configuration, ports and +base paths, health checks, and the service container image. + +Read this when adding a service, adding an endpoint to one, changing configuration or +environment variables, assigning a port or base path, or debugging a service that will not +start. + +Default framework: **NestJS**. If the project architecture specifies otherwise, follow the +architecture — the layout rules below that are framework-neutral still apply. + +--- + +## 1. Service workspace layout + +```text +applications-backend// +├── src/ +│ ├── main.ts # bootstrap — the only file that binds a port +│ ├── app.module.ts # root module, imports feature modules +│ ├── config/ +│ │ ├── configuration.ts # typed config factory, reads process.env once +│ │ └── validation.ts # schema; process exits if the env is invalid +│ ├── health/ # liveness and readiness endpoints +│ └── / +│ ├── .module.ts +│ ├── .controller.ts # HTTP surface only +│ ├── .service.ts # business logic +│ ├── dto/ # request/response shapes +│ └── entities/ +├── test/ +├── Dockerfile +├── nest-cli.json +├── package.json +├── tsconfig.json +└── tsconfig.build.json +``` + +One responsibility per layer, and the boundary is enforceable by review: + +| Layer | Holds | Never holds | +|---|---|---| +| `*.controller.ts` | routing, param binding, response shape | business rules, direct DB access | +| `*.service.ts` | business logic, orchestration | HTTP concerns, `@Res()`, status codes | +| `dto/` | validated request/response shapes | logic | +| `entities/` | persistence models | HTTP or transport concerns | + +A controller that reaches past its service into a repository is the most common drift in this +layout, and it is invisible until you try to reuse the logic from a queue consumer or a cron +job and discover it only exists inside an HTTP handler. + +## 2. Configuration and environment + +Read `process.env` in **one** place — the config factory — and inject typed config everywhere +else. Scattered `process.env` reads make it impossible to know a service's full input surface, +which is exactly what you need when writing the Helm values. + +```ts +// src/config/configuration.ts +export default () => ({ + port: parseInt(process.env.PORT ?? "3000", 10), + basePath: process.env.API_BASE_PATH ?? "/api/", + database: { url: process.env.DATABASE_URL }, +}); +``` + +Validate at startup and **fail fast**. A service that boots with a missing variable and fails +on the first request that needs it turns a config error into a production incident, and the +stack trace points at the wrong place. + +Every variable added here must also be added to the Helm values and the service's documented +env surface — see `references/containers-and-helm.md`. + +## 3. Ports and base paths + +Both are **cross-cutting contracts**, not local settings: + +- The port appears in `main.ts`, the Dockerfile `EXPOSE`, the Helm values, and the service's + Kubernetes Service definition. +- The base path appears in `main.ts` (`app.setGlobalPrefix()`), the gateway or ingress routing, + and every frontend caller. + +Assign a port from the repository's documented range and record it in the paired code-agent +`memory-bank/` — that record is what stops the next service from colliding with it. Changing +either value is a blast-radius change (SKILL.md §8). + +```ts +// src/main.ts +const app = await NestFactory.create(AppModule); +app.setGlobalPrefix(config.basePath); +app.enableCors({ origin: config.corsOrigins }); +await app.listen(config.port); +``` + +## 4. Health checks + +Expose **two** endpoints, and keep them different: + +- **Liveness** — is the process alive? No dependency checks. A liveness probe that checks the + database restarts a healthy service every time the database blips, turning a partial outage + into a total one. +- **Readiness** — can it serve traffic now? Checks dependencies it genuinely needs. + +## 5. Consuming shared packages + +Backend services consume `packages-backend/*` with `workspace:*`. They must never import from +`packages-frontend/*`. + +A service that defines an API contract publishes it as `packages-backend/-contracts`, +types only, so frontend applications can import it type-only — see SKILL.md §3. Adding a +runtime dependency to a contract package breaks that guarantee for every frontend consumer. + +## 6. Container image + +Multi-stage build using `turbo prune` to produce a minimal snapshot of just this service and +its workspace dependencies. Pruning is what stops an unrelated workspace change from +invalidating this image's layers. + +```dockerfile +FROM node:22-alpine AS pruner +WORKDIR /app +RUN corepack enable +COPY . . +RUN pnpm dlx turbo prune --scope=@/ --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=@/ + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup -S app && adduser -S app -G app +COPY --from=builder --chown=app:app /app . +USER app +EXPOSE +CMD ["node", "applications-backend//dist/main.js"] +``` + +Points that matter: + +- `out/json/` is copied and installed **before** `out/full/` so dependency installation caches + independently of source changes. +- `--frozen-lockfile` makes the build fail rather than silently resolve different versions. +- Run as a non-root user. The default is root, and nothing warns you. + +## 7. Validation + +```bash +pnpm check-types --filter="@/" +pnpm lint --filter="@/" +pnpm build --filter="@/" +pnpm dev --filter="@/" # it must actually boot + +curl -s localhost://health | jq . +``` + +A build that passes proves the types line up; only starting the service proves the config +validation and port binding do. + +## 8. Mistakes to avoid + +- Reading `process.env` outside the config factory. +- Booting successfully with invalid configuration instead of failing fast. +- A liveness probe that checks dependencies. +- Business logic in a controller. +- Importing from `packages-frontend/*`. +- Giving a contract package a runtime dependency. +- Changing a port or base path without updating Helm values, gateway routing, and callers. +- Hardcoding a base path string in a frontend caller instead of deriving it from the contract. +- Running the container as root. diff --git a/references/applications-frontend.md b/references/applications-frontend.md new file mode 100644 index 0000000..060facd --- /dev/null +++ b/references/applications-frontend.md @@ -0,0 +1,202 @@ +# 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// +├── src/ +│ ├── index.tsx # entry — mounts React, imports Carbon styles +│ ├── bootstrap.tsx # async boundary, required when federated (§4) +│ ├── App.tsx +│ ├── pages// +│ ├── components// # 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 "@/user-contracts"; + +export async function fetchUser(id: string): Promise { + const res = await fetch(`${BASE_PATH}/users/${id}`); + if (!res.ok) throw new Error(`fetchUser failed: ${res.status}`); + return res.json() as Promise; +} +``` + +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:///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=@/ --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=@/ + +FROM nginx:alpine AS runner +COPY --from=builder /app/applications-frontend//dist /usr/share/nginx/html +COPY applications-frontend//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="@/" +pnpm lint --filter="@/" +pnpm build --filter="@/" +``` + +Federated applications additionally require host and remote **running together** — separate +green builds prove nothing about a runtime contract: + +```bash +pnpm dev --filter="@/" +pnpm dev --filter="@/" +``` + +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. diff --git a/references/containers-and-helm.md b/references/containers-and-helm.md new file mode 100644 index 0000000..5101bcc --- /dev/null +++ b/references/containers-and-helm.md @@ -0,0 +1,128 @@ +# Containers and Helm + +Building images for both sides of the monorepo, and the Helm assets that deploy them. + +Read this when editing a Dockerfile, adding a chart, wiring an image tag, changing values or +secrets, or propagating a port or base-path change into deployment. + +--- + +## 1. `turbo prune` is what makes image builds sane + +Both sides build the same way: prune the monorepo to one workspace and its dependencies, +install, build, then copy the result into a minimal runtime image. + +```bash +turbo prune --scope=@/ --docker +``` + +This produces `out/json/` (package manifests plus the lockfile) and `out/full/` (source). +Copying and installing `out/json/` **before** `out/full/` is the whole point: dependency +installation caches on manifests alone, so editing source does not reinstall, and an unrelated +workspace's change does not invalidate this image at all. + +Without pruning, every image rebuilds on any change anywhere in the repository. + +## 2. The two runtime shapes + +| | backend | frontend | +|---|---|---| +| Runtime stage | `node:22-alpine` | `nginx:alpine` | +| Ships | compiled server + `node_modules` | static assets only | +| Serves | the Node process | nginx | +| Needs `try_files` fallback | no | **yes** | +| Runtime env vars | yes — read at startup | **no** — baked in at build time | + +That last row is the one that causes incidents. A frontend build inlines its environment +variables into the bundle, so they are public and fixed at build time. **A secret in a frontend +build argument is a published secret.** Anything genuinely per-environment on the frontend must +be fetched at runtime from an endpoint, not baked in. + +Full Dockerfiles: `references/applications-backend.md` §6 and +`references/applications-frontend.md` §5. + +## 3. Chart layout + +```text +helm-charts// +├── Chart.yaml +├── values.yaml # defaults — never environment-specific secrets +├── values-.yaml # per-environment overrides +└── templates/ + ├── deployment.yaml + ├── service.yaml + ├── ingress.yaml + ├── configmap.yaml + └── _helpers.tpl +``` + +Rules: + +- **Never hardcode an image tag in a template.** It comes from values, supplied by the + pipeline. A hardcoded tag makes every environment deploy whatever was current when someone + last edited the chart. +- Ports and base paths in values must match the service's actual configuration (§5). +- Resource requests and limits are set for every container. A container without them competes + unboundedly with everything else on the node. +- Liveness and readiness probes point at the service's two distinct health endpoints — see + `references/applications-backend.md` §4. + +## 4. Secrets + +- **No secret values in `values.yaml`, `values-.yaml`, templates, or the repository.** +- Reference an existing Kubernetes `Secret` by name, or use an external secrets provider that + materialises it in-cluster. +- A base64 string in a committed manifest is encoding, not encryption — treat it as plaintext. +- Never log a secret's value from a container's startup path. + +## 5. Port and base-path propagation + +Changing a service's port or base path touches, at minimum: + +1. the service's own config (`main.ts`, config factory), +2. the Dockerfile `EXPOSE`, +3. the chart's values and `service.yaml`, +4. ingress or gateway routing, +5. every frontend caller, +6. the paired code-agent `memory-bank/` record. + +Miss any one and the failure appears at runtime, in a different component from the one you +edited. Treat it as a blast-radius change (SKILL.md §8) and update all six in the same change. + +## 6. Validation + +```bash +helm lint helm-charts/ +helm template helm-charts/ -f helm-charts//values-.yaml + +# what would actually change in the cluster +helm diff upgrade helm-charts/ -f values-.yaml +``` + +`helm lint` checks structure, not intent — a chart that lints cleanly can still point at the +wrong port. Always read the rendered output for the values that matter: image tag, port, base +path, probe paths, resource limits. + +Verify an image locally before shipping it: + +```bash +docker build -f applications-backend//Dockerfile -t :local . +docker run --rm -p : :local +``` + +Build from the **repository root** — the Dockerfile's `COPY . .` expects the whole monorepo as +context, because `turbo prune` runs inside the image. + +## 7. Mistakes to avoid + +- Building an image without `turbo prune`. +- Copying `out/full/` before installing from `out/json/`, losing all dependency-layer caching. +- Running a container as root. +- A secret in a frontend build argument or any committed values file. +- A hardcoded image tag in a template. +- Missing `try_files` in the frontend nginx config. +- Containers with no resource requests or limits. +- A liveness probe that checks dependencies. +- Changing a port or base path in fewer than all six places in §5. +- Building the image from the workspace directory instead of the repository root. +- Editing rendered manifests instead of the chart that produces them. diff --git a/references/packages.md b/references/packages.md new file mode 100644 index 0000000..05a8017 --- /dev/null +++ b/references/packages.md @@ -0,0 +1,137 @@ +# Shared Packages + +Everything under `packages-backend/*` and `packages-frontend/*`: which root a package belongs +in, package tiers, public-API safety, and changesets publishing. + +Read this when creating a shared package, changing one's exports or dependencies, deciding +where a package belongs, or cutting a release. + +--- + +## 1. Choosing the root + +**A package lives in the root that matches its consumers, never its author.** A utility +extracted from a service but used only by browser applications belongs in +`packages-frontend/*`. + +| Consumed by | Root | +|---|---| +| backend services only | `packages-backend/*` | +| browser applications only | `packages-frontend/*` | +| both — API contract types | `packages-backend/-contracts`, types only (SKILL.md §3) | +| both — genuine runtime code | see below | + +Truly shared **runtime** code is rarer than it looks. Before creating one, check that it is +free of Node built-ins (`fs`, `path`, `crypto`), free of DOM APIs, and free of anything +environment-specific. If it is not, you have two packages, not one — and forcing it into a +single package pulls one environment's assumptions into the other. + +When such a package is justified, place it under the root of its **primary** consumer, keep it +dependency-free, and document the constraint at the top of its README. A dependency added +later is what breaks the other side. + +## 2. Package tiers + +| Tier | Purpose | Examples | +|---|---|---| +| Configuration presets | shared build/lint/type config | `eslint-config`, `typescript-config` | +| Contracts | API types shared across the boundary | `-contracts` | +| Domain libraries | business logic for one domain | `billing-core`, `user-core` | +| Utilities | domain-independent helpers | `date-utils`, `result` | + +Configuration presets are shared from day one — that is their job. Every other tier earns its +existence only after a second consumer appears. + +## 3. Package anatomy + +```text +packages-// +├── src/ +│ ├── index.ts # barrel — the entire public API +│ └── .ts +├── package.json +├── tsconfig.json +├── .eslintrc.cjs | eslint.config.js +└── README.md # what it is, who consumes it, constraints +``` + +```json +{ + "name": "@/", + "version": "0.1.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, + "scripts": { + "build": "tsc", + "check-types": "tsc --noEmit", + "lint": "eslint src" + } +} +``` + +**`src/index.ts` is the public API.** Anything not exported from the barrel is internal and may +change freely; anything exported is a contract with every consumer. Deep imports into +`dist/` or `src/` from a consumer defeat this — treat one as a bug in the consumer. + +Script names must match the tasks in `turbo.json`, or Turborepo silently skips the workspace. + +## 4. Changing a public API + +Adding an export is safe. Changing or removing one is not. + +1. Search every consumer across all four roots before editing: + +```bash +grep -Rn "from '@/'" applications-backend applications-frontend \ + packages-backend packages-frontend +``` + +2. Decide explicitly whether the change is backward-compatible. +3. If it is not: update consumers in the same change, or add the new export alongside the old + one, migrate, then remove. +4. Build every affected consumer with its own `--filter`. + +A shared package's dependencies propagate to every consumer, so weigh each one twice. This +matters most in `packages-frontend/*`, where a dependency lands in a browser bundle that ships +to users. + +## 5. Configuration presets + +`typescript-config` and `eslint-config` exist so rules live in one place. Every workspace +extends them rather than restating `compilerOptions` or lint rules locally. + +Their blast radius is the **whole repository** — a rule added to the shared ESLint preset can +fail the build in workspaces you never opened. Change one, then run an unfiltered +`pnpm lint && pnpm check-types` before committing. + +Each side may need its own preset variant (a DOM `lib` for frontend, Node types for backend). +Keep the shared base shared and put only the genuine differences in the variant. + +## 6. Changesets and publishing + +```bash +pnpm changeset # describe the change, pick the bump +pnpm publish-packages # build:ci + lint, then version and publish +``` + +- One changeset per meaningful change, written for a consumer deciding whether to upgrade — + not a restatement of the commit message. +- A breaking change to an exported API is a **major** bump, even if every current consumer + lives in this repository. +- Never hand-edit versions or `CHANGELOG.md`; changesets owns both. +- Internal-only packages are marked `"private": true` and are never published. + +## 7. Mistakes to avoid + +- Putting a package in the root matching its author instead of its consumers. +- Creating a "shared" runtime package that imports Node built-ins or DOM APIs. +- Giving a contract package a runtime dependency. +- Exporting something from the barrel that was meant to be internal. +- Deep-importing another package's `src/` or `dist/` from a consumer. +- Changing an exported API without searching consumers first. +- Adding a dependency to a frontend package without considering bundle size. +- Restating `compilerOptions` or lint rules locally instead of changing the shared preset. +- Editing versions or changelogs by hand. +- Promoting logic into a shared package before a second consumer exists. diff --git a/references/workspace-and-tooling.md b/references/workspace-and-tooling.md new file mode 100644 index 0000000..2d69779 --- /dev/null +++ b/references/workspace-and-tooling.md @@ -0,0 +1,143 @@ +# Workspace and Tooling + +Root-level configuration: workspace globs, the Turborepo pipeline and its caching, root +scripts, Prettier, dependency overrides, and lockfile discipline. + +Read this when adding a workspace, changing `turbo.json` or root scripts, debugging a task +that does not run or a cache that never hits, or touching `pnpm.overrides`. + +--- + +## 1. Workspace globs + +```yaml +# pnpm-workspace.yaml +packages: + - "applications-backend/*" + - "applications-frontend/*" + - "packages-backend/*" + - "packages-frontend/*" +``` + +Exactly one level of nesting under each root. A directory that no glob matches is invisible to +pnpm and to Turborepo — it will not install dependencies, will not build, and will not fail +loudly. The symptom is usually "my new package isn't found by its consumer". + +After adding a workspace, run `pnpm install` so the workspace links are created. + +## 2. Root `package.json` + +```json +{ + "packageManager": "pnpm@10.17.1", + "engines": { "node": ">=22", "pnpm": ">=10" }, + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "check-types": "turbo run check-types", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,scss}\"", + "publish-packages": "turbo run build:ci lint && changeset version && changeset publish", + "prepush": "turbo build --filter=...[HEAD^]" + } +} +``` + +- `packageManager` pins the exact pnpm version; corepack reads it, so every contributor and CI + runner resolves dependencies identically. +- Root scripts **always** delegate to Turborepo, never to a single workspace — calling a + workspace script directly bypasses the dependency graph and builds stale upstream code. +- Root dependencies are tooling only: turbo, prettier, husky, changesets, typescript. + +## 3. Turborepo pipeline + +| Task | Depends on | Cached outputs | +|---|---|---| +| `build` | `^build`, `check-types`, `lint` | `dist/**`, `.next/**` | +| `build:ci` | `^build:ci` | `dist/**`, `dist-internal/**` | +| `lint` | `^lint` | — | +| `check-types` | `^check-types` | — | +| `dev` | — | — (persistent, never cached) | + +`^task` means the task must complete in every upstream workspace dependency first. That is +what makes `--filter` safe: filtering to one workspace still builds what it depends on. + +Two consequences worth internalising: + +- **`build` does not run until `check-types` and `lint` pass.** A type or lint error is a build + failure for every downstream workspace, not a warning in one. +- **Every output directory a workspace produces must be listed in `outputs`.** An unlisted + directory is not restored from cache, so a "cache hit" silently yields a missing build + artifact — which surfaces later as a container image built from nothing. + +Task names in a workspace's `package.json` must match the task names in `turbo.json`. A +mismatch is not an error; Turborepo just skips that workspace, and the task appears to pass. + +Cache inputs include `$TURBO_DEFAULT$` and `.env*`. + +## 4. Dependency rules + +- Add a dependency to the **smallest** workspace that needs it. +- Inter-workspace dependencies use `workspace:*`. +- Run `pnpm install` after every `package.json` edit; commit the `pnpm-lock.yaml` change + together with the edit that caused it. A lockfile committed separately is unreviewable. +- Never hand-edit `pnpm-lock.yaml`. + +### `pnpm.overrides` — security pins + +```json +{ "pnpm": { "overrides": { "undici": "^6.21.1", "ws": "^8.17.1" } } } +``` + +Each entry exists because a **transitive** dependency shipped a vulnerability its direct +parent had not yet picked up. Removing an entry silently reinstates the vulnerable version and +nothing in the build complains — no error, no warning, a green pipeline. + +Remove one only after confirming the direct dependency now resolves to a fixed version, and +record that verification in the change. + +## 5. Formatting + +Root `.prettierrc` is the single authority for the whole repository. Do not add per-package +Prettier config: two configs mean every file formatted from one side produces churn diffs when +touched from the other. + +```bash +pnpm format +``` + +## 6. `Taskfile.yml` + +The root `Taskfile.yml` is a **thin delegator** — it only `includes:` module Taskfiles under +`.scripts/`. Never add inline commands to it. New automation becomes a `.scripts//` +with its own Taskfile, which is then included. + +## 7. Diagnosing common failures + +| Symptom | Likely cause | +|---|---| +| New workspace not found by its consumer | no matching glob, or `pnpm install` not re-run | +| Task appears to pass but does nothing | script name does not match a `turbo.json` task | +| Cache hit but missing build artifact | output directory not listed in `outputs` | +| Lockfile churn on every install | `packageManager` not pinned, or pnpm version drift | +| Build fails only in CI | `--frozen-lockfile` exposing an uncommitted lockfile change | +| Vulnerability reappears after an install | an overrides entry was removed | + +```bash +pnpm list --depth 0 +pnpm why +turbo run build --dry-run # what would run, and in what order +turbo run build --filter=...[HEAD^] +``` + +## 8. Mistakes to avoid + +- Adding a workspace outside the four roots, or without updating the globs. +- Calling a workspace script directly instead of going through Turborepo. +- Adding a root dependency for a need local to one workspace. +- Omitting an output directory from a task's `outputs`. +- A workspace script name that does not match its `turbo.json` task. +- Removing a `pnpm.overrides` entry without a security review. +- Per-package Prettier configuration. +- Inline commands in the root `Taskfile.yml`. +- Committing a lockfile change apart from the `package.json` edit that caused it.