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
scripts/
append-scheduled.ts # workaround — see "Cron" below
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 easiest fix: an HTTP endpoint that does the ingest, plus a post-build script that appends a scheduled handler calling that endpoint.
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");
};
Post-build patch:
// scripts/append-scheduled.ts
import { readFileSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const workerPath = join(root, ".svelte-kit", "cloudflare", "_worker.js");
writeFileSync(
workerPath,
readFileSync(workerPath, "utf-8") +
`
worker_default.scheduled = async function (event, env, ctx) {
const req = new Request("http://localhost/api/cron", {
method: "POST",
headers: { "X-Cron-Secret": env.CRON_SECRET ?? "" },
});
ctx.waitUntil(this.fetch(req, env, ctx));
};
`
);
Wire it into build:
// package.json
"scripts": {
"build": "vite build && tsx scripts/append-scheduled.ts"
}
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
- 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, so theappend-scheduled.tspatch has to run aftervite build. Don’t try to put it inprebuild. D1Databasetype in platform env needs@cloudflare/workers-typesindevDependenciesandtypesin your tsconfig.