Fan out a stream to multiple Iceberg tab·续

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(); }, };


*src/index.tsts*

```ts
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>;

Generate types for your bindings:

npmyarnpnpm

npx wrangler types
yarn wrangler types
pnpm wrangler types

7. Deploy and start the consumer

Deploy the Worker:

npmyarnpnpm

npx wrangler deploy
yarn wrangler deploy
pnpm wrangler deploy

Open the Worker URL once to start the firehose. The cron trigger keeps it running:

curl https://bluesky-pipeline.YOUR_SUBDOMAIN.workers.dev

The command returns:

{ "connected": true }

Tail the logs to watch it work:

npmyarnpnpm

npx wrangler tail
yarn wrangler tail
pnpm wrangler tail

8. Query the tables with Basin SQL

The first data lands a few minutes after the first events arrive, while the pipeline warms up.

Set your Basin SQL token, then query each table. Replace YOUR_WAREHOUSE_NAME with the warehouse name you noted in step 3.

export WRANGLER_BASIN_SQL_AUTH_TOKEN=YOUR_CATALOG_TOKEN

Count the rows in the likes table:

npmyarnpnpm

npx wrangler basin sql query "YOUR_WAREHOUSE_NAME" "SELECT COUNT(*) FROM bluesky.bsky_like"
yarn wrangler basin sql query "YOUR_WAREHOUSE_NAME" "SELECT COUNT(*) FROM bluesky.bsky_like"
pnpm wrangler basin sql query "YOUR_WAREHOUSE_NAME" "SELECT COUNT(*) FROM bluesky.bsky_like"

Query English-language posts:

npmyarnpnpm

npx wrangler basin sql query "YOUR_WAREHOUSE_NAME" "SELECT text, langs FROM bluesky.bsky_post WHERE langs LIKE '%en%' LIMIT 10"
yarn wrangler basin sql query "YOUR_WAREHOUSE_NAME" "SELECT text, langs FROM bluesky.bsky_post WHERE langs LIKE '%en%' LIMIT 10"
pnpm wrangler basin sql query "YOUR_WAREHOUSE_NAME" "SELECT text, langs FROM bluesky.bsky_post WHERE langs LIKE '%en%' LIMIT 10"

Each table contains only its event type, projected to the relevant columns. The single pipeline did all the routing.

Conclusion

You consumed a high-velocity public WebSocket firehose with a Durable Object, ingested it into one Basin Pipelines stream, and used a single pipeline with multiple SQL statements to fan the stream out into five Iceberg tables by event type.

This one-stream-to-many-tables pattern generalizes to any tagged event source: clickstreams (by event_type), logs (by service or status), or IoT telemetry (by device_class). To extend it, add a sink and a matching INSERT ... WHERE statement.

To learn more about the SQL used here, refer to SELECT statements and Manage pipelines.

Was this helpful?

YesNo

On this page

Fan out a stream to multiple Iceberg tab·续 - 图1Docs

{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/basin-pipelines/examples/bluesky-firehose-fanout/#page","headline":"Fan out a stream to multiple Iceberg tables","description":"Route Bluesky Jetstream events into multiple Basin Catalog tables using a single Basin Pipelines stream and multiple SQL statements.","url":"https://developers.cloudflare.com/basin-pipelines/examples/bluesky-firehose-fanout/","inLanguage":"en","image":"https://developers.cloudflare.com/basin-pipelines/examples/bluesky-firehose-fanout/og.png?v=447a006739af8be5","dateModified":"2026-10-12","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["TypeScript"]}


description: Migration guide for pipelines created before September 2025 to the new Basin Pipelines architecture. title: Legacy pipelines image: https://developers.cloudflare.com/basin-pipelines/reference/legacy-pipelines/og.png?v=dca9a67a548c1471


Skip to content

Documentation Index
Fetch the complete documentation index at: https://developers.cloudflare.com/basin-pipelines/llms.txt
Use this file to discover all available pages before exploring further.