Node.js: caratteristiche moderne

Node.js ha subito un'evoluzione significativa nel corso degli ultimi anni, introducendo funzionalità che hanno trasformato radicalmente il modo in cui gli sviluppatori costruiscono applicazioni server-side. Dalla gestione nativa dei moduli ECMAScript al supporto per le API web standard, passando per i test runner integrati e le primitive di concorrenza avanzate, Node.js si è affermato come una piattaforma matura e completa. In questo articolo esamineremo in dettaglio le caratteristiche moderne che ogni sviluppatore dovrebbe conoscere.

Moduli ECMAScript nativi

Una delle evoluzioni più attese è stata l'adozione piena dei moduli ECMAScript (ESM) come sistema di moduli di prima classe. A differenza del tradizionale sistema CommonJS basato su require(), i moduli ESM utilizzano le istruzioni import e export, allineando Node.js alla specifica del linguaggio JavaScript.

Per abilitare i moduli ESM in un progetto è sufficiente specificare il campo "type": "module" nel file package.json:

{
  "name": "modern-node-app",
  "version": "1.0.0",
  "type": "module"
}

Con questa configurazione tutti i file .js del progetto vengono interpretati come moduli ESM. È possibile quindi utilizzare le importazioni e le esportazioni standard:

// Importazione di moduli nativi
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

// Esportazione di una funzione di utilità
export async function loadConfig(configPath) {
  const absolutePath = join(process.cwd(), configPath);
  const content = await readFile(absolutePath, "utf-8");
  // Restituisce la configurazione come oggetto
  return JSON.parse(content);
}

Un aspetto importante è il prefisso node: per i moduli built-in. Questa convenzione, introdotta a partire dalla versione 16, disambigua i moduli nativi da quelli installati tramite npm, rendendo il codice più leggibile e prevenendo potenziali conflitti di denominazione.

Top-level await

All'interno dei moduli ESM è possibile utilizzare await al livello superiore del modulo, senza la necessità di racchiudere il codice in una funzione async. Questa caratteristica semplifica notevolmente l'inizializzazione delle applicazioni:

import { readFile } from "node:fs/promises";

// Lettura asincrona al livello superiore del modulo
const data = await readFile("settings.json", "utf-8");
const settings = JSON.parse(data);

// Uso diretto delle impostazioni caricate
console.log(`Porta del server: ${settings.port}`);

Il top-level await è particolarmente utile per caricare configurazioni, stabilire connessioni a database o eseguire qualsiasi operazione asincrona necessaria prima che il modulo venga reso disponibile agli altri moduli che lo importano.

Il test runner integrato

A partire dalla versione 18, Node.js include un test runner nativo accessibile tramite il modulo node:test. Questa aggiunta elimina in molti casi la necessità di dipendere da librerie esterne come Jest o Mocha per i test unitari:

import { describe, it } from "node:test";
import assert from "node:assert/strict";

// Funzione da testare
function add(a, b) {
  return a + b;
}

describe("add", () => {
  it("dovrebbe sommare due numeri positivi", () => {
    // Verifica del risultato atteso
    assert.strictEqual(add(2, 3), 5);
  });

  it("dovrebbe gestire numeri negativi", () => {
    assert.strictEqual(add(-1, -4), -5);
  });

  it("dovrebbe restituire zero per addendi opposti", () => {
    assert.strictEqual(add(5, -5), 0);
  });
});

Il test runner supporta anche i test asincroni, i mock, il code coverage e la possibilità di filtrare i test tramite pattern. Per eseguire i test si utilizza il comando:

node --test

È possibile anche specificare percorsi e pattern:

# Esecuzione con copertura del codice
node --test --experimental-test-coverage

# Filtro per nome del test
node --test --test-name-pattern="sommare"

Il modulo node:test offre inoltre un supporto completo per i mock, consentendo di sostituire funzioni e moduli durante i test:

import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";

describe("UserService", () => {
  it("dovrebbe recuperare i dati dell'utente", async () => {
    // Creazione di un mock per la funzione fetch
    const mockFetch = mock.fn(async () => ({
      json: async () => ({ id: 1, name: "Mario Rossi" })
    }));

    // Sostituzione temporanea di fetch globale
    mock.method(globalThis, "fetch", mockFetch);

    const response = await fetch("https://api.example.com/users/1");
    const user = await response.json();

    assert.strictEqual(user.name, "Mario Rossi");
    assert.strictEqual(mockFetch.mock.calls.length, 1);

    // Ripristino del mock
    mock.restoreAll();
  });
});

Le API Web standard

Node.js ha progressivamente integrato le API Web standard, rendendo possibile la scrittura di codice isomorfico che funziona sia nel browser sia sul server. Tra le API più significative troviamo fetch, Request, Response, Headers, FormData, ReadableStream, WritableStream, Blob, File, structuredClone e i WebSocket.

