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
pnpm add -D @atmo-dev/contrail-lexicons @atcute/lex-cli
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);
/** Typed in-process XRPC client for loaders / actions. Pass `did` to act as
* that user (no JWT / PDS roundtrip); omit for anonymous public reads. */
export function getServerClient(db: D1Database, did?: string): Client {
return createServerClient(async (req) => {
await ensureInit(db);
return handle(req, db) as Promise<Response>;
}, did);
}
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, locals }) => {
const rpc = getServerClient(platform!.env.DB, locals.did ?? undefined);
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 fetch — the loader runs contrail’s XRPC handler in-process, no extra network hop. did sets the caller identity without requiring a signed JWT (it’s a server-to-server trust boundary; anything crossing an untrusted boundary still needs real service-auth).
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
npx wrangler d1 create yourapp # copy the id into wrangler.jsonc
pnpm build && pnpm wrangler deploy
npx 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. - Need to reconcile after an outage?
pnpm contrail refresh --remote.
Where to go next
- Indexing — config options, adapter choices
- Querying — filters, sorts, hydration, search
- Lexicons — generate TS types for your XRPC surface
- Feeds — personalized timelines via follow + target collections
- Auth — service-auth JWTs, invite tokens, watch tickets, OAuth permission sets
- Spaces / Communities — private records + group-controlled DIDs, which both slot into the same handler you just mounted
- Sync — reactive client-side subscriptions (
createWatchStore) wrapped in Svelte$state
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.