Conserver un journal d’activité de salle

CloudflareBeginner
Pratiquer maintenant

Introduction

Un objet JavaScript en cours d’exécution peut conserver des valeurs dans les propriétés d’une classe, mais ces valeurs disparaissent lorsque le runtime redémarre, tombe en panne ou retire un objet inactif de la mémoire. Un journal d’activité ne peut pas prendre ce risque : un membre d’une salle s’attend à retrouver l’événement d’hier après le redéploiement du code de l’application.

Dans ce lab, chaque nom de salle validé sélectionne un Durable Object. Cet objet possède une base de données SQLite privée contenant ses événements d’activité. Le Worker en façade appelle l’objet via RPC ; les clients n’accèdent donc pas directement au stockage. Vous arrêterez et redémarrerez le runtime local, puis redéploierez le Worker dans le cloud et ouvrirez une nouvelle connexion. Dans les deux cas, les lignes écrites précédemment doivent rester disponibles. Une deuxième salle montre que le stockage appartient à une identité d’objet précise, et non à l’ensemble du namespace.

Vous comparerez également deux types d’état :

  • L’état en mémoire réside dans les propriétés JavaScript et ne sert que de cache temporaire.
  • L’état durable est écrit dans le stockage de l’objet avant la fin de la requête et survit au remplacement du runtime.

Avant d’accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM nécessite sa propre autorisation Wrangler. Vous devez déjà comprendre les gestionnaires de requêtes des Workers, les noms des Durable Objects, les bindings et le RPC vus dans le lab précédent. Les notions de base sur les clés SQL et les requêtes ordonnées sont expliquées au moment où elles apparaissent.

Cloudflare prend actuellement en charge les Durable Objects basés sur SQLite avec Workers Free. Ce lab crée un namespace de classe jetable, quelques objets nommés de petite taille et uniquement des requêtes limitées. La configuration installe Node.js 22.22.0 et Wrangler 4.132.0 dans /home/labex/project/room-activity-log, mais elle n’autorise pas Cloudflare, ne crée pas de namespace, ne déploie pas de Worker et n’écrit pas d’enregistrements d’activité pour l’apprenant.

Autoriser la VM et configurer le namespace des salles

Dans cette étape, vous allez autoriser cette nouvelle VM, sélectionner votre compte d’apprentissage et décrire une classe de Durable Object basée sur SQLite. La connexion au Dashboard et l’autorisation de la VM sont distinctes, car la VM n’a pas accès à votre session de navigateur.

Accédez au projet préparé et vérifiez la version verrouillée de Wrangler :

cd /home/labex/project/room-activity-log
npx wrangler --version

La sortie attendue est 4.132.0. Lancez l’autorisation par appareil :

npx wrangler login --device --browser=false

Ouvrez l’URL affichée dans le navigateur, saisissez le code court, vérifiez le compte sélectionné et les autorisations, puis autorisez l’accès. Ne revenez au terminal qu’après la confirmation de réussite dans le navigateur et par Wrangler. Ne collez jamais de mot de passe ou de jeton dans le lab.

Lisez les informations d’identité structurées et sélectionnez en privé l’ID du compte voulu :

WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"

La première expression jq affiche uniquement des champs d’identité non sensibles. La deuxième conserve l’ID du compte dans une variable shell au lieu de l’afficher. Si votre compte d’apprentissage dédié porte un autre nom d’affichage, remplacez le nom utilisé par celui que vous avez confirmé.

Générez un nom de Worker unique :

RUN="labex-c10-o02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

