Stedefast

Deployment

Provider Architecture

5 min read

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.