écrits/tutorial/2026/06
Tutorial21 juin 2026·26 min

Cap'n Web : RPC typé avec pipelining de promesses en TypeScript

Apprenez à construire une couche RPC typée et sans dépendances entre votre navigateur et votre serveur avec Cap'n Web. Ce guide pratique couvre les serveurs RpcTarget, les clients HTTP batch et WebSocket, le pipelining de promesses, les callbacks et la méthode magique .map().

Si vous avez déjà appelé un point de terminaison REST juste pour obtenir un identifiant, puis appelé un autre point de terminaison pour utiliser cet identifiant, vous connaissez la douleur de la « cascade réseau ». Chaque aller-retour ajoute de la latence, et sur une connexion mobile lente dans la région MENA, ces allers-retours s'accumulent vite. Cap'n Web est le système RPC natif en JavaScript de Cloudflare, sorti fin 2025, qui replie ces chaînes en un seul aller-retour — sans compilateur de schéma, sans génération de code, et en moins de 10 ko avec zéro dépendance.

Dans ce tutoriel, vous construirez une API RPC petite mais complète et la consommerez depuis un client TypeScript. En chemin, vous découvrirez les éléments qui distinguent réellement Cap'n Web de tRPC ou d'un simple fetch : le pipelining de promesses, les objets passés par référence, les callbacks qui s'exécutent sur le serveur, et la surprenante méthode .map().

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé (Cap'n Web s'appuie sur des fonctionnalités ES modernes)
  • De l'aisance avec TypeScript et async/await
  • Une compréhension de base de HTTP et des WebSockets
  • Un éditeur de code (VS Code recommandé)

Vous n'avez pas besoin de compte Cloudflare. Cap'n Web fonctionne dans Node.js, Deno, Bun, les navigateurs et Cloudflare Workers — nous utiliserons Node.js pour le serveur afin que tout tourne en local.

Ce que vous allez construire

Une API utilisateur authentifiée minimale avec trois capacités :

  1. Une méthode publique pour s'authentifier avec un jeton, renvoyant un objet de session authentifié.
  2. Des méthodes sur cette session pour lire l'identifiant de l'utilisateur et sa liste d'amis.
  3. Une recherche de profil que nous chaînerons à la session en un seul aller-retour réseau grâce au pipelining de promesses.

À la fin, vous aurez un serveur (server.ts), un contrat de types partagé (api.ts) et un client (client.ts) qui illustre le batching, le pipelining et .map().

Ce qui rend Cap'n Web différent

La plupart des outils RPC (tRPC, oRPC, gRPC-web) envoient une requête par appel, ou exigent de regrouper manuellement. Cap'n Web modélise les objets distants sous forme de stubs : quand vous appelez une méthode, vous obtenez immédiatement une promesse représentant une valeur future vivant sur le serveur. Vous pouvez passer cette promesse à l'appel suivant avant même qu'elle ne soit résolue. Cap'n Web enregistre tout le graphe de dépendances et l'expédie au serveur en un seul message — le serveur exécute la chaîne localement et ne renvoie que les résultats finaux.

C'est le pipelining de promesses, une idée empruntée à Cap'n Proto. C'est la fonctionnalité phare, et nous y arriverons étape par étape.

Étape 1 : Mise en place du projet

Créez un nouveau projet et installez l'unique dépendance :

mkdir capnweb-demo && cd capnweb-demo
npm init -y
npm install capnweb
npm install -D typescript tsx @types/node ws @types/ws

Nous utilisons ws pour le serveur WebSocket et tsx pour exécuter directement les fichiers TypeScript. Créez un tsconfig.json avec des réglages modernes — les déclarations using requièrent ES2022 ou ultérieur pour les symboles de disposition :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2023", "DOM"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

Définissez le type du paquet sur ESM pour que les imports fonctionnent tels quels :

{
  "type": "module"
}

Étape 2 : Définir le contrat d'API partagé

Cap'n Web n'a pas de langage de schéma — vos interfaces TypeScript sont le contrat. Créez api.ts et décrivez la forme des objets distants. Le serveur et le client importent tous deux ces types, ce qui vous donne une sûreté de typage de bout en bout sans aucune génération de code.

// api.ts
 
export interface UserProfile {
  id: number;
  name: string;
  bio: string;
}
 
