- name
- react-webapp
- description
- How to build a React SPA on the platform — project layout, the build-verify command, and this stack's constraints and pitfalls. Apply when a component's `type` is `web-application`.
- metadata
- {"aep":{"kind":"org","audience":["coding"]}}
# React Webapp
A web-app on this platform: a Vite + TS SPA built to static files, served by
stock `nginx:alpine`. The image is **byte-identical across every environment**.
Per-env values the **browser** needs (OIDC config, flags) arrive at request time
in `window._env_`, never at build time. Sibling API addresses are **not**
browser config — they are pod env for nginx.
## Development flow
1. **Scaffold** per Layout, including the nginx drop-in copy in step 1 of Layout.
Read [mock-mode.md](references/mock-mode.md) and include its dependencies in
`package.json`, then install **once** with the complete dependency set — npm
can only satisfy a peer set it sees all at once, and a package added to an
already-resolved tree costs a second full resolve at best. With a design
system, its own Setup step IS that install (it writes the manifest and runs
`npm install` itself); without one, `npm install`.
2. **Prepare shared interfaces** — write `src/env.ts`, generate `src/generated/`
from each dependency's OpenAPI contract, and write `src/api.ts` with a
**same-origin** `baseUrl`. With auth, copy `thunder-authentication`'s
`assets/app/` tree (one `cp -r`, which lays down `src/authz/` and
`mock/authz/`), set the `USER_AUTH_` prefix in `src/authz/session.ts` (mock
mode substitutes that module), and run `npm run gen` once so the three
generated tables exist for the type-check.
3. **Implement pages** — follow Constraints, and check `src/api.ts` against the
**first** page with `npx tsc --noEmit` before writing the rest: that pair proves
how the generated client types, and every later page repeats the pattern.
4. **Mock mode** — author `mock/` per `references/mock-mode.md`, including the four
wiring files in its §2. It stands the same app up with no cluster, no sibling
service and no IDP behind it — with the API gateway's own refusals modelled
from `openapi.yaml`, so a role that cannot reach a screen fails here the way
it fails in a cell. The build eliminates all of it as dead code.
5. **Verify** — from the app path:
```bash
npm install # regenerates package-lock.json
# ← the design system's check goes here (see below)
npm run gen # with auth: so the standalone tsc below reads a
# fresh table (`npm run build` re-runs it)
npx tsc --noEmit # type-check without emitting
npm run build # actually build
! grep -rq mockServiceWorker dist/ # the bundle carries no mock — step 4
git status --porcelain --ignored=matching -- . \
| grep '^!!' | grep -vE 'node_modules|dist' # ← output MUST be empty
```
Commit the `package-lock.json` this produces. Never commit `node_modules/`.
**The last line is not a formality.** Every step above it reads your working
tree; the cluster builds the *committed* tree of this folder alone. A build
input that git ignores is present for all four checks and absent from the
image, and git will not tell you: `git add <app-path>` skips an ignored file
**silently, exit 0**, and leaves `git status` clean. `src/generated/` is the
one that bites, because the repo-root `.gitignore` is shared with backend
components that legitimately ignore a `generated/` directory — an unanchored
pattern there reaches down into this app. `--ignored=matching` is what makes
those paths visible; `node_modules` and `dist` are the only two the builder
stage makes for itself, which is why they are the only two filtered out.
A `!!` line naming anything else means the image will not carry that file.
Fix the pattern (anchor it in the repo-root `.gitignore`), never `git add -f`.
**The design-system skill contributes one step to this sequence**, and it is
mandatory: run the command its own Verify section names, after `npm install`
and before the type-check, and treat a non-zero exit exactly like a failing
build. That slot exists because a design system's own wiring — a missing
build plugin, an unimported theme, a peer-dependency mismatch — is the one
class of fault `tsc` and `vite build` cannot see: it type-checks and builds
perfectly clean, then renders an unstyled page in the cluster. If the pinned
design-system skill names no such command, the sequence is just the five
above.
The `build` script is `tsc --noEmit && vite build` — **not** `tsc -b`, which
needs a composite project: a `tsconfig.json` that `references` a
`tsconfig.node.json` setting `noEmit` fails with `TS6310: Referenced project
may not disable emit`, and unwinding that costs more than it buys.
**With an auth dependency `build` gains a `gen` step in front:**
`npm run gen && tsc --noEmit && vite build`, where `gen` regenerates the
authorization tables (`src/authz/roles.gen.ts`, `src/authz/operations.gen.ts`,
`mock/authz/roles.gen.ts`) from the permission catalog and the contracts.
It is not optional and it must come BEFORE the type-check — a stale
generated union type-checks perfectly green while the app gates on
operations the design no longer has (`thunder-authentication`).
Verification ends at exit 0. **Never run `npm audit` or `npm audit fix`** —
the advisories land on Vite's dev-only transitive dependencies, which never
reach a static bundle served by nginx, and `audit fix` bumps pinned
dependencies behind your back.
5. **Walk** — `mock-verification`, another agent's dispatch. Your job ends at
a clean Verify with `mock/` in place.
6. **PR** — the lead's, once the walk has reported; an open `[ ]` line rides in
its body (the component contract's **Walks**).
## Constraints
**Runtime config, not build-time.** The platform mounts `/env-config.js` into
the served root and it populates `window._env_`. You never generate or commit
that file. `import.meta.env.VITE_*`, `process.env.REACT_APP_*`,
`NEXT_PUBLIC_*` and `.env` files are all build-time mechanisms the platform does
not use — reading one gets you `undefined` in production.
**The key set is fixed.** It is hardcoded in platform code, so a key you invent
is `undefined` at module load. Use these exact spellings:
| Key | Set when | Meaning |
|---|---|---|
| `<NAME>_URL` | `dependencies` include an `external`-kind entry `<name>` | URL of that **external** upstream (browser may call it). Not used for a sibling `component`-kind service. |
| `<DEP>_*` | this web-app declares an auth `platform-resource` dependency named `<dep>` | OIDC config — `<DEP>_CLIENT_ID`, `<DEP>_ISSUER`, `<DEP>_JWKS_URL`, `<DEP>_SCOPES` and **`<DEP>_RESOURCE`** (the project's resource-server identifier, the RFC 8707 `resource` indicator the SPA must send or every `/api` call 401s). `<DEP>` = UPPER_SNAKE of the dependency name (`user-auth` → `USER_AUTH_*`) — owned by `thunder-authentication` |
| `<NAME>` (any) | you declared it in `workload.yaml` `configurations.env` | app-config default, per-env override possible |
There is **no** `API_BASE_URL` and **no** `<UPSTREAM>_URL` in `window._env_` for
a sibling service. The sibling lives at **same-origin** `/api` (extra siblings:
`/api/<component-name>/`). Its addresses — `<DEP>_GATEWAY_URL` and `<DEP>_URL` —
are **pod** env vars, never browser keys; only the nginx drop-in reads them (see
**Same-origin API proxy** below).
**Throw on a missing key, never default it.** No `?? ""`, no `|| ''`, for keys
this table says are set. A silent fallback hides a missing OIDC issuer. Do not
declare sibling API URL keys on `Env` just to throw — they are not emitted.
**Served at host root.** Each web-app gets its **own** gateway hostname, so the
stock Vite default is correct: **do NOT set `base`**. Asset URLs, any react-router
`basename`, and any OAuth `redirect_uri` are plain root paths (`/assets/…`,
`/callback`). Services ARE path-routed, under `/<project>-<component>-http` on a
shared gateway — copying that prefix into `base` 404s every asset.
**Same-origin API proxy, through the gateway.** Nginx reverse-proxies
`location /api/` to the primary sibling. Two pod env vars address that sibling and
they are NOT interchangeable:
| Pod env var | Reaches | Auth |
|---|---|---|
| `<DEP>_GATEWAY_URL` | the API gateway's **runtime Service**, `…-gateway-runtime.<ns>.svc.cluster.local:22893` — a cluster address, never the public vhost. Set by the platform for a sibling whose design declares `exposesAPI.auth`. Carries a **context path prefix**. | validates the bearer token, injects `X-User-*` from its claims |
| `<DEP>_URL` | the project Service, directly | none — nothing validates a token, nothing injects identity |
The asset **prefers `<DEP>_GATEWAY_URL`** and falls back to `<DEP>_URL`. That
order is the whole point: browser traffic is untrusted, and this proxy is the one
hop that would otherwise carry it into the project's trusted lane with no
authentication in between. Two rules follow, and the asset already obeys both —
which is why you copy it rather than write it:
- **Preserve the context prefix.** The gateway routes on it; a rewrite that
strips it 404s every call.
- **Clear inbound identity.** Identity is the gateway's to assert
(`api-management`), and nothing a browser sends on this lane may stand in for
it. The asset clears six headers: **`x-jwt-assertion`** — the gateway-signed
statement the service actually believes, which on a public operation the
gateway overwrites with nothing — and the five unsigned `X-User-*`.
Copy the assets in Layout; do not hand-write a different `proxy_pass`, do not add
`/oidc/` (token endpoint stays cross-origin; `thunder-authentication`), do not
copy `apps/console/docker-entrypoint.sh`. Keep the official `nginx:alpine`
`ENTRYPOINT`. The image adds exactly the three asset files: `nginx.conf`, the
`default.conf` template and `/docker-entrypoint.d/15-aep-api-proxy.sh`.
**Auth.** If the component declares an auth `platform-resource` dependency, its
sign-in, screen gating and API-client wiring are `thunder-authentication`'s:
copy that skill's `assets/app/` tree as one (`src/authz/`, `mock/authz/`,
`scripts/gen-authz.mjs`), and reach the API through `apiFetch`/`apiJson` rather
than attaching a bearer by hand. A screen is gated on the **operation it
loads**, named once in `src/authz/screens.ts` and checked against the generated
`src/authz/operations.gen.ts`; nothing about screens is in `security.json`
(`authorization-model`).
**Never `exposesAPI`.** That toggle is for backends only; a web-app expresses
auth through its auth dependency instead.
**The UI comes from the organization's design system.** Every component, layout
primitive and style under `src/` comes from the design-system skill pinned on
this component — no raw HTML styling, no second component or styling library.
The one carve-out is `thunder-authentication`'s copied assets: `src/authz/gates.tsx`'s
`Forbidden` and `NoAccess` ship as plain `<main>/<section>/<h1>` so the file is
copied verbatim into any app, whatever design system it pins. Treat that markup
as structure to restyle with the design system's components after copying, and
keep the words — they are prescribed.
That skill owns everything inside `src/`; this skill owns the app around it.
Where the two appear to disagree — `base`, the `index.html` script tags, nginx,
`window._env_` — **this skill wins**, because those are deployment facts, not
style preferences. The data layer is untouched either way: `openapi-fetch` and
the committed `src/generated/` client stay exactly as specified above.
**Contract-first client, never hand-rolled shapes.** Every dependency has a
committed OpenAPI contract: `specs/design/components/<component-name>/openapi.yaml`
for a `component`-kind dependency, or
`specs/design/components/<this-app>/dependencies/<dep-name>.openapi.yaml` for an
`external`-kind one — project-root paths, sibling to this app's own folder.
Generate types from it and call through `openapi-fetch`'s typed client (Layout);
don't hand-write request/response shapes. Commit `src/generated/` — the
per-component Docker build's context is this app's own folder alone.
**An `ai-agent` dependency has no OpenAPI contract — it has one fixed chat
contract.** It is a sibling like any other: reached **same-origin**, never
through a `window._env_` URL — an agent has no browser-visible `<AGENT>_URL`
any more than an API sibling does. If it is the primary dependency, call it at
`/api`; if it is an extra sibling, call it at `/api/<agent-component-name>/`
(Layout's nginx section covers wiring either into the drop-in). It is called
with the same bearer token as any sibling service, but there is nothing to
generate from; hand-write this one small client against the shape every
platform agent speaks:
```ts
// POST /api/chat (or /api/<agent-component-name>/chat for an extra sibling)
// in: { conversationId?: string, message: string,
// attachments?: [{ name: string, mediaType: string, data: string /* base64 */ }] }
// out: { conversationId: string, text: string, toolCalls: unknown[] }
```
The agent keeps the conversation. Your state is exactly two things:
- **`conversationId`** — from the first response; send it on every later
message. "New conversation" = drop it. Persist it in `sessionStorage` if the
conversation should survive a refresh.
- **Your own transcript** — `{ role: "user" | "assistant", text: string }[]`,
built as you go: push the user's text when they send, push `response.text`
when the agent replies. That list is for RENDERING and is yours alone; the
agent never sees it and never returns one. No `messages` array crosses the
wire in either direction.
**Files, only when the agent takes them.** If the agent's
`specs/design/components/<agent>/agent.afm.md` declares `x-aep.attachments`,
give the chat an attach control and compile its `types`, `maxFiles` and
`maxFileSizeMB` in as constants: the picker's `accept` is the types; count,
per-file size and the 15 MB total are checked before sending, naming the file
that fails. Send each file base64 in `attachments`; `message` may be empty when
files are present. Without the block, render no attach control. The agent keeps
no files, so on a 400, 413 or 422 keep the files and their text in the composer
for a retry and show the agent's `error`.
A `404` from `/chat` means the conversation is gone or was never yours — drop
the stored id, start fresh, and tell the user the previous conversation
expired. Do not retry the same id.
A `422` whose body carries `guardrail` (`{ error, guardrail }`) means the
platform's AI gateway refused that message on policy — the agent is up. Say so
in the user's terms with the agent's `error` as the reason ("This message was
blocked by a safety check: <error>"), keep their text in the composer to edit,
and keep the conversation as it was: the blocked turn left nothing behind.
Reserve "couldn't reach the assistant" for a network failure or a 5xx.
## Layout
```
<app-path>/
├── package.json
├── tsconfig.json # ONE file — no project references, no tsconfig.node.json
├── vite.config.ts # no `base` — served at host root
├── index.html
├── scripts/
│ └── gen-authz.mjs # only with an auth dependency — copied verbatim, run by `npm run gen`
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── env.ts # typed window._env_ shim
│ ├── generated/ # openapi-typescript output, one file per dependency — commit, never hand-edit
│ ├── api.ts # openapi-fetch client(s), typed against generated/
│ ├── authz/ # ┐ only with an auth dependency — thunder-authentication owns
│ │ ├── session.ts # │ the whole directory. session/core/gates/client arrive
│ │ ├── core.ts # │ verbatim with one `cp -r`; screens.ts is a pattern you
│ │ ├── gates.tsx # │ adapt; the two *.gen.ts are GENERATED and COMMITTED —
│ │ ├── client.ts # │ the per-component build context cannot see ../specs,
│ │ ├── screens.ts # │ so an uncommitted one fails the image build
│ │ ├── roles.gen.ts # │
│ │ └── operations.gen.ts # ┘
│ ├── shell/AppShell.tsx # the app chrome — every gated screen renders inside it
│ └── pages/ # design-system components only, never raw HTML
├── mock/ # mock mode — references/mock-mode.md (mock/authz/ comes with the tree above)
├── nginx/
│ ├── nginx.conf # copied from the skill assets, unchanged
│ ├── default.conf # copied from the skill assets, then /api locations kept
│ └── 15-aep-api-proxy.sh
├── Dockerfile
├── .dockerignore # what `COPY . .` leaves behind
└── .gitignore # `node_modules` and `dist` — both are made by a build
# and neither belongs in the project repo
```
**Copy the nginx assets first, and never run a project generator** — `npm create
vite` and friends emit a different shape (project-referenced tsconfigs, starter
CSS, sample SVGs) and all of it has to be undone before Verify passes. The tree
above IS the shape. From the App Path:
```bash
mkdir -p nginx
cp "$AEP_SKILLS_DIR/react-webapp/assets/nginx.conf" nginx/nginx.conf
cp "$AEP_SKILLS_DIR/react-webapp/assets/nginx-default.conf" nginx/default.conf
cp "$AEP_SKILLS_DIR/react-webapp/assets/15-aep-api-proxy.sh" nginx/15-aep-api-proxy.sh
```
GitHubで見る