Ein Aktivitätsprotokoll für einen Raum dauerhaft speichern

CloudflareBeginner
Jetzt üben

Einführung

Ein laufendes JavaScript-Objekt kann Werte in Klassen-Eigenschaften speichern. Diese Werte gehen jedoch verloren, wenn die Laufzeit neu startet, abstürzt oder ein inaktives Objekt aus dem Speicher entfernt. Ein Aktivitätsprotokoll darf dieses Risiko nicht eingehen: Ein Mitglied des Raums erwartet, dass das Ereignis von gestern nach einer erneuten Bereitstellung des Anwendungscodes weiterhin sichtbar ist.

In diesem Lab wählt jeder validierte Raumname genau ein Durable Object aus. Dieses Objekt besitzt eine private SQLite-Datenbank mit seinen Aktivitätsereignissen. Der vorgeschaltete Worker ruft das Objekt über RPC auf, sodass Clients nicht direkt auf den Speicher zugreifen. Sie stoppen und starten die lokale Laufzeit neu, stellen anschließend den Cloud-Worker erneut bereit und öffnen eine neue Verbindung. In beiden Fällen müssen zuvor geschriebene Zeilen weiterhin verfügbar sein. Ein zweiter Raum zeigt, dass der Speicher zu einer einzelnen Objektidentität gehört und nicht zum gesamten Namespace.

Sie vergleichen außerdem zwei Arten von Zustand:

  • Zustand im Arbeitsspeicher wird in JavaScript-Eigenschaften gespeichert und eignet sich nur als temporärer Cache.
  • Dauerhafter Zustand wird vor Abschluss der Anfrage in den Speicher des Objekts geschrieben und übersteht den Austausch der Laufzeit.

Bevor Sie direkt mit diesem Kurs beginnen, absolvieren Sie Connect LabEx to Your Cloudflare Account. Jede neue VM benötigt eine eigene Wrangler-Autorisierung. Sie sollten Request-Handler von Workern, Namen von Durable Objects, Bindings und RPC aus dem vorherigen Lab bereits verstehen. Grundlegende SQL-Schlüssel und sortierte Abfragen werden an den jeweiligen Stellen erklärt.

Cloudflare unterstützt derzeit SQLite-basierte Durable Objects im Workers Free-Tarif. Dieses Lab erstellt einen kurzlebigen Klassen-Namespace, einige kleine benannte Objekte und ausschließlich begrenzte Requests. Die Einrichtung installiert Node.js 22.22.0 und Wrangler 4.132.0 als projektlokale Abhängigkeit unter /home/labex/project/room-activity-log; sie autorisiert Cloudflare nicht, erstellt keinen Namespace, stellt keinen Worker bereit und schreibt keine Aktivitätsdatensätze der Lernenden.

VM autorisieren und den Raum-Namespace konfigurieren

In diesem Schritt autorisieren Sie diese neue VM, wählen Ihr Lernkonto aus und beschreiben eine SQLite-basierte Durable-Object-Klasse. Die Anmeldung im Dashboard und die Autorisierung der VM sind getrennt, weil die VM keinen Zugriff auf Ihre Browsersitzung hat.

Wechseln Sie in das vorbereitete Projekt und prüfen Sie die festgelegte Wrangler-Version:

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

Erwartet wird 4.132.0. Starten Sie die Geräteautorisierung:

npx wrangler login --device --browser=false

Öffnen Sie die angezeigte URL im Browser, geben Sie den kurzen Code ein, prüfen Sie das ausgewählte Konto und die Berechtigungen und autorisieren Sie anschließend den Zugriff. Kehren Sie erst zum Terminal zurück, wenn sowohl der Browser als auch Wrangler den Erfolg melden. Fügen Sie niemals ein Passwort oder Token in das Lab ein.

Lesen Sie strukturierte Identitätsinformationen aus und wählen Sie die gewünschte Konto-ID vertraulich aus:

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"

Der erste jq-Ausdruck zeigt nur sichere Identitätsfelder. Der zweite speichert die Konto-ID in einer Shell-Variablen, anstatt sie auszugeben. Wenn Ihr eigenes Lernkonto einen anderen Anzeigenamen hat, ersetzen Sie den bestätigten Namen.

Generieren Sie einen eindeutigen Worker-Namen:

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