// La session authentifiée, renvoyée après connexion.
// Les méthodes ici s'exécutent à distance sur le serveur.
export interface AuthedApi {
  getUserId(): number;
  getFriendIds(): number[];
}
 
// Le point d'entrée public exposé sur le endpoint RPC.
export interface PublicApi {
  authenticate(token: string): AuthedApi;
  getUserProfile(userId: number): UserProfile;
}

Remarquez que authenticate renvoie AuthedApi — un autre objet, pas de simples données. C'est la clé du pipelining : la session renvoyée est une référence distante sur laquelle vous pouvez continuer à appeler.

Étape 3 : Implémenter le serveur

Un objet serveur étend RpcTarget. Seules ses méthodes et getters de prototype sont exposés via RPC ; les champs d'instance restent privés. Créez server.ts :

// server.ts
import http from "node:http";
import { WebSocketServer } from "ws";
import {
  RpcTarget,
  newWebSocketRpcSession,
  nodeHttpBatchRpcResponse,
} from "capnweb";
import type { PublicApi, AuthedApi, UserProfile } from "./api.ts";
 
// Magasin de données factice
const USERS: Record<number, UserProfile> = {
  1: { id: 1, name: "Amira", bio: "Ingénieure backend à Tunis" },
  2: { id: 2, name: "Youssef", bio: "Designer à Riyad" },
  3: { id: 3, name: "Lina", bio: "Cheffe de produit à Casablanca" },
};
 
// La session authentifiée — passée par référence car elle étend RpcTarget.
class AuthedSession extends RpcTarget implements AuthedApi {
  // userId est stocké sur l'instance, il reste donc privé au serveur.
  constructor(private readonly userId: number) {
    super();
  }
 
  getUserId(): number {
    return this.userId;
  }
 
  getFriendIds(): number[] {
    // Supposons que chacun est ami avec les deux utilisateurs suivants.
    return [this.userId + 1, this.userId + 2].filter((id) => USERS[id]);
  }
}
 
// Le point d'entrée public.
class PublicServer extends RpcTarget implements PublicApi {
  authenticate(token: string): AuthedApi {
    // En vrai code, vérifiez un JWT ou un jeton de session ici.
    if (!token.startsWith("user-")) {
      throw new Error("Jeton invalide");
    }
    const userId = Number(token.slice("user-".length));
    if (!USERS[userId]) {
      throw new Error("Utilisateur inconnu");
    }
    // Renvoyer un RpcTarget remet au client une référence distante vivante.
    return new AuthedSession(userId);
  }
 
  getUserProfile(userId: number): UserProfile {
    const profile = USERS[userId];
    if (!profile) {
      throw new Error(`Aucun profil pour l'utilisateur ${userId}`);
    }
    return profile;
  }
}
 
// Servir HTTP batch sur /api et WebSocket sur le même port.
const httpServer = http.createServer(async (req, res) => {
  if (req.url === "/api") {
    try {
      await nodeHttpBatchRpcResponse(req, res, new PublicServer(), {
        headers: { "Access-Control-Allow-Origin": "*" },
      });
    } catch (err) {
      res.writeHead(500, { "content-type": "text/plain" });
      res.end(String((err as Error)?.stack ?? err));
    }
    return;
  }
  res.writeHead(404);
  res.end("Not Found");
});
 
// Attacher un serveur WebSocket pour les sessions longue durée.
const wsServer = new WebSocketServer({ server: httpServer });
wsServer.on("connection", (ws) => {
  // Un PublicServer neuf par connexion.
  newWebSocketRpcSession(ws as unknown as WebSocket, new PublicServer());
});
 
httpServer.listen(8080, () => {
  console.log("Serveur Cap'n Web sur http://localhost:8080/api");
});

Deux transports, une seule classe de serveur :

  • nodeHttpBatchRpcResponse gère les requêtes HTTP batch — parfait pour des lots d'appels sans état, en un coup.
  • newWebSocketRpcSession gère un WebSocket longue durée — parfait quand le client appelle au fil du temps ou quand le serveur doit rappeler le client.

Lancez-le :

npx tsx server.ts

Astuce : Chaque connexion WebSocket obtient son propre new PublicServer(). Conservez l'état propre à chaque connexion (comme la session authentifiée) sur les instances, jamais sur des variables globales au niveau du module, afin que deux clients ne voient jamais les données l'un de l'autre.

