What shipped
| Before (Path A) | After (Path B) |
|---|---|
| Client: browser-esbuild | Client + SSR + optional SSG: application |
| SSR: ng run TopFans:server → dist/TopFans/server/main.js | Same ng build writes dist/TopFans/server/server.mjs |
| SSG: ng run TopFans:prerender:qa | PRERENDER=true ng build --configuration=qa |
| S3 still uploads dist/TopFans/browser/ | Same folder. outputPath is now the parent dist/TopFans |
Karma and the leftover serverless Webpack target were left alone. Docker still runs build-stage:ssr then serve:ssr.
1. One architect target
In angular.json, main becomes browser, polyfills become an array, and SSR/prerender are build options. serviceWorker: true is invalid on this schema; production-like configs use the config path.
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/TopFans",
"browser": "src/main.ts",
"polyfills": ["src/polyfills.ts"],
"server": "src/main.server.ts",
"ssr": { "entry": "server.ts" },
"prerender": false,
"externalDependencies": ["domino", "express", "compression", "helmet"]
}
development keeps prerender: false. QA / staging / production set:
"prerender": {
"routesFile": "prerender.routes.txt",
"discoverRoutes": false
}
Fast compiles pass --prerender=false so Docker and build-qa do not walk the full routes file. Deploy still uses prerender-all-qa.
"prerender-all-qa": "npm run prefetch-pages-qa && npm run generate-static-qa && PRERENDER=true … ng build --configuration=qa && node generate-sitemap.mjs --env=qa"
"serve:ssr": "node dist/TopFans/server/server.mjs"
2. What prerender actually loads
Workers import main.server.mjs, not Express. That file must default-export AppServerModule, load Zone (polyfills are not applied in the worker), and install a DOM before any app import that touches document.
import 'zone.js/node';
import './server-dom';
import '@angular/localize/init';
import '@angular/platform-server/init';
export { AppServerModule } from './app/app.server.module';
export { AppServerModule as default } from './app/app.server.module';
tsconfig.app.json lists the same entries so the compiler sees them:
"files": [
"src/main.ts",
"src/main.server.ts",
"src/server-dom.ts",
"src/polyfills.ts",
"server.ts"
]
3. Why src/server-dom.ts exists
Webpack used to evaluate server.ts as the SSR entry, so Domino globals were in place before render. The application builder prerender worker never runs Express. Putting the DOM install only in server.ts would leave prerender without document.
The new file is the shared install: both the worker (side-effect import) and Express (installServerDom(indexHtml)) use it. Domino stays an external package so esbuild does not break its instanceof checks. After createWindow, the file patches APIs Domino does not implement:
- requestAnimationFrame — CDK / marketing measure loops
- getBoundingClientRect — pinned horizontal scroll
- localStorage, matchMedia, ResizeObserver
export function installServerDom(html = DEFAULT_HTML): Window {
const win = domino.createWindow(html);
setGlobal('window', win);
setGlobal('document', win.document);
patchWindowApis(win);
patchElementLayoutApis(win);
return win;
}
installServerDom();
4. Express only listens as the main module
__non_webpack_require__ is gone. The ESM bundle starts the HTTP server only when Node executed that file:
function isMainModule(url: string): boolean {
const entryPoint = process.argv[1];
if (!entryPoint) return false;
return url === pathToFileURL(entryPoint).href
|| fileURLToPath(url) === entryPoint;
}
if (isMainModule(import.meta.url) || process.argv[1]?.includes('iisnode')) {
run();
}
Dist lookup is still dist/<project>/browser. That is correct because the builder writes browser/ under dist/TopFans.
5. White-label generator
scripts/generate-whitelabel.js clones the application config: parent outputPath dist/<tenant>, drops leftover Webpack targets, sets a per-tenant routesFile, and emits ng build <tenant> instead of ng run …:prerender. Existing tenants also get env files again (the old skip-if-exists path was wrong).
newProject.architect.build.options.outputPath = `dist/${userName}`;
delete newProject.architect.server;
delete newProject.architect['serve-ssr'];
delete newProject.architect.prerender;
6. Prerender-only app fixes
Pinned scroll treated “window exists” as “we are in a browser.” Domino defines window, so measure ran and crashed. It now uses isPlatformBrowser.
private get isBrowser(): boolean {
return isPlatformBrowser(this.platformId);
}
private scheduleMeasure(): void {
if (!this.isBrowser || this.measureFramePending) {
return;
}
error.interceptor.ts no longer throws on the server for 401 (no session) or XHR status === 0 (dropped socket). Those still happen; they should not abort or spam prerender.
if (!this.cs.isBrowser && err.status === Application.ZERO) {
return EMPTY;
}
// in handle401Error:
if (!this.cs.isBrowser) {
return EMPTY;
}
Validation already done
- Development and QA application builds (with and without SSG).
- serve:ssr on home, login, /creators.
- Smoke prerender of pinned-layout routes after the Domino / getBoundingClientRect fixes.
- Full QA prerender of the committed routes file succeeded in this workspace (141 routes at one checkpoint). prerender-all-qa can still log API 400s for missing posts; that is data, not the builder.
What to run after pull
- Restart ng serve if it was started on browser-esbuild.
- QA deploy path: npm run prerender-all-qa.
- Confirm S3 still publishes dist/TopFans/browser/ and Docker CMD still calls serve:ssr.
Open questions
- Whether live SSR on Node 22 needs extra read-only global handling beyond what installServerDom already does.
- Whether dashboard routes belong in prerender.routes.txt at all (they trigger unauthenticated API calls by design).