Adds a new Angular SSR client to an existing Aspire-orchestrated project: scaffolds under clients/, installs Tailwind CSS v4, patches npm scripts for Aspire's PORT, 0.0.0.0 binding, and HTTPS dev-server flags, registers the client in the AppHost (dev server + per-client Envoy listener), registers the client with the unified SSR host (clients/host) that serves every client from one Node process in publish mode, creates a SERVER_URL injection token with TransferState for resolving the backend URL in SSR and browser contexts, wires buf + Connect proto codegen for gRPC-Web, and registers the client's tenant with the auth BFF (dev + production host→realm maps and client-secret plumbing). Use when the user asks to add a new Angular client, frontend app, web UI, or SPA — either during initial bootstrap or after the project is already running.
Resources
1Install
npx skillscat add duncan-g/protofast/add-angular-client Install via the SkillsCat registry.
Add Angular Client
Adds a single Angular SSR client to an Aspire-orchestrated project. Works
in two contexts:
- During bootstrap — called by
bootstrap-project. Envoy does not
exist yet; the orchestrator creates it later with the full route set. - Standalone — called directly on an already-bootstrapped project.
Envoy exists and this skill registers the client with it.
Architecture
- Dev: each client runs its own
ng serve(HMR), but the browser
enters through a per-client Envoy listener — pages and API share
one origin per client.proxy.WithClient(builder, "«clientname»")
creates the listener and returns its endpoint, which becomes the
client'sSERVER_URL. - Publish: a single unified SSR host container
(clients/host/) serves every client's SSR bundle from one Node
process. Envoy matches the client's subdomain and tags requests with
anx-clientheader; the host dispatches to the matching bundle. - Dev smoke test: running the AppHost with
SsrHost__Dev=true
(launch profilehttps-ssr-host) runs the unified host locally
instead of the per-client dev servers, behind the same Envoy
listener URLs.
Placeholders
«ProjectName»— PascalCase root namespace, detected from the AppHost.csprojfilename (e.g.Nimbus.AppHost.csproj→Nimbus).«clientname»— lowercase folder, Aspire resource name, and Angular
project name (e.g.app,portal).«CLIENTNAME»— uppercase env-var form (-becomes_).
Prerequisites
apphost/exists with a working.csprojandProgram.cs.clients/directory exists.node,npm, anddotnetare available.- At least one .NET gRPC service exists under
services/(for proto
codegen to have inputs).
Step 1 — Gather inputs
Detect «ProjectName» from the AppHost .csproj:
ls apphost/*.csprojThe name before .AppHost.csproj is the project name.
Ask the user
- Client name — short, lowercase (e.g.
app,portal). - Which services' protos to generate TS for — defaults to all
services whose API project contains aProtos/folder (i.e.services/*/src/*/Protos/). - Production domain — the subdomain this client will answer on in
publish mode (e.g.portal.example.com). Defaults to«clientname».example.com; it is wired as the Aspire parameter«clientname»-domainand can be changed at deploy time. - Should this client become the default? — the default client
answers for unmatched hosts and missingx-clientheaders. Usually
"no" (keep the existing default).
Step 2 — Scaffold and configure the Angular app
Load references/angular-setup.md and follow Steps 2a–2f.
Scaffolds the Angular SSR project into clients/«clientname»/, installs
Tailwind CSS v4, verifies SSR, patches npm scripts for Aspire's PORT
env var and 0.0.0.0 binding, guards OTel instrumentation against
double-init in the unified host, and registers the client inapphost/Program.cs (per-client Envoy listener + dev server).
Step 3 — Wire proto codegen (buf + Connect)
Load references/proto-codegen.md and follow Steps 3a–3e. Installs
buf + Connect dependencies, creates buf.gen.yaml selecting service
protos, runs initial generation, creates the gRPC transport provider,
and adds generated code to .gitignore.
Step 4 — Register with the unified SSR host
Load references/update-envoy.md and follow it. The unified host is
generic — it bakes in no client assets and has no per-client loader map,
so you do not edit clients/host/server.mjs orclients/host/Dockerfile. Instead you: (1) add a.github/workflows/deploy-client-«clientname».yml workflow (each client
builds to S3 and deploys independently), and (2) register «clientname»
in the CLIENTS env var (the instance seed in infra/; the first deploy
also self-registers it on a running box). The host discovers clients fromCLIENTS and pulls each pinned build from S3 at start
(docs/03-services-and-clients.md, "The unified SSR host").
If clients/host/ does not exist yet (mid-bootstrap), skip this step —
the orchestrator creates the unified host with the full client set.
Step 5 — Wire OpenTelemetry into the client
If the project has OpenTelemetry set up (apphost/OpenTelemetryCollector/
exists and AddOpenTelemetryCollector appears in apphost/Program.cs),
load ../add-opentelemetry/references/client-otel.md and follow it
for the new client. It installs the OTel npm packages, creates browser
telemetry (src/lib/telemetry.browser.ts), the ConnectRPC trace
interceptor (src/lib/grpc-trace.interceptor.ts), and Node SSR
instrumentation (src/telemetry.server.ts), and wires them intosrc/main.ts, src/server.ts, and src/app/grpc-transport.ts.
When registering the client in apphost/Program.cs, pass the
collector's HTTP endpoint for both OTel parameters:
var «clientname»Dev = builder.AddClientApp(
"«clientname»", "../clients/«clientname»", «clientname»Web, otelHttp, otelHttp);If another client under clients/ is already instrumented, mirror its
telemetry files (and OTel package versions) instead of the reference
verbatim — the existing client reflects the project's current
conventions and any fixes applied since the reference was written.
If the project does not have OTel yet, skip this step — theadd-opentelemetry skill wires every client when it runs.
Step 6 — Register the client with the auth BFF
If the project has an auth BFF (services/auth/ exists), loadreferences/auth-tenant.md and follow it. The auth service resolves
the tenant from the request Host and 404s unknown hosts, so a client
missing from the tenant map serves pages fine but 404s on/signup//signin — and the dev and production maps live in different
files (appsettings.Development.json vs the auth service env indeploy/docker-compose.host-services.yml), so a working dev sign-in
does not prove the production wiring exists.
If the project has no auth BFF, skip this step.
Guardrails
- In dev mode Aspire assigns the dev-server port dynamically via
thePORTenv var — never hardcode it. Per-client Envoy listener
ports are fixed internal targets (20000, 20001, …) assigned in
registration order byWithClient; do not pick them manually. - The dev server runs HTTPS using Aspire's developer certificate.
AddClientAppinjectsSSL_CERTandSSL_KEYenv vars viaWithHttpsCertificateConfiguration; thestartscript passes them
tong serve --ssl --ssl-cert --ssl-key. - There is no dev-server proxy config (
proxy.conf.mjs) and no
per-client Dockerfile. The browser enters through the client's Envoy
listener; theSERVER_URLinjection token (resolved via AngularTransferState) carries that listener's URL to the gRPC transport
and browser telemetry. In publish modeSERVER_URLis unset and the
browser falls back towindow.location.origin(same-origin). - Each client under
clients/is its own standalone Angular project,
not a multi-project workspace. Only the built SSR bundles are
combined — by the unified host at image build time. - Each client has its own
buf.gen.yamlselecting which service protos
to codegen. This is where per-client selectivity lives. - The
--host 0.0.0.0and--allowed-hostsflags are required for
containers (Envoy) to reach the dev server. - The client's
src/telemetry.server.ts(namedsrc/instrumentation.ts
in the oldest client) must keep theglobalThis.__nodeOtelSdkStarted
guard — in the unified host every bundle shares one process and only
the first may start the Node SDK.