Étape 4 : Un client basique et le batching HTTP

Maintenant le client. Créez client.ts et commencez par le cas le plus simple — deux appels indépendants regroupés dans une seule requête HTTP :

// client.ts
import { newHttpBatchRpcSession } from "capnweb";
import type { PublicApi } from "./api.ts";
 
async function basicBatch() {
  // 'using' dispose automatiquement la session à la sortie du bloc.
  using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
 
  // Démarrez les deux appels SANS await — ils s'accumulent en file.
  const aliceProfile = api.getUserProfile(1);
  const youssefProfile = api.getUserProfile(2);
 
  // L'await vide le lot : les deux appels voyagent dans UNE requête HTTP.
  const [a, y] = await Promise.all([aliceProfile, youssefProfile]);
  console.log(a.name, "/", y.name); // Amira / Youssef
}
 
basicBatch();

Le mot-clé using est la gestion explicite des ressources de JavaScript. Quand api sort de portée, sa connexion est disposée automatiquement — pas besoin de try/finally. Avec le transport HTTP batch, la requête n'est envoyée qu'au moment du await, donc tout ce que vous mettez en file avant voyage ensemble.

Lancez-le dans un autre terminal :

npx tsx client.ts

Étape 5 : Le pipelining de promesses — l'événement principal

Voici ce que REST ne peut pas faire. Nous voulons :

  1. Nous authentifier avec un jeton.
  2. Obtenir l'identifiant de l'utilisateur authentifié.
  3. Récupérer le profil complet de cet utilisateur.

Avec fetch, ce sont trois allers-retours séquentiels car chaque étape dépend du résultat précédent. Avec Cap'n Web, c'est un seul :

// client.ts (suite)
import { newHttpBatchRpcSession } from "capnweb";
import type { PublicApi } from "./api.ts";
 
async function pipeline() {
  using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
 
  // 1. authenticate() renvoie un stub vers AuthedApi — non attendu.
  const session = api.authenticate("user-1");
 
  // 2. Appelez sur ce stub. userIdPromise est une valeur future sur le serveur.
  const userIdPromise = session.getUserId();
 
  // 3. Passez la promesse non résolue directement à l'appel suivant.
  //    Cap'n Web enregistre la dépendance au lieu de la résoudre localement.
  const profilePromise = api.getUserProfile(userIdPromise);
 
  // Un seul await -> un seul aller-retour pour les trois opérations.
  const profile = await profilePromise;
  console.log(profile); // { id: 1, name: 'Amira', bio: '...' }
}
 
pipeline();

Relisez : api.getUserProfile(userIdPromise) reçoit une promesse, pas un nombre. Cap'n Web voit que userIdPromise est le résultat d'un appel antérieur dans le même lot et réécrit la requête de sorte que le serveur résolve getUserId() d'abord, puis l'injecte dans getUserProfile() — le tout avant de renvoyer quoi que ce soit. Le client ne voit jamais l'identifiant intermédiaire à moins de le demander.

C'est la différence entre trois requêtes HTTP et une. Sur une connexion mobile à 200 ms, cela fait 600 ms contre 200 ms — un gain tangible pour les utilisateurs sur des réseaux plus lents.

Étape 6 : La méthode magique .map()

Et si vous vous authentifiez, obtenez une liste d'identifiants d'amis, et voulez le profil de chaque ami ? Naïvement, c'est un appel pour la liste, puis N appels pour les profils. La méthode .map() de Cap'n Web exécute la transformation sur le serveur, donc tout tient encore en un seul aller-retour :

// client.ts (suite)
async function fanOut() {
  using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
 
  const session = api.authenticate("user-1");
 
  // getFriendIds() renvoie un RpcPromise<number[]>.
  // .map() planifie une transformation côté serveur sur chaque élément.
  const friendProfiles = session
    .getFriendIds()
    .map((friendId) => api.getUserProfile(friendId));
 
  // Toujours UN aller-retour : auth -> ids amis -> N recherches de profil.
  const profiles = await friendProfiles;
  console.log(profiles.map((p) => p.name)); // ['Youssef', 'Lina']
}
 
fanOut();

