Come creare un mono-repo con Quasar 2 e NestJS 9

Come sfruttare al massimo le potenzialità dei due framework in un’unica codebase

In Dreamonkey sviluppiamo applicazioni usando Quasar per la parte frontend, il framework open source di cui siamo Platinum Sponsor. Ci capita sempre più spesso di affiancarlo a NestJS per gestire il lato backend. Siccome entrambi i framework sono basati su JavaScript, solitamente organizziamo i nuovi progetti come monorepo che li ospitino entrambi e che condividano la configurazione di linting e formatting, per quanto possibile.

Requisiti

Questa guida prevede che Yarn, la Quasar CLI e la Nest CLI siano già installati a livello globale, che si conoscano le nozioni base di Quasar, Vue e NestJS e che si abbia un account GitHub.

Ecco il repository di esempio che mostra, commit per commit, i passi spiegati in questa guida.

Creiamo la struttura

Per prima cosa creiamo la cartella che ospiterà la codebase e lanciamo quindi il seguente comando che ci permetterà di creare il relativo package.json nella root della nostra cartella di progetto:

$ yarn init

Nel terminale ci verranno poste una serie di domande utili a descrivere il nostro progetto, come il nome, la versione iniziale, una descrizione testuale, l’autore, l’url del repository e l’entry point. Compilate questi campi in base al vostro progetto e alle vostre necessità e in particolare se voleste mantenere il progetto privato, assegnate rispettivamente ai campi license e private i valori UNLICENSED e true. Sempre all’interno del package.json appena creato, aggiungiamo la seguente configurazione che permetterà a Yarn di gestire le dipendenze dei pacchetti:

// package.json
{
  // ... other fields
  "workspaces": ["packages/*"]
}

Come software di versionamento distribuito per i nostri progetti abbiamo optato per Git ma è possibile usarne altri dipendentemente dalle necessità.

Prima però di inizializzare Git sul progetto andremo a creare il .gitignore nella root del progetto utilizzando le seguenti righe come base:

# .gitignore

.env
.env.*
!.env.example

# ==== Node related files ====
node_modules

# ==== Log related files ====
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*

# ==== IDE files ====
.vs
.idea
*.suo
*.ntvs*
*.njsproj
*.sln

# ==== OS Related ====
.DS_Store
.thumbs.db

Procediamo con l’inizializzazione di Git sul progetto ed effettuiamo il primo commit lanciando i seguenti comandi a livello di root:

$ git init
$ git add -A
$ git commit -m 'Initialize the project 🚀' --no-verify

Il prossimo step è creare una cartella a livello di root chiamata packages, in cui creare i progetti con Quasar (frontend/client) e NestJS (backend/server).

Client: Applicazione Quasar

Lanciamo il seguente comando all’interno della cartella packages per creare il progetto che gestirà la parte frontend della nostra applicazione:

$ yarn create quasar

Codice di esempio di configurazione per creare un progetto Quasar

Esempio di configurazione per creare un progetto Quasar.

Sentitevi liberi di rispondere alle varie domande in base alle vostre preferenze. Per lo scopo della guida abbiamo deciso di nominare client la cartella del progetto Quasar, indicando il nome del progetto come @<project-name>/client, dove indica il nome nel package.json a livello di root.

Spostate la cartella .vscode creata con il progetto Quasar a livello di root in modo che possa funzionare correttamente con la struttura monorepo e che sia condiviso anche con il pacchetto server che creeremo più avanti.

Infine assicuriamoci che il comando, all’interno del package.json del client, per avviare il server di sviluppo sia presente. In caso così non fosse aggiungetelo:

// packages/client/package.json
{
  // ... other fields
  "scripts": {
    "dev": "quasar dev"
  }
}
Server: Applicazione NestJS

Lanciamo, sempre nella cartella packages, il comando per creare il progetto NestJS. Il nome della cartella in cui inizializziamo il progetto si chiamerà server:

$ nest new server --skip-git

Assicuriamoci di rinominare il nuovo progetto all’interno del package.json, come abbiamo già fatto per il client, in @<project-name>/server e rinominiamo lo script già esistente start:dev in dev.

Uniamo i progetti

Dopo aver creato i due pacchetti dovremo estrarre a livello di root alcune delle dipendenze di sviluppo, e le relative configurazioni, condivise fra i due progetti. Queste dipendenze sono solitamente quelle che si occupano del linting e formatting, estrarle ci permetterà di usare la stessa configurazione condivisa tra i due progetti.

Nel caso di questo monorepo le dipendenze che dovremo portare a livello di route sono le seguenti:

