# SvelteKit + Cloudflare Workers How to add contrail to an existing SvelteKit project deployed on Cloudflare Workers (via `@sveltejs/adapter-cloudflare`). Gives you XRPC endpoints alongside your pages, Jetstream ingestion on cron, and a typed in-process client for server loaders. Assumes you already have a SvelteKit app with `@sveltejs/adapter-cloudflare` and a D1 binding. If you don't, [`apps/sveltekit-cloudflare-workers`](https://github.com/flo-bit/contrail/tree/main/apps/sveltekit-cloudflare-workers) is a complete starting point. ## Install ```bash pnpm add @atmo-dev/contrail @atcute/client ``` ## Project layout ``` src/ lib/ contrail.config.ts # your config — auto-detected by the CLI contrail/ index.ts # Contrail instance + ensureInit + server client routes/ xrpc/[...path]/+server.ts # mounts all contrail XRPC endpoints api/cron/+server.ts # hit by the cron trigger (see below) wrangler.jsonc ``` ## 1. Declare the config ```ts // src/lib/contrail.config.ts import type { ContrailConfig } from "@atmo-dev/contrail"; export const config: ContrailConfig = { namespace: "com.example", collections: { event: { collection: "community.lexicon.calendar.event", queryable: { startsAt: { type: "range" } }, searchable: ["name", "description"], }, }, }; ``` ## 2. The Contrail instance ```ts // src/lib/contrail/index.ts import { Contrail } from "@atmo-dev/contrail"; import { createHandler, createServerClient } from "@atmo-dev/contrail/server"; import type { Client } from "@atcute/client"; import { config } from "../contrail.config"; export const contrail = new Contrail(config); let initialized = false; export async function ensureInit(db: D1Database) { if (!initialized) { await contrail.init(db); initialized = true; } } const handle = createHandler(contrail); /** In-process XRPC client for loaders and actions. */ export function getServerClient(db: D1Database): Client { return createServerClient(async (req) => { await ensureInit(db); return handle(req, db) as Promise; }); } ``` Why the lazy `ensureInit`: Workers cold-start many times; doing schema init on the first request keeps the boot path fast and means `contrail.init()` doesn't need top-level `await` (which the adapter doesn't love). ## 3. Mount the XRPC routes One catch-all that forwards to contrail's handler: ```ts // src/routes/xrpc/[...path]/+server.ts import type { RequestHandler } from "./$types"; import { createHandler } from "@atmo-dev/contrail/server"; import { contrail, ensureInit } from "$lib/contrail"; const handle = createHandler(contrail); async function h(req: Request, platform: App.Platform | undefined) { const db = platform!.env.DB; await ensureInit(db); return handle(req, db) as Promise; } export const GET: RequestHandler = ({ request, platform }) => h(request, platform); export const POST: RequestHandler = ({ request, platform }) => h(request, platform); ``` Now every `com.example.*.listRecords` / `com.example.*.getRecord` / `com.example.notifyOfUpdate` / etc. is served under `/xrpc/...`. ## 4. Using the typed client in loaders ```ts // src/routes/+page.server.ts import { getServerClient } from "$lib/contrail"; import type { PageServerLoad } from "./$types"; export const load: PageServerLoad = async ({ platform }) => { const rpc = getServerClient(platform!.env.DB); const res = await rpc.get("com.example.event.listRecords", { params: { startsAtMin: "2026-01-01", limit: 20 }, }); return { events: res.ok ? res.data.records : [] }; }; ``` `createServerClient` bypasses the network — the loader runs Contrail's public XRPC handler in-process. ## 5. Cron ingest — the workaround SvelteKit's `@sveltejs/adapter-cloudflare` doesn't expose a `scheduled()` export on the generated worker ([issue #4841](https://github.com/sveltejs/kit/issues/4841)). The fix is an HTTP endpoint that does the ingest, plus a post-build patch on `_worker.js` that appends a `scheduled` handler calling it. The patch is what `contrail append-scheduled` does. **Endpoint:** ```ts // src/routes/api/cron/+server.ts import type { RequestHandler } from "./$types"; import { contrail, ensureInit } from "$lib/contrail"; export const POST: RequestHandler = async ({ request, platform }) => { if (request.headers.get("X-Cron-Secret") !== platform!.env.CRON_SECRET) { return new Response("Unauthorized", { status: 401 }); } const db = platform!.env.DB; await ensureInit(db); await contrail.ingest({}, db); return new Response("OK"); }; ``` **Wire `contrail append-scheduled` into your `build` script:** ```jsonc // package.json "scripts": { "build": "vite build && contrail append-scheduled" } ``` `contrail append-scheduled` patches `.svelte-kit/cloudflare/_worker.js` to append a `scheduled()` export that POSTs to `/api/cron` with `env.CRON_SECRET`. Override with `--worker `, `--cron-path `, or `--secret-env ` if your project diverges. `CRON_SECRET` is any random string — generate one, set it as a secret with `wrangler secret put CRON_SECRET`. The cron handler self-auths with it so nobody external can trigger your ingest. ## 6. Wrangler config ```jsonc // wrangler.jsonc { "main": ".svelte-kit/cloudflare/_worker.js", "compatibility_date": "2025-12-25", "compatibility_flags": ["nodejs_compat_v2"], "assets": { "binding": "ASSETS", "directory": ".svelte-kit/cloudflare" }, "d1_databases": [ { "binding": "DB", "database_name": "yourapp", "database_id": "..." } ], "triggers": { "crons": ["*/1 * * * *"] } } ``` Type the D1 binding in `src/app.d.ts`: ```ts declare global { namespace App { interface Platform { env: { DB: D1Database; CRON_SECRET: string; // ...other bindings }; } } } ``` ## 7. Deploy + backfill ```bash pnpm wrangler d1 create yourapp # copy the id into wrangler.jsonc pnpm build && pnpm wrangler deploy pnpm wrangler secret put CRON_SECRET # paste any random string pnpm contrail backfill --remote # one-time historical backfill ``` From now on: - Pages and XRPC endpoints are served under your domain. - The cron fires every minute, hitting `/api/cron`, which runs `contrail.ingest()`. - Loaders that need live data use `getServerClient()` for zero-overhead typed calls. - After a short outage, ingestion resumes from its saved cursor. If source history has expired, rebuild into a fresh database with `pnpm backfill:remote`. ## Where to go next - [Indexing](../01-indexing.md) — config options, adapter choices - [Querying](../02-querying.md) — filters, sorts, hydration, search - [Feeds](../04-feeds.md) — personalized timelines via follow + target collections - [Labels](../09-labels.md) — moderation label hydration Use Atcute directly for Lexicon pulling, validation, and TypeScript generation. ## Common gotchas - **Top-level await in `$lib/contrail/index.ts`** will fail to bundle — use the lazy `ensureInit` pattern above. - **`ensureInit` is per-isolate, not global.** Cloudflare cold-starts spin new isolates; each one pays one init call on its first request. `contrail.init()` is idempotent so this is safe, just not instant. - **SvelteKit's `adapter-cloudflare` regenerates `_worker.js` on every build**, so `contrail append-scheduled` has to run *after* `vite build`. Don't try to put it in `prebuild`. - **`D1Database` type in platform env** needs `@cloudflare/workers-types` in `devDependencies` and `types` in your tsconfig.