Le callback passé à .map() est spécial : il doit être synchrone, ses seuls effets de bord pertinents sont d'autres appels RPC, et le friendId qu'il reçoit est lui-même un RpcPromise distant — vous ne pouvez pas, par exemple, faire le calcul friendId + 1 directement dessus. Voyez .map() comme « décrire une boucle côté serveur », pas « exécuter une boucle JavaScript ». Dans ces limites, elle élimine entièrement la cascade classique de requêtes N+1.

Étape 7 : Passer des callbacks au serveur

Comme les fonctions sont de première classe dans Cap'n Web, vous pouvez passer une fonction du client en argument et le serveur peut l'appeler à distance. C'est la base des abonnements en direct et des callbacks de progression. Ajoutez une méthode au serveur :

// server.ts — à ajouter à PublicServer
notifyEach(
  ids: number[],
  onItem: (name: string) => void,
): number {
  for (const id of ids) {
    const profile = USERS[id];
    if (profile) onItem(profile.name); // rappelle le client
  }
  return ids.length;
}

Puis côté client, passez une fonction ordinaire. Utilisez une session WebSocket ici, car les callbacks nécessitent une connexion bidirectionnelle longue durée :

// client.ts (suite)
import { newWebSocketRpcSession } from "capnweb";
import type { PublicApi } from "./api.ts";
 
async function withCallback() {
  using api = newWebSocketRpcSession<PublicApi & {
    notifyEach(ids: number[], onItem: (name: string) => void): number;
  }>("ws://localhost:8080");
 
  const count = await api.notifyEach([1, 2, 3], (name) => {
    // Ceci s'exécute sur le CLIENT, invoqué à distance par le serveur.
    console.log("Le serveur a signalé :", name);
  });
 
  console.log("Total notifiés :", count);
}
 
withCallback();

La fonction est passée par référence sous forme de stub. Le serveur en détient une poignée et l'invoque à travers le réseau. Ce même mécanisme permet à un serveur de pousser des mises à jour vers un client connecté — une alternative propre à la mise en place d'un canal d'événements séparé.

Étape 8 : Gestion des erreurs et disposition

Les erreurs levées sur le serveur sont sérialisées et relancées sur le client, donc un try/catch ordinaire fonctionne :

async function handleErrors() {
  using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
  try {
    await api.authenticate("bad-token"); // le serveur lève "Jeton invalide"
  } catch (err) {
    console.error("Échec de l'authentification :", (err as Error).message);
  }
}

Pour les sessions WebSocket longue durée, écoutez la rupture de connexion afin de pouvoir vous reconnecter ou afficher un état hors ligne :

const api = newWebSocketRpcSession<PublicApi>("ws://localhost:8080");
api.onRpcBroken((error) => {
  console.error("Connexion perdue :", error);
  // Tous les appels suivants sur ce stub seront rejetés.
});

Quand vous créez un stub sans using, disposez-le manuellement pour que le serveur puisse libérer les objets qui y sont liés :

const api = newWebSocketRpcSession<PublicApi>("ws://localhost:8080");
await api.getUserProfile(1);
api[Symbol.dispose](); // ferme la connexion

Si vous avez besoin qu'un objet distant survive à l'appel dont il provient, dupliquez-le avec .dup() avant de passer l'original quelque part où il pourrait être disposé.

Étape 9 : Déploiement sur Cloudflare Workers

Cap'n Web a été conçu par Cloudflare, donc Workers est son environnement le plus naturel. La même classe de serveur RpcTarget fonctionne sans changement — seul le point d'entrée diffère :

// worker.ts
import { RpcTarget, newWorkersRpcResponse } from "capnweb";
import type { PublicApi } from "./api.ts";
 
class PublicServer extends RpcTarget implements PublicApi {
  // ...même implémentation qu'avant...
}
 
export default {
  fetch(request: Request) {
    const url = new URL(request.url);
    if (url.pathname === "/api") {
      return newWorkersRpcResponse(request, new PublicServer());
    }
    return new Response("Not found", { status: 404 });
  },
};

Déployez avec wrangler deploy, pointez l'URL de votre client vers le Worker, et vous obtenez un endpoint RPC distribué mondialement. Bun et Deno sont également pris en charge, via newBunWebSocketRpcHandler et newHttpBatchRpcResponse respectivement.

Tester votre implémentation