L'API fetch, disponibile globalmente senza necessità di importazione, è oggi il modo raccomandato per effettuare richieste HTTP:

// Richiesta GET con gestione degli errori
async function fetchUserData(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`, {
    headers: {
      "Accept": "application/json",
      "Authorization": "Bearer token123"
    },
    signal: AbortSignal.timeout(5000) // Timeout di 5 secondi
  });

  if (!response.ok) {
    // Gestione degli errori HTTP
    throw new Error(`Errore HTTP: ${response.status}`);
  }

  return response.json();
}

I Web Streams rappresentano un'altra aggiunta fondamentale, offrendo un'interfaccia standard per la gestione dei flussi di dati:

import { Readable } from "node:stream";

// Creazione di un ReadableStream web-standard
const webStream = new ReadableStream({
  start(controller) {
    // Inserimento dei dati nel flusso
    controller.enqueue(new TextEncoder().encode("Prima parte. "));
    controller.enqueue(new TextEncoder().encode("Seconda parte."));
    controller.close();
  }
});

// Conversione da Web Stream a stream Node.js
const nodeStream = Readable.fromWeb(webStream);

// Lettura dei dati dallo stream Node.js
nodeStream.on("data", (chunk) => {
  console.log(chunk.toString());
});

La API Permissions e le policy di sicurezza

Node.js ha introdotto un modello di permessi sperimentale che consente di limitare l'accesso del processo alle risorse di sistema. Questa funzionalità si ispira al modello di sicurezza di Deno e rappresenta un passo importante verso un'esecuzione più sicura del codice:

# Avvio con permessi limitati
node --experimental-permission --allow-fs-read=/app/config --allow-fs-write=/app/logs app.js

# Permesso di accesso alla rete per specifici host
node --experimental-permission --allow-net=api.example.com app.js

All'interno del codice è possibile verificare i permessi disponibili:

import { permission } from "node:process";

// Verifica del permesso di lettura su un percorso specifico
if (permission.has("fs.read", "/app/config")) {
  console.log("Accesso in lettura consentito a /app/config");
} else {
  console.log("Accesso in lettura negato");
}

Watch mode

Il watch mode integrato elimina la necessità di strumenti esterni come nodemon per il riavvio automatico dell'applicazione durante lo sviluppo. Con il flag --watch, Node.js monitora i file sorgenti e riavvia il processo quando rileva modifiche:

# Riavvio automatico al cambiamento dei file
node --watch server.js

# Monitoraggio di percorsi specifici
node --watch-path=./src --watch-path=./config server.js

Esiste anche la variante --watch-preserve-output che mantiene l'output della console tra un riavvio e l'altro, utile per mantenere visibile la cronologia dei log durante lo sviluppo.

Variabili d'ambiente da file

A partire dalla versione 20.6, Node.js supporta il caricamento nativo delle variabili d'ambiente da file .env, eliminando la dipendenza dal pacchetto dotenv:

# Caricamento del file .env predefinito
node --env-file=.env server.js

# Caricamento di più file .env
node --env-file=.env --env-file=.env.local server.js

Il formato del file .env segue le convenzioni standard:

DATABASE_URL=postgres://user:password@localhost:5432/mydb
PORT=3000
NODE_ENV=production
# Commento: chiave API per il servizio esterno
API_KEY=sk-abc123

Le variabili caricate sono accessibili tramite process.env come di consueto:

// Accesso alle variabili d'ambiente caricate dal file .env
const port = process.env.PORT || 3000;
const databaseUrl = process.env.DATABASE_URL;

console.log(`Server in ascolto sulla porta ${port}`);

Worker Threads

I Worker Threads consentono di eseguire codice JavaScript in thread separati, superando il vincolo del singolo thread di esecuzione che ha caratterizzato Node.js fin dalla sua nascita. Questa funzionalità è particolarmente utile per operazioni CPU-intensive che altrimenti bloccherebbero l'event loop:

import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";

if (isMainThread) {
  // Thread principale: crea un worker per il calcolo pesante
  const worker = new Worker(new URL(import.meta.url), {
    workerData: { numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }
  });

  worker.on("message", (result) => {
    // Ricezione del risultato dal worker
    console.log(`Risultato del calcolo: ${result}`);
  });

  worker.on("error", (error) => {
    console.error(`Errore nel worker: ${error.message}`);
  });
} else {
  // Thread del worker: esegue il calcolo intensivo
  const { numbers } = workerData;
  const result = numbers.reduce((sum, n) => {
    // Simulazione di un calcolo complesso
    let value = n;
    for (let i = 0; i < 1_000_000; i++) {
      value = Math.sqrt(value * value + i);
    }
    return sum + value;
  }, 0);

  // Invio del risultato al thread principale
  parentPort.postMessage(result);
}

Per scenari più avanzati è possibile utilizzare SharedArrayBuffer e Atomics per condividere memoria tra thread e sincronizzare le operazioni:

import { Worker, isMainThread } from "node:worker_threads";

if (isMainThread) {
  // Creazione di un buffer di memoria condivisa (4 byte per un intero a 32 bit)
  const sharedBuffer = new SharedArrayBuffer(4);
  const sharedArray = new Int32Array(sharedBuffer);

  // Inizializzazione del contatore condiviso
  Atomics.store(sharedArray, 0, 0);

  const worker = new Worker(new URL(import.meta.url), {
    workerData: { sharedBuffer }
  });

  worker.on("message", () => {
    // Lettura atomica del valore aggiornato dal worker
    const finalValue = Atomics.load(sharedArray, 0);
    console.log(`Valore finale del contatore: ${finalValue}`);
  });
}

AbortController e gestione della cancellazione

AbortController è disponibile globalmente in Node.js e fornisce un meccanismo standard per annullare operazioni asincrone. Questa API si integra con fetch, i timer, gli stream e molte altre API native:

// Creazione di un controller per la cancellazione
const controller = new AbortController();
const { signal } = controller;

// Timeout automatico dopo 10 secondi
const timeoutId = setTimeout(() => {
  controller.abort(new Error("Operazione scaduta"));
}, 10_000);

try {
  const response = await fetch("https://api.example.com/long-running", { signal });
  const data = await response.json();
  // Pulizia del timeout se la richiesta ha successo
  clearTimeout(timeoutId);
  console.log(data);
} catch (error) {
  if (error.name === "AbortError") {
    // Gestione specifica dell'annullamento
    console.log("Richiesta annullata");
  } else {
    throw error;
  }
}

È possibile combinare più segnali di abort con AbortSignal.any():

// Segnale di timeout
const timeoutSignal = AbortSignal.timeout(5000);

// Segnale controllato dall'utente
const userController = new AbortController();

// Combinazione di entrambi i segnali: la prima cancellazione vince
const combinedSignal = AbortSignal.any([timeoutSignal, userController.signal]);

const response = await fetch("https://api.example.com/data", {
  signal: combinedSignal
});

Il modulo diagnostics_channel

Il modulo node:diagnostics_channel fornisce un meccanismo di pubblicazione e sottoscrizione per la raccolta di dati diagnostici durante l'esecuzione dell'applicazione. Questa funzionalità è particolarmente utile per il monitoraggio delle prestazioni e il debugging:

import diagnostics_channel from "node:diagnostics_channel";

// Creazione di un canale diagnostico personalizzato
const httpChannel = diagnostics_channel.channel("app:http:request");

// Sottoscrizione al canale per il monitoraggio
httpChannel.subscribe((message) => {
  const { method, url, duration } = message;
  // Registrazione delle metriche della richiesta
  console.log(`${method} ${url} completata in ${duration}ms`);
});

// Pubblicazione di dati diagnostici
function handleRequest(req, res) {
  const startTime = performance.now();

  res.on("finish", () => {
    // Invio dei dati diagnostici al canale
    httpChannel.publish({
      method: req.method,
      url: req.url,
      statusCode: res.statusCode,
      duration: Math.round(performance.now() - startTime)
    });
  });
}

Supporto nativo per TypeScript

Le versioni più recenti di Node.js hanno introdotto un supporto sperimentale per l'esecuzione diretta di file TypeScript attraverso il cosiddetto type stripping. Questa funzionalità rimuove le annotazioni di tipo dal codice TypeScript prima dell'esecuzione, senza effettuare la transpilazione completa:

# Esecuzione diretta di file TypeScript
node --experimental-strip-types app.ts

Un esempio di file TypeScript eseguibile direttamente:

// Definizione dell'interfaccia per la configurazione del server
interface ServerConfig {
  port: number;
  host: string;
  cors: boolean;
}

// Definizione del tipo per il risultato dell'operazione
type OperationResult<T> = {
  success: boolean;
  data: T | null;
  error: string | null;
};

// Funzione con tipi espliciti
function createConfig(overrides: Partial<ServerConfig>): ServerConfig {
  // Valori predefiniti della configurazione
  const defaults: ServerConfig = {
    port: 3000,
    host: "localhost",
    cors: true
  };

  return { ...defaults, ...overrides };
}

// Funzione generica con vincolo di tipo
function wrapResult<T>(data: T): OperationResult<T> {
  return {
    success: true,
    data,
    error: null
  };
}

const config = createConfig({ port: 8080 });
const result = wrapResult(config);

// Stampa del risultato
console.log(result);

Va precisato che il type stripping non effettua il controllo dei tipi: per la verifica statica dei tipi è comunque necessario utilizzare il compilatore TypeScript (tsc) separatamente.

Glob e pattern matching nel file system

Node.js ha introdotto il supporto nativo per il glob pattern matching nel modulo node:fs, eliminando la necessità di pacchetti esterni come glob o fast-glob:

import { glob, globSync } from "node:fs";

// Ricerca asincrona di tutti i file JavaScript nella cartella src
const jsFiles = await glob("src/**/*.js");

for await (const file of jsFiles) {
  // Elaborazione di ciascun file trovato
  console.log(`File trovato: ${file}`);
}

// Versione sincrona per script di build
const configFiles = globSync("config/*.json");
console.log(`Trovati ${configFiles.length} file di configurazione`);

Gestione migliorata degli errori

Node.js ha migliorato significativamente la gestione degli errori introducendo codici di errore sistematici, la classe Error.cause per il concatenamento degli errori e l'oggetto AggregateError per raggruppare più errori:

// Concatenamento degli errori con Error.cause
async function connectToDatabase(connectionString) {
  try {
    // Tentativo di connessione al database
    const connection = await createConnection(connectionString);
    return connection;
  } catch (originalError) {
    // Creazione di un errore di livello superiore che preserva la causa originale
    throw new Error("Impossibile connettersi al database", {
      cause: originalError
    });
  }
}

// Gestione di errori multipli con AggregateError
async function fetchMultipleResources(urls) {
  const results = await Promise.allSettled(
    urls.map((url) => fetch(url))
  );

  // Raccolta degli errori
  const errors = results
    .filter((r) => r.status === "rejected")
    .map((r) => r.reason);

  if (errors.length > 0) {
    // Raggruppamento di tutti gli errori in un unico oggetto
    throw new AggregateError(errors, "Alcune risorse non sono disponibili");
  }

  return results
    .filter((r) => r.status === "fulfilled")
    .map((r) => r.value);
}

Il modulo SQLite integrato

Una delle aggiunte più recenti e significative è il modulo node:sqlite, che fornisce un database SQLite integrato senza dipendenze esterne. Questa funzionalità è particolarmente utile per applicazioni leggere, prototipi e test:

import { DatabaseSync } from "node:sqlite";

// Creazione di un database in memoria
const db = new DatabaseSync(":memory:");

// Creazione della tabella
db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT UNIQUE NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  )
`);