// package.json
{
  // ... other fields
  "devDependencies": {
    "@types/node": "18.11.18",
    "@typescript-eslint/eslint-plugin": "^5.10.0",
    "@typescript-eslint/parser": "^5.10.0",
    "eslint": "^8.10.0",
    "eslint-config-prettier": "^8.3.0",
    "prettier": "^2.5.1",
    "typescript": "^4.7.4"
  }
}

Oltre alle dipendenze, dovremo estrarre a livello di root anche i seguenti file di configurazione: .eslintrc.js, tsconfig.json, tsconfig.eslint.json, .prettierc e .editorconfig. Puoi controllare qui come abbiamo fatto nel repository di esempio.

Testiamo i progetti creati

Prima di procedere testiamo che tutto funzioni correttamente. Aggiorniamo le dipendenze del progetto lanciando il seguente comando dalla cartella di root:

$ yarn install

Per testare sia il client che il server ci basterà lanciare lo stesso comando yarn dev in due terminali differenti nelle rispettive cartelle dei due pacchetti.

Per verificare il funzionamento del client ci basterà controllare che la pagina di default di Quasar venga aperta correttamente.

ATTENZIONE! Utilizzando le configurazioni del progetto citato in precedenza, lanciare il web server di sviluppo del client potrebbe generare dei warning di linting in console. Per risolverli potete leggere direttamente le soluzioni consigliate dall’IDE oppure controllare nella history dei commits del progetto d’esempio.

Per verificare il server dovremo aprire un terzo terminale e lanciare il seguente comando per interrogare il server:

$ curl http://localhost:3000

Un nuovo progetto NestJS espone di default un endpoint che ritornerà una risposta contenente la stringa “Hello world!”.

Client: Configuration

All’interno del nostro quasar.config.js aggiungiamo la seguente configurazione:

// packages/client/quasar.config.js

// These are just examples
const APP_PROTOCOL = 'http'
const APP_HOST = 'localhost'
const SERVER_PORT = '3000'
const CLIENT_PORT = '9000'

module.exports = configure(function (ctx) {
  return {
    // ... other configuration options
    build: {
      env:{
        API_URL: `${APP_PROTOCOL}://${APP_HOST}:${SERVER_PORT}/api`,
      },
    },
    devServer: {
      server: {
        type: 'http',
      },
      port: CLIENT_PORT,
    },
    framework: {
      plugins: ['Notify'], // We need this plugin to test the connection later on
    },
  };
}

Le costanti dichiarate all’inizio del file contengono le informazioni condivise tra i due progetti, necessarie a farli comunicare correttamente.

Da notare il suffisso che abbiamo deciso di aggiungere alla fine del nostro API_URL. Questo ci permetterà di accedere alle API che creeremo tramite il prefisso /api, lasciando disponibile l’endpoint di root per servire l’applicazione client.

Client: API

Per effettuare una chiamata API e testare la connessione tra client e server modificheremo la pagina IndexPage.vue in modo da ospitare un bottone che quando cliccato effetui la chiamata usando fetch. La risposta sarà poi mostrata attraverso una notifica.

Prestiamo particolare attenzione anche a come otteniamo il valore di API_URL, precedentemente dichiarato all’interno del quasar.config.js, utilizzando process

<!-- packages/client/src/pages/IndexPage.vue -->
<template>
  <q-page class="column items-center justify-center">
    <q-btn label="test api" @click="getHello()" />
  </q-page>
</template>

<script setup lang="ts">
  import { Notify } from 'quasar';

  const BASE_API_URL = process.env.API_URL as string;

  async function getHello() {
    const res = await fetch(`${BASE_API_URL}`, {
      method: 'GET',
    });

    Notify.create({
      message: await res.text(),
      color: res.ok ? 'positive' : 'negative',
    });
  }
</script>
Server: Configuration

Dentro alla cartella server del nostro server creiamo quindi una nuova cartella chiamata config e un file chiamato index.ts che ci permetterà di utilizzare le nostre variabili d’ambiente, ma prima sarà necessario aggiungere questa dipendenza all’interno del pacchetto server:

$ yarn add @nestjs/config

Come possiamo notare alcune delle costanti che abbiamo dichiarato hanno lo stesso nome e valore di quelle presenti all’interno del pacchetto client. Queste, come già detto, dovranno essere uguali in modo da permettere la connessione tra i due pacchetti. Solitamente questi dati di configurazione sono centralizzati in un file .env condiviso tra i due progetti, ma il setup di quel sistema non è strettamente necessario per il corretto funzionamento del nostro monorepo. Per trovare più informazioni le documentazioni NestJS e Quasar possono tornare utili.

// packages/server/src/config/index.ts
import { ConfigType, registerAs } from '@nestjs/config';

// These are just examples
const APP_PROTOCOL = 'http';
const APP_HOST = 'localhost';
const SERVER_PORT = '3000';
const ENABLE_CORS = true;

export type RootConfiguration = ConfigType<typeof rootConfiguration>;

export const rootConfiguration = registerAs('root', () => ({
  port: parseInt(SERVER_PORT, 10),
  host: APP_HOST,
  enableCors: ENABLE_CORS,
  protocol: APP_PROTOCOL,
}));

Referenziamolo quindi aggiungendo le seguenti righe di codice nel file app.module.ts:

// packages/server/src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { rootConfiguration } from './config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [rootConfiguration],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

e settiamo le impostazioni di configurazione nel main.js:

// packages/server/src/main.js
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { rootConfiguration, RootConfiguration } from './config';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api'); // <- Placing our api under the /api endpoint

  const { port, host, enableCors, protocol } = app.get<RootConfiguration>(
    rootConfiguration.KEY
  );

  if (enableCors) {
    app.enableCors();
  }

  await app.listen(port, host);

  Logger.log(
    `🚀 Server running on ${protocol}://${host}:${port}`,
    'NestApplication'
  );
}