Erstellen Sie die Konfiguration. Der nicht quotierte JSON-Delimiter ersetzt $RUN und $ACCOUNT_ID; \$schema hält den JSON-Schlüssel als Literalwert fest.

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 ist das Handle des Workers für den Namespace. Der Eintrag exports teilt Cloudflare mit, dass jedes RoomActivity-Objekt seine eigene SQLite-Datenbank verwendet. Diese Datei erstellt noch keine Cloud-Ressource; die Bereitstellung erfolgt später.

Raumereignisse in SQLite speichern

In diesem Schritt implementieren Sie die vom Raum verwaltete Tabelle sowie zwei RPC-Methoden: Eine hängt ein Ereignis an, die andere gibt den sortierten Verlauf zurück.

Ein Aktivitätsereignis besitzt einen stabilen Textschlüssel, einen kurzen Typ, eine für Menschen lesbare Detailangabe und einen Zeitstempel des Servers. Die Einschränkung PRIMARY KEY verhindert, dass zwei Zeilen innerhalb eines Raums dieselbe Ereignis-ID verwenden. AUTOINCREMENT weist eine monoton steigende sequence zu. Dadurch kann die Leseabfrage die Einfügereihenfolge beibehalten, ohne sich auf Zeitstempel zu verlassen, die identisch sein könnten.

Erstellen Sie den Einstiegspunkt des Workers:

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() ist auf die Erstellung des Schemas beschränkt. Die Methode verzögert Requests, bis die Tabelle vorhanden ist, umschließt aber weder den normalen Datenverkehr noch externe I/O. Der wichtige Anwendungszustand wird niemals nur in einer Klassen-Eigenschaft gespeichert: appendEvent() schreibt die Zeile in SQLite, bevor die Methode sie zurückgibt.

Führen Sie die bereitgestellten deterministischen Tests für das HTTP-Routing sowie eine echte Wrangler-Bundle-Prüfung aus:

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

Erwartet werden zwei erfolgreiche Tests und ein erfolgreicher Dry Run. Diese Prüfungen führen keine entfernte Bereitstellung durch.

Lokale Persistenz über einen Neustart hinweg nachweisen

In diesem Schritt schreiben Sie zwei Ereignisse für den Raum planning, stoppen die lokale Workers-Laufzeit vollständig, starten eine neue Laufzeit mit demselben lokalen Speicherverzeichnis und lesen die Zeilen erneut aus.

Wrangler legt Daten lokaler Bindings normalerweise unter .wrangler/state ab. Dieses Lab verwendet ausdrücklich das Verzeichnis .labex/local-state, damit die Persistenzgrenze sichtbar ist. Das Verzeichnis enthält ausschließlich Daten der lokalen Entwicklung und ist vom Cloudflare-Speicher getrennt.

Starten Sie die erste lokale Laufzeit:

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

Hängen Sie zwei Ereignisse an planning an. --data sendet den JSON-Body, und der Content-Type-Header teilt dem Worker mit, wie er ihn interpretieren soll.

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

Lesen Sie den Raum aus und achten Sie auf die Sequenzen 1 und 2:

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

Beenden Sie nun diese Laufzeit und warten Sie, bis der Prozess abgeschlossen ist:

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

Starten Sie mit demselben Persistenzverzeichnis einen neuen Laufzeitprozess:

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

Lesen Sie planning erneut aus. Lesen Sie anschließend einen anderen Raum aus, für den noch kein Ereignis eingegangen ist:

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

Die neue Laufzeit gibt beide Ereignisse aus planning in der richtigen Reihenfolge zurück, während support ein leeres events-Array zurückgibt. Durch den Neustart wurden alle JavaScript-Klasseninstanzen entfernt, die SQLite-Zeilen jedoch nicht. Der leere zweite Raum zeigt, dass jedes benannte Objekt seinen eigenen privaten Speicher besitzt.

Bereitstellen und Cloud-Aktivität schreiben

In diesem Schritt stoppen Sie den lokalen Prozess, stellen den Klassen-Namespace bereit und schreiben einen kleinen Cloud-Aktivitätsverlauf.

Stoppen Sie die neu gestartete lokale Laufzeit, damit spätere Requests nicht mit Cloud-Antworten verwechselt werden:

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

Stellen Sie den Worker bereit und speichern Sie dabei die normale Terminalausgabe. tee /dev/tty hält die Ausgabe sichtbar, während $(...) sie in einer Shell-Variablen erfasst:

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

