angulartypescriptserver-side-renderingpnpmangular-ssr

Typescript compilation errors when building Angular with SSR + pnpm + preserveSymlinks


I had an Angular app that was building fine. Only quirks of this app is that has preserveSymlinks: true in angular.json and a local library installed for development using the link: protocol in package.json dependencies.

Everything was working until I added SSR following guide in docs by executing

ng add @angular/ssr

Then, ng build failed with several Typescript errors. Here's an excerpt of those (including one of each kind):

✘ [ERROR] TS2688: Cannot find type definition file for 'express-serve-static-core'. [plugin angular-compiler]

    node_modules/@types/express/index.d.ts:8:22:
      8 │ /// <reference types="express-serve-static-core" />
        ╵                       ~~~~~~~~~~~~~~~~~~~~~~~~~
// ...
✘ [ERROR] TS2307: Cannot find module 'body-parser' or its corresponding type declarations. [plugin angular-compiler]

    node_modules/@types/express/index.d.ts:11:28:
      11 │ import * as bodyParser from "body-parser";
         ╵                             ~~~~~~~~~~~~~
// ... 
✘ [ERROR] TS7006: Parameter 'req' implicitly has an 'any' type. [plugin angular-compiler]

    server.ts:28:19:
      28 │   server.get('*', (req, res, next) => {
         ╵                    ~~~
// ...
✘ [ERROR] TS2339: Property 'listen' does not exist on type 'Express'. [plugin angular-compiler]

    server.ts:51:9:
      51 │   server.listen(port, () => {
         ╵          ~~~~~~
// ...
✘ [ERROR] Could not resolve "debug"

    node_modules/express/lib/view.js:15:20:
      15 │ var debug = require('debug')('express:view');
         ╵                     ~~~~~~~

  You can mark the path "debug" as external to exclude it from the bundle, which will remove this error and leave the unresolved path in the bundle. You can also surround this "require" call with a try/catch block to handle this failure at run-time instead of bundle-time.

Software used:


Solution

  • TL;DR

    If using pnpm + preserveSymlinks in Angular CLI's build target options + SSR for an Angular application, set node-linker to hoisted by adding to an .npmrc file in the repo (create it if doesn't exist):

    node-linker=hoisted
    

    Then, remove node_modules directory and run pnpm install again. ng build works 🎉

    You can also use npm instead of pnpm to solve the issue, but you'll loose the p of pnpm 😜

    Trying to reproduce the issue

    After trying to create a minimal reproducible example app, found out that creating a new Angular app with SSR, pnpm as package manager was building properly:

    pnpm dlx @angular/cli@17 new a17-ssr --ssr --package-manager=pnpm
    pnpm run build
    

    So decided to look for differences between both projects.

    Eventually found out I had something different in angular.json. Inside build target options (projects.{name}.architect.build.options): preserveSymlinks was set to true

    If disabling it, the app builds properly.

    However, I need that option in order to link a local NPM dependency/package. This way I can update the dependency locally and immediately see change in the app without having to reinstall the local dependency.

    Tried using npm instead of pnpm to see if that was a package manager issue. Removed node_modules, used npm install and the app built. Even with preserveSymlinks enabled 🤔 So seems something related to pnpm installs

    The issue

    After a quick search, found out it is actually related to how pnpm installs packages. Given dependencies are actually symlinked from node_modules into a pnpm's virtual store at node_modules/.pnpm. And that messes up the build. Unsure why exactly TBH.

    The solution

    To fix it, we can change the way packages are installed by setting node-linker configuration. See above in TL;DR for how to do it.

    Other alternatives

    File protocol

    Switching to use file: protocol solved the issue. Also preserveSymlinks was no longer required in angular.json workspace configuration. This protocol uses hard-links, so changes to the linked library installed reflect immediately in the app without having to install again. As stated in pnpm docs it works better with peerDependencies resolution.

    pnp node-linker

    Tried quickly the pnp node-linker strategy with symlink enabled, but build fails due to not found dependencies. Maybe if you properly configure Plug'n'Play it works, but didn't want to spend time on it