Créez la configuration. Le délimiteur JSON non cité développe $RUN et $ACCOUNT_ID ; \$schema conserve la clé JSON littérale.

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  },
  "durable_objects": {
    "bindings": [
      { "name": "ROOMS", "class_name": "RoomActivity" }
    ]
  },
  "exports": {
    "RoomActivity": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

ROOMS est la référence utilisée par le Worker pour accéder au namespace. L’entrée exports indique à Cloudflare que chaque objet RoomActivity utilise sa propre base de données SQLite. Ce fichier ne crée encore aucune ressource cloud ; le déploiement aura lieu plus tard.

Stocker les événements des salles dans SQLite

Dans cette étape, vous allez implémenter la table détenue par la salle et deux méthodes RPC : l’une ajoute un événement et l’autre renvoie l’historique dans l’ordre.

Un événement d’activité possède une clé texte stable, un type court, un détail lisible et un horodatage fourni par le serveur. La contrainte PRIMARY KEY empêche deux lignes d’utiliser le même ID d’événement dans une même salle. AUTOINCREMENT attribue un sequence croissant de manière monotone, ce qui permet à la requête de lecture de conserver l’ordre d’insertion sans dépendre d’horodatages susceptibles d’être identiques.

Créez le point d’entrée du Worker :

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";

export class RoomActivity extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS activity_events (
          sequence INTEGER PRIMARY KEY AUTOINCREMENT,
          event_id TEXT NOT NULL UNIQUE,
          event_type TEXT NOT NULL,
          detail TEXT NOT NULL,
          created_at INTEGER NOT NULL
        )
      `);
    });
  }

  appendEvent(event) {
    const createdAt = Date.now();
    return this.ctx.storage.sql.exec(
      `INSERT INTO activity_events (event_id, event_type, detail, created_at)
       VALUES (?, ?, ?, ?)
       RETURNING sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt`,
      event.eventId,
      event.type,
      event.detail,
      createdAt
    ).one();
  }

  listEvents() {
    return this.ctx.storage.sql.exec(
      `SELECT sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt
       FROM activity_events
       ORDER BY sequence`
    ).toArray();
  }
}

function json(data, status = 200) {
  return Response.json(data, { status });
}

function roomRoute(pathname) {
  const match = pathname.match(/^\/rooms\/([^/]+)\/events$/);
  if (!match) return { error: "not_found", status: 404 };
  let room;
  try {
    room = decodeURIComponent(match[1]);
  } catch {
    return { error: "invalid_room_name", status: 400 };
  }
  if (!/^[a-z][a-z0-9-]{0,31}$/.test(room)) {
    return { error: "invalid_room_name", status: 400 };
  }
  return { room };
}

function validEvent(value) {
  return value &&
    /^[a-z][a-z0-9-]{2,31}$/.test(value.eventId) &&
    /^[a-z][a-z0-9_]{2,31}$/.test(value.type) &&
    typeof value.detail === "string" &&
    value.detail.length >= 1 && value.detail.length <= 160;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (request.method === "GET" && url.pathname === "/health") {
      return json({ status: "ok" });
    }

    const parsed = roomRoute(url.pathname);
    if (parsed.error) return json({ error: parsed.error }, parsed.status);
    if (request.method !== "GET" && request.method !== "POST") {
      return json({ error: "method_not_allowed" }, 405);
    }

    const room = parsed.room;
    let body;
    if (request.method === "POST") {
      try {
        body = await request.json();
      } catch {
        return json({ error: "invalid_json" }, 400);
      }
      if (!validEvent(body)) return json({ error: "invalid_event" }, 400);
    }

    const stub = env.ROOMS.getByName(room);
    try {
      if (request.method === "POST") {
        const event = await stub.appendEvent(body);
        console.log(JSON.stringify({ event: "room_activity_appended", room, eventId: event.eventId, sequence: event.sequence }));
        return json({ room, event }, 201);
      }
      const events = await stub.listEvents();
      console.log(JSON.stringify({ event: "room_activity_listed", room, count: events.length }));
      return json({ room, events });
    } catch (error) {
      if (String(error).includes("UNIQUE constraint failed")) {
        return json({ error: "duplicate_event_id" }, 409);
      }
      throw error;
    }
  }
};
JS

blockConcurrencyWhile() est limité à la création du schéma. Il retarde les requêtes jusqu’à ce que la table existe, mais n’entoure ni le trafic ordinaire ni les entrées-sorties externes. L’état important de l’application n’est jamais conservé uniquement dans une propriété de classe : appendEvent() écrit la ligne dans SQLite avant de la renvoyer.

Exécutez les tests déterministes fournis pour le routage HTTP ainsi qu’une vérification réelle de la compilation avec Wrangler :

NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run

Vous devez obtenir deux tests réussis et une simulation de déploiement réussie. Ces vérifications n’effectuent aucun déploiement distant.

Vérifier la persistance locale après un redémarrage

Dans cette étape, vous allez écrire deux événements dans la salle planning, arrêter complètement le runtime Workers local, démarrer un nouveau runtime avec le même répertoire de stockage local, puis relire les lignes.

Wrangler place normalement les données des bindings locaux sous .wrangler/state. Ce lab utilise explicitement le répertoire .labex/local-state afin de rendre visible la limite de persistance. Ce répertoire contient uniquement des données de développement local ; il est distinct du stockage Cloudflare.

Démarrez le premier runtime local :

npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/health && break
  sleep 1
done

Ajoutez deux événements à planning. --data envoie le corps JSON, et l’en-tête content-type indique au Worker comment l’interpréter.

curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq

Lisez la salle et vérifiez les séquences 1 et 2 :

curl --silent http://127.0.0.1:8787/rooms/planning/events | jq

Arrêtez maintenant ce runtime et attendez la fin de son processus :

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true

Démarrez un nouveau processus runtime avec le même répertoire de persistance :

npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev-restarted.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/health && break
  sleep 1
done

Relisez planning, puis lisez une autre salle qui n’a jamais reçu d’événement :

curl --silent http://127.0.0.1:8787/rooms/planning/events | jq
curl --silent http://127.0.0.1:8787/rooms/support/events | jq

Le nouveau runtime renvoie les deux événements de planning dans l’ordre, tandis que support renvoie un tableau events vide. Le redémarrage a supprimé chaque instance de classe JavaScript, mais pas les lignes SQLite. La deuxième salle vide montre que chaque objet nommé possède un stockage privé.

Déployer et écrire l’activité cloud

Dans cette étape, vous allez arrêter le processus local, déployer le namespace de la classe et écrire un petit historique d’activité dans le cloud.

Arrêtez le runtime local redémarré afin que les requêtes suivantes ne puissent pas être confondues avec des réponses cloud :

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true

Déployez le Worker tout en enregistrant la sortie normale du terminal. tee /dev/tty laisse cette sortie visible tandis que $(...) la capture dans une variable shell :

DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"

Le premier déploiement réconcilie l’export RoomActivity et crée son namespace basé sur SQLite. Extrayez l’URL workers.dev affichée sans supposer qu’un autre apprenant utilise le même sous-domaine :

APP_URL="$(printf '%s\n' "$DEPLOY_OUTPUT" | grep -Eo 'https://[a-z0-9.-]+\.workers\.dev' | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL"

grep -Eo n’affiche que le texte correspondant à l’URL, et tail -1 sélectionne la dernière adresse si d’autres lignes d’information contiennent des liens.

Un déploiement réussi peut nécessiter quelques secondes avant que le code du Worker et son nouveau namespace de Durable Object soient accessibles depuis tous les points de présence. Attendez qu’une lecture de l’objet support, encore vide, renvoie le JSON attendu avant d’envoyer des écritures :

for attempt in $(seq 1 30); do
  if curl --silent --fail "$APP_URL/rooms/support/events" |
    jq -e '.room == "support" and .events == []' >/dev/null; then
    break
  fi
  sleep 1
done
curl --silent --fail "$APP_URL/rooms/support/events" |
  jq -e '.room == "support" and .events == []'
sleep 5

La lecture finale rend la disponibilité explicite : le lab s’arrête ici si la route du Durable Object ne renvoie toujours pas de JSON valide, au lieu de transmettre une page d’erreur du réseau edge aux commandes suivantes. Le court délai d’attente réduit également le risque de créer un deuxième objet nommé pendant la propagation d’un namespace nouvellement réconcilié sur le réseau edge.

Écrivez les deux mêmes événements logiques de planning dans le stockage cloud. Les bases de données des Durable Objects locaux et distants sont volontairement séparées ; la salle cloud est donc vide au départ.

curl --silent --request POST "$APP_URL/rooms/planning/events" \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST "$APP_URL/rooms/planning/events" \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq

Lisez les salles planning et support, qui n’a encore reçu aucune écriture :

curl --silent "$APP_URL/rooms/planning/events" | jq
curl --silent "$APP_URL/rooms/support/events" | jq

L’objet cloud planning contient deux lignes et support reste vide. Cela prouve l’identité et l’isolation dans le cloud avant de tester le remplacement du déploiement.

Redéployer et examiner l’état durable

Dans cette étape, vous allez redéployer le même nom de Worker et la même déclaration de classe, puis lire les lignes existantes via une nouvelle connexion HTTP et relier ces preuves d’exécution au Dashboard.

Déployez de nouveau l’application inchangée :

npx wrangler deploy

Un déploiement de code peut remplacer l’instance Durable Object en cours d’exécution et efface donc les propriétés de classe. Il ne remplace pas le namespace lorsque le même export actif RoomActivity reste déclaré. Ouvrez une nouvelle requête et lisez l’historique de planning :

curl --silent "$APP_URL/rooms/planning/events" | jq

Les lignes evt-opening et evt-notes doivent toujours apparaître dans l’ordre des séquences. C’est la différence essentielle entre un tableau temporaire en mémoire et un état durable basé sur SQLite.

Ouvrez le Cloudflare Dashboard et sélectionnez le même compte. Accédez à Workers & Pages, recherchez le Worker exact labex-c10-o02-... et vérifiez que son binding de Durable Object s’appelle ROOMS et cible RoomActivity. Ouvrez ensuite Durable Objects, sélectionnez ce namespace et consultez sa vue Overview. Le nom du namespace identifie le Worker et la classe déployés, tandis que Storage: SQL confirme le backend choisi dans wrangler.jsonc.

Le binding du Worker déployé relie ROOMS au namespace Durable Object RoomActivity

La capture d’écran montre l’exécution testée. Le suffixe généré sera différent chez vous, mais le type, le nom et la classe cible du binding doivent correspondre à votre configuration.

La vue d’ensemble du namespace RoomActivity identifie son backend de stockage SQL

Le Dashboard peut agréger les métriques du namespace avec un délai ; la réponse HTTP reste donc la preuve faisant autorité que les deux lignes ont persisté. La vue Overview sert de point de contrôle et d’orientation, mais ne remplace pas la lecture effectuée par le runtime.

Ouvrez la vue Logs du namespace. Les lignes RoomActivity.jsrpc réussies confirment que Cloudflare a appelé la classe via RPC. Des IDs d’objet répétés identifient plusieurs appels au même objet, tandis que d’autres IDs proviennent de l’autre salle et de la salle unique utilisée par le vérificateur. Ces IDs sont des exemples générés par Cloudflare ; vous ne devez pas les copier comme noms de salle. Les journaux prouvent les invocations ; la réponse HTTP ordonnée prouve le contenu de l’activité stockée.

Les invocations RPC réussies de RoomActivity apparaissent avec les IDs des Durable Objects dans les journaux du namespace

Exécutez une nouvelle fois la vérification indépendante du déploiement. Elle vérifie le binding et le namespace détenu, lit les lignes préservées de planning, confirme que la salle support est vide et crée une salle de vérification distincte portant un nom unique :

python3 .labex/verify.py deployed

Supprimer le namespace et révoquer l’accès de la VM

Dans cette étape, vous allez supprimer le namespace Durable Object et toutes ses bases de données de salles avant de supprimer le Worker restant et de vous déconnecter.

La suppression d’un Worker ne retire pas explicitement une classe Durable Object. Le cycle de vie déclaratif utilise un deleted tombstone. Il supprime définitivement ce namespace de classe et ne dispose d’aucune corbeille ; vérifiez donc que $RUN commence par labex-c10-o02- avant de continuer.

Créez un point d’entrée de nettoyage sans état :

cat > src/cleanup.js <<'JS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
JS

Construisez la configuration de nettoyage pour exactement le même Worker et le même compte. Elle supprime le binding et marque uniquement RoomActivity comme supprimé :

ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "exports": {
    "RoomActivity": { "type": "durable-object", "state": "deleted" }
  }
}
JSON

Déployez le tombstone et examinez la sortie de réconciliation :

npx wrangler deploy --config wrangler.cleanup.jsonc

La sortie doit indiquer que RoomActivity a été supprimé. Cette opération supprime planning, support, la salle temporaire du vérificateur et toutes les lignes SQLite du namespace appartenant à ce lab. Supprimez le Worker sans état restant :

npx wrangler delete --config wrangler.cleanup.jsonc

Vérifiez qu’il s’agit uniquement du Worker généré exact. Exécutez la vérification authentifiée de l’absence tant que l’autorisation est encore disponible :

python3 .labex/verify.py deleted

Attendez qu’elle affiche PASS: deleted, puis déconnectez-vous et vérifiez l’état structuré de la déconnexion :

npx wrangler logout
npx wrangler whoami --json

La sortie finale doit indiquer loggedIn: false. Une erreur réseau ne prouve ni la suppression des ressources ni la déconnexion.

Résumé

Vous avez créé un service d’activité de salle dans lequel chaque nom de salle stable sélectionne un Durable Object et une base de données SQLite privée. Vous avez créé une table d’événements indexée et ordonnée, exposé les opérations d’ajout et de lecture via RPC, validé les requêtes avant la sélection de l’objet et vérifié qu’une deuxième salle n’hérite pas de l’historique d’une autre salle.

Vous avez également distingué la mémoire JavaScript temporaire du stockage durable en relisant les mêmes lignes après un redémarrage du runtime local et un redéploiement cloud. Enfin, vous avez inspecté le binding, le namespace, les lignes stockées et les journaux dans le Dashboard, puis supprimé le namespace et le Worker exacts avant de révoquer l’autorisation de la VM.