écrits/tutorial/2026/05
Tutorial16 mai 2026·30 min

Cloudflare Workflows : construire des applications multi-étapes durables avec TypeScript en 2026

Apprenez à construire des applications multi-étapes durables et tolérantes aux pannes avec Cloudflare Workflows et TypeScript. Vous construirez un pipeline de traitement de commandes complet avec retries, attentes et validation humaine — exécuté sans serveur en périphérie du réseau.

Des tâches longues qui survivent aux crashs, aux retries et aux redémarrages — sans file d'attente, sans base de données, sans un seul serveur. Cloudflare Workflows apporte l'exécution durable aux Workers : écrivez du code multi-étapes comme s'il était synchrone, et laissez la plateforme gérer la persistance, les retries et la récupération.

Ce que vous allez construire

Dans ce tutoriel, vous construirez un pipeline de traitement de commandes complet sous forme de Cloudflare Workflow. Le pipeline :

  1. Valide une commande entrante
  2. Débite la carte du client via une API de paiement externe
  3. Réserve l'inventaire dans un service en aval
  4. Attend jusqu'au jour ouvré suivant avant de planifier l'expédition
  5. Envoie un email de confirmation
  6. Se remet de toute défaillance d'étape grâce aux retries automatiques

À la fin, vous disposerez d'un workflow idempotent prêt pour la production, qui s'exécute en périphérie, survit aux redémarrages des Workers et peut se mettre en pause pendant des heures ou des jours sans consommer de calcul.


Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé (téléchargement)
  • Un compte Cloudflare — l'offre gratuite prend en charge Workflows (inscription)
  • Wrangler CLI v3.99+ — l'outil développeur de Cloudflare (installé ci-dessous)
  • Des bases en TypeScript et async/await
  • Un éditeur de code (VS Code recommandé)

Pourquoi Workflows ? Les scripts Workers traditionnels doivent se terminer en quelques secondes. Workflows peut s'exécuter pendant des minutes, des heures, voire des jours. Conçus pour la réalité chaotique des systèmes distribués : les API externes tombent, les paiements ont besoin de retries, et les humains ont besoin de temps pour approuver.


Étape 1 : installer Wrangler et créer le projet

Commencez par installer Wrangler globalement et créer un nouveau projet Workers avec support Workflows :

npm install -g wrangler@latest
wrangler login

Une fenêtre de navigateur s'ouvre pour authentifier votre compte Cloudflare. De retour dans le terminal, générez un projet TypeScript :

npm create cloudflare@latest noqta-order-pipeline -- \
  --type=hello-world \
  --lang=ts \
  --git=true \
  --deploy=false
 
cd noqta-order-pipeline

Cela crée un Worker minimal avec TypeScript, Vitest et un fichier de configuration wrangler.jsonc.


Étape 2 : activer Workflows dans wrangler.jsonc

Ouvrez wrangler.jsonc et ajoutez un binding workflows. Cela indique à Cloudflare que votre Worker expose une classe Workflow nommée OrderPipeline :

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "noqta-order-pipeline",
  "main": "src/index.ts",
  "compatibility_date": "2026-05-01",
  "compatibility_flags": ["nodejs_compat"],
  "workflows": [
    {
      "name": "order-pipeline",
      "binding": "ORDER_PIPELINE",
      "class_name": "OrderPipeline"
    }
  ],
  "observability": {
    "enabled": true
  }
}

Le binding est utilisé par votre handler HTTP pour référencer le workflow à l'exécution. Le class_name doit correspondre exactement à la classe exportée dans votre code.


Étape 3 : modéliser la commande

Créez un fichier src/types.ts avec des types stricts pour les données qui traversent le pipeline :

// src/types.ts
export interface OrderItem {
  sku: string;
  quantity: number;
  unitPriceCents: number;
}
 
export interface OrderParams {
  orderId: string;
  customerId: string;
  customerEmail: string;
  paymentToken: string;
  items: OrderItem[];
}
 
export interface PaymentReceipt {
  transactionId: string;
  chargedCents: number;
  chargedAt: string;
}
 
export interface InventoryReservation {
  reservationId: string;
  reservedAt: string;
}

Ces types forment le contrat entre chaque étape du workflow. Comme Workflows persiste la sortie de chaque étape dans un stockage durable, toute valeur retournée doit être sérialisable en JSON — donc des interfaces avec uniquement des primitives.


Étape 4 : écrire la classe Workflow

Voici la pièce maîtresse. Remplacez le contenu de src/index.ts par ce qui suit :

