Skip to content

About

a general-purpose api mocking service with sandboxed javascript handlers, built on cloudflare workers and d1.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

mocksmith-api

Cloudflare Workers D1 ci License: MIT

a general-purpose api mocking service built with cloudflare workers and d1. dynamically create, manage, and serve rest-api endpoints with custom javascript handlers.

this project started out on bun and sqlite. that version is available on the bun-sqlite branch.

overview

mocksmith-api allows you to create, update and delete mock apis on-the-fly. define endpoints with custom javascript handlers that can access request data (query params, headers, path params, body) and return dynamic responses. features include path parameter support, request analytics, optional jwt authentication, and d1 persistence.

handlers execute in an isolated quickjs wasm sandbox — user code runs in a separate javascript engine with no access to worker bindings, environment variables, or the host runtime.

quick start

# install dependencies
npm install

# create d1 database (one-time)
npx wrangler d1 create mocksmith
# update database_id in wrangler.toml with the output

# apply migrations locally
npm run db:migrate:local

# start dev server
npm run dev

the api will be available at http://localhost:6543.

deploy

# login to cloudflare (one-time)
npx wrangler login

# apply migrations to remote d1
npm run db:migrate:remote

# deploy
npm run deploy

# set secrets (if auth enabled)
npx wrangler secret put AUTH_SECRET

architecture

component responsibility
Router custom middleware composition engine with path-to-regexp matching
Middleware class-based, each implements handle(context, next)
ServiceRegistry typed service locator for dependency wiring
SandboxService quickjs wasm-isolated javascript execution with timeout
EventPublisher node eventemitter-based pub/sub for decoupled analytics
Repository generic base with create, read, update, delete

handler format

handlers are javascript arrow function strings that receive a context object and must return a response object:

(context: { query, headers, params, body }) => ({
  status: number;     // http status code
  headers?: object;   // response headers (optional)
  body: any;          // response body
})

context properties:

  • query - url query parameters as key-value pairs
  • headers - request headers as key-value pairs
  • params - url path parameters from dynamic routes (:id returns string, *path returns array)
  • body - request body (for post/put/patch requests)

api reference

endpoints management

method endpoint description
GET / api information
GET /healthcheck health status
GET /endpoints list all endpoints
GET /endpoints/:id get endpoint by id
POST /endpoints create new endpoint
PUT /endpoints/:id update endpoint
DELETE /endpoints/:id delete endpoint
GET /analytics view request analytics
GET /analytics/:id analytics for specific endpoint

create endpoint request body

{
  path: string;     // url pattern (e.g., "/users/:id")
  method: string;   // http method (get, post, put, patch, delete)
  handler: string;  // javascript handler function body
}

environment variables

configured in wrangler.toml under [vars]. secrets via npx wrangler secret put.

variable default description
NODE_ENV production development or production
AUTH_ENABLED false enable jwt auth
AUTH_SECRET - jwt secret (if auth enabled)
CORS_ORIGIN * allowed cors origins

scripts

command description
npm run dev local dev server (localhost:6543)
npm run deploy deploy to cloudflare
npm run lint run eslint
npm test run test suite (vitest)
npm run db:migrate:local apply d1 migrations locally
npm run db:migrate:remote apply d1 migrations to production
npx wrangler tail live tail production logs

security

sandbox isolation

user-submitted handlers execute in an isolated quickjs wasm engine:

layer mechanism
ast validation acorn parses handler code — rejects invalid syntax
blocked identifiers eval, Function, globalThis, self, this, navigator, WebSocket, Atomics, SharedArrayBuffer
wasm isolation quickjs runs in a separate js engine — no access to worker env/bindings
memory limit 10mb per execution
timeout enforcement interrupt handler kills execution after 10s
dynamic imports ast validation rejects ImportExpression nodes

attack vectors mitigated