Bei der ersten Bereitstellung wird der Export RoomActivity abgeglichen und sein SQLite-basierter Namespace erstellt. Extrahieren Sie die ausgegebene workers.dev-URL, ohne vorauszusetzen, dass ein anderer Lernender dieselbe Subdomain verwendet:

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 gibt ausschließlich den passenden URL-Text aus, und tail -1 wählt die letzte Adresse aus, falls andere Informationszeilen Links enthalten.

Eine erfolgreiche Bereitstellung kann einige Sekunden benötigen, bis sowohl der Worker-Code als auch der neue Durable-Object-Namespace an jedem Edge erreichbar sind. Warten Sie, bis ein Lesezugriff auf das weiterhin leere Objekt support das erwartete JSON zurückgibt, bevor Sie Schreibzugriffe senden:

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

Der abschließende Lesezugriff macht die Bereitschaft explizit: Das Lab stoppt an dieser Stelle, wenn die Route des Durable Objects weiterhin kein gültiges JSON zurückgibt, statt eine Edge-Fehlerseite in nachfolgende Befehle weiterzuleiten. Die kurze Wartezeit verhindert außerdem, dass ein zweites benanntes Objekt erstellt wird, während ein neu abgeglichener Namespace noch über den Edge verteilt wird.

Schreiben Sie dieselben beiden logischen planning-Ereignisse in den Cloud-Speicher. Die lokalen und entfernten Durable-Object-Datenbanken sind absichtlich getrennte Umgebungen, daher ist der Cloud-Raum zunächst leer.

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

Lesen Sie sowohl den Raum planning als auch den unveränderten Raum support aus:

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

Das Cloud-Objekt planning enthält zwei Zeilen, während support leer bleibt. Damit sind Cloud-Identität und Isolation nachgewiesen, bevor Sie einen Austausch der Bereitstellung testen.

Erneut bereitstellen und den dauerhaften Zustand prüfen

In diesem Schritt stellen Sie denselben Worker-Namen und dieselbe Klassendeklaration erneut bereit. Anschließend lesen Sie die vorhandenen Zeilen über eine neue HTTP-Verbindung aus und verknüpfen die Laufzeitbeobachtung mit dem Dashboard.

Stellen Sie die unveränderte Anwendung erneut bereit:

npx wrangler deploy

Durch eine Code-Bereitstellung kann die laufende Durable-Object-Instanz ersetzt werden; dadurch werden Klassen-Eigenschaften geleert. Der Namespace wird jedoch nicht ersetzt, solange derselbe aktive Export RoomActivity weiterhin deklariert ist. Öffnen Sie eine neue Anfrage und lesen Sie den Verlauf von planning aus:

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

Die Zeilen evt-opening und evt-notes müssen weiterhin in der Reihenfolge ihrer Sequenz erscheinen. Dies ist der entscheidende Unterschied zwischen einem temporären Array im Arbeitsspeicher und dauerhaftem, SQLite-basiertem Zustand.

Öffnen Sie das Cloudflare-Dashboard und wählen Sie dasselbe Konto aus. Gehen Sie zu Workers & Pages, suchen Sie den exakten Worker labex-c10-o02-... und bestätigen Sie, dass sein Durable-Object-Binding ROOMS heißt und auf RoomActivity verweist. Öffnen Sie anschließend Durable Objects, wählen Sie diesen Namespace aus und prüfen Sie dessen Overview. Der Namespace-Name identifiziert den bereitgestellten Worker und die Klasse, während Storage: SQL das von wrangler.jsonc ausgewählte Backend bestätigt.

Das Binding des bereitgestellten Workers verbindet ROOMS mit dem Durable-Object-Namespace RoomActivity

Der Screenshot zeigt den geprüften Lauf. Ihr generiertes Suffix wird abweichen, aber Binding-Typ, Name und Zielklasse müssen mit Ihrer Konfiguration übereinstimmen.

Die Übersicht des RoomActivity-Namespace identifiziert dessen SQL-Speicher-Backend

Das Dashboard aggregiert Namespace-Metriken möglicherweise erst nach einer Verzögerung. Daher bleibt die HTTP-Antwort der maßgebliche Nachweis, dass die beiden Zeilen erhalten geblieben sind. Die Übersicht dient als Orientierungspunkt und ersetzt nicht das Lesen aus der Laufzeit.

