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 is a complete starting point.
Install #
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 #
// 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 #
// 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<Response>;
});
}
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:
// 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<Response>;
}
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 #
// 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). 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:
// 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:
// 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 <path>, --cron-path <path>, or --secret-env <name> 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 #
// 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:
declare global {
namespace App {
interface Platform {
env: {
DB: D1Database;
CRON_SECRET: string;
// ...other bindings
};
}
}
}
7. Deploy + backfill #
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 runscontrail.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 — config options, adapter choices
- Querying — filters, sorts, hydration, search
- Feeds — personalized timelines via follow + target collections
- Labels — moderation label hydration
Use Atcute directly for Lexicon pulling, validation, and TypeScript generation.
Common gotchas #
- Top-level await in
$lib/contrail/index.tswill fail to bundle — use the lazyensureInitpattern above. ensureInitis 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-cloudflareregenerates_worker.json every build, socontrail append-scheduledhas to run aftervite build. Don't try to put it inprebuild. D1Databasetype in platform env needs@cloudflare/workers-typesindevDependenciesandtypesin your tsconfig.