vector mitigation
infinite loops quickjs interrupt handler terminates after timeout
memory bombs 10mb memory limit per execution
env/secret access handler runs in separate wasm engine — no access to env
prototype pollution fresh quickjs context per execution — no shared state
static imports sourceType: 'script' rejects import/export declarations
dynamic import() ast validation rejects ImportExpression nodes
eval/Function blocked by ast identifier check

design decisions

why quickjs wasm over new Function()?

  • cloudflare workers blocks eval() and new Function() at request time
  • quickjs provides a completely separate javascript engine via wasm
  • handler code cannot access worker bindings, env vars, or global scope
  • memory and cpu limits enforced by quickjs runtime configuration
  • dynamic workers would be the ideal approach (true v8 isolate per handler), but requires the $5/month workers paid plan
  • quickjs wasm achieves the same isolation goal on the free tier

why custom router over hono/elysia?

  • demonstrates middleware composition from scratch (~50 lines)
  • no dependency for a core abstraction
  • full control over route matching, middleware ordering, and skip logic
  • the compose pattern is the same one express/koa use internally

handler cookbook

static responses

basic json response:

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/users",
    "method": "get",
    "handler": "(context) => ({ status: 200, body: { users: [{ id: 1, name: \"john\" }, { id: 2, name: \"jane\" }] } })"
  }'
curl http://localhost:6543/users
# { "users": [{ "id": 1, "name": "john" }, { "id": 2, "name": "jane" }] }

path matching

named parameters:

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/orgs/:orgId/teams/:teamId/projects/:projectId",
    "method": "get",
    "handler": "(context) => ({ status: 200, body: { org: context.params.orgId, team: context.params.teamId, project: context.params.projectId } })"
  }'
curl http://localhost:6543/orgs/acme/teams/engineering/projects/alpha
# { "org": "acme", "team": "engineering", "project": "alpha" }