Lancez le serveur dans un terminal et le client dans un autre. Vous devez vérifier chaque comportement indépendamment :

  • Batching : journalisez un compteur dans nodeHttpBatchRpcResponse ou observez le réseau — deux appels getUserProfile produisent une seule requête.
  • Pipelining : le flux d'authentification en trois étapes (auth → id → profil) renvoie un profil avec un seul await. Ajoutez un console.log dans chaque méthode du serveur pour confirmer qu'elles s'exécutent toutes au sein d'une seule requête.
  • .map() : fanOut() affiche ['Youssef', 'Lina'] sans boucle côté client sur le réseau.
  • Callbacks : withCallback() affiche « Le serveur a signalé : » trois fois, prouvant que le serveur a invoqué votre fonction cliente.

Si un appel se bloque, vérifiez que le serveur écoute sur le port 8080 et que l'URL de votre client utilise http:// pour le batch et ws:// pour le WebSocket.

Dépannage

using est une erreur de syntaxe. Votre cible TypeScript est inférieure à ES2022, ou votre runtime est ancien. Passez target à ES2022 et utilisez Node.js 20+.

Cannot read properties of undefined lors d'un appel de méthode. Seules les méthodes de prototype sont exposées. Si vous avez défini une méthode comme champ d'instance de type fonction fléchée (method = () => {}), déplacez-la vers une méthode de classe normale pour qu'elle vive sur le prototype.

Le client ne reçoit jamais de réponse en HTTP batch. Le lot n'est vidé qu'au await. Assurez-vous que quelque chose attend les promesses mises en file.

Un argument Map, Set ou RegExp échoue à la sérialisation. Ces types ne sont pas passés par valeur dans Cap'n Web. Convertissez-les en objets/tableaux ordinaires, ou encapsulez le comportement dans un RpcTarget.

Des états fuient entre utilisateurs. Vous avez stocké les données de session sur une variable globale de module au lieu d'une instance. Créez un new PublicServer() par connexion et conservez l'état propre à chaque utilisateur sur des instances RpcTarget.

Cap'n Web vs tRPC et oRPC

Si vous avez utilisé tRPC ou oRPC, voici le changement de modèle mental. tRPC vous donne des procédures typées via HTTP, regroupées mais toujours plates — chaque appel est indépendant. Cap'n Web vous donne des objets typés dont les méthodes renvoient d'autres objets, et il pipeline les appels dépendants en un seul aller-retour. tRPC s'intègre aujourd'hui étroitement à React Query et à l'écosystème Next.js ; Cap'n Web est plus léger, agnostique du transport, et brille quand les chaînes d'appels sont profondes ou que la latence domine. Ils ne sont pas mutuellement exclusifs — vous pourriez garder tRPC pour votre couche de données Next.js et recourir à Cap'n Web sur un chemin critique en latence et riche en graphes d'objets.

Prochaines étapes

  • Ajoutez une vraie authentification en vérifiant un JWT dans authenticate() et en stockant les revendications vérifiées sur l'instance AuthedSession.
  • Explorez les sessions bidirectionnelles via WebSocket où le serveur détient des stubs de callbacks clients pour pousser des mises à jour en direct.
  • Combinez Cap'n Web avec Cloudflare Workers et Durable Objects pour un RPC à état, distribué mondialement.
  • Lisez la spécification du protocole pour comprendre comment les tables d'export/import et le pipelining sont câblés sur le réseau.

Conclusion

Cap'n Web repense le modèle requête/réponse autour des objets et des valeurs futures plutôt que des points de terminaison plats. En traitant les résultats distants comme des promesses que vous pouvez transmettre, il replie des chaînes d'appels entières en un seul aller-retour — éliminant à la fois la cascade de latence et le problème des requêtes N+1. Avec des types TypeScript de bout en bout à partir d'une simple interface, sans génération de code, et une empreinte inférieure à 10 ko, c'est une façon remarquablement propre de communiquer entre navigateur et serveur.

Vous avez construit une API authentifiée complète, l'avez consommée de trois façons — lot, pipeline et fan-out .map() — passé un callback que le serveur a invoqué à distance, et vu comment la même classe de serveur se déploie sur Cloudflare Workers. La prochaine fois que vous vous surprendrez à enchaîner des appels fetch, rappelez-vous que vous pouvez décrire toute la chaîne une fois et laisser le serveur l'exécuter.

Pour en savoir plus sur les API typées et le déploiement en périphérie, explorez nos guides sur tRPC avec l'App Router et Cloudflare Workers avec Hono et D1.