// src/index.ts
import {
  WorkflowEntrypoint,
  WorkflowEvent,
  WorkflowStep,
} from "cloudflare:workers";
import type {
  OrderParams,
  PaymentReceipt,
  InventoryReservation,
} from "./types";
 
interface Env {
  ORDER_PIPELINE: Workflow;
  PAYMENT_API_KEY: string;
  INVENTORY_API_URL: string;
  EMAIL_API_URL: string;
}
 
export class OrderPipeline extends WorkflowEntrypoint<Env, OrderParams> {
  async run(event: WorkflowEvent<OrderParams>, step: WorkflowStep) {
    const order = event.payload;
 
    await step.do("validate-order", async () => {
      if (order.items.length === 0) {
        throw new Error("Order has no items");
      }
      const total = order.items.reduce(
        (sum, i) => sum + i.unitPriceCents * i.quantity,
        0
      );
      if (total <= 0) throw new Error("Order total must be positive");
      return { total };
    });
 
    const receipt = await step.do<PaymentReceipt>(
      "charge-payment",
      {
        retries: {
          limit: 5,
          delay: "10 seconds",
          backoff: "exponential",
        },
        timeout: "30 seconds",
      },
      async () => chargeCard(order, this.env.PAYMENT_API_KEY)
    );
 
    const reservation = await step.do<InventoryReservation>(
      "reserve-inventory",
      { retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
      async () => reserveInventory(order, this.env.INVENTORY_API_URL)
    );
 
    await step.sleepUntil("wait-next-business-day", nextBusinessDay());
 
    await step.do("send-confirmation", async () =>
      sendEmail(order.customerEmail, receipt, reservation, this.env.EMAIL_API_URL)
    );
 
    return {
      status: "completed",
      transactionId: receipt.transactionId,
      reservationId: reservation.reservationId,
    };
  }
}
 
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    if (req.method !== "POST") {
      return new Response("Use POST to start an order", { status: 405 });
    }
    const body = (await req.json()) as OrderParams;
    const instance = await env.ORDER_PIPELINE.create({
      id: body.orderId,
      params: body,
    });
    return Response.json({
      instanceId: instance.id,
      status: await instance.status(),
    });
  },
} satisfies ExportedHandler<Env>;

Quelques points clés :

  • step.do(name, ...) est la primitive durable. Chaque étape nommée s'exécute avec succès au plus une fois ; sa valeur de retour est persistée et rejouée lors de la reprise.
  • La configuration retries est déclarative. Si chargeCard lève une exception, la plateforme réessaie avec un backoff exponentiel — votre code reste propre.
  • step.sleepUntil libère le calcul. Le workflow consomme zéro CPU pendant le sommeil et reprend à l'heure prévue.
  • Retourner une valeur depuis step.do la rend disponible aux étapes suivantes, même après l'éviction du Worker de la mémoire.

Étape 5 : implémenter les appels externes

Ajoutez les fonctions utilitaires à src/index.ts (ou extrayez-les dans leur propre module) :

async function chargeCard(
  order: OrderParams,
  apiKey: string
): Promise<PaymentReceipt> {
  const total = order.items.reduce(
    (sum, i) => sum + i.unitPriceCents * i.quantity,
    0
  );
  const res = await fetch("https://payments.example.com/charge", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": order.orderId,
    },
    body: JSON.stringify({
      token: order.paymentToken,
      amount: total,
      currency: "USD",
    }),
  });
  if (!res.ok) throw new Error(`Payment failed: ${res.status}`);
  const data = (await res.json()) as { id: string; amount: number };
  return {
    transactionId: data.id,
    chargedCents: data.amount,
    chargedAt: new Date().toISOString(),
  };
}
 
async function reserveInventory(
  order: OrderParams,
  apiUrl: string
): Promise<InventoryReservation> {
  const res = await fetch(`${apiUrl}/reservations`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": `${order.orderId}-inv`,
    },
    body: JSON.stringify({ orderId: order.orderId, items: order.items }),
  });
  if (!res.ok) throw new Error(`Inventory error: ${res.status}`);
  const data = (await res.json()) as { id: string };
  return {
    reservationId: data.id,
    reservedAt: new Date().toISOString(),
  };
}
 
async function sendEmail(
  to: string,
  receipt: PaymentReceipt,
  reservation: InventoryReservation,
  apiUrl: string
) {
  await fetch(`${apiUrl}/send`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      to,
      subject: "Order confirmation",
      body: `Charged ${receipt.chargedCents} cents. Reservation: ${reservation.reservationId}`,
    }),
  });
}
 