wildcard parameters:

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/files/*filepath",
    "method": "get",
    "handler": "(context) => ({ status: 200, body: { segments: context.params.filepath, path: context.params.filepath?.join(\"/\") } })"
  }'
curl http://localhost:6543/files/docs/2024/reports/annual.pdf
# { "segments": ["docs", "2024", "reports", "annual.pdf"], "path": "docs/2024/reports/annual.pdf" }

note: wildcards return an array of segments. use .join("/") to reconstruct the full path.

status codes

resource not found (404):

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/users/:id",
    "method": "get",
    "handler": "(context) => { const users = { \"123\": { id: \"123\", name: \"john\" } }; const user = users[context.params.id]; if (!user) return { status: 404, body: { error: \"user not found\", id: context.params.id } }; return { status: 200, body: { user } }; }"
  }'
curl http://localhost:6543/users/123
# { "user": { "id": "123", "name": "john" } }

curl http://localhost:6543/users/999
# { "error": "user not found", "id": "999" }

temporary redirect (302):

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/login",
    "method": "get",
    "handler": "(context) => { const token = context.headers.authorization?.replace(\"bearer \", \"\"); if (token === \"valid-token\") return { status: 302, headers: { location: \"/dashboard\" }, body: { message: \"redirecting\" } }; return { status: 200, body: { form: true } }; }"
  }'
curl http://localhost:6543/login -H "authorization: bearer valid-token"
# => 302 location: /dashboard

permanent redirect (301):

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/v1/*path",
    "method": "get",
    "handler": "(context) => ({ status: 301, headers: { location: \"/v2\" + context.params.path?.join(\"/\") } })"
  }'
curl -L http://localhost:6543/v1/users
# => 301 location: /v2/users

validation errors (422):

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/register",
    "method": "post",
    "handler": "(context) => { const errors = []; if (!context.body?.email) errors.push({ field: \"email\", message: \"required\" }); else if (!context.body.email.includes("@")) errors.push({ field: \"email\", message: \"invalid format\" }); if (!context.body?.password) errors.push({ field: \"password\", message: \"required\" }); else if (context.body.password.length < 8) errors.push({ field: \"password\", message: \"must be at least 8 characters\" }); if (errors.length > 0) return { status: 422, body: { error: \"validation failed\", errors } }; return { status: 201, body: { id: 123, email: context.body.email } }; }"
  }'
curl -X post http://localhost:6543/register -H "content-type: application/json" -d '{"email": "invalid", "password": "123"}'
# { "error": "validation failed", "errors": [{ "field": "email", "message": "invalid format" }, { "field": "password", "message": "must be at least 8 characters" }] }

curl -X post http://localhost:6543/register -H "content-type: application/json" -d '{"email": "user@example.com", "password": "secure-password"}'
# { "id": 123, "email": "user@example.com" }

error simulation:

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/api/status",
    "method": "get",
    "handler": "(context) => { const code = parseInt(context.query.status) || 200; const responses = { 200: { body: { ok: true } }, 400: { body: { error: \"bad request\" } }, 401: { body: { error: \"unauthorized\" } }, 404: { body: { error: \"not found\" } }, 429: { body: { error: \"rate limited\" } }, 500: { body: { error: \"internal error\" } }, 503: { body: { error: \"service unavailable\" } } }; const response = responses[code] || responses[200]; return { status: code, ...response }; }"
  }'
curl "http://localhost:6543/api/status"                # 200
curl "http://localhost:6543/api/status?status=404"      # 404
curl "http://localhost:6543/api/status?status=500"      # 500

advanced patterns

paginated list:

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/items",
    "method": "get",
    "handler": "(context) => { const page = parseInt(context.query.page) || 1; const limit = parseInt(context.query.limit) || 10; const items = Array.from({ length: limit }, (_, i) => ({ id: (page - 1) * limit + i + 1, name: \"item \" + ((page - 1) * limit + i + 1) })); return { status: 200, body: { data: items, meta: { page, limit, total: 100 } } }; }"
  }'
curl "http://localhost:6543/items?page=2&limit=3"
# { "data": [{ "id": 4, "name": "item 4" }, ...], "meta": { "page": 2, "limit": 3, "total": 100 } }

authentication flow (401 / 200):

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/profile",
    "method": "get",
    "handler": "(context) => { const token = context.headers.authorization?.replace(\"bearer \", \"\"); if (!token) return { status: 401, headers: { \"www-authenticate\": \"bearer\" }, body: { error: \"authentication required\" } }; if (token !== \"valid-token-123\") return { status: 401, body: { error: \"invalid token\" } }; return { status: 200, body: { id: 1, name: \"john\", email: \"john@example.com\" } }; }"
  }'
curl http://localhost:6543/profile
# { "error": "authentication required" }

curl http://localhost:6543/profile -H "authorization: bearer valid-token-123"
# { "id": 1, "name": "john", "email": "john@example.com" }

conditional response by header:

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/negotiate",
    "method": "get",
    "handler": "(context) => { const accept = context.headers.accept || \"\"; if (accept.includes(\"text/plain\")) return { status: 200, headers: { \"content-type\": \"text/plain\" }, body: \"hello world\" }; return { status: 200, body: { message: \"hello world\" } }; }"
  }'
curl http://localhost:6543/negotiate -H "accept: text/plain"
# hello world

curl http://localhost:6543/negotiate -H "accept: application/json"
# { "message": "hello world" }

delayed response (simulated latency):

curl -X post http://localhost:6543/endpoints   -H "content-type: application/json"   -d '{
    "path": "/slow",
    "method": "get",
    "handler": "async (context) => { const delay = parseInt(context.query.delay) || 1000; await new Promise(r => setTimeout(r, delay)); return { status: 200, body: { delayed: true, ms: delay } }; }"
  }'
curl "http://localhost:6543/slow?delay=2000"
# { "delayed": true, "ms": 2000 }  (after 2 seconds)

contributing

contributions are welcome! please feel free to submit a pull request.

About

a general-purpose api mocking service with sandboxed javascript handlers, built on cloudflare workers and d1.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Used by

Contributors

Languages