diff --git a/npm/ng-packs/packages/schematics/src/collection.json b/npm/ng-packs/packages/schematics/src/collection.json index 16f148d78a..b84de19733 100644 --- a/npm/ng-packs/packages/schematics/src/collection.json +++ b/npm/ng-packs/packages/schematics/src/collection.json @@ -34,7 +34,17 @@ "description": "ABP Change Styles of Theme Schematics", "factory": "./commands/change-theme", "schema": "./commands/change-theme/schema.json" - + }, + "server": { + "factory": "./commands/ssr-add/server", + "description": "Create an Angular server app.", + "schema": "./commands/ssr-add/server/schema.json", + "hidden": true + }, + "ssr-add": { + "description": "ABP SSR Add Schematics", + "factory": "./commands/ssr-add", + "schema": "./commands/ssr-add/schema.json" } } } diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/application-builder/server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/application-builder/server.ts.template new file mode 100644 index 0000000000..dcb25952a3 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/application-builder/server.ts.template @@ -0,0 +1,192 @@ +import { + AngularNodeAppEngine, + createNodeRequestHandler, + isMainModule, + writeResponseToNodeResponse, +} from '@angular/ssr/node'; +import express from 'express'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import {environment} from './environments/environment'; +import { ServerCookieParser } from '@abp/ng.core'; + +// ESM import +import * as oidc from 'openid-client'; + +if (environment.production === false) { + process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; +} + +const serverDistFolder = dirname(fileURLToPath(import.meta.url)); +const browserDistFolder = resolve(serverDistFolder, '../browser'); + +const app = express(); +const angularApp = new AngularNodeAppEngine(); + +const ISSUER = new URL(environment.oAuthConfig.issuer); +const CLIENT_ID = environment.oAuthConfig.clientId; +const REDIRECT_URI = environment.oAuthConfig.redirectUri; +const SCOPE = environment.oAuthConfig.scope; +// @ts-ignore +const CLIENT_SECRET = environment.oAuthConfig.clientSecret || undefined; + +const config = await oidc.discovery(ISSUER, CLIENT_ID, CLIENT_SECRET); +const secureCookie = { httpOnly: true, sameSite: 'lax' as const, secure: environment.production, path: '/' }; +const tokenCookie = { ...secureCookie, httpOnly: false }; + +app.use(ServerCookieParser.middleware()); + +const sessions = new Map(); + +app.get('/authorize', async (_req, res) => { + const code_verifier = oidc.randomPKCECodeVerifier(); + const code_challenge = await oidc.calculatePKCECodeChallenge(code_verifier); + const state = oidc.randomState(); + + if (_req.query.returnUrl) { + const returnUrl = String(_req.query.returnUrl || null); + res.cookie('returnUrl', returnUrl, { ...secureCookie, maxAge: 5 * 60 * 1000 }); + } + + const sid = crypto.randomUUID(); + sessions.set(sid, { pkce: code_verifier, state }); + res.cookie('sid', sid, secureCookie); + + const url = oidc.buildAuthorizationUrl(config, { + redirect_uri: REDIRECT_URI, + scope: SCOPE, + code_challenge, + code_challenge_method: 'S256', + state, + }); + res.redirect(url.toString()); +}); + +app.get('/logout', async (req, res) => { + try { + const sid = req.cookies.sid; + + if (sid && sessions.has(sid)) { + sessions.delete(sid); + } + + res.clearCookie('sid', secureCookie); + res.clearCookie('access_token', tokenCookie); + res.clearCookie('refresh_token', secureCookie); + res.clearCookie('expires_at', tokenCookie); + res.clearCookie('returnUrl', secureCookie); + + const endSessionEndpoint = config.serverMetadata().end_session_endpoint; + if (endSessionEndpoint) { + const logoutUrl = new URL(endSessionEndpoint); + logoutUrl.searchParams.set('post_logout_redirect_uri', REDIRECT_URI); + logoutUrl.searchParams.set('client_id', CLIENT_ID); + + return res.redirect(logoutUrl.toString()); + } + res.redirect('/'); + + } catch (error) { + console.error('Logout error:', error); + res.status(500).send('Logout error'); + } +}); + +app.get('/', async (req, res, next) => { + try { + const { code, state } = req.query as any; + if (!code || !state) return next(); + + const sid = req.cookies.sid; + const sess = sid && sessions.get(sid); + if (!sess || state !== sess.state) return res.status(400).send('invalid state'); + + const tokenEndpoint = config.serverMetadata().token_endpoint!; + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: String(code), + redirect_uri: environment.oAuthConfig.redirectUri, + code_verifier: sess.pkce!, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET || '' + }); + + const resp = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!resp.ok) { + const errTxt = await resp.text(); + console.error('token error:', resp.status, errTxt); + return res.status(500).send('token error'); + } + + const tokens = await resp.json(); + + const expiresInSec = + Number(tokens.expires_in ?? tokens.expiresIn ?? 3600); + const skewSec = 60; + const accessExpiresAt = new Date( + Date.now() + Math.max(0, expiresInSec - skewSec) * 1000 + ); + + sessions.set(sid, { ...sess, at: tokens.access_token, refresh: tokens.refresh_token }); + res.cookie('access_token', tokens.access_token, {...tokenCookie, maxAge: accessExpiresAt.getTime()}); + res.cookie('refresh_token', tokens.refresh_token, secureCookie); + res.cookie('expires_at', String(accessExpiresAt.getTime()), tokenCookie); + + const returnUrl = req.cookies?.returnUrl ?? '/'; + res.clearCookie('returnUrl', secureCookie); + + return res.redirect(returnUrl); + } catch (e) { + console.error('OIDC error:', e); + return res.status(500).send('oidc error'); + } +}); + +/** + * Serve static files from /browser + */ +app.use( + express.static(browserDistFolder, { + maxAge: '1y', + index: false, + redirect: false, + }), +); + +/** + * Handle all other requests by rendering the Angular application. + */ +app.use((req, res, next) => { + angularApp + .handle(req) + .then(response => { + if (response) { + res.cookie('ssr-init', 'true', {...secureCookie, httpOnly: false}); + return writeResponseToNodeResponse(response, res); + } else { + return next() + } + }) + .catch(next); +}); + +/** + * Start the server if this module is the main entry point. + * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000. + */ +if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4200; + app.listen(port, () => { + console.log(`Node Express server listening on http://localhost:${port}`); + }); +} + +/** + * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions. + */ +export const reqHandler = createNodeRequestHandler(app); diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template new file mode 100644 index 0000000000..e20cc2e222 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template @@ -0,0 +1,74 @@ +import 'zone.js/node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { CommonEngine } from '@angular/ssr/node'; +import express from 'express'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %> from './main.server'; + +// The Express app is exported so that it can be used by serverless Functions. +export function app(): express.Express { + const server = express(); + const distFolder = join(process.cwd(), '<%= browserDistDirectory %>'); + const indexHtml = existsSync(join(distFolder, 'index.original.html')) + ? join(distFolder, 'index.original.html') + : join(distFolder, 'index.html'); + + const commonEngine = new CommonEngine(); + + server.set('view engine', 'html'); + server.set('views', distFolder); + + // Example Express Rest API endpoints + // server.get('/api/{*splat}', (req, res) => { }); + // Serve static files from /browser + server.use(express.static(distFolder, { + maxAge: '1y', + index: false, + })); + + // All regular routes use the Angular engine + server.use((req, res, next) => { + const { protocol, originalUrl, baseUrl, headers } = req; + + commonEngine + .render({ + <% if (isStandalone) { %>bootstrap<% } else { %>bootstrap: AppServerModule<% } %>, + documentFilePath: indexHtml, + url: `${protocol}://${headers.host}${originalUrl}`, + publicPath: distFolder, + providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }, { provide: 'cookies', useValue: JSON.stringify(req.headers.cookie) }], + }) + .then((html) => res.send(html)) + .catch((err) => next(err)); + }); + + return server; +} + +function run(): void { + const port = process.env['PORT'] || 4000; + + // Start up the Node server + const server = app(); + server.listen(port, (error) => { + if (error) { + throw error; + } + + console.log(`Node Express server listening on http://localhost:${port}`); + }); +} + +// Webpack will replace 'require' with '__webpack_require__' +// '__non_webpack_require__' is a proxy to Node 'require' +// The below code is to ensure that the server is run only when not requiring the bundle. +declare const __non_webpack_require__: NodeRequire; +const mainModule = __non_webpack_require__.main; +const moduleFilename = mainModule && mainModule.filename || ''; +if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { + run(); +} + +export default <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %>; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts b/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts new file mode 100644 index 0000000000..17e4bb2912 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts @@ -0,0 +1,444 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isJsonObject, join, normalize, strings } from '@angular-devkit/core'; +import { + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + applyTemplates, + chain, + mergeWith, + move, + schematic, + url, +} from '@angular-devkit/schematics'; +import { posix } from 'node:path'; +// @ts-ignore +import { Schema as ServerOptions } from './server/schema'; +import { + DependencyType, + ExistingBehavior, + InstallBehavior, + addDependency, + readWorkspace, + updateWorkspace, +} from '../../utils/angular'; +import { JSONFile } from '../../utils/angular/json-file'; +import { latestVersions } from '../../utils/angular/latest-versions'; +import { isStandaloneApp } from '../../utils/angular/ng-ast-utils'; +import { + isUsingApplicationBuilder, + targetBuildNotFoundError, +} from '../../utils/angular/project-targets'; +import { getMainFilePath } from '../../utils/angular/standalone/util'; +import { getWorkspace } from '../../utils/angular/workspace'; + +// @ts-ignore +import { Schema as SSROptions } from './schema'; + +const SERVE_SSR_TARGET_NAME = 'serve-ssr'; +const PRERENDER_TARGET_NAME = 'prerender'; +const DEFAULT_BROWSER_DIR = 'browser'; +const DEFAULT_MEDIA_DIR = 'media'; +const DEFAULT_SERVER_DIR = 'server'; + +async function getLegacyOutputPaths( + host: Tree, + projectName: string, + target: 'server' | 'build', +): Promise { + // Generate new output paths + const workspace = await readWorkspace(host); + const project = workspace.projects.get(projectName); + const architectTarget = project?.targets.get(target); + if (!architectTarget?.options) { + throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`); + } + + const { outputPath } = architectTarget.options; + if (typeof outputPath !== 'string') { + throw new SchematicsException( + `outputPath for ${projectName} ${target} target is not a string.`, + ); + } + + return outputPath; +} + +async function getApplicationBuilderOutputPaths( + host: Tree, + projectName: string, +): Promise<{ browser: string; server: string; base: string }> { + // Generate new output paths + const target = 'build'; + const workspace = await readWorkspace(host); + const project = workspace.projects.get(projectName); + const architectTarget = project?.targets.get(target); + + if (!architectTarget?.options) { + throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`); + } + + let { outputPath } = architectTarget.options; + // Use default if not explicitly specified + outputPath ??= posix.join('dist', projectName); + + const defaultDirs = { + server: DEFAULT_SERVER_DIR, + browser: DEFAULT_BROWSER_DIR, + }; + + if (outputPath && isJsonObject(outputPath)) { + return { + ...defaultDirs, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(outputPath as any), + }; + } + + if (typeof outputPath !== 'string') { + throw new SchematicsException( + `outputPath for ${projectName} ${target} target is not a string.`, + ); + } + + return { + base: outputPath, + ...defaultDirs, + }; +} + +function addScriptsRule({ project }: SSROptions, isUsingApplicationBuilder: boolean): Rule { + return async host => { + const pkgPath = '/package.json'; + const pkg = host.readJson(pkgPath) as { scripts?: Record } | null; + if (pkg === null) { + throw new SchematicsException('Could not find package.json'); + } + + if (isUsingApplicationBuilder) { + const { base, server } = await getApplicationBuilderOutputPaths(host, project); + pkg.scripts ??= {}; + pkg.scripts[`serve:ssr:${project}`] = `node ${posix.join(base, server)}/server.mjs`; + } else { + const serverDist = await getLegacyOutputPaths(host, project, 'server'); + pkg.scripts = { + ...pkg.scripts, + 'dev:ssr': `ng run ${project}:${SERVE_SSR_TARGET_NAME}`, + 'serve:ssr': `node ${serverDist}/main.js`, + 'build:ssr': `ng build && ng run ${project}:server`, + prerender: `ng run ${project}:${PRERENDER_TARGET_NAME}`, + }; + } + + host.overwrite(pkgPath, JSON.stringify(pkg, null, 2)); + }; +} + +function updateApplicationBuilderTsConfigRule(options: SSROptions): Rule { + return async host => { + const workspace = await readWorkspace(host); + const project = workspace.projects.get(options.project); + const buildTarget = project?.targets.get('build'); + if (!buildTarget || !buildTarget.options) { + return; + } + + const tsConfigPath = buildTarget.options.tsConfig; + if (!tsConfigPath || typeof tsConfigPath !== 'string') { + // No tsconfig path + return; + } + + const json = new JSONFile(host, tsConfigPath); + + const include = json.get(['include']); + if (Array.isArray(include) && include.includes('src/**/*.ts')) { + return; + } + + const filesPath = ['files']; + const files = new Set((json.get(filesPath) as string[] | undefined) ?? []); + files.add('src/server.ts'); + json.modify(filesPath, [...files]); + }; +} + +function updateRootTsConfigRule(options: SSROptions): Rule { + return async (host: Tree) => { + const workspace = await readWorkspace(host); + const project = workspace.projects.get(options.project); + + let tsConfigPath: string | undefined = options.tsconfigPath; + + if (!tsConfigPath && project) { + const projRoot = normalize(project.root || ''); + const candidate = projRoot ? join(projRoot, 'tsconfig.json') : 'tsconfig.json'; + if (host.exists(candidate)) { + tsConfigPath = candidate; + } + } + + if (!tsConfigPath && host.exists('tsconfig.json')) { + tsConfigPath = 'tsconfig.json'; + } + + if (!tsConfigPath || !host.exists(tsConfigPath)) { + return; + } + + const json = new JSONFile(host, tsConfigPath); + + const moduleResolutionPath = ['compilerOptions', 'moduleResolution']; + const modulePath = ['compilerOptions', 'module']; + + const currentModuleResolution = json.get(moduleResolutionPath); + if (currentModuleResolution !== 'bundler') { + json.modify(moduleResolutionPath, 'bundler'); + } + const currentModule = json.get(modulePath); + if (currentModule !== 'preserve') { + json.modify(modulePath, 'preserve'); + } + }; +} + +function updateApplicationBuilderWorkspaceConfigRule( + projectSourceRoot: string, + options: SSROptions, + { logger }: SchematicContext, +): Rule { + return updateWorkspace(workspace => { + const buildTarget = workspace.projects.get(options.project)?.targets.get('build'); + if (!buildTarget) { + return; + } + + let outputPath = buildTarget.options?.outputPath; + if (outputPath && isJsonObject(outputPath)) { + if (outputPath.browser === '') { + const base = outputPath.base as string; + logger.warn( + `The output location of the browser build has been updated from "${base}" to "${posix.join( + base, + DEFAULT_BROWSER_DIR, + )}". + You might need to adjust your deployment pipeline.`, + ); + + if ( + (outputPath.media && outputPath.media !== DEFAULT_MEDIA_DIR) || + (outputPath.server && outputPath.server !== DEFAULT_SERVER_DIR) + ) { + delete outputPath.browser; + } else { + outputPath = outputPath.base; + } + } + } + + buildTarget.options = { + ...buildTarget.options, + outputPath, + outputMode: 'server', + ssr: { + entry: join(normalize(projectSourceRoot), 'server.ts'), + }, + }; + }); +} + +function updateWebpackBuilderWorkspaceConfigRule( + projectSourceRoot: string, + options: SSROptions, +): Rule { + return updateWorkspace(workspace => { + const projectName = options.project; + const project = workspace.projects.get(projectName); + if (!project) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const serverTarget = project.targets.get('server')!; + (serverTarget.options ??= {}).main = posix.join(projectSourceRoot, 'server.ts'); + + const serveSSRTarget = project.targets.get(SERVE_SSR_TARGET_NAME); + if (serveSSRTarget) { + return; + } + + project.targets.add({ + name: SERVE_SSR_TARGET_NAME, + builder: '@angular-devkit/build-angular:ssr-dev-server', + defaultConfiguration: 'development', + options: {}, + configurations: { + development: { + browserTarget: `${projectName}:build:development`, + serverTarget: `${projectName}:server:development`, + }, + production: { + browserTarget: `${projectName}:build:production`, + serverTarget: `${projectName}:server:production`, + }, + }, + }); + + const prerenderTarget = project.targets.get(PRERENDER_TARGET_NAME); + if (prerenderTarget) { + return; + } + + project.targets.add({ + name: PRERENDER_TARGET_NAME, + builder: '@angular-devkit/build-angular:prerender', + defaultConfiguration: 'production', + options: { + routes: ['/'], + }, + configurations: { + production: { + browserTarget: `${projectName}:build:production`, + serverTarget: `${projectName}:server:production`, + }, + development: { + browserTarget: `${projectName}:build:development`, + serverTarget: `${projectName}:server:development`, + }, + }, + }); + }); +} + +function updateWebpackBuilderServerTsConfigRule(options: SSROptions): Rule { + return async host => { + const workspace = await readWorkspace(host); + const project = workspace.projects.get(options.project); + const serverTarget = project?.targets.get('server'); + if (!serverTarget || !serverTarget.options) { + return; + } + + const tsConfigPath = serverTarget.options.tsConfig; + if (!tsConfigPath || typeof tsConfigPath !== 'string') { + // No tsconfig path + return; + } + + const tsConfig = new JSONFile(host, tsConfigPath); + const filesAstNode = tsConfig.get(['files']); + const serverFilePath = 'src/server.ts'; + if (Array.isArray(filesAstNode) && !filesAstNode.some(({ text }) => text === serverFilePath)) { + tsConfig.modify(['files'], [...filesAstNode, serverFilePath]); + } + }; +} + +function addDependencies({ skipInstall }: SSROptions, isUsingApplicationBuilder: boolean): Rule { + const install = skipInstall ? InstallBehavior.None : InstallBehavior.Auto; + + const rules: Rule[] = [ + addDependency('express', latestVersions.dependencies.express, { + type: DependencyType.Default, + install, + existing: ExistingBehavior.Replace, + }), + addDependency('@types/express', latestVersions.dependencies['@types/express'], { + type: DependencyType.Dev, + install, + existing: ExistingBehavior.Replace, + }), + addDependency('openid-client', latestVersions.dependencies['openid-client'], { + type: DependencyType.Default, + install, + existing: ExistingBehavior.Skip, + }), + ]; + + if (!isUsingApplicationBuilder) { + rules.push( + addDependency('browser-sync', latestVersions.dependencies['browser-sync'], { + type: DependencyType.Dev, + install, + }), + ); + } + + return chain(rules); +} + +function addServerFile( + projectSourceRoot: string, + options: ServerOptions, + isStandalone: boolean, +): Rule { + return async host => { + const projectName = options.project; + const workspace = await readWorkspace(host); + const project = workspace.projects.get(projectName); + if (!project) { + throw new SchematicsException(`Invalid project name (${projectName})`); + } + const usingApplicationBuilder = isUsingApplicationBuilder(project); + const browserDistDirectory = usingApplicationBuilder + ? (await getApplicationBuilderOutputPaths(host, projectName)).browser + : await getLegacyOutputPaths(host, projectName, 'build'); + + return mergeWith( + apply(url(`./files/${usingApplicationBuilder ? 'application-builder' : 'server-builder'}`), [ + applyTemplates({ + ...strings, + ...options, + browserDistDirectory, + isStandalone, + }), + move(projectSourceRoot), + ]), + ); + }; +} + +export default function (options: SSROptions): Rule { + return async (host, context) => { + const browserEntryPoint = await getMainFilePath(host, options.project); + const isStandalone = isStandaloneApp(host, browserEntryPoint); + + const workspace = await getWorkspace(host); + const clientProject = workspace.projects.get(options.project); + if (!clientProject) { + throw targetBuildNotFoundError(); + } + + const usingApplicationBuilder = isUsingApplicationBuilder(clientProject); + const sourceRoot = clientProject.sourceRoot ?? posix.join(clientProject.root, 'src'); + + return chain([ + schematic('server', { + ...options, + skipInstall: true, + }), + ...(usingApplicationBuilder + ? [ + updateApplicationBuilderWorkspaceConfigRule(sourceRoot, options, context), + updateApplicationBuilderTsConfigRule(options), + updateRootTsConfigRule(options) + ] + : [ + updateWebpackBuilderServerTsConfigRule(options), + updateWebpackBuilderWorkspaceConfigRule(sourceRoot, options), + ]), + addServerFile(sourceRoot, options, isStandalone), + addScriptsRule(options, usingApplicationBuilder), + addDependencies(options, usingApplicationBuilder), + ]); + }; +} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/schema.json b/npm/ng-packs/packages/schematics/src/commands/ssr-add/schema.json new file mode 100644 index 0000000000..08699b8ff2 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "SchematicsAngularSSR", + "title": "ABP Angular SSR Options Schema", + "type": "object", + "description": "Enables Server-Side Rendering (SSR) for your Angular application. SSR allows your app to be rendered on the server, which can significantly improve its initial load performance and Search Engine Optimization (SEO). This schematic configures your project for SSR, generating the necessary files and making the required modifications to your project's structure.", + "properties": { + "project": { + "type": "string", + "description": "The name of the project you want to enable SSR for.", + "$default": { + "$source": "projectName" + } + }, + "skipInstall": { + "description": "Skip the automatic installation of packages. You will need to manually install the dependencies later.", + "type": "boolean", + "default": false + } + }, + "required": ["project"], + "additionalProperties": false +} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template new file mode 100644 index 0000000000..04a1129164 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template @@ -0,0 +1,23 @@ +import { NgModule, inject, PLATFORM_ID, TransferState } from '@angular/core'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; +import { <%= appComponentName %> } from '<%= appComponentPath %>'; +import { <%= appModuleName %> } from '<%= appModulePath %>'; +import { serverRoutes } from './app.routes.server'; +import { SSR_FLAG } from '@abp/ng.core'; + +@NgModule({ + imports: [<%= appModuleName %>], + providers: [{ + provide: APP_INITIALIZER, + useFactory: () => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }, + multi: true + }, provideServerRendering(withRoutes(serverRoutes))], + bootstrap: [<%= appComponentName %>], +}) +export class AppServerModule {} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.routes.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.routes.server.ts.template new file mode 100644 index 0000000000..2c5a11d34b --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.routes.server.ts.template @@ -0,0 +1,8 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { + path: '**', + renderMode: RenderMode.Server + } +]; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/main.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/main.server.ts.template new file mode 100644 index 0000000000..dfb6fdb3f1 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/main.server.ts.template @@ -0,0 +1 @@ +export { AppServerModule as default } from './app/app.module.server'; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.config.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.config.server.ts.template new file mode 100644 index 0000000000..8ae2e03e00 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.config.server.ts.template @@ -0,0 +1,29 @@ +import { + mergeApplicationConfig, + ApplicationConfig, + provideAppInitializer, + inject, + PLATFORM_ID, + TransferState +} from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; + +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; +import { SSR_FLAG } from '@abp/ng.core'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideAppInitializer(() => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }), + provideServerRendering(withRoutes(serverRoutes)), + ], +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.routes.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.routes.server.ts.template new file mode 100644 index 0000000000..2c5a11d34b --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.routes.server.ts.template @@ -0,0 +1,8 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { + path: '**', + renderMode: RenderMode.Server + } +]; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/main.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/main.server.ts.template new file mode 100644 index 0000000000..bc0b6ba597 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/main.server.ts.template @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { <%= appComponentName %> } from '<%= appComponentPath %>'; +import { config } from './app/app.config.server'; + +const bootstrap = () => bootstrapApplication(<%= appComponentName %>, config); + +export default bootstrap; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template new file mode 100644 index 0000000000..f5b86f4a6e --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template @@ -0,0 +1,28 @@ +import { NgModule, inject, PLATFORM_ID, TransferState } from '@angular/core'; +import { ServerModule } from '@angular/platform-server'; + +import { <%= appModuleName %> } from '<%= appModulePath %>'; +import { <%= appComponentName %> } from '<%= appComponentPath %>'; +import { SSR_FLAG } from '@abp/ng.core'; + +@NgModule({ + imports: [ + <%= appModuleName %>, + ServerModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + useFactory: () => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }, + multi: true + } + ], + bootstrap: [<%= appComponentName %>], +}) +export class AppServerModule {} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/main.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/main.server.ts.template new file mode 100644 index 0000000000..dfb6fdb3f1 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/main.server.ts.template @@ -0,0 +1 @@ +export { AppServerModule as default } from './app/app.module.server'; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/root/tsconfig.server.json.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/root/tsconfig.server.json.template new file mode 100644 index 0000000000..392d457067 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/root/tsconfig.server.json.template @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./<%= tsConfigExtends %>", + "compilerOptions": { + "outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/server", + "types": [ + "node"<% if (hasLocalizePackage) { %>, + "@angular/localize"<% } %> + ] + }, + "files": [ + "src/main.server.ts" + ] +} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/app/app.config.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/app/app.config.server.ts.template new file mode 100644 index 0000000000..a27a4affde --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/app/app.config.server.ts.template @@ -0,0 +1,29 @@ +import { + mergeApplicationConfig, + ApplicationConfig, + provideAppInitializer, + inject, + PLATFORM_ID, + TransferState +} from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; + +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; +import { SSR_FLAG } from '@abp/ng.core'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideAppInitializer(() => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }), + provideServerRendering(withRoutes(serverRoutes)) + ] +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/main.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/main.server.ts.template new file mode 100644 index 0000000000..bc0b6ba597 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/main.server.ts.template @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { <%= appComponentName %> } from '<%= appComponentPath %>'; +import { config } from './app/app.config.server'; + +const bootstrap = () => bootstrapApplication(<%= appComponentName %>, config); + +export default bootstrap; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/index.ts b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/index.ts new file mode 100644 index 0000000000..3cd2d0371c --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/index.ts @@ -0,0 +1,268 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JsonValue, Path, basename, dirname, join, normalize } from '@angular-devkit/core'; +import { + Rule, + SchematicsException, + Tree, + apply, + applyTemplates, + chain, + mergeWith, + move, + strings, + url, +} from '@angular-devkit/schematics'; +import { posix } from 'node:path'; +import { + DependencyType, + InstallBehavior, + addDependency, + addRootProvider, +} from '../../../utils/angular'; +import { getPackageJsonDependency } from '../../../utils/angular'; +import { JSONFile } from '../../../utils/angular/json-file'; +import { latestVersions } from '../../../utils/angular/latest-versions'; +import { isStandaloneApp } from '../../../utils/angular/ng-ast-utils'; +import { relativePathToWorkspaceRoot } from '../../../utils/angular/paths'; +import { + isUsingApplicationBuilder, + targetBuildNotFoundError, +} from '../../../utils/angular/project-targets'; +import { resolveBootstrappedComponentData } from '../../../utils/angular/standalone/app_component'; +import { getMainFilePath } from '../../../utils/angular/standalone/util'; +import { getWorkspace, updateWorkspace } from '../../../utils/angular/workspace'; +import { Builders } from '../../../utils/angular/workspace-models'; + +// @ts-ignore +import { Schema as ServerOptions } from './schema'; + +const serverMainEntryName = 'main.server.ts'; + +function updateConfigFileBrowserBuilder(options: ServerOptions, tsConfigDirectory: Path): Rule { + return updateWorkspace(workspace => { + const clientProject = workspace.projects.get(options.project); + + if (clientProject) { + // In case the browser builder hashes the assets + // we need to add this setting to the server builder + // as otherwise when assets it will be requested twice. + // One for the server which will be unhashed, and other on the client which will be hashed. + const getServerOptions = (options: Record = {}): {} => { + return { + buildOptimizer: options?.buildOptimizer, + outputHashing: options?.outputHashing === 'all' ? 'media' : options?.outputHashing, + fileReplacements: options?.fileReplacements, + optimization: options?.optimization === undefined ? undefined : !!options?.optimization, + sourceMap: options?.sourceMap, + localization: options?.localization, + stylePreprocessorOptions: options?.stylePreprocessorOptions, + resourcesOutputPath: options?.resourcesOutputPath, + deployUrl: options?.deployUrl, + i18nMissingTranslation: options?.i18nMissingTranslation, + preserveSymlinks: options?.preserveSymlinks, + extractLicenses: options?.extractLicenses, + inlineStyleLanguage: options?.inlineStyleLanguage, + vendorChunk: options?.vendorChunk, + }; + }; + + const buildTarget = clientProject.targets.get('build'); + if (buildTarget?.options) { + buildTarget.options.outputPath = `dist/${options.project}/browser`; + } + + const buildConfigurations = buildTarget?.configurations; + const configurations: Record = {}; + if (buildConfigurations) { + for (const [key, options] of Object.entries(buildConfigurations)) { + configurations[key] = getServerOptions(options); + } + } + + const sourceRoot = clientProject.sourceRoot ?? join(normalize(clientProject.root), 'src'); + const serverTsConfig = join(tsConfigDirectory, 'tsconfig.server.json'); + clientProject.targets.add({ + name: 'server', + builder: Builders.Server, + defaultConfiguration: 'production', + options: { + outputPath: `dist/${options.project}/server`, + main: join(normalize(sourceRoot), serverMainEntryName), + tsConfig: serverTsConfig, + ...(buildTarget?.options ? getServerOptions(buildTarget?.options) : {}), + }, + configurations, + }); + } + }); +} + +function updateConfigFileApplicationBuilder(options: ServerOptions): Rule { + return updateWorkspace(workspace => { + const project = workspace.projects.get(options.project); + if (!project) { + return; + } + + const buildTarget = project.targets.get('build'); + if (!buildTarget) { + return; + } + + buildTarget.options ??= {}; + buildTarget.options['server'] = posix.join( + project.sourceRoot ?? posix.join(project.root, 'src'), + serverMainEntryName, + ); + + buildTarget.options['outputMode'] = 'static'; + }); +} + +function updateTsConfigFile(tsConfigPath: string): Rule { + return (host: Tree) => { + const json = new JSONFile(host, tsConfigPath); + // Skip adding the files entry if the server entry would already be included. + const include = json.get(['include']); + if (!Array.isArray(include) || !include.includes('src/**/*.ts')) { + const filesPath = ['files']; + const files = new Set((json.get(filesPath) as string[] | undefined) ?? []); + files.add('src/' + serverMainEntryName); + json.modify(filesPath, [...files]); + } + + const typePath = ['compilerOptions', 'types']; + const types = new Set((json.get(typePath) as string[] | undefined) ?? []); + types.add('node'); + json.modify(typePath, [...types]); + }; +} + +function addDependencies(skipInstall: boolean | undefined): Rule { + return (host: Tree) => { + const coreDep = getPackageJsonDependency(host, '@angular/core'); + if (coreDep === null) { + throw new SchematicsException('Could not find version.'); + } + + const install = skipInstall ? InstallBehavior.None : InstallBehavior.Auto; + + return chain([ + addDependency('@angular/ssr', '~20.0.0', { + type: DependencyType.Default, + install, + }), + addDependency('@angular/platform-server', coreDep.version, { + type: DependencyType.Default, + install, + }), + addDependency('@types/node', latestVersions.dependencies['@types/node'], { + type: DependencyType.Dev, + install, + }), + ]); + }; +} + +export default function (options: ServerOptions): Rule { + return async (host: Tree) => { + const workspace = await getWorkspace(host); + const clientProject = workspace.projects.get(options.project); + if (clientProject?.extensions.projectType !== 'application') { + throw new SchematicsException(`Server schematic requires a project type of "application".`); + } + + const clientBuildTarget = clientProject.targets.get('build'); + if (!clientBuildTarget) { + throw targetBuildNotFoundError(); + } + + const usingApplicationBuilder = isUsingApplicationBuilder(clientProject); + + if ( + clientProject.targets.has('server') || + (usingApplicationBuilder && clientBuildTarget.options?.server !== undefined) + ) { + // Server has already been added. + return; + } + + const clientBuildOptions = clientBuildTarget.options as Record; + const browserEntryPoint = await getMainFilePath(host, options.project); + const isStandalone = isStandaloneApp(host, browserEntryPoint); + const sourceRoot = clientProject.sourceRoot ?? join(normalize(clientProject.root), 'src'); + + let filesUrl = `./files/${usingApplicationBuilder ? 'application-builder/' : 'server-builder/'}`; + filesUrl += isStandalone ? 'standalone-src' : 'ngmodule-src'; + + const { componentName, componentImportPathInSameFile, moduleName, moduleImportPathInSameFile } = + resolveBootstrappedComponentData(host, browserEntryPoint) || { + componentName: 'App', + componentImportPathInSameFile: './app/app', + moduleName: 'AppModule', + moduleImportPathInSameFile: './app/app.module', + }; + const templateSource = apply(url(filesUrl), [ + applyTemplates({ + ...strings, + ...options, + appComponentName: componentName, + appComponentPath: componentImportPathInSameFile, + appModuleName: moduleName, + appModulePath: + moduleImportPathInSameFile === null + ? null + : `./${posix.basename(moduleImportPathInSameFile)}`, + }), + move(sourceRoot), + ]); + + const clientTsConfig = normalize(clientBuildOptions.tsConfig); + const tsConfigExtends = basename(clientTsConfig); + const tsConfigDirectory = dirname(clientTsConfig); + + return chain([ + mergeWith(templateSource), + ...(usingApplicationBuilder + ? [ + updateConfigFileApplicationBuilder(options), + updateTsConfigFile(clientBuildOptions.tsConfig), + ] + : [ + mergeWith( + apply(url('./files/server-builder/root'), [ + applyTemplates({ + ...strings, + ...options, + stripTsExtension: (s: string) => s.replace(/\.ts$/, ''), + tsConfigExtends, + hasLocalizePackage: !!getPackageJsonDependency(host, '@angular/localize'), + relativePathToWorkspaceRoot: relativePathToWorkspaceRoot(tsConfigDirectory), + }), + move(tsConfigDirectory), + ]), + ), + updateConfigFileBrowserBuilder(options, tsConfigDirectory), + ]), + addDependencies(options.skipInstall), + addRootProvider( + options.project, + ({ code, external }) => + code`${external('provideClientHydration', '@angular/platform-browser')}(${external( + 'withEventReplay', + '@angular/platform-browser', + )}(), ${external( + 'withIncrementalHydration', + '@angular/platform-browser', + )}(), ${external('withHttpTransferCacheOptions', '@angular/platform-browser')}({}))`, + ), + ]); + }; +} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/schema.json b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/schema.json new file mode 100644 index 0000000000..225574d921 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsAngularServerApp", + "title": "Angular Server App Options Schema", + "type": "object", + "additionalProperties": false, + "description": "Sets up server-side rendering (SSR) for your Angular application. SSR allows your app to be rendered on the server, improving initial load performance and SEO. This schematic configures your project for SSR and generates the necessary files.", + "properties": { + "project": { + "type": "string", + "description": "The name of the project to enable server-side rendering for.", + "$default": { + "$source": "projectName" + } + }, + "skipInstall": { + "description": "Skip the automatic installation of packages. You will need to manually install the dependencies later.", + "type": "boolean", + "default": false + } + }, + "required": ["project"] +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/index.ts b/npm/ng-packs/packages/schematics/src/utils/angular/index.ts index 2c1bfd086e..30d203c643 100644 --- a/npm/ng-packs/packages/schematics/src/utils/angular/index.ts +++ b/npm/ng-packs/packages/schematics/src/utils/angular/index.ts @@ -12,3 +12,4 @@ export * from './validation'; export * from './workspace'; export * from './workspace-models'; export * from './standalone'; +export * from './dependency'; diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/index.ts b/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/index.ts new file mode 100644 index 0000000000..860f34a7aa --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/index.ts @@ -0,0 +1,28 @@ +export const latestVersions = { + description: 'Package versions used by schematics in @schematics/angular.', + comment: 'This file is needed so that dependencies are synced by Renovate.', + private: true, + dependencies: { + '@types/express': '~5.0.0', + '@types/jasmine': '~5.1.0', + '@types/node': '~20.11.0', + 'browser-sync': '^3.0.0', + express: '~5.1.0', + 'jasmine-core': '~5.9.0', + 'jasmine-spec-reporter': '~7.0.0', + 'karma-chrome-launcher': '~3.2.0', + 'karma-coverage': '~2.2.0', + 'karma-jasmine-html-reporter': '~2.1.0', + 'karma-jasmine': '~5.1.0', + karma: '~6.4.0', + less: '^4.2.0', + postcss: '^8.5.3', + protractor: '~7.0.0', + rxjs: '~7.8.0', + tslib: '^2.3.0', + 'ts-node': '~10.9.0', + typescript: '~5.8.0', + 'zone.js': '~0.15.0', + 'openid-client': '^6.6.4', + }, +}; diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/package.json b/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/package.json deleted file mode 100644 index 8eaa153d89..0000000000 --- a/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "description": "Package versions used by schematics in @schematics/angular.", - "comment": "This file is needed so that dependencies are synced by Renovate.", - "private": true, - "dependencies": { - "@types/jasmine": "~4.3.0", - "@types/node": "^14.15.0", - "jasmine-core": "~4.5.0", - "jasmine-spec-reporter": "~7.0.0", - "karma-chrome-launcher": "~3.1.0", - "karma-coverage": "~2.2.0", - "karma-jasmine-html-reporter": "~2.0.0", - "karma-jasmine": "~5.1.0", - "karma": "~6.4.0", - "ng-packagr": "^15.0.0", - "protractor": "~7.0.0", - "rxjs": "~7.5.0", - "tslib": "^2.3.0", - "ts-node": "~10.9.0", - "typescript": "~4.8.4", - "zone.js": "~0.14.0" - } -} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts b/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts index b831458edf..6d44e08241 100644 --- a/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts +++ b/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts @@ -161,3 +161,5 @@ export function* allTargetOptions( } } } + +export { getWorkspace as readWorkspace } from './workspace'; // for backwards compatibility diff --git a/npm/ng-packs/scripts/build-schematics.ts b/npm/ng-packs/scripts/build-schematics.ts index 0699eb0567..c953a362ca 100644 --- a/npm/ng-packs/scripts/build-schematics.ts +++ b/npm/ng-packs/scripts/build-schematics.ts @@ -40,6 +40,9 @@ const FILES_TO_COPY_AFTER_BUILD: (FileCopy | string)[] = [ { src: 'src/commands/api/files-model', dest: 'commands/api/files-model' }, { src: 'src/commands/api/files-service', dest: 'commands/api/files-service' }, { src: 'src/commands/api/schema.json', dest: 'commands/api/schema.json' }, + { src: 'src/commands/ssr-add/schema.json', dest: 'commands/ssr-add/schema.json' }, + { src: 'src/commands/ssr-add/files', dest: 'commands/ssr-add/files' }, + { src: 'src/commands/ssr-add/server', dest: 'commands/ssr-add/server' }, { src: 'src/collection.json', dest: 'collection.json' }, 'package.json', 'README.md',