← Cloudflare Pipelines / pipelines / examples
Rozdělení streamu do více tabulek Iceberg
V tomto příkladu budete odebírat veřejný Bluesky Jetstream ↗ firehose, živý WebSocket stream všech příspěvků, lajků, repostů, sledování a blokování v síti, a uložte jej do R2 Data Catalog jako dotazovatelné tabulky Apache Iceberg.
Naučíte se základní vzor služby Pipelines: všechny události pošlete do jednoho streamu a poté pomocí jediné pipeline s několika SQL příkazy tento stream rozdělíte ("fan out") do několika cílových tabulek, jedné pro každý typ události, aniž byste pro každou z nich museli spouštět samostatnou pipeline.
flowchart TD
A[Bluesky Jetstream WebSocket] --> B[Durable Object]
B -->|send| C[bsky_events_stream]
C --> D[bsky_pipeline]
D --> E[bsky_post]
D --> F[bsky_like]
D --> G[bsky_repost]
D --> H[bsky_follow]
D --> I[bsky_block]
Předpoklady
- Zaregistrujte si účet Cloudflare ↗.
- Nainstalujte
Node.js↗.
Správce verzí Node.js
Použijte správce verzí Node, jako je Volta ↗ nebo nvm ↗ abyste se vyhnuli problémům s oprávněními a mohli měnit verze Node.js. Wrangler, o kterém se dozvíte dále v této příručce, vyžaduje verzi Node 16.17.0 nebo novější.
Budete také potřebovat Token R2 API hodnotou Admin Read & Write oprávnění, což zahrnuje přístup k R2 Data Catalog a R2 SQL. Tento token předáte každému sinku. Účet Bluesky ani API klíč nejsou potřeba, protože Jetstream je veřejný a nevyžaduje ověření.
1. Vytvořte nový projekt Worker
Nový projekt Worker vytvoříte spuštěním následujícího příkazu:
npm create cloudflare@latest -- bluesky-pipelinePři nastavení vyberte následující možnosti:
- Pro S čím byste chtěli začít?, vyberte
Hello World example. - Pro Jakou šablonu chcete použít?, vyberte
Worker only. - Pro Jaký jazyk chcete použít?, vyberte
TypeScript. - Pro Chcete používat git pro správu verzí?, vyberte
Yes. - Pro Chcete nasadit svou aplikaci?, vyberte
No(před nasazením provedeme ještě několik změn).
Přejděte do adresáře svého nového projektu:
cd bluesky-pipeline pipelines příkazy použité dále vyžadují Wrangler v4 nebo novější. Pokud byl váš projekt vytvořen se starší verzí, aktualizujte ji nyní:
npm i -D wrangler@latest2. Definujte schéma streamu
Stream má jediné schéma. Protože stejným streamem prochází všechny typy událostí, je schéma sjednocením polí požadovaných napříč všemi typy. Každý směrovací příkaz pak vybírá jen sloupce relevantní pro svůj cíl.
Vytvořte schema.json soubor v kořenovém adresáři vašeho projektu:
{
"fields": [
{ "name": "event_id", "type": "string", "required": true },
{ "name": "event_type", "type": "string", "required": true },
{ "name": "did", "type": "string", "required": false },
{ "name": "operation", "type": "string", "required": false },
{ "name": "event_time", "type": "timestamp", "required": false },
{ "name": "created_at", "type": "string", "required": false },
{ "name": "text", "type": "string", "required": false },
{ "name": "langs", "type": "string", "required": false },
{ "name": "subject_uri", "type": "string", "required": false },
{ "name": "subject_did", "type": "string", "required": false }
]
}3. Vytvořte R2 bucket a povolte R2 Data Catalog
Vaše sinky zapisují do tabulek Iceberg v R2 Data Catalog, proto potřebujete bucket s povoleným katalogem.
Vytvořte bucket R2 s názvem bluesky-pipeline:
npx wrangler r2 bucket create bluesky-pipelinePovolte R2 Data Catalog na bucketu:
npx wrangler r2 bucket catalog enable bluesky-pipelinePři spuštění tohoto příkazu si poznamenejte Název warehouse. Budete ho potřebovat k dotazování vašich dat pomocí R2 SQL.
4. Vytvořte stream, sinky a pipeline
Nejprve vytvořte stream ze souboru schématu:
npx wrangler pipelines streams create bsky_events_stream --schema-file schema.jsonVšimněte si stream ID ve výstupu. V dalším kroku ji použijete ke konfiguraci Worker bindingu.
Dále vytvořte jeden sink na cílovou tabulku. Každý sink zapisuje do vlastní tabulky Iceberg v R2 Data Catalog. Nahraďte YOUR_CATALOG_TOKEN s vaším API tokenem R2.
for t in post like repost follow block; do
npx wrangler pipelines sinks create bsky_${t}_sink \
--type r2-data-catalog \
--bucket bluesky-pipeline \
--namespace bluesky \
--table bsky_${t} \
--catalog-token YOUR_CATALOG_TOKEN \
--roll-interval 60
doneNyní vytvořte jeden pipeline, jehož SQL obsahuje více INSERT příkazy, jeden pro každou trasu (route). Každý příkaz filtruje stream podle event_type a promítá pouze sloupce, které jsou pro danou tabulku relevantní.
Vytvořte fanout.sql soubor:
INSERT INTO bsky_post_sink
SELECT event_id, did, operation, event_time, created_at, text, langs
FROM bsky_events_stream WHERE event_type = 'post';
INSERT INTO bsky_like_sink
SELECT event_id, did, operation, event_time, created_at, subject_uri
FROM bsky_events_stream WHERE event_type = 'like';
INSERT INTO bsky_repost_sink
SELECT event_id, did, operation, event_time, created_at, subject_uri
FROM bsky_events_stream WHERE event_type = 'repost';
INSERT INTO bsky_follow_sink
SELECT event_id, did, operation, event_time, created_at, subject_did
FROM bsky_events_stream WHERE event_type = 'follow';
INSERT INTO bsky_block_sink
SELECT event_id, did, operation, event_time, created_at, subject_did
FROM bsky_events_stream WHERE event_type = 'block';Vytvořte pipeline ze souboru:
npx wrangler pipelines create bsky_pipeline --sql-file fanout.sqlJeden pipeline zapisuje do pěti tabulek. Pokud chcete později přidat nový typ události, přidejte jeden sink a jeden INSERT příkaz. SQL pipeline nelze po vytvoření upravovat, takže ke změně je nutné pipeline smazat a znovu vytvořit. Další informace najdete v Směrování jednoho streamu do více tabulek.
5. Připojte stream ke svému Workeru
Přidejte stream binding, Durable Object pro uchování WebSocket připojení a Cron Trigger aby konzument zůstal aktivní. Nahraďte <STREAM_ID> s ID streamu z kroku 4.
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "bluesky-pipeline",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-28",
"pipelines": [
{
"binding": "BSKY_STREAM",
"stream": "<STREAM_ID>"
}
],
"durable_objects": {
"bindings": [
{
"name": "JETSTREAM",
"class_name": "JetstreamConsumer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"JetstreamConsumer"
]
}
],
"triggers": {
"crons": [
"*/2 * * * *"
]
}
}name = "bluesky-pipeline"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-28"
[[pipelines]]
binding = "BSKY_STREAM"
stream = "<STREAM_ID>"
[[durable_objects.bindings]]
name = "JETSTREAM"
class_name = "JetstreamConsumer"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["JetstreamConsumer"]
[triggers]
crons = ["*/2 * * * *"]6. Zpracujte firehose v Durable Object
A Durable Object je vhodným místem pro dlouhodobě otevřený WebSocket. Zůstává rezidentní, dokud je socket otevřený, a alarm ho znovu připojí, pokud spojení vypadne. Ukládejte příchozí události do vyrovnávací paměti a send() je do streamu v dávkách, abyste zůstali pod limitem 5 MB na požadavek. Uchovávejte Jetstream time_us kurzor, aby opětovné připojení pokračovalo bez výpadků.
Nahraďte obsah src/index.ts následujícím:
import { DurableObject } from "cloudflare:workers";
// Jetstream collection -> our short event_type. Only these are kept.
const COLLECTION_TO_TYPE = {
"app.bsky.feed.post": "post",
"app.bsky.feed.like": "like",
"app.bsky.feed.repost": "repost",
"app.bsky.graph.follow": "follow",
"app.bsky.graph.block": "block",
};
const WANTED = Object.keys(COLLECTION_TO_TYPE);
const JETSTREAM_URL = "https://jetstream2.us-east.bsky.network/subscribe";
const FLUSH_MAX = 500; // rows per send()
const FLUSH_MS = 1000; // flush at least once per second
const RECONNECT_MS = 15000;
// Flatten one Jetstream message into a unified stream row, or null to skip.
function toRow(ev) {
if (ev?.kind !== "commit" || !ev.commit) return null;
const c = ev.commit;
const event_type = COLLECTION_TO_TYPE[c.collection];
if (!event_type) return null;
const r = c.record ?? {};
const subject = r.subject;
return {
event_id: `${ev.did}/${c.collection}/${c.rkey}`,
event_type,
did: ev.did ?? null,
operation: c.operation ?? null,
event_time:
typeof ev.time_us === "number"
? new Date(ev.time_us / 1000).toISOString()
: null,
created_at: typeof r.createdAt === "string" ? r.createdAt : null,
text: event_type === "post" && typeof r.text === "string" ? r.text : null,
langs:
event_type === "post" && Array.isArray(r.langs)
? r.langs.join(",")
: null,
subject_uri: typeof subject === "object" ? (subject?.uri ?? null) : null,
subject_did: typeof subject === "string" ? subject : null,
};
}
export class JetstreamConsumer extends DurableObject {
ws = null;
buf = [];
lastFlush = 0;
cursor = null;
flushing = false;
// Arm the reconnect watchdog first, then connect (idempotent).
async start() {
await this.ctx.storage.setAlarm(Date.now() + RECONNECT_MS);
await this.ensureConnected();
return { connected: this.ws !== null };
}
async ensureConnected() {
if (this.ws) return;
this.cursor ??= (await this.ctx.storage.get("cursor")) ?? null;
const params = new URLSearchParams();
for (const c of WANTED) params.append("wantedCollections", c);
if (this.cursor) params.set("cursor", String(this.cursor));
const resp = await fetch(`${JETSTREAM_URL}?${params}`, {
headers: { Upgrade: "websocket" },
});
const ws = resp.webSocket;
if (!ws) throw new Error(`Jetstream handshake failed: ${resp.status}`);
ws.accept();
this.ws = ws;
ws.addEventListener("message", (e) => this.onMessage(e));
ws.addEventListener("close", () => (this.ws = null));
ws.addEventListener("error", () => (this.ws = null));
}
onMessage(e) {
let ev;
try {
ev = JSON.parse(e.data);
} catch {
return;
}
if (typeof ev.time_us === "number") this.cursor = ev.time_us;
const row = toRow(ev);
if (row) this.buf.push(row);
if (
this.buf.length >= FLUSH_MAX ||
Date.now() - this.lastFlush >= FLUSH_MS
) {
void this.flush();
}
}
// Serialize sends: flush one batch at a time, advancing the cursor on success.
async flush() {
if (this.flushing) return;
this.flushing = true;
try {
while (this.buf.length > 0) {
this.lastFlush = Date.now();
const batch = this.buf.splice(0, this.buf.length);
const batchCursor = this.cursor;
try {
await this.env.BSKY_STREAM.send(batch);
await this.ctx.storage.put("cursor", batchCursor);
} catch (err) {
this.buf.unshift(...batch);
console.error("send failed, will retry", err);
return;
}
}
} finally {
this.flushing = false;
}
}
// Watchdog: reconnect if dropped, flush stragglers, always reschedule.
async alarm() {
try {
await this.ensureConnected();
await this.flush();
} catch (err) {
console.error("alarm error", err);
} finally {
await this.ctx.storage.setAlarm(Date.now() + RECONNECT_MS);
}
}
}
export default {
async fetch(_req, env) {
const stub = env.JETSTREAM.get(env.JETSTREAM.idFromName("singleton"));
return Response.json(await stub.start());
},
async scheduled(_event, env) {
const stub = env.JETSTREAM.get(env.JETSTREAM.idFromName("singleton"));
await stub.start();
},
};import { DurableObject } from "cloudflare:workers";
import type { Pipeline } from "cloudflare:pipelines";
interface Env {
BSKY_STREAM: Pipeline;
JETSTREAM: DurableObjectNamespace<JetstreamConsumer>;
}
// Jetstream collection -> our short event_type. Only these are kept.
const COLLECTION_TO_TYPE: Record<string, string> = {
"app.bsky.feed.post": "post",
"app.bsky.feed.like": "like",
"app.bsky.feed.repost": "repost",
"app.bsky.graph.follow": "follow",
"app.bsky.graph.block": "block",
};
const WANTED = Object.keys(COLLECTION_TO_TYPE);
const JETSTREAM_URL = "https://jetstream2.us-east.bsky.network/subscribe";
const FLUSH_MAX = 500; // rows per send()
const FLUSH_MS = 1000; // flush at least once per second
const RECONNECT_MS = 15000;
// Flatten one Jetstream message into a unified stream row, or null to skip.
function toRow(ev: any) {
if (ev?.kind !== "commit" || !ev.commit) return null;
const c = ev.commit;
const event_type = COLLECTION_TO_TYPE[c.collection];
if (!event_type) return null;
const r = c.record ?? {};
const subject = r.subject;
return {
event_id: `${ev.did}/${c.collection}/${c.rkey}`,
event_type,
did: ev.did ?? null,
operation: c.operation ?? null,
event_time:
typeof ev.time_us === "number"
? new Date(ev.time_us / 1000).toISOString()
: null,
created_at: typeof r.createdAt === "string" ? r.createdAt : null,
text: event_type === "post" && typeof r.text === "string" ? r.text : null,
langs:
event_type === "post" && Array.isArray(r.langs)
? r.langs.join(",")
: null,
subject_uri: typeof subject === "object" ? (subject?.uri ?? null) : null,
subject_did: typeof subject === "string" ? subject : null,
};
}
export class JetstreamConsumer extends DurableObject<Env> {
private ws: WebSocket | null = null;
private buf: Record<string, unknown>[] = [];
private lastFlush = 0;
private cursor: number | null = null;
private flushing = false;
// Arm the reconnect watchdog first, then connect (idempotent).
async start() {
await this.ctx.storage.setAlarm(Date.now() + RECONNECT_MS);
await this.ensureConnected();
return { connected: this.ws !== null };
}
private async ensureConnected() {
if (this.ws) return;
this.cursor ??= (await this.ctx.storage.get<number>("cursor")) ?? null;
const params = new URLSearchParams();
for (const c of WANTED) params.append("wantedCollections", c);
if (this.cursor) params.set("cursor", String(this.cursor));
const resp = await fetch(`${JETSTREAM_URL}?${params}`, {
headers: { Upgrade: "websocket" },
});
const ws = resp.webSocket;
if (!ws) throw new Error(`Jetstream handshake failed: ${resp.status}`);
ws.accept();
this.ws = ws;
ws.addEventListener("message", (e) => this.onMessage(e));
ws.addEventListener("close", () => (this.ws = null));
ws.addEventListener("error", () => (this.ws = null));
}
private onMessage(e: MessageEvent) {
let ev: any;
try {
ev = JSON.parse(e.data as string);
} catch {
return;
}
if (typeof ev.time_us === "number") this.cursor = ev.time_us;
const row = toRow(ev);
if (row) this.buf.push(row);
if (
this.buf.length >= FLUSH_MAX ||
Date.now() - this.lastFlush >= FLUSH_MS
) {
void this.flush();
}
}
// Serialize sends: flush one batch at a time, advancing the cursor on success.
private async flush() {
if (this.flushing) return;
this.flushing = true;
try {
while (this.buf.length > 0) {
this.lastFlush = Date.now();
const batch = this.buf.splice(0, this.buf.length);
const batchCursor = this.cursor;
try {
await this.env.BSKY_STREAM.send(batch);
await this.ctx.storage.put("cursor", batchCursor);
} catch (err) {
this.buf.unshift(...batch);
console.error("send failed, will retry", err);
return;
}
}
} finally {
this.flushing = false;
}
}
// Watchdog: reconnect if dropped, flush stragglers, always reschedule.
async alarm() {
try {
await this.ensureConnected();
await this.flush();
} catch (err) {
console.error("alarm error", err);
} finally {
await this.ctx.storage.setAlarm(Date.now() + RECONNECT_MS);
}
}
}
export default {
async fetch(_req, env): Promise<Response> {
const stub = env.JETSTREAM.get(env.JETSTREAM.idFromName("singleton"));
return Response.json(await stub.start());
},
async scheduled(_event, env): Promise<void> {
const stub = env.JETSTREAM.get(env.JETSTREAM.idFromName("singleton"));
await stub.start();
},
} satisfies ExportedHandler<Env>;Vygenerujte typy pro vaše bindings:
npx wrangler types7. Nasaďte a spusťte consumer
Nasaďte Worker:
npx wrangler deployOtevřete Worker URL jednou, čímž spustíte firehose. V chodu ho pak udržuje cron trigger:
curl https://bluesky-pipeline.YOUR_SUBDOMAIN.workers.devPříkaz vrátí:
{ "connected": true }Sledujte logy příkazem tail a pozorujte, jak vše funguje:
npx wrangler tail8. Dotazujte tabulky pomocí R2 SQL
První data se objeví několik minut po příchodu prvních událostí, zatímco se pipeline teprve rozjíždí.
Nastavte svůj token R2 SQL a poté dotazujte jednotlivé tabulky. Nahraďte YOUR_WAREHOUSE_NAME s názvem warehouse, který jste si poznamenali v kroku 3.
export WRANGLER_R2_SQL_AUTH_TOKEN=YOUR_CATALOG_TOKEN
npx wrangler r2 sql query "YOUR_WAREHOUSE_NAME" \
"SELECT COUNT(*) FROM bluesky.bsky_like"
npx wrangler r2 sql query "YOUR_WAREHOUSE_NAME" \
"SELECT text, langs FROM bluesky.bsky_post WHERE langs LIKE '%en%' LIMIT 10"Každá tabulka obsahuje pouze svůj typ události, promítnutý do příslušných sloupců. Veškeré směrování zajistil jediný pipeline.
Závěr
Pomocí Durable Object jste zpracovali vysokorychlostní veřejný WebSocket firehose, přijali jej do jednoho streamu Pipelines a pomocí jediné pipeline s několika SQL příkazy jste tento stream rozdělili do pěti tabulek Iceberg podle typu události.
Tento vzor jeden stream pro více tabulek lze zobecnit na jakýkoli zdroj událostí se štítky: clickstreamy (podle event_type), logy (podle service nebo status), nebo IoT telemetrii (podle device_class). Chcete-li ji rozšířit, přidejte sink a odpovídající INSERT ... WHERE příkaz.
Chcete-li se dozvědět více o SQL použitém zde, přečtěte si Příkazy SELECT a Správa pipeline.