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-sqlitebranch.
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.
# 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 devthe api will be available at http://localhost:6543.
# 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| 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 |
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 pairsheaders- request headers as key-value pairsparams- url path parameters from dynamic routes (:idreturns string,*pathreturns array)body- request body (for post/put/patch requests)
| 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 |
{
path: string; // url pattern (e.g., "/users/:id")
method: string; // http method (get, post, put, patch, delete)
handler: string; // javascript handler function body
}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 |
| 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 |
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 |
| 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 |
- cloudflare workers blocks
eval()andnew 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
- demonstrates middleware composition from scratch (~50 lines)
- no dependency for a core abstraction
- full control over route matching, middleware ordering, and skip logic
- the
composepattern is the same one express/koa use internally
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" }] }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.
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: /dashboardpermanent 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/usersvalidation 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" # 500paginated 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)contributions are welcome! please feel free to submit a pull request.