function nextBusinessDay(): Date {
  const now = new Date();
  const next = new Date(now);
  next.setUTCDate(now.getUTCDate() + 1);
  next.setUTCHours(9, 0, 0, 0);
  const day = next.getUTCDay();
  if (day === 6) next.setUTCDate(next.getUTCDate() + 2);
  if (day === 0) next.setUTCDate(next.getUTCDate() + 1);
  return next;
}

Les clés d'idempotence sont essentielles. Les étapes de workflow peuvent s'exécuter plus d'une fois si un Worker est tué en cours d'exécution. Passez une clé d'idempotence à toute API externe qui modifie un état, scopée au nom de l'étape et à l'ID de l'instance.


Étape 6 : ajouter les secrets et les bindings locaux

Workflows lit les secrets de la même manière que les Workers classiques. Pour le développement local, créez un fichier .dev.vars à la racine du projet :

PAYMENT_API_KEY="test_sk_local_xxxxxxxxxxxxxxxx"
INVENTORY_API_URL="https://staging.inventory.example.com"
EMAIL_API_URL="https://staging.email.example.com"

Pour la production, poussez les secrets vers Cloudflare :

wrangler secret put PAYMENT_API_KEY
wrangler secret put INVENTORY_API_URL
wrangler secret put EMAIL_API_URL

Chaque commande vous demande la valeur et la stocke chiffrée dans le coffre à secrets Cloudflare.


Étape 7 : exécuter le Workflow en local

Lancez le serveur de développement avec la simulation complète des workflows :

wrangler dev --x-dev-env

La CLI affiche une URL locale comme http://127.0.0.1:8787. Dans un autre terminal, lancez une commande :

curl -X POST http://127.0.0.1:8787 \
  -H "Content-Type: application/json" \
  -d '{
    "orderId": "ord_2026_001",
    "customerId": "cust_42",
    "customerEmail": "ada@example.com",
    "paymentToken": "tok_fake_visa",
    "items": [
      { "sku": "TSHIRT-RED-L", "quantity": 2, "unitPriceCents": 1999 }
    ]
  }'

Vous recevez une réponse JSON avec un instanceId. Ouvrez http://127.0.0.1:8787/__workflows dans votre navigateur (Wrangler expose un inspecteur intégré) et observez chaque étape passer par running, success, puis la longue phase sleeping.


Étape 8 : inspecter et contrôler les instances actives

Ajoutez un second endpoint pour interroger et piloter les workflows. Remplacez le handler fetch par ce routeur plus riche :

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    const id = url.searchParams.get("id");
 
    if (req.method === "POST" && url.pathname === "/orders") {
      const body = (await req.json()) as OrderParams;
      const instance = await env.ORDER_PIPELINE.create({
        id: body.orderId,
        params: body,
      });
      return Response.json({ instanceId: instance.id });
    }
 
    if (req.method === "GET" && url.pathname === "/orders" && id) {
      const instance = await env.ORDER_PIPELINE.get(id);
      return Response.json(await instance.status());
    }
 
    if (req.method === "POST" && url.pathname === "/orders/pause" && id) {
      const instance = await env.ORDER_PIPELINE.get(id);
      await instance.pause();
      return Response.json({ paused: true });
    }
 
    if (req.method === "POST" && url.pathname === "/orders/resume" && id) {
      const instance = await env.ORDER_PIPELINE.get(id);
      await instance.resume();
      return Response.json({ resumed: true });
    }
 
    return new Response("Not found", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

Vous pouvez désormais :

  • POST /orders — démarrer un nouveau workflow de commande
  • GET /orders?id=ord_2026_001 — lire l'étape courante, le statut et le temps écoulé
  • POST /orders/pause?id=... — mettre en pause un workflow indéfiniment
  • POST /orders/resume?id=... — reprendre un workflow en pause

C'est la base d'un tableau de bord d'administration ou d'un outil de support qui permet aux agents de suspendre des expéditions en cours.


Étape 9 : ajouter une étape de validation humaine

Pour les commandes de gros montant, supposons que vous souhaitiez une approbation humaine avant le paiement. Utilisez step.waitForEvent :

const total = order.items.reduce(
  (sum, i) => sum + i.unitPriceCents * i.quantity,
  0
);
 
if (total > 50000) {
  const decision = await step.waitForEvent<{ approved: boolean }>(
    "wait-for-manager-approval",
    {
      type: "order.approval",
      timeout: "24 hours",
    }
  );
  if (!decision.payload.approved) {
    return { status: "rejected" };
  }
}

Puis exposez une route pour livrer l'événement :

if (req.method === "POST" && url.pathname === "/orders/approve" && id) {
  const body = (await req.json()) as { approved: boolean };
  const instance = await env.ORDER_PIPELINE.get(id);
  await instance.sendEvent({ type: "order.approval", payload: body });
  return Response.json({ delivered: true });
}

Pendant l'attente, le workflow ne consomme aucune ressource de calcul et survit aux redémarrages indéfinis du Worker. Quand le manager approuve via votre interface d'administration, le workflow reprend exactement où il s'était arrêté.


Étape 10 : écrire un test d'intégration Vitest

Les workflows sont testables de bout en bout avec le pool de tests Cloudflare Workers. Ajoutez ce code dans test/order.spec.ts :

import { env, createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import worker from "../src/index";
 
describe("OrderPipeline", () => {
  it("creates a workflow instance for a valid order", async () => {
    const req = new Request("http://example.com/orders", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        orderId: `ord_${crypto.randomUUID()}`,
        customerId: "cust_1",
        customerEmail: "test@example.com",
        paymentToken: "tok_test",
        items: [{ sku: "SKU1", quantity: 1, unitPriceCents: 500 }],
      }),
    });
    const ctx = createExecutionContext();
    const res = await worker.fetch(req, env, ctx);
    await waitOnExecutionContext(ctx);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { instanceId: string };
    expect(body.instanceId).toBeDefined();
  });
});

