import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; import { Schema } from './schema'; type PackageManager = 'npm' | 'pnpm' | 'yarn'; interface PackageManagerCommands { install: string; build: string; prepare?: string; } const DOCKERFILE_PATH = '/Dockerfile'; const DOCKERIGNORE_PATH = '/.dockerignore'; const NGINX_CONFIG_PATH = '/nginx.conf'; export function docker(options: Schema): Rule { return (tree: Tree, context: SchematicContext) => { const files = [DOCKERFILE_PATH, DOCKERIGNORE_PATH, NGINX_CONFIG_PATH]; const existingFiles = files.filter((file) => tree.exists(file)); if (existingFiles.length > 0 && !options.force) { throw new SchematicsException(`${existingFiles.join(', ')} already exist. Re-run with --force to overwrite them.`); } const workspace = readJson(tree, '/angular.json'); const projectName = resolveProjectName(workspace, options.project); const outputPath = resolveBuildOutputPath(workspace, projectName); const packageManager = detectPackageManager(tree); const commands = getPackageManagerCommands(packageManager, tree.exists('/package-lock.json')); writeFile(tree, DOCKERFILE_PATH, buildDockerfile(outputPath, commands)); writeFile(tree, DOCKERIGNORE_PATH, buildDockerignore()); writeFile(tree, NGINX_CONFIG_PATH, buildNginxConfig()); context.logger.warn( "NOTE: nginx.conf's Content-Security-Policy allows style-src 'unsafe-inline' — required because " + "Angular's production build inlines critical CSS and per-component encapsulated styles by default. " + 'To remove it, add ngCspNonce="__CSP_NONCE__" to your root element in index.html (e.g. ' + '), then swap \'unsafe-inline\' for a matching ' + "'nonce-$request_id' in nginx.conf and add a sub_filter directive to substitute it at request time " + '— see SECURITY_ROADMAP.md.', ); }; } function resolveProjectName(workspace: Record, projectName?: string): string { if (projectName) { if (!workspace.projects?.[projectName]) { throw new SchematicsException(`Project "${projectName}" was not found in angular.json.`); } return projectName; } if (workspace.defaultProject && workspace.projects?.[workspace.defaultProject]) { return workspace.defaultProject as string; } const projects = Object.keys(workspace.projects ?? {}); if (projects.length === 0) { throw new SchematicsException('No Angular project was found in angular.json.'); } return projects[0]; } function resolveBuildOutputPath(workspace: Record, projectName: string): string { const project = workspace.projects?.[projectName] ?? {}; const buildTarget = project.architect?.build ?? project.targets?.build; const outputPath = buildTarget?.options?.outputPath; if (typeof outputPath === 'string') { return trimSlashes(outputPath); } if (outputPath?.base) { return trimSlashes([outputPath.base, outputPath.browser].filter(Boolean).join('/')); } return `dist/${projectName}/browser`; } function detectPackageManager(tree: Tree): PackageManager { if (tree.exists('/pnpm-lock.yaml')) { return 'pnpm'; } if (tree.exists('/yarn.lock')) { return 'yarn'; } return 'npm'; } function getPackageManagerCommands(packageManager: PackageManager, hasNpmLockfile: boolean): PackageManagerCommands { if (packageManager === 'pnpm') { return { prepare: 'RUN corepack enable', install: 'pnpm install --frozen-lockfile', build: 'pnpm run build', }; } if (packageManager === 'yarn') { return { prepare: 'RUN corepack enable', install: 'yarn install --frozen-lockfile', build: 'yarn build', }; } return { install: hasNpmLockfile ? 'npm ci' : 'npm install', build: 'npm run build', }; } function buildDockerfile(outputPath: string, commands: PackageManagerCommands): string { const prepareStep = commands.prepare ? `${commands.prepare}\n` : ''; return `FROM node:24-alpine AS build WORKDIR /app COPY package*.json ./ COPY pnpm-lock.yaml* yarn.lock* ./ ${prepareStep}RUN ${commands.install} COPY . . RUN ${commands.build} FROM nginx:1.27-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/${outputPath} /usr/share/nginx/html EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] `; } function buildDockerignore(): string { return `node_modules dist .angular .git .github coverage playwright-report test-results .DS_Store npm-debug.log* yarn-debug.log* yarn-error.log* `; } function buildNginxConfig(): string { return `server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # Baseline security headers (see angular.dev/best-practices/security). Sent on every # response, including the SPA shell and static assets. add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; # Ignored by browsers unless the response is served over HTTPS (e.g. behind a TLS-terminating # load balancer) — harmless to send over plain HTTP. add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; # script-src is intentionally 'self' only, no 'unsafe-inline'/'unsafe-eval': Angular's build # output never needs inline scripts. style-src allows 'unsafe-inline' because Angular's # production build inlines critical CSS into a