// Inserimento con statement preparati
const insertStmt = db.prepare(
  "INSERT INTO users (name, email) VALUES (?, ?)"
);

insertStmt.run("Mario Rossi", "mario@example.com");
insertStmt.run("Luca Bianchi", "luca@example.com");

// Query con parametri nominati
const selectStmt = db.prepare(
  "SELECT * FROM users WHERE name LIKE :pattern"
);

const results = selectStmt.all({ ":pattern": "%Rossi%" });

// Iterazione sui risultati
for (const row of results) {
  console.log(`${row.name} (${row.email})`);
}

Performance hooks e misurazione delle prestazioni

Il modulo node:perf_hooks fornisce strumenti avanzati per la misurazione precisa delle prestazioni, in linea con l'API Performance del browser:

import { performance, PerformanceObserver } from "node:perf_hooks";

// Configurazione dell'osservatore per le misurazioni
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Registrazione delle metriche di durata
    console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
  }
});

observer.observe({ entryTypes: ["measure"] });

// Marcatura dell'inizio dell'operazione
performance.mark("fetch-start");

const response = await fetch("https://api.example.com/data");
const data = await response.json();

// Marcatura della fine e misurazione
performance.mark("fetch-end");
performance.measure("fetch-duration", "fetch-start", "fetch-end");

// Funzione per misurare automaticamente l'esecuzione
function measureExecution(name, fn) {
  const start = performance.now();
  const result = fn();
  const duration = performance.now() - start;
  // Registrazione del tempo di esecuzione
  console.log(`${name}: ${duration.toFixed(2)}ms`);
  return result;
}

Conclusioni

Le caratteristiche moderne di Node.js dimostrano una piattaforma in costante evoluzione che abbraccia gli standard web, migliora l'esperienza dello sviluppatore e affronta le sfide architetturali contemporanee. L'adozione dei moduli ESM, l'integrazione delle API Web standard, il test runner nativo, il supporto per TypeScript e SQLite, insieme ai meccanismi avanzati di sicurezza e diagnostica, rendono Node.js una scelta sempre più completa per lo sviluppo di applicazioni moderne. La convergenza con gli standard del browser riduce la complessità dello sviluppo full-stack e apre nuove possibilità per la condivisione del codice tra client e server.