Deployment
Provider Architecture
Stedefast modules are designed to be portable — they shouldn't know or care whether they're running on Cloudflare Workers, a Node.js server, or in a test harness. The provider layer makes this possible.
The problem providers solve
A naïve module implementation reaches directly into the Cloudflare runtime:
// ❌ Tightly coupled to Cloudflare
export const clapHandler: WorkerHandler<{ CLAPS_KV: KVNamespace }> = {
handler: async ({ env }) => {
const count = await env.CLAPS_KV.get("claps:my-page");
// ...
},
};
This works on Cloudflare but breaks in unit tests, makes modules impossible to reuse on other platforms, and leaks infrastructure concerns into business logic.
Providers fix this by introducing an abstraction between what a module needs (a key-value store) and how that need is satisfied at runtime (Cloudflare KV, Redis, in-memory map, etc.).
ModuleEnv
Every worker handler receives an env argument typed as ModuleEnv (or an extension of it):
interface ModuleEnv {
ENVIRONMENT: "development" | "production";
SITE_BASE_URL: string;
BETTER_AUTH_SECRET: string;
BETTER_AUTH_URL: string;
// Provider-abstracted access to runtime services
providers: ModuleProviders;
// Escape hatch for platform-specific values (API keys, feature flags)
raw: Record<string, unknown>;
}
Modules access services through env.providers rather than platform-specific bindings. env.raw holds anything that doesn't have an abstraction yet — Turnstile keys, third-party API tokens, etc.
ModuleProviders
interface ModuleProviders {
db?: Database; // SQL database (D1, SQLite, PostgreSQL)
kv?: KVStore; // Key-value store (CF KV, Redis, Upstash)
storage?: ObjectStore; // Object storage (R2, S3)
email?: EmailSender; // Email delivery (CF Email Routing, Resend)
}
All providers are optional — a module that only needs a database doesn't require a KV binding.
Provider interfaces
KVStore
interface KVStore {
get(key: string): Promise<string | null>;
put(key: string, value: string, opts?: KVPutOptions): Promise<void>;
delete(key: string): Promise<void>;
list(opts?: KVListOptions): Promise<KVListResult>;
getWithMetadata<M>(key: string): Promise<{ value: string | null; metadata: M | null }>;
}
Database
interface Database {
prepare(sql: string): PreparedStatement;
batch(statements: PreparedStatement[]): Promise<DatabaseResult[]>;
exec(sql: string): Promise<void>;
}
interface PreparedStatement {
bind(...values: unknown[]): PreparedStatement;
run(): Promise<DatabaseResult>;
first<T = Record<string, unknown>>(): Promise<T | null>;
all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;
}
ObjectStore
interface ObjectStore {
get(key: string): Promise<ObjectStoreObject | null>;
put(key: string, value: ReadableStream | ArrayBuffer | string, opts?: ObjectStorePutOptions): Promise<void>;
delete(key: string | string[]): Promise<void>;
}
EmailSender
interface EmailSender {
send(opts: EmailSendOptions): Promise<void>;
}
interface EmailSendOptions {
to: string;
from?: string;
subject: string;
text?: string;
html?: string;
replyTo?: string;
}
The raw escape hatch
Some platform-specific values don't map cleanly to a provider interface. API keys, feature flags, and third-party secrets live in env.raw:
// Reading a Turnstile secret in a contact form handler
const turnstileSecret = env.raw["TURNSTILE_SECRET_KEY"] as string | undefined;
if (!turnstileSecret) {
return Response.json({ error: "Turnstile not configured" }, { status: 503 });
}
At runtime, raw is populated with all environment variables from the Cloudflare Worker environment that don't have a typed provider binding.
Extending ModuleEnv
Each module can extend ModuleEnv to declare its own config variables:
import type { ModuleEnv } from "@stedefast/module-interface";
export interface CommentsEnv extends ModuleEnv {
COMMENTS_REQUIRE_APPROVAL?: string; // "true" | "false"
COMMENTS_MAX_PER_PAGE?: string;
}
export const submitHandler: WorkerHandler<CommentsEnv> = {
method: "POST",
path: "/submit",
handler: async ({ request, env }) => {
const db = env.providers.db;
if (!db) return Response.json({ error: "No database configured" }, { status: 503 });
const requireApproval = env.COMMENTS_REQUIRE_APPROVAL !== "false";
// ...
},
};
Module-specific env variables (like COMMENTS_REQUIRE_APPROVAL) are declared on the extended interface, not stuffed into raw, because they're first-class config for this module.
Registering a provider
Providers are registered in stedefast.config.ts:
import { defineConfig } from "@stedefast/core";
import { createCloudflareProvider } from "@stedefast/provider-cloudflare";
export default defineConfig({
// ...
providers: createCloudflareProvider({
dbBinding: "DB", // default
kvBinding: "KV", // default
storageBinding: "STATIC_R2", // default
emailProvider: "resend",
sendEmailBinding: "RESEND_API_KEY",
}),
});
At runtime, createCloudflareProvider() wraps the matching Cloudflare bindings in the provider interfaces above. If a binding doesn't exist in the environment, the corresponding provider is undefined — modules degrade gracefully.
Testing with provider mocks
Because modules depend on interfaces rather than CF-specific types, you can test them with plain objects:
import type { KVStore } from "@stedefast/providers";
function createMockKV(initial: Record<string, string> = {}): KVStore {
const store = new Map(Object.entries(initial));
return {
get: async (key) => store.get(key) ?? null,
put: async (key, value) => { store.set(key, value); },
delete: async (key) => { store.delete(key); },
list: async () => ({ keys: [], list_complete: true }),
getWithMetadata: async (key) => ({ value: store.get(key) ?? null, metadata: null }),
};
}
it("increments clap count", async () => {
const kv = createMockKV({ "claps:my-page": "5" });
const env = {
ENVIRONMENT: "development" as const,
SITE_BASE_URL: "https://example.com",
BETTER_AUTH_SECRET: "secret",
BETTER_AUTH_URL: "https://example.com",
providers: { kv },
raw: {},
};
const response = await clapHandler.handler({
request: new Request("...", { method: "POST", body: JSON.stringify({ pageId: "my-page", count: 1 }) }),
env: env as any,
ctx: undefined as any,
params: {},
});
expect(response.status).toBe(200);
expect(await kv.get("claps:my-page")).toBe("6");
});
No Miniflare, no mocking CF globals — just a plain Map.
Writing your own provider
If you're deploying to a platform other than Cloudflare, you can implement StedefastProvider:
import type { StedefastProvider, ModuleProviders } from "@stedefast/providers";
export function createMyProvider(): StedefastProvider {
return {
name: "my-platform",
createProviders(runtimeEnv: Record<string, unknown>): ModuleProviders {
return {
db: createMyDatabase(runtimeEnv.DATABASE_URL as string),
kv: createMyKV(runtimeEnv.REDIS_URL as string),
};
},
};
}
Then register it in stedefast.config.ts the same way as the Cloudflare provider.