Öffnen Sie die Ansicht Logs des Namespace. Erfolgreiche Zeilen mit RoomActivity.jsrpc bestätigen, dass Cloudflare die Klasse über RPC aufgerufen hat. Wiederholte Objekt-IDs kennzeichnen wiederholte Aufrufe desselben Objekts, während andere IDs aus dem anderen Raum und aus dem lauf-eindeutigen Raum des Verifizierers stammen. Diese IDs werden von Cloudflare generiert. Sie sind Beispiele und keine Raumnamen, die Sie kopieren sollen. Die Logs belegen die Aufrufe; die sortierte HTTP-Antwort belegt den gespeicherten Aktivitätsinhalt.

Erfolgreiche RoomActivity-RPC-Aufrufe erscheinen mit Durable-Object-IDs in den Namespace-Logs

Führen Sie die unabhängige Prüfung der bereitgestellten Anwendung noch einmal aus. Sie überprüft das Binding und den zugehörigen Namespace, liest die erhaltenen planning-Zeilen aus, bestätigt einen leeren Raum support und erstellt einen separaten, eindeutig benannten Prüfraum:

python3 .labex/verify.py deployed

Namespace löschen und VM-Zugriff widerrufen

In diesem Schritt entfernen Sie den Durable-Object-Namespace und alle Datenbanken seiner Räume. Anschließend löschen Sie den verbleibenden Worker und melden sich ab.

Das alleinige Löschen eines Workers beendet eine Durable-Object-Klasse nicht ausdrücklich. Der deklarative Lebenszyklus verwendet ein deleted tombstone. Dadurch wird dieser Klassen-Namespace dauerhaft gelöscht; es gibt keinen Papierkorb. Bestätigen Sie daher vor dem Fortfahren, dass $RUN mit labex-c10-o02- beginnt.

Erstellen Sie einen zustandslosen Cleanup-Einstiegspunkt:

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

Erstellen Sie die Cleanup-Konfiguration für genau denselben Worker und dasselbe Konto. Sie entfernt das Binding und markiert ausschließlich RoomActivity als gelöscht:

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

Stellen Sie das Tombstone bereit und prüfen Sie die Ausgabe des Abgleichs:

npx wrangler deploy --config wrangler.cleanup.jsonc

Es sollte gemeldet werden, dass RoomActivity gelöscht wurde. Dadurch werden planning, support, der temporäre Raum des Verifizierers und jede SQLite-Zeile in diesem vom Lab verwalteten Namespace entfernt. Löschen Sie den verbleibenden zustandslosen Worker:

npx wrangler delete --config wrangler.cleanup.jsonc

Bestätigen Sie ausschließlich den exakt generierten Worker. Führen Sie die authentifizierte Prüfung auf Abwesenheit aus, solange die Autorisierung noch verfügbar ist:

python3 .labex/verify.py deleted

Melden Sie sich erst ab, nachdem PASS: deleted ausgegeben wurde, und prüfen Sie anschließend den strukturierten Abmeldestatus:

npx wrangler logout
npx wrangler whoami --json

Die abschließende Ausgabe muss loggedIn: false melden. Ein Netzwerkfehler ist weder ein Nachweis für die Löschung der Ressourcen noch für die Abmeldung.

Zusammenfassung

Sie haben einen Raumaktivitätsdienst erstellt, bei dem jeder stabile Raumname ein Durable Object und eine private SQLite-Datenbank auswählt. Sie haben eine nach Schlüssel organisierte, sortierte Ereignistabelle erstellt, über RPC Operationen zum Anhängen und Auflisten bereitgestellt, Requests vor der Objektauswahl validiert und nachgewiesen, dass ein zweiter Raum nicht den Verlauf eines anderen Raums übernimmt.

Außerdem haben Sie temporären JavaScript-Speicher von dauerhaftem Speicher unterschieden, indem Sie dieselben Zeilen nach einem Neustart der lokalen Laufzeit und einer erneuten Bereitstellung in der Cloud ausgelesen haben. Abschließend haben Sie Binding, Namespace, gespeicherte Zeilen und Logs im Dashboard geprüft und anschließend den exakten Namespace und Worker gelöscht, bevor Sie die Autorisierung der VM widerrufen haben.