Read OSS

Architecture Overview: How Emulate Organizes 12 Service Emulators

Intermediate

Prerequisites

  • Basic TypeScript knowledge
  • Familiarity with HTTP frameworks and REST APIs
  • General understanding of monorepo project structures

Architecture Overview: How Emulate Organizes 12 Service Emulators

Every developer has been there: your CI pipeline needs to talk to GitHub, Stripe, and Slack, but you're mocking fetch with hand-crafted JSON blobs that drift out of sync with reality. Emulate takes a radically different approach — it spins up fully stateful HTTP servers that behave like the real APIs, backed by an in-memory store instead of a remote database. Twelve cloud services, one unified architecture.

This article maps out how the project organizes that ambition. We'll trace the three-layer architecture — shared core, service plugins, and the CLI/programmatic API — and understand the design decisions that keep 12 emulators structurally identical despite emulating very different services.

Monorepo Layout and Package Roles

Emulate is a pnpm workspace monorepo orchestrated by Turborepo. The workspace definition is minimal:

pnpm-workspace.yaml

packages:
  - "packages/*"
  - "packages/@emulators/*"
  - "apps/*"
  - "examples/*"

This gives us four zones with distinct roles:

Directory Purpose Examples
packages/@emulators/core Shared infrastructure — Store, middleware, plugin interface, UI store.ts, server.ts, plugin.ts
packages/@emulators/{service} One package per emulated service github, vercel, stripe, apple
packages/@emulators/adapter-next Next.js route handler adapter for same-origin embedding createEmulateHandler()
packages/emulate CLI package — emulate start, emulate init, emulate list registry.ts, api.ts
apps/web Documentation website
examples/* Example integrations nextjs-embedded, oauth
graph TD
    subgraph "packages/emulate (CLI)"
        CLI[CLI Entry Point]
        API[Programmatic API]
        REG[Service Registry]
    end
    subgraph "packages/@emulators/core"
        CORE_SERVER[Server Factory]
        CORE_STORE[Store & Collection]
        CORE_MW[Middleware Stack]
        CORE_PLUGIN[ServicePlugin Interface]
    end
    subgraph "packages/@emulators/{service}"
        GH[GitHub]
        VER[Vercel]
        STRIPE[Stripe]
        DOTS[". . . 9 more"]
    end
    subgraph "packages/@emulators/adapter-next"
        NEXT[Next.js Handler]
    end

    CLI --> REG
    API --> REG
    REG --> GH
    REG --> VER
    REG --> STRIPE
    REG --> DOTS
    GH --> CORE_PLUGIN
    VER --> CORE_PLUGIN
    STRIPE --> CORE_PLUGIN
    CORE_SERVER --> CORE_STORE
    CORE_SERVER --> CORE_MW
    CORE_SERVER --> CORE_PLUGIN
    NEXT --> CORE_SERVER

Turborepo orchestrates builds with a simple task graph in turbo.json: every task depends on ^build (build dependencies first), and test/dev tasks are uncached. This is intentional — emulators are stateful, so caching test results would be meaningless.

The ServicePlugin Interface

At the heart of the architecture sits a two-method contract that every emulator must implement:

packages/@emulators/core/src/plugin.ts#L14-L18

export interface ServicePlugin {
  name: string;
  register(app: Hono<AppEnv>, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void;
  seed?(store: Store, baseUrl: string): void;
}

Three things to notice:

  1. register() is imperative, not declarative. The plugin receives a live Hono app and mounts routes directly. There's no route descriptor DSL — just app.get(), app.post(), etc. This keeps things simple and gives plugins full control.

  2. seed() is optional. Some emulators need default entities (GitHub creates a ghost user and an admin user). Others can start empty.

  3. All shared state flows through Store and WebhookDispatcher. Plugins never create their own storage — they receive it from the composition root.

classDiagram
    class ServicePlugin {
        +name: string
        +register(app, store, webhooks, baseUrl, tokenMap): void
        +seed(store, baseUrl): void
    }
    class RouteContext {
        +app: Hono
        +store: Store
        +webhooks: WebhookDispatcher
        +baseUrl: string
        +tokenMap: TokenMap
    }
    ServicePlugin ..> RouteContext : receives via register()

The companion RouteContext type (lines 6–12 of the same file) bundles these dependencies into a single object that route modules receive. This avoids passing five parameters through every function call.

Tip: If you're reading a service emulator's source, start with its index.ts file. It always exports a ServicePlugin and calls route modules with a RouteContext.

The Service Registry and Dynamic Imports

The CLI needs to know about all 12 services, but loading every emulator eagerly would be wasteful — especially when a user only enables one or two. The registry solves this with lazy dynamic imports.

packages/emulate/src/registry.ts#L9-L15

export interface ServiceEntry {
  label: string;
  endpoints: string;
  load(): Promise<LoadedService>;
  defaultFallback(svcSeedConfig?: Record<string, unknown>): AuthFallback;
  initConfig: Record<string, unknown>;
}

Each service entry has a load() method that performs the dynamic import only when called:

packages/emulate/src/registry.ts#L34-L41

vercel: {
  label: "Vercel REST API emulator",
  endpoints: "projects, deployments, domains, env vars, users, teams...",
  async load() {
    const mod = await import("@emulators/vercel");
    return { plugin: mod.vercelPlugin, seedFromConfig: mod.seedFromConfig };
  },
flowchart LR
    A[CLI receives --service github] --> B{Registry lookup}
    B --> C["SERVICE_REGISTRY['github']"]
    C --> D["entry.load()"]
    D --> E["await import('@emulators/github')"]
    E --> F[LoadedService with plugin + seedFromConfig]
    F --> G[createServer with plugin]

The defaultFallback() method is clever — it provides a fallback user identity for unauthenticated requests. Each service customizes this: GitHub defaults to the first configured user's login, Google defaults to the first email, and Slack defaults to a user ID. This means any non-empty bearer token "just works" in local development, mapping to a sensible default identity.

The service name list is defined as a const tuple, giving us a ServiceName union type that's used throughout the codebase for type-safe service references:

packages/emulate/src/registry.ts#L17-L32

Server Factory: The Composition Root

createServer() is where everything comes together. It takes a ServicePlugin and options, and returns a fully wired Hono application:

packages/@emulators/core/src/server.ts#L25-L106

The middleware stack is ordered intentionally:

sequenceDiagram
    participant Req as Incoming Request
    participant Fonts as Font Routes
    participant Err as Error Handler
    participant CORS as CORS
    participant Auth as Auth Middleware
    participant Rate as Rate Limiter
    participant Plugin as Plugin Routes
    participant NotFound as 404 Handler

    Req->>Fonts: Static font assets (short-circuit)
    Req->>Err: Sets docsUrl context
    Req->>CORS: Permissive CORS headers
    Req->>Auth: Token lookup / JWT / fallback
    Req->>Rate: 5000 req/hour per token
    Req->>Plugin: Service-specific routes
    Req->>NotFound: Catch-all 404
  1. Font routes come first because they're static assets that don't need auth, CORS, or rate limiting.
  2. Error handler wraps via app.onError() plus a context-setting middleware. The split exists because Hono's onError only catches thrown errors from routes, not middleware.
  3. CORS is permissive — emulators are local dev tools, not production APIs.
  4. Auth resolves identity from bearer tokens, JWTs, or falls back to a default user.
  5. Rate limiting tracks per-token request counts with a 5000/hour budget and hourly window resets.
  6. Plugin routes are registered by the ServicePlugin.register() call.
  7. 404 handler catches anything the plugin didn't match.

The function returns { app, store, webhooks, port, baseUrl, tokenMap } — everything the caller needs to serve or test the emulator.

CLI Startup and Multi-Service Orchestration

The CLI entry point defines three commands via Commander:

packages/emulate/src/index.ts#L1-L53

The start command is the workhorse. Its boot sequence handles config discovery, service inference, and multi-service port assignment:

packages/emulate/src/commands/start.ts#L28-L69

Config discovery tries six filenames in order: emulate.config.yaml, .yml, .json, and service-emulator.config.* variants. If no explicit --service flag is given, services are inferred from the config file's top-level keys — if your YAML has a github: section, the GitHub emulator starts.

flowchart TD
    A[emulate start] --> B{--seed flag?}
    B -->|Yes| C[Load specified file]
    B -->|No| D[Auto-discover config files]
    D --> E{Found?}
    E -->|Yes| C
    E -->|No| F[No config, use defaults]
    C --> G{--service flag?}
    F --> G
    G -->|Yes| H[Parse comma-separated services]
    G -->|No| I{Config has service keys?}
    I -->|Yes| J[Infer services from config]
    I -->|No| K[Enable all 12 services]
    H --> L[Boot loop: port = base + i]
    J --> L
    K --> L
    L --> M[Each service gets its own HTTP server]

Port assignment is simple but effective: each service gets basePort + i where i is its index in the service list. A per-service port override in the config takes precedence. This means emulate start -p 4000 with three services will listen on ports 4000, 4001, and 4002.

Tip: Set EMULATE_PORT or PORT environment variables to control the base port without CLI flags — useful in Docker and CI environments.

Programmatic API: createEmulator()

For test runners, the CLI is too heavy. createEmulator() provides a single-service API designed for beforeAll/afterAll patterns:

packages/emulate/src/api.ts#L24-L83

The returned object exposes three things:

  • url — The base URL (e.g., http://localhost:4000) to point your HTTP client at.
  • reset() — Clears all state and re-seeds defaults. Call this in beforeEach for test isolation.
  • close() — Shuts down the HTTP server. Returns a Promise for graceful teardown.
flowchart TD
    A["createEmulator({ service: 'github', port: 4100 })"] --> B[Registry lookup]
    B --> C["entry.load() — dynamic import"]
    C --> D[createServer with plugin]
    D --> E[seed defaults + config]
    E --> F["serve({ fetch: app.fetch, port })"]
    F --> G["Return { url, reset, close }"]

    G --> H["reset() → store.reset() + re-seed"]
    G --> I["close() → httpServer.close()"]

The reset() implementation is worth noting: it calls store.reset() (which clears all collections and _data) then re-runs the seed functions. This guarantees every test starts with a known state, not an empty one. Without re-seeding, tests that depend on default entities (like GitHub's admin user) would fail after a reset.

The token setup is also interesting: if the seed config provides tokens, they're mapped to user identities with auto-incrementing IDs starting at 100. If no tokens are configured, a single test_token_admin is created. This means you can start writing tests immediately with Authorization: Bearer test_token_admin.

What's Next

We've mapped the skeleton — the monorepo, the plugin contract, the registry, and the two consumption modes. But the emulator's power comes from its stateful behavior, and that lives in the Store. In the next article, we'll dive into the Collection<T> generic class that replaces a database, its secondary index system for O(1) lookups, and the snapshot/restore mechanism that enables persistence without ever touching disk by default.