Search code examples
angularkotlingradleintellij-ideakotlin-multiplatform

How to add Angular to Kotlin/Multiplatform "Full Stack" Application?


I'm creating a web app with the intent of using Angular for the client and Google's Cloud-Run for the backend. I tried a different approach (asked previously) but it ended up not being up to the task of also managing backend code.

Using IntelliJ, I've created a Kotlin Multiplatform "Full-Stack Web Application" and written some commonMain code to be used by both ends. It builds and passes tests.

It appears that the intent is to write the backend code inside jvmMain and the client code inside jsMain. This seems reasonable. The backend side looks pretty easy as everything is added by hand when setting that up.

On the client side, though... Angular has a CLI program to set it up and what is created doesn't match the existing src directory structure plus needs to be built via ng build (or npm build) which isn't called by the Gradle config created for the project as a whole.

Another option is to have IntelliJ create a new "Angular/CLI" module of the project that somehow references the code at the main/project level. I would of course then have (for my OCD's sake) to move the backend into its own, parallel module. But this seems to go against the intent to have them in jsMain and jvmMain, respectively.

What is the best way to add Angular to a Kotlin "Full-Stack Web Application" and have it able to access the commonMain code?


Solution

  • Definitely non-trivial. Here's how I did it:

    1. In some scratch location, create a dummy Angular project of the configuration you want.
    cd /tmp/
    npm update
    ng new myapp
    
    2. In your main project, create the directory structure under which you want the app to be and move the generated source to it. Also move the config files to the top level of your project. Finally, transfer the fetched Node libraries.
    cd /path/to/kmp/project
    mkdir -p src/jsMain/typescript/com/example/myapp
    mv /tmp/myapp/src/app src/jsMain/typescript/com/example/myapp/
    mv /tmp/myapp/{angular.json,*.ts} ./
    mv /tmp/{node_modules,package.json,package-lock.json} ./
    
    3. Move the non-typescript files out of the /typescript/ directory.
    mv src/jsMain/typescript/com/example/myapp/{*.ico,*.html,*.css} src/jsMain/
    
    4. Look at /tmp/myapp/.gitignore and merge it with the existing .gitignore of your project. The output of git status will be helpful to look for things that shouldn't be committed.
    5. Start your IDE. IntelliJ was smart enough to recognize the existence of a new Angular module and offer to add it to the project which saved having to figure out the proper "run config". It just worked! (eventually)
    6. Edit the angular.json file with the following changes. Note that it will cause everything under the standard src/resources/ subdirectory to be available as assets/ in the built application.
    {
      "projects": {
        "myapp": {
          "architect": {
            "build": {
              "options": {
                "outputPath": "build/install/webapp/myapp",
                "index": "src/jsMain/index.html",
                "main": "src/jsMain/typescript/com/example/myapp/main.ts",
                "tsConfig": "tsconfig.app.json",
                "assets": [
                  { "input":"src/jsMain/resources/", "glob":"**/*", "output": "/assets/" }
                ],
                "styles": [
                  "src/jsMain/styles.css"
                ],
              },
            },
            "test": {
              "options": {
                "tsConfig": "tsconfig.spec.json",
                "assets": [
                  { "input":"src/jsMain/resources/", "glob":"**/*", "output":"/assets/" }
                ],
                "styles": [
                  "src/jsMain/styles.css"
                ],
              }
            }
          }
        }
      }
    }
    
    7. Tell typescript where to find the sources.

    tsconfig.app.json:

      "files": [
        "src/jsMain/typescript/com/example/myapp/main.ts"
      ],
      "include": [
        "src/jsMain/typescript/**/*.d.ts"
      ]
    

    tsconfig.spec.json:

      "include": [
        "src/jsMain/typescript/**/*.spec.ts",
        "src/jsMain/typescript/**/*.d.ts"
      ]
    

    tsconfig.json:

    {
      "compilerOptions": {
        "paths": {
          // Create an alias "common" for the entire commonMain.
          // By default, it's built with the name of the top-level project.
          "common": [ "build/js/packages/myproject/kotlin/myproject" ]
        },
      }
    }
    
    8. Tell Gradle how to build everything. What's below isn't a complete file; just what needs to be updated/added.

    build.gradle.kts:

    import com.github.gradle.node.npm.task.NpxTask
    
    plugins {
        kotlin("multiplatform") version "1.8.+"
        kotlin("plugin.serialization") version "1.8.+"
        id("com.github.node-gradle.node") version "3.5.+"
    }
    
    
    kotlin {
        sourceSets.all {
            languageSettings.apply {
                optIn("kotlin.js.ExperimentalJsExport")
            }
        }
    
        js(IR) {
            binaries.executable()
        }
    }
    
    val ngBuild = tasks.register<NpxTask>("buildWebapp") {
        command.set("ng")
        args.set(listOf("build", "--configuration=production"))
        dependsOn(tasks.npmInstall)
        inputs.dir(project.fileTree("src/jsMain").exclude("**/*.spec.ts"))
        inputs.dir("node_modules")
        inputs.files("angular.json", ".browserslistrc", "tsconfig.json", "tsconfig.app.json")
        outputs.dir("${project.buildDir}/install/webapp")
    }
    
    val ngTest = tasks.register<NpxTask>("testWebapp") {
        command.set("ng")
        args.set(listOf("test", "--watch=false"))
        dependsOn(tasks.npmInstall)
        inputs.dir("src/jsTest")
        inputs.dir("node_modules")
        inputs.files("angular.json", ".browserslistrc", "tsconfig.json", "tsconfig.spec.json", "karma.conf.js")
        outputs.upToDateWhen { true }
    }
    
    sourceSets {
        java {
            main {
                resources {
                    // This makes the processResources task automatically depend on the buildWebapp one
                    srcDir(ngBuild)
                }
            }
        }
    }
    
    tasks.test {
        dependsOn(ngTest)
    }
    

    I believe that's the end. I created this answer by going through my command-line history while looking at a git diff against before I started but it's possible I missed something. I'll try to update this answer if people have difficulties.

    You should be able to ng build and ng serve the Angular sample application. Once that is working, it can be changed to create something new.

    9. Import the common code into the Angular project.

    To access the commonMain classes annotated with @JsExport from within typescript, the generated library has to be imported. However, only the top-level "com" namespace can be imported directly so some alias trickery is needed to make it (a) more manageable and (b) not conflict with another library also starting with "com".

    import { Component } from '@angular/core';
    import * as common_top from "common";
    import common = common_top.com.example.whatever;