Emulator Conventions
Package layout and core patterns for adding a new service emulator.
Every service emulator follows the @emulators/* patterns from
vercel-labs/emulate and uses
@emulators/core@0.6.0 as an npm dependency.
Package layout
Section titled “Package layout”packages/<service>/ package.json # @pleaseai/emulate-<service>, includes the bun export condition tsconfig.json # extends ../../tsconfig.base.json tsup.config.ts src/ entities.ts # types extending core Entity store.ts # getXxxStore(store) — namespaced collections helpers.ts # service-specific error/response helpers index.ts # exports plugin + seedFromConfig routes/*.ts # route registration functions taking RouteContext __tests__/*.test.ts # bun:test testsCore patterns
Section titled “Core patterns”entities.ts
Section titled “entities.ts”Every entity extends core Entity (id: number, created_at, updated_at —
assigned automatically on insert). Externally visible service IDs live in a
separate field (uuid, etc.).
import type { Entity } from "@emulators/core";
export interface KakaoUser extends Entity { user_id: number; // Kakao member number (the externally exposed id) nickname: string; email: string | null;}store.ts
Section titled “store.ts”import { Store, type Collection } from "@emulators/core";
export interface KakaoStore { users: Collection<KakaoUser>; apps: Collection<KakaoApp>;}
export function getKakaoStore(store: Store): KakaoStore { return { users: store.collection<KakaoUser>("kakao.users", ["user_id", "email"]), apps: store.collection<KakaoApp>("kakao.apps", ["client_id"]), };}- Collection names are namespaced as
<service>.<plural>. - The second argument lists index fields — only the fields queried via
findBy/findOneBy. - Re-requesting an existing collection with different indexes throws, so indexes are defined exclusively in store.ts.
routes/*.ts
Section titled “routes/*.ts”import type { RouteContext } from "@emulators/core";import { getKakaoStore } from "../store.js";
export function userRoutes(ctx: RouteContext): void { const { app, store, webhooks, baseUrl } = ctx; const ks = () => getKakaoStore(store);
app.get("/v2/user/me", (c) => { // c.req.param("id"), c.req.query("key"), await c.req.json(), c.req.header("Authorization") return c.json({ ... }); });}- Reproduce the real service’s response JSON field names and formats exactly (snake_case/camelCase per the real service).
- Error responses also follow the real service’s format (implemented as helpers in each service’s helpers.ts).
index.ts
Section titled “index.ts”export const kakaoPlugin: ServicePlugin = { name: "kakao", register(app, store, webhooks, baseUrl, tokenMap) { const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; oauthRoutes(ctx); userRoutes(ctx); }, seed(store, baseUrl) { // Default seed (minimal data so the emulator works without a seed config) },};
export function seedFromConfig(store: Store, baseUrl: string, config: KakaoSeedConfig, webhooks?: WebhookDispatcher): void { // Receives the service section of emulate.config.yaml. Must be idempotent // (findOneBy then skip), and config values win over plugin.seed() defaults.}
export default kakaoPlugin;The config shape accepted by seedFromConfig must match the service’s
initConfig in the CLI registry (packages/emulate/src/registry.ts).
Authentication
Section titled “Authentication”- Core’s
authMiddlewareis registered globally but passes through when no Authorization header is present. Service-specific auth (Bearer token validation, Basic auth, apikey headers, etc.) is implemented directly in routes/helpers. - OAuth access tokens are stored in a service store collection
(
<service>.tokens, etc.) and looked up in routes. - Core provides
constantTimeSecretEqual,normalizeUri,matchesRedirectUri,parseCookies, andbodyStrfor OAuth implementations.
OAuth login page
Section titled “OAuth login page”renderCardPage (a core export) renders a user-picker login page.
The authorize endpoint shows the seeded users and, on selection, issues a code
and 302s to the redirect_uri. Also provide an instant-approval path via a
?user=<id> query for automation (tests/CI).
Webhooks
Section titled “Webhooks”Dispatch with webhooks.dispatch(event, undefined, payload, "<service>").
Subscribe with webhooks.register({ url, events, active: true, owner: "<service>" }).
If the seed config accepts webhooks: [{ url, events }], register them in
seedFromConfig.
Tests (bun:test)
Section titled “Tests (bun:test)”Send Requests directly through app.fetch without binding a port:
import { describe, it, expect, beforeEach } from "bun:test";import { createServer } from "@emulators/core";import { kakaoPlugin } from "../index.js";
function makeApp() { const { app, store } = createServer(kakaoPlugin, { port: 4000 }); kakaoPlugin.seed?.(store, "http://localhost:4000"); return { app, store };}
it("issues token for authorization code", async () => { const { app } = makeApp(); const res = await app.fetch( new Request("http://localhost:4000/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, client_id }), }), ); expect(res.status).toBe(200);});- Cover the happy path plus the main error paths (bad code/token/key, missing required fields, 404s).
- No external network access — everything stays in memory.
- ESM with
.jsextension imports (./store.js). - One route group per file. Response formatting functions (
formatXxx) go at the bottom of the route file. - Comment only non-obvious behavior (places that intentionally diverge from the real service).