void bootstrap();

Come anticipato, abbiamo specificato all’interno della configurazione del server che i nostri endpoint REST saranno accessibili usando il prefisso /api.

Rilanciando il comando che puntava alla root del server ora riceveremo un errore:

$ curl http://localhost:3000

Se invece aggiungiamo il prefisso /api, ci verrà ritornata la stringa “Hello World!”:

$ curl http://localhost:3000/api
Controllo finale

Per assicurarci che la configurazione sia stata fatta correttamente, apriamo un terminale per il progetto Quasar ed uno per il progetto NestJS e lanciamo il comando per avviare il dev server in entrambi i progetti:

$ yarn dev

Cliccando il bottone al centro della pagina dovremmo vedere una notifica verde che contenente il messaggio “Hello world!”

Configurazione build: Client

A questo punto avremo un repository contenente i pacchetti per il server e per il client in grado di avviare un dev server locale adatto per lo sviluppo. Di seguito vedremo come configurare il progetto in modo che sia possibile generare un pacchetto di produzione dell’applicazione Quasar da servire in modo statico dal server NestJS.

Per prima cosa specifichiamo la destinazione della build del nostro progetto client all’interno del quasar.config.js in modo che punti alla cartella del nostro server:

// quasar.config.js
module.exports = configure(function (/* ctx */) {
  return {
    build: {
      distDir: '../server/client-dist/', // ⇐
    },
  };
});

Ricordiamoci di aggiungere la cartella in cui andrà il client buildato al .gitignore e al tsconfig.build.json:

# .gitignore

# ... other ignored patterns
client-dist
# tsconfig.build.json

{
  "extends": "./tsconfig.json",
  "exclude": ["node_modules", "test", "dist", "client-dist", "**/*spec.ts"]
}

Lanciamo il seguente comando per generare una build statica del nostro client

$ yarn build

Infine assicuriamoci di aver ottenuto questo risultato:

Screenshot della struttura del progetto che mostra la cartella di build statica del client generata all'interno della cartella "server"

Struttura del progetto dopo aver lanciato la build statica del client.
Configurazione build: Server

Dopo esserci assicurati che la cartella contenente il pacchetto di produzione del nostro client sia presente all’interno della nostra cartella server, possiamo procedere con la configurazione di quest’ultimo permettendogli di servire il pacchetto statico appena creato.

Aggiungiamo la seguente estensione al nostro server:

$ yarn add @nestjs/serve-static

e aggiungiamo poi la seguente configurazione al file packages/server/src/app.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { rootConfiguration } from 'src/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [rootConfiguration],
    }),

    // This one
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', '/client-dist'),
      exclude: ['/api*'],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Testare il pacchetto di produzione

Ora il nostro server sarà in grado di intercettare eventuali richieste dirette verso l’endpoint /api. Le richieste che puntando all’endpoint di root richiederanno di servire l’applicazione client statica. Proprio per questo ci basterà avviare solamente il server attraverso il seguente comando:

$ yarn start

e aprire una pagina web che punti all’indirizzo http://localhost:3000. Riceveremo il solito messaggio “Hello World!” e avremo quindi un monorepo funzionante composto da frontend gestito da Quasar e un backend gestito da NestJS.

Sei curioso di sapere come continuare lo sviluppo della tua applicazione o vuoi dei consigli per migliorare un tuo progetto? Mettiti in contatto con noi, chiedi un preventivo o supportaci con una donazione.

Se NestJS non è quello che fa per te e preferisci un framework backend per PHP, leggi la nostra guida sul come inizializzare un monorepo per una PWA utilizzando Quasar 2 and Laravel 9.