From 3a4b108dd8ef69b05a6c7a2ec74f16bf04732419 Mon Sep 17 00:00:00 2001 From: Caleb Ukle Date: Tue, 7 Mar 2023 13:09:57 -0600 Subject: [PATCH] feat(web): add a generator to add @nrwl/web:file-server target (#15434) --- docs/generated/manifests/menus.json | 8 + docs/generated/manifests/packages.json | 9 + docs/generated/packages-metadata.json | 9 + .../web/generators/static-config.json | 35 +++ e2e/web/src/file-server.test.ts | 48 +++- packages/web/generators.json | 10 + .../src/generators/static-serve/schema.json | 24 ++ .../static-serve-configuration.spec.ts | 208 ++++++++++++++++++ .../static-serve-configuration.ts | 112 ++++++++++ 9 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 docs/generated/packages/web/generators/static-config.json create mode 100644 packages/web/src/generators/static-serve/schema.json create mode 100644 packages/web/src/generators/static-serve/static-serve-configuration.spec.ts create mode 100644 packages/web/src/generators/static-serve/static-serve-configuration.ts diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 78b25beddc..6c1d1f2112 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -6285,6 +6285,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "static-config", + "path": "/packages/web/generators/static-config", + "name": "static-config", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/packages.json b/docs/generated/manifests/packages.json index 8cb435de3d..9ccad8ef62 100644 --- a/docs/generated/manifests/packages.json +++ b/docs/generated/manifests/packages.json @@ -2788,6 +2788,15 @@ "originalFilePath": "/packages/web/src/generators/application/schema.json", "path": "/packages/web/generators/application", "type": "generator" + }, + "/packages/web/generators/static-config": { + "description": "Add a new static-serve target to a project.", + "file": "generated/packages/web/generators/static-config.json", + "hidden": false, + "name": "static-config", + "originalFilePath": "/packages/web/src/generators/static-serve/schema.json", + "path": "/packages/web/generators/static-config", + "type": "generator" } }, "path": "/packages/web" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index d75fbab054..1fa02491aa 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -2756,6 +2756,15 @@ "originalFilePath": "/packages/web/src/generators/application/schema.json", "path": "web/generators/application", "type": "generator" + }, + { + "description": "Add a new static-serve target to a project.", + "file": "generated/packages/web/generators/static-config.json", + "hidden": false, + "name": "static-config", + "originalFilePath": "/packages/web/src/generators/static-serve/schema.json", + "path": "web/generators/static-config", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/web/generators/static-config.json b/docs/generated/packages/web/generators/static-config.json new file mode 100644 index 0000000000..6249445d5e --- /dev/null +++ b/docs/generated/packages/web/generators/static-config.json @@ -0,0 +1,35 @@ +{ + "name": "static-config", + "factory": "./src/generators/static-serve/static-serve-configuration", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxWebStaticServe", + "cli": "nx", + "title": "Static Serve Configuration", + "description": "Add a new serve target to serve a build apps static files. This allows for faster serving of the static build files by reusing the case. Helpful when reserving the app over and over again like in e2e tests.", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "Name of the build target to serve" + }, + "outputPath": { + "type": "string", + "description": "Path to the directory of the built files. This is only needed if buildTarget doesn't specify an outputPath executor option." + }, + "targetName": { + "type": "string", + "description": "Name of the serve target to add. Defaults to 'serve-static'.", + "default": "serve-static" + } + }, + "required": ["buildTarget"], + "presets": [] + }, + "description": "Add a new static-serve target to a project.", + "implementation": "/packages/web/src/generators/static-serve/static-serve-configuration.ts", + "aliases": [], + "hidden": false, + "path": "/packages/web/src/generators/static-serve/schema.json", + "type": "generator" +} diff --git a/e2e/web/src/file-server.test.ts b/e2e/web/src/file-server.test.ts index 369569cbbe..29c428bc11 100644 --- a/e2e/web/src/file-server.test.ts +++ b/e2e/web/src/file-server.test.ts @@ -10,10 +10,12 @@ import { } from '@nrwl/e2e/utils'; describe('file-server', () => { - afterEach(() => cleanupProject()); + beforeAll(() => { + newProject({ name: uniq('fileserver') }); + }); + afterAll(() => cleanupProject()); it('should serve folder of files', async () => { - newProject({ name: uniq('fileserver') }); const appName = uniq('app'); const port = 4301; @@ -37,4 +39,46 @@ describe('file-server', () => { // ignore } }, 300_000); + + it('should setup and serve static files from app', async () => { + const ngAppName = uniq('ng-app'); + const reactAppName = uniq('react-app'); + + runCLI(`generate @nrwl/angular:app ${ngAppName} --no-interactive`); + runCLI(`generate @nrwl/react:app ${reactAppName} --no-interactive`); + runCLI( + `generate @nrwl/web:static-config --buildTarget=${ngAppName}:build --no-interactive` + ); + runCLI( + `generate @nrwl/web:static-config --buildTarget=${reactAppName}:build --targetName=custom-serve-static --no-interactive` + ); + + const ngServe = await runCommandUntil( + `serve-static ${ngAppName}`, + (output) => { + return output.indexOf('localhost:4200') > -1; + } + ); + + try { + await promisifiedTreeKill(ngServe.pid, 'SIGKILL'); + await killPorts(4200); + } catch { + // ignore + } + + const reactServe = await runCommandUntil( + `custom-serve-static ${reactAppName}`, + (output) => { + return output.indexOf('localhost:4200') > -1; + } + ); + + try { + await promisifiedTreeKill(reactServe.pid, 'SIGKILL'); + await killPorts(4200); + } catch { + // ignore + } + }, 300_000); }); diff --git a/packages/web/generators.json b/packages/web/generators.json index 3c5b669902..eb6791687e 100644 --- a/packages/web/generators.json +++ b/packages/web/generators.json @@ -15,6 +15,11 @@ "aliases": ["app"], "x-type": "application", "description": "Create an web application." + }, + "static-config": { + "factory": "./src/generators/static-serve/static-serve-configuration", + "schema": "./src/generators/static-serve/schema.json", + "description": "Add a new static-serve target to a project." } }, "schematics": { @@ -30,6 +35,11 @@ "aliases": ["app"], "x-type": "application", "description": "Create an web application." + }, + "static-config": { + "factory": "./src/generators/static-serve/static-serve-configuration#compat", + "schema": "./src/generators/static-serve/schema.json", + "description": "Add a new static-serve target to a project." } } } diff --git a/packages/web/src/generators/static-serve/schema.json b/packages/web/src/generators/static-serve/schema.json new file mode 100644 index 0000000000..c6c7090924 --- /dev/null +++ b/packages/web/src/generators/static-serve/schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxWebStaticServe", + "cli": "nx", + "title": "Static Serve Configuration", + "description": "Add a new serve target to serve a build apps static files. This allows for faster serving of the static build files by reusing the case. Helpful when reserving the app over and over again like in e2e tests.", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "Name of the build target to serve" + }, + "outputPath": { + "type": "string", + "description": "Path to the directory of the built files. This is only needed if buildTarget doesn't specify an outputPath executor option." + }, + "targetName": { + "type": "string", + "description": "Name of the serve target to add. Defaults to 'serve-static'.", + "default": "serve-static" + } + }, + "required": ["buildTarget"] +} diff --git a/packages/web/src/generators/static-serve/static-serve-configuration.spec.ts b/packages/web/src/generators/static-serve/static-serve-configuration.spec.ts new file mode 100644 index 0000000000..60b708e5a4 --- /dev/null +++ b/packages/web/src/generators/static-serve/static-serve-configuration.spec.ts @@ -0,0 +1,208 @@ +import { + addProjectConfiguration, + readProjectConfiguration, + Tree, + updateProjectConfiguration, +} from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { webStaticServeGenerator } from './static-serve-configuration'; + +describe('Static serve configuration generator', () => { + let tree: Tree; + beforeEach(() => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + }); + + it('should add a `serve-static` target to the project', () => { + addReactConfig(tree, 'react-app'); + addAngularConfig(tree, 'angular-app'); + addStorybookConfig(tree, 'storybook'); + + webStaticServeGenerator(tree, { + buildTarget: 'react-app:build', + }); + + expect(readProjectConfiguration(tree, 'react-app').targets['serve-static']) + .toMatchInlineSnapshot(` + Object { + "executor": "@nrwl/web:file-server", + "options": Object { + "buildTarget": "react-app:build", + }, + } + `); + webStaticServeGenerator(tree, { + buildTarget: 'angular-app:build', + }); + + expect( + readProjectConfiguration(tree, 'angular-app').targets['serve-static'] + ).toMatchInlineSnapshot(` + Object { + "executor": "@nrwl/web:file-server", + "options": Object { + "buildTarget": "angular-app:build", + }, + } + `); + + webStaticServeGenerator(tree, { + buildTarget: 'storybook:build-storybook', + }); + expect(readProjectConfiguration(tree, 'storybook').targets['serve-static']) + .toMatchInlineSnapshot(` + Object { + "executor": "@nrwl/web:file-server", + "options": Object { + "buildTarget": "storybook:build-storybook", + "staticFilePath": "dist/apps/storybook/storybook", + }, + } + `); + }); + + it('should support custom target name', () => { + addReactConfig(tree, 'react-app'); + webStaticServeGenerator(tree, { + buildTarget: 'react-app:build', + targetName: 'serve-static-custom', + }); + + expect( + readProjectConfiguration(tree, 'react-app').targets['serve-static-custom'] + ).toMatchInlineSnapshot(` + Object { + "executor": "@nrwl/web:file-server", + "options": Object { + "buildTarget": "react-app:build", + }, + } + `); + }); + + it('should infer outputPath via the buildTarget#outputs', () => { + addAngularConfig(tree, 'angular-app'); + const projectConfig = readProjectConfiguration(tree, 'angular-app'); + delete projectConfig.targets.build.options.outputPath; + projectConfig.targets.build.outputs = ['{options.myPath}']; + projectConfig.targets.build.options.myPath = 'dist/apps/angular-app'; + + updateProjectConfiguration(tree, 'angular-app', projectConfig); + + webStaticServeGenerator(tree, { + buildTarget: 'angular-app:build', + }); + + expect( + readProjectConfiguration(tree, 'angular-app').targets['serve-static'] + ).toMatchInlineSnapshot(` + Object { + "executor": "@nrwl/web:file-server", + "options": Object { + "buildTarget": "angular-app:build", + "staticFilePath": "dist/apps/angular-app", + }, + } + `); + }); + + it('should not override targets', () => { + addStorybookConfig(tree, 'storybook'); + + const pc = readProjectConfiguration(tree, 'storybook'); + pc.targets['serve-static'] = { + executor: 'custom:executor', + }; + + updateProjectConfiguration(tree, 'storybook', pc); + + expect(() => { + webStaticServeGenerator(tree, { + buildTarget: 'storybook:build-storybook', + }); + }).toThrowErrorMatchingInlineSnapshot(` + "Project storybook already has a 'serve-static' target configured. + Either rename or remove the existing 'serve-static' target and try again. + Optionally, you can provide a different name with the --target-name option other than 'serve-static'" + `); + }); +}); + +function addReactConfig(tree: Tree, name: string) { + addProjectConfiguration(tree, name, { + name, + projectType: 'application', + root: `apps/${name}`, + sourceRoot: `apps/${name}/src`, + targets: { + build: { + executor: '@nrwl/vite:build', + outputs: ['{options.outputPath}'], + defaultConfiguration: 'production', + options: { + outputPath: `dist/apps/${name}`, + }, + configurations: { + development: { + mode: 'development', + }, + production: { + mode: 'production', + }, + }, + }, + }, + }); +} + +function addAngularConfig(tree: Tree, name: string) { + addProjectConfiguration(tree, name, { + name, + projectType: 'application', + root: `apps/${name}`, + sourceRoot: `apps/${name}/src`, + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + outputs: ['{options.outputPath}'], + options: { + outputPath: `dist/apps/${name}`, + index: `apps/${name}/src/index.html`, + main: `apps/${name}/src/main.ts`, + polyfills: [`zone.js`], + tsConfig: `apps/${name}/tsconfig.app.json`, + inlineStyleLanguage: `scss`, + assets: [`apps/${name}/src/favicon.ico`, `apps/${name}/src/assets`], + styles: [`apps/${name}/src/styles.scss`], + scripts: [], + }, + }, + }, + }); +} + +function addStorybookConfig(tree: Tree, name: string) { + addProjectConfiguration(tree, name, { + name, + projectType: 'application', + root: `apps/${name}`, + sourceRoot: `apps/${name}/src`, + targets: { + 'build-storybook': { + executor: '@storybook/angular:build-storybook', + outputs: ['{options.outputDir}'], + options: { + outputDir: `dist/apps/${name}/storybook`, + configDir: `apps/${name}/.storybook`, + browserTarget: `storybook:build-storybook`, + compodoc: false, + }, + configurations: { + ci: { + quiet: true, + }, + }, + }, + }, + }); +} diff --git a/packages/web/src/generators/static-serve/static-serve-configuration.ts b/packages/web/src/generators/static-serve/static-serve-configuration.ts new file mode 100644 index 0000000000..6e7f255760 --- /dev/null +++ b/packages/web/src/generators/static-serve/static-serve-configuration.ts @@ -0,0 +1,112 @@ +import { + convertNxGenerator, + logger, + parseTargetString, + readProjectConfiguration, + stripIndents, + TargetConfiguration, + Tree, + updateProjectConfiguration, +} from '@nrwl/devkit'; +import { Schema as FileServerExecutorSchema } from '../../executors/file-server/schema.d'; +interface WebStaticServeSchema { + buildTarget: string; + outputPath?: string; + targetName?: string; +} + +interface NormalizedWebStaticServeSchema extends WebStaticServeSchema { + projectName: string; + targetName: string; +} + +export function webStaticServeGenerator( + tree: Tree, + options: WebStaticServeSchema +) { + const opts = normalizeOptions(tree, options); + addStaticConfig(tree, opts); +} + +function normalizeOptions( + tree: Tree, + options: WebStaticServeSchema +): NormalizedWebStaticServeSchema { + const target = parseTargetString(options.buildTarget); + const opts: NormalizedWebStaticServeSchema = { + ...options, + targetName: options.targetName || 'serve-static', + projectName: target.project, + }; + + const projectConfig = readProjectConfiguration(tree, target.project); + const buildTargetConfig = projectConfig?.targets?.[target.target]; + if (!buildTargetConfig) { + throw new Error(stripIndents`Unable to read the target configuration for the provided build target, ${opts.buildTarget} +Are you sure this target exists?`); + } + + if (projectConfig.targets[opts.targetName]) { + throw new Error(stripIndents`Project ${target.project} already has a '${opts.targetName}' target configured. +Either rename or remove the existing '${opts.targetName}' target and try again. +Optionally, you can provide a different name with the --target-name option other than '${opts.targetName}'`); + } + + // NOTE: @nrwl/web:file-server only looks for the outputPath option + if (!buildTargetConfig.options?.outputPath && !opts.outputPath) { + // attempt to find the suiteable path from the outputs + let maybeOutputValue: any; + for (const o of buildTargetConfig?.outputs || []) { + const isInterpolatedOutput = o.trim().startsWith('{options.'); + if (!isInterpolatedOutput) { + continue; + } + const noBracketParts = o.replace(/[{}]/g, '').split('.'); + + if (noBracketParts.length === 2 && noBracketParts?.[1]) { + const key = noBracketParts[1].trim(); + const value = buildTargetConfig.options?.[key]; + if (value) { + maybeOutputValue = value; + break; + } + } + } + + // NOTE: outputDir is the storybook option. + opts.outputPath = buildTargetConfig.options?.outputDir || maybeOutputValue; + if (opts.outputPath) { + logger.warn(`Automatically detected the output path to be ${opts.outputPath}. +If this is incorrect, the update the staticFilePath option in the ${target.project}:${opts.targetName} target configuration`); + } else { + logger.warn( + stripIndents`${opts.buildTarget} did not have an outputPath property set and --output-path was not provided. +Without either options, the static serve will most likely be unable to serve your project. +It's recommend to provide a --output-path option in this case.` + ); + } + } + + return opts; +} + +function addStaticConfig(tree: Tree, opts: NormalizedWebStaticServeSchema) { + const projectConfig = readProjectConfiguration(tree, opts.projectName); + + const staticServeOptions: TargetConfiguration< + Partial + > = { + executor: '@nrwl/web:file-server', + options: { + buildTarget: opts.buildTarget, + staticFilePath: opts.outputPath, + }, + }; + + projectConfig.targets[opts.targetName] = staticServeOptions; + + updateProjectConfiguration(tree, opts.projectName, projectConfig); +} + +export const compat = convertNxGenerator(webStaticServeGenerator); +export default webStaticServeGenerator;