10 · Capstone — A Production Angular App¶
The capstone takes the Level 3 catalog (a server-rendered shop with prerendered product pages, per-request search, a client-only cart and incremental hydration) and applies Level 4 to it: build configuration, error handling, deployment concerns and a production-readiness review. The goal isn't more features — it's an app you could run for real users and sleep at night.
Start from the finished Level 3 project (lesson 10). Everything below was built, tested, served and exercised on Angular 22.2; results are at the end.
Requirements¶
- Release tagging — every build carries a version; every error report includes it.
- Error reporting — one
ErrorHandlerfor all errors, deduplicated, with route and release. - Containment — a failure in a secondary section doesn't break the product page.
- Deploy safety — users on an old version recover from missing chunks without a reload loop.
- Budgets and source maps — a budget close to the real size; maps generated but not public.
- Security headers — sent by the server for every response.
- Readiness — the lesson 09 audit passes, and tests run in CI.
1. Release tagging and build configuration¶
Three settings changed in angular.json, shown here keyed by where each lives inside
projects.catalog.architect.build (this is a summary, not a file to paste):
{
"options.define": {
"BUILD_VERSION": "'dev'"
},
"configurations.production.budgets[0]": {
"type": "initial",
"maximumWarning": "340kB",
"maximumError": "400kB"
},
"configurations.production.sourceMap": {
"scripts": true,
"hidden": true
}
}
BUILD_VERSION defaults to 'dev' and CI overrides it:
ng build --define "BUILD_VERSION='1.0.0'". Defining a default in angular.json avoids
the ReferenceError we hit in lesson 01 when a constant was only defined on the command
line. The budget was tightened from the generated 500 kB/1 MB to 340 kB/400 kB, just
above the app's actual initial size.
2. Error reporting¶
import { ErrorDetails, ErrorHandler, Service, inject } from '@angular/core';
import { Router } from '@angular/router';
declare const BUILD_VERSION: string;
export interface ErrorReport {
message: string;
stack?: string;
url: string;
release: string;
count: number;
}
/**
* Collects errors, deduplicates them and (in a real deployment) ships them to a
* monitoring endpoint. Here the transport is console.error so the behaviour is visible.
*/
@Service({ autoProvided: false })
export class ErrorReporter implements ErrorHandler {
private readonly router = inject(Router);
private readonly seen = new Map<string, ErrorReport>();
handleError(error: unknown): void {
this.record(error);
}
onViewError(error: Error, details: ErrorDetails): void {
this.record(error, details.declarationType.name);
}
reports(): ErrorReport[] {
return [...this.seen.values()];
}
private record(error: unknown, where?: string) {
const err = error instanceof Error ? error : new Error(String(error));
const key = `${err.message}|${err.stack?.split('\n')[1] ?? ''}`;
const existing = this.seen.get(key);
if (existing) {
existing.count++;
return; // already reported: don't flood the monitoring service
}
const report: ErrorReport = {
message: where ? `[${where}] ${err.message}` : err.message,
stack: err.stack,
url: this.router.url,
release: BUILD_VERSION,
count: 1,
};
this.seen.set(key, report);
console.error('[error report]', JSON.stringify({ message: report.message, url: report.url, release: report.release }));
}
}
It deduplicates by message and first stack frame (lesson 08 showed rendering errors
repeat on every refresh), records the route and release, and implements onViewError so
errors caught by @boundary blocks arrive with the component type. In production,
replace console.error with a batched fetch/navigator.sendBeacon to your monitoring
endpoint.
3. Deploy safety¶
import { NavigationError } from '@angular/router';
const KEY = 'reloaded-for-release';
declare const BUILD_VERSION: string;
/** After a deploy, old chunk names 404. Reload once per release to pick up the new ones. */
export function reloadOnMissingChunk(e: NavigationError): void {
const missingChunk = String(e.error).includes('Failed to fetch dynamically imported module');
if (!missingChunk || typeof window === 'undefined') return;
if (sessionStorage.getItem(KEY) === BUILD_VERSION) return; // already tried: avoid a loop
sessionStorage.setItem(KEY, BUILD_VERSION);
window.location.assign(e.url);
}
4. Wiring¶
import { ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding, withNavigationErrorHandler } from '@angular/router';
import { reloadOnMissingChunk } from './core/chunk-reload';
import { ErrorReporter } from './core/error-reporter';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding(), withNavigationErrorHandler(reloadOnMissingChunk)),
ErrorReporter,
{ provide: ErrorHandler, useExisting: ErrorReporter },
provideHttpClient(),
provideClientHydration(withEventReplay()),
],
};
5. Containment with @boundary¶
The related-products section is now wrapped in a boundary inside its deferred block:
template: `
@if (product(); as p) {
<h2>{{ p.name }}</h2>
<p>{{ p.summary }}</p>
<p class="price">{{ p.price | currency: 'USD' }}</p>
<button type="button" (click)="cart.add(p.slug)">Add to cart</button>
@defer (hydrate on viewport) {
@boundary {
<app-related-products [slug]="p.slug" />
} @error {
<p class="muted">Related products are unavailable right now.</p>
}
}
} @else {
<h2>Product not found</h2>
<p><a routerLink="/">Back to all products</a></p>
}
`,
If related products fail to render, the product, price and "Add to cart" button still
work, and the error is reported through onViewError.
6. Security headers¶
Our first attempt enabled "security": { "autoCsp": true }, and the build refused:
Automatic hash-based CSP only works for client-rendered builds. A strict script-src
policy for an SSR app needs a per-request nonce, which is a bigger change; for the
capstone we set the headers that don't depend on scripts, in server.ts:
/**
* Security headers for every response. A strict script-src CSP for an SSR app needs a
* per-request nonce (Angular's autoCsp option can't be combined with SSR), so this policy
* restricts framing, plugins and <base> but not scripts.
*/
app.use((_req, res, next) => {
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'; object-src 'none'; base-uri 'self'");
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
const angularApp = new AngularNodeAppEngine();
7. Tests¶
The Level 3 tests (product page and cart) still pass, plus one for the reporter:
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ErrorReporter } from './error-reporter';
describe('ErrorReporter', () => {
it('reports each distinct error once and counts repeats', () => {
TestBed.configureTestingModule({ providers: [provideRouter([]), ErrorReporter] });
const reporter = TestBed.inject(ErrorReporter);
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
const boom = new Error('boom');
reporter.handleError(boom);
reporter.handleError(boom);
reporter.handleError(boom);
reporter.handleError(new Error('other'));
expect(log).toHaveBeenCalledTimes(2);
expect(reporter.reports().map((r) => [r.message, r.count])).toEqual([['boom', 3], ['other', 1]]);
expect(reporter.reports()[0].release).toBe('dev'); // BUILD_VERSION default from angular.json
});
});
Verification¶
Unit tests: ng test --watch=false → Tests 5 passed (5).
Production build (ng build --define "BUILD_VERSION='1.0.0'"):
Readiness audit (lesson 09's script):
✔ production is the default build configuration
✔ initial bundle budget has an error limit — 340kB / 400kB
✔ optimization enabled in production
✔ output hashing = all
✔ a Content Security Policy is configured — header set in src/server.ts
✔ all JS file names are content-hashed — 8 files
✔ bundles do not link source maps publicly
✔ index.html declares a language
✔ index.html has a title
✔ index.html has a viewport meta tag
✔ total JavaScript on disk — 309.9 kB across 8 files
✔ no bypassSecurityTrust* calls
✔ no console.log left in src/app
✔ custom ErrorHandler registered
✔ unit tests exist — 3 spec files
15/15 checks passed
Server (NG_ALLOWED_HOSTS=localhost node dist/catalog/server/server.mjs), status and
headers per request:
/ 200 CSP: frame-ancestors 'none'; object-src 'none'; base-uri 'self' nosniff
/products/oak-standing-desk 200 (same headers)
/products/nope 404 (same headers)
/search?q=desk 200 (same headers)
/main-GIXUEJH3.js 200 (same headers) Cache-Control: public, max-age=31536000
Deploy-safety drill. In Chromium we made the server return 404 for the search page's
chunk — what happens to a user holding an old index.html after a deploy — and clicked
Search:
full page navigations: ['/', '/search', '/']
sessionStorage 'reloaded-for-release': 1.0.0
error reports: 2 × "Failed to fetch dynamically imported module: …/chunk-VVlfEql5.js" (release 1.0.0)
The app reloaded once to /search. Because our simulated server still returned 404
for the chunk, the second attempt failed too — and the release flag stopped a reload loop,
leaving the user on the home page with the error reported. On a real deploy, the reload
fetches the new index.html whose chunk names exist, and the navigation succeeds.
What's left (and would be next)¶
Honest gaps a production review would still list for this app:
- Prerendered HTML has no
Cache-Controlheader (the audit above showsNonefor/); addno-cachefor HTML inserver.tsso new releases are picked up promptly. - No strict
script-srcCSP — needs nonce support end to end. - Errors go to the console, not a monitoring service; source maps aren't uploaded anywhere.
- No end-to-end tests in CI (Playwright would cover the flows we drove by hand).
- Accessibility hasn't had a screen-reader pass.
- The Docker image from lesson 07 wasn't built on the machine used for this course.
Writing these down is part of the job: a readiness review that ends with "no known issues" usually means nobody looked.
How It Actually Works¶
Follow a request for /products/oak-standing-desk in the finished system:
- Express runs the security-header middleware, then
express.staticfinds the prerenderedproducts/oak-standing-desk/index.htmland returns it — no Angular rendering on the server for this request. - The browser paints it, downloads the hashed
main-*.js(cached for a year after the first visit) and the product-page chunk, and hydrates. The related-products block stays dehydrated until scrolled into view (Level 3). - If anything throws — in an event handler, a promise, a timer, change detection or
the related-products boundary — it lands in
ErrorReporter, deduplicated and tagged with/products/oak-standing-deskand release1.0.0. - After a deploy, a user still on 1.0.0 who navigates to a lazy route whose chunk
was removed triggers
reloadOnMissingChunk, which reloads once per release. - In CI, tests, the production build (with budgets) and the readiness audit gate every change.
Exercise¶
Complete at least three of the "What's left" items:
- Add
Cache-Control: no-cachefor HTML responses and confirm withcurl -I. - Replace
console.errorinErrorReporterwith a batched POST to a small Express endpoint inserver.tsthat writes reports to a log file. - Write Playwright tests for: browsing to a product, adding to cart, searching, and the 404 page.
- Do a keyboard-only and screen-reader pass of the whole app and fix what you find.
- Build and run the Docker image from lesson 07 for this app, with
NG_ALLOWED_HOSTSset, and repeat the verification steps against the container. - Add a GitHub Actions workflow that runs tests, the production build with
BUILD_VERSIONset to the commit SHA, and the readiness audit.