Lancez-le :

npm test

Les tests s'exécutent dans un runtime Workers en mémoire — aucun appel réseau, aucun appel réel à Cloudflare.


Étape 11 : déployer en production

Quand vous êtes prêt à livrer :

wrangler deploy

Wrangler téléverse votre Worker, enregistre la classe OrderPipeline et affiche une URL publique. Désormais, le workflow s'exécute sur le réseau périphérique mondial de Cloudflare avec persistance intégrée.

Testez-le avec une vraie commande :

curl -X POST https://noqta-order-pipeline.your-subdomain.workers.dev/orders \
  -H "Content-Type: application/json" \
  -d @order.json

Ouvrez le tableau de bord Cloudflare, allez dans Workers and Pages → Workflows, et cliquez sur votre instance pour voir une chronologie détaillée de chaque retry, attente et événement.


Tester votre implémentation

Vérifiez le workflow avec cette checklist :

  • Une commande valide retourne un instanceId et progresse à travers toutes les étapes.
  • Tuer le serveur de développement en cours puis le redémarrer reprend à la dernière étape complétée.
  • Forcer chargeCard à lever une exception montre les retries s'enchaîner avec backoff exponentiel.
  • Appeler /orders/pause arrête l'exécution ; /orders/resume continue à la même étape.
  • Les commandes de gros montant bloquent sur wait-for-manager-approval jusqu'à livraison de l'événement.

Dépannage

Workflow class not found — confirmez que le class_name dans wrangler.jsonc correspond exactement à la classe exportée.

Step output is not serializable — ne retournez que des valeurs JSON-safe depuis step.do. Encapsulez les objets Date avec .toISOString() et évitez Map, Set ou les instances de classes.

Les étapes s'exécutent plusieurs fois — c'est volontaire quand un Worker crashe en cours d'étape. Utilisez des clés d'idempotence sur chaque mutation externe.

Les sleeps se déclenchent instantanément en dev local — les anciennes versions de Wrangler accéléraient les sleeps. Mettez à jour vers wrangler@3.99 ou plus récent, ou passez --x-dev-env pour utiliser la simulation des minuteurs de production.

Cannot find module cloudflare:workers — assurez-vous que votre tsconfig.json contient "types": ["@cloudflare/workers-types/2026-05-01"] et que vous avez installé @cloudflare/workers-types.


Pour aller plus loin


Conclusion

Cloudflare Workflows redéfinit la manière dont on écrit du code serverless de longue durée. Au lieu d'assembler des files d'attente, des planificateurs cron, des boîtes aux lettres mortes et des machines d'état pour les retries, vous écrivez du TypeScript linéaire et laissez la plateforme gérer la durabilité. Combiné à Workers, D1, Queues et R2, c'est la pièce manquante du puzzle full-stack Cloudflare — et l'exécution durable devient accessible à toute équipe à l'aise avec npm install.

Dans ce tutoriel, vous êtes passé d'un projet vierge à un pipeline de commandes prêt pour la production avec retries, sleeps, pause et reprise, validations humaines et tests d'intégration. Considérez-le comme un canevas : remplacez les API externes par les vôtres et vous obtenez un backend résilient qui passe à zéro, coûte quelques centimes par million d'invocations et s'exécute dans chaque data center Cloudflare de la planète.