Application builder implementation

TopFans now builds browser, SSR, and prerender with one Angular 17 application builder. Webpack server, serve-ssr, and prerender targets are gone.

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:

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

What to run after pull

  1. Restart ng serve if it was started on browser-esbuild.
  2. QA deploy path: npm run prerender-all-qa.
  3. Confirm S3 still publishes dist/TopFans/browser/ and Docker CMD still calls serve:ssr.

Open questions