ovenless
v0.1.2
Published
Type-safe RPC for AWS Serverless Framework v3. One Lambda, one router, Zod validation, OpenAPI docs, and a proxy client with full TypeScript inference—no code generation.
Downloads
475
Maintainers
Readme
Ovenless
Type-safe RPC for AWS Serverless Framework v3. One Lambda, one router, Zod validation, OpenAPI docs, and a proxy client with full TypeScript inference—no code generation.
Why Ovenless
| Need | Ovenless |
| -------------------------------- | -------------------------------------------------------------------------------- |
| End-to-end types without codegen | createClient<typeof router>() infers inputs/outputs from your Zod schemas |
| Single Lambda, many routes | Catch-all HTTP API (/{proxy+}) routes to one handler—fewer cold starts |
| Public API docs | GET /docs (Scalar) and GET /openapi.json generated from the same Zod schemas |
| Serverless v3 deploy | ovenless build emits dist/handler.js + serverless.yml |
Requirements: Bun for CLI and local dev · Node.js 20+ Lambda runtime (default) · TypeScript 5+ · Zod 4+
Quick start
1. Create a project
mkdir my-api && cd my-api
bun init -y
bun add ovenless zod
bun add -d typescript @types/bun2. Define the router
src/router.ts:
import { z } from "zod";
import { createRouter, mutation, query } from "ovenless";
const users = [
{ id: "1", name: "Alice", email: "[email protected]" },
{ id: "2", name: "Bob", email: "[email protected]" },
];
export const appRouter = createRouter({
health: query({
output: z.object({
status: z.literal("ok"),
timestamp: z.string(),
}),
handler: () => ({
status: "ok" as const,
timestamp: new Date().toISOString(),
}),
}),
users: createRouter({
getById: query({
input: z.object({ id: z.string().min(1) }),
output: z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
}),
handler: ({ id }) => {
const user = users.find((u) => u.id === id);
if (!user) throw new Error(`User not found: ${id}`);
return user;
},
}),
create: mutation({
input: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
output: z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
}),
handler: (input) => {
const user = { id: String(users.length + 1), ...input };
users.push(user);
return user;
},
}),
}),
});
export type AppRouter = typeof appRouter;3. Add config
ovenless.config.ts:
import { defineConfig } from "ovenless";
import { appRouter } from "./src/router.ts";
export default defineConfig({
router: appRouter,
service: "my-api",
title: "My API",
version: "1.0.0",
port: 3000,
aws: {
region: "us-east-1",
runtime: "nodejs20.x",
stage: "dev",
},
});4. Run locally
bunx ovenless start
# or with file watching:
bunx ovenless start --watchExample startup output:
▲ Ovenless start · development
- Environments: .env, .env.development, .env.local
- Variables: 4 from env files (+ OVENLESS_PROFILE)
Ovenless development → http://localhost:3000
API http://localhost:3000/
Docs http://localhost:3000/docs
OpenAPI http://localhost:3000/openapi.json5. Call procedures over HTTP
# Health (no input)
curl -s -X POST http://localhost:3000/health | jq
# Query with JSON body
curl -s -X POST http://localhost:3000/users/getById \
-H "Content-Type: application/json" \
-d '{"id":"1"}' | jq
# Query via GET + query string (values are strings — use z.coerce in schemas)
curl -s "http://localhost:3000/users/getById?id=2" | jq
# Mutation
curl -s -X POST http://localhost:3000/users/create \
-H "Content-Type: application/json" \
-d '{"name":"Charlie","email":"[email protected]"}' | jqPaths accept slashes (/users/getById) or dots (/users.getById).
Type-safe client
Install the client entry (same package, separate export):
bun add ovenlesssrc/client.ts (frontend or monorepo app):
import { createClient, OvenlessClientError } from "ovenless/client";
import type { AppRouter } from "./router.ts";
import { appRouter } from "./router.ts";
export const api = createClient<AppRouter>({
url: process.env.API_URL ?? "http://localhost:3000",
router: appRouter, // optional: runtime path checks + GET for void-input queries
headers: () => ({
Authorization: `Bearer ${getToken()}`,
}),
});
function getToken(): string {
return "";
}
// Usage
async function main() {
const health = await api.health();
console.log(health.status); // "ok"
const user = await api.users.getById({ id: "1" });
console.log(user.name); // "Alice"
const created = await api.users.create({
name: "Dana",
email: "[email protected]",
});
console.log(created.id);
try {
await api.users.getById({ id: "missing" });
} catch (err) {
if (err instanceof OvenlessClientError) {
console.error(err.status, err.message, err.body);
}
}
}Import only types on the frontend to avoid bundling server code:
import type { AppRouter } from "../api/src/router.ts";
import { createClient } from "ovenless/client";
const api = createClient<AppRouter>({
url: import.meta.env.VITE_API_URL,
});Optional: emit client types for publishing (tsconfig.client.json in your app):
bunx ovenless build:clientProcedures
Queries
Read-only operations. Allowed methods: GET or POST.
// No input
health: query({
output: z.object({ ok: z.boolean() }),
handler: () => ({ ok: true }),
}),
// With input
list: query({
input: z.object({
limit: z.coerce.number().optional().default(10),
}),
output: z.object({ items: z.array(z.string()) }),
handler: ({ limit }) => ({ items: fetchItems(limit) }),
}),For GET requests, input comes from the query string. All values are strings until Zod coerces them—use z.coerce.number(), z.coerce.boolean(), etc.
Mutations
Write operations. Allowed method: POST only.
create: mutation({
input: z.object({ title: z.string() }),
output: z.object({ id: z.string() }),
handler: async (input) => {
const id = await db.insert(input);
return { id };
},
}),Nested routers
Group related procedures (maps to client.users.getById):
export const appRouter = createRouter({
users: createRouter({
getById: query({
/* ... */
}),
create: mutation({
/* ... */
}),
}),
});Configuration
ovenless.config.ts must default-export the result of defineConfig():
| Field | Required | Description |
| ------------- | -------- | --------------------------------------------------------------- |
| router | yes | Router from createRouter() |
| service | yes | Serverless service name |
| title | no | OpenAPI title (defaults to service) |
| version | no | OpenAPI version (default 0.1.0 in dev) |
| port | no | Local dev port (default 3000, overridden by PORT) |
| aws.region | no | AWS region (default us-east-1 or AWS_REGION) |
| aws.runtime | no | Lambda runtime (default nodejs20.x) |
| aws.stage | no | Deploy stage (default from profile: dev / staging / prod) |
Environment files
Loaded in order (later overrides earlier), Next.js-style logging on start and build:
| File | When |
| ------------------------------------------------------- | ------------------------- |
| .env | Always |
| .env.development / .env.staging / .env.production | Matching --profile |
| .env.local | Always (highest priority) |
# .env
LOG_LEVEL=info
# .env.development
API_URL=http://localhost:3000
# .env.local (gitignored)
DATABASE_URL=postgres://localhost:5432/devovenless start defaults to profile development.ovenless build defaults to profile production unless you pass --profile.
bunx ovenless start --profile staging
bunx ovenless build --profile stagingCLI
| Command | Description |
| ------------------------------ | -------------------------------------------------- |
| ovenless start | Local Bun HTTP server |
| ovenless start --watch | Restart on file changes |
| ovenless start --profile <p> | Load .env.<p> |
| ovenless build --profile <p> | Bundle Lambda + write serverless.yml |
| ovenless build:client | Emit client .d.ts (needs tsconfig.client.json) |
| ovenless certs | Generate RSA PEM keys for JWT signing (RS256) |
| ovenless help | Show usage |
JWT signing keys (ovenless certs)
Cross-platform (no ssh-keygen / openssl required). Writes keys under cert/<profile>/:
bunx ovenless certs --profile development
bunx ovenless certs --profile staging --comment "my-api staging"
bunx ovenless certs --profile production --force # overwrite existing keysOutput layout:
cert/
├── development/
│ ├── id_rsa # private key (PEM PKCS#1, mode 600)
│ └── id_rsa.pub # public key (PEM)
├── staging/
└── production/Use in your app (example with jose or jsonwebtoken):
import { readFileSync } from "node:fs";
import { join } from "node:path";
const profile = process.env.OVENLESS_PROFILE ?? "development";
const privateKey = readFileSync(join("cert", profile, "id_rsa"), "utf8");
const publicKey = readFileSync(join("cert", profile, "id_rsa.pub"), "utf8");Add cert/ to .gitignore.
JWT authentication (built-in)
Enable auth on the router (not per-procedure). All routes are protected unless marked meta: { public: true } or listed in auth.public.
bunx ovenless certs --profile developmentimport { z } from "zod";
import { createRouter, mutation, query } from "ovenless";
const claimsSchema = z.object({ role: z.enum(["user", "admin"]) });
export const appRouter = createRouter(
{
health: query({
meta: { public: true },
output: z.object({ ok: z.boolean() }),
handler: () => ({ ok: true }),
}),
login: mutation({
meta: { public: true },
input: z.object({ userId: z.string() }),
output: z.object({ ok: z.literal(true) }),
handler: async ({ userId, auth }) => {
await auth.sign({
principalId: userId,
claims: { role: "user" },
});
return { ok: true };
},
}),
me: query({
output: z.object({ principalId: z.string(), role: z.string() }),
handler: ({ principalId, claims }) => ({
principalId,
role: claims.role,
}),
}),
},
{
auth: {
mode: "bearer", // or "cookie"
ttl: "7d", // "10m", "1h", or seconds
claims: claimsSchema,
autoRotate: true, // cookie mode: refresh Set-Cookie when near expiry
public: ["health", "login"],
authorizer: true, // generate Lambda REQUEST authorizer on build
cookie: { name: "ovenless_token", secure: true, sameSite: "lax" },
},
},
);Handler context
When auth is enabled, handlers receive one merged object: validated input fields (spread), plus input (the full parsed body), plus auth fields.
// Destructure input fields and auth together
handler: ({ limit, principalId, claims }) => { ... }
// Or use the nested `input` alias
handler: ({ input, principalId }) => createUser(input)| Field | Description |
|-------|-------------|
| …input fields | Spread from the procedure input schema |
| input | Full parsed input object (same fields as spread) |
| principalId | JWT sub (Serverless principalId compatible) |
| claims | Typed from auth.claims Zod schema |
| auth.sign | Issue JWT (Set-Cookie automatically in cookie mode) |
| auth.setCookie / auth.clearCookie | Cookie helpers |
Client
import { createClient, setClientBearerToken } from "ovenless/client";
const api = createClient<AppRouter>({
url: "http://localhost:3000",
auth: { mode: "bearer" },
});
await api.login({ userId: "alice" });
// After login, set token from your app logic or use cookie mode:
const apiCookie = createClient<AppRouter>({
url: "http://localhost:3000",
auth: { mode: "cookie" },
});setClientBearerToken("eyJhbG...");
await api.me();Lambda authorizer
When auth.authorizer: true, ovenless build emits dist/authorizer.js and wires HTTP API ovenlessJwt in serverless.yml. The main handler still validates JWT in-process; API Gateway can reject invalid tokens earlier.
import { createJwtAuthorizer } from "ovenless";
export const jwtAuthorizer = createJwtAuthorizer({ auth: appRouter.auth! });Deploy to AWS
1. Build for the target profile
bunx ovenless build --profile productionOutputs:
dist/handler.js— CommonJS bundle, exportawsHandlerserverless.yml— generated Serverless Framework v3 config
Generated serverless.yml (excerpt):
service: my-api
frameworkVersion: "3"
provider:
name: aws
runtime: nodejs20.x
region: us-east-1
stage: prod
functions:
api:
handler: dist/handler.awsHandler
events:
- httpApi:
path: /{proxy+}
method: ANY
- httpApi:
path: /
method: ANY2. Deploy with Serverless
npm i -g serverless
serverless deploy --stage prod3. Custom Lambda entry (advanced)
If you hand-roll the handler instead of ovenless build:
import { createAwsHandler } from "ovenless";
import config from "./ovenless.config.ts";
export const handler = createAwsHandler(config.router, {
title: config.title ?? config.service,
version: config.version ?? "1.0.0",
});HTTP API
Built-in routes
| Method | Path | Description |
| --------- | --------------- | -------------------------------- |
| GET | / | API metadata + procedure list |
| GET | /docs | Scalar interactive documentation |
| GET | /openapi.json | OpenAPI 3.0 document |
| OPTIONS | * | CORS preflight |
Procedure routes
| Type | Methods | Body |
| -------- | ------------- | ------------------------------------- |
| Query | GET, POST | GET: query string · POST: JSON object |
| Mutation | POST | JSON object |
Success response
JSON body = validated procedure output schema.
Error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Input validation failed",
"details": []
}
}| Code | HTTP | Meaning |
| -------------------- | ---- | ----------------------------------------- |
| NOT_FOUND | 404 | Unknown procedure path |
| METHOD_NOT_ALLOWED | 405 | Wrong HTTP method |
| INVALID_BODY | 400 | Body not a JSON object |
| INVALID_JSON | 400 | Malformed JSON (dev server) |
| VALIDATION_ERROR | 400 | Zod input/output validation failed |
| INTERNAL_ERROR | 500 | Handler threw (message hidden by default) |
Production 500 responses use a generic message. Enable details only in development:
createHandler(router, {
exposeErrorDetails: process.env.NODE_ENV !== "production",
});Architecture
┌──────────────┐ types only ┌─────────────────┐
│ Frontend │ ◄────────────────── │ appRouter │
│ createClient │ fetch/POST │ (Zod + TS) │
└──────┬───────┘ └────────┬────────┘
│ │
│ POST /users.getById │ createHandler
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Bun dev server · or AWS API Gateway HTTP API │
│ └─► Lambda (awsHandler) │
│ ├─ Zod input parse │
│ ├─ procedure.handler() │
│ ├─ Zod output parse │
│ └─ /docs · /openapi.json │
└──────────────────────────────────────────────────────────┘Package exports
| Import | Use |
| ----------------- | ------------------------------------------------------------ |
| ovenless | Router, handler, AWS adapter, config, CLI |
| ovenless/client | createClient, client types (InferClient, AppClient, …) |
Programmatic usage
Standalone HTTP handler (tests, custom servers):
import { createHandler } from "ovenless";
import { appRouter } from "./src/router.ts";
const handle = createHandler(appRouter, {
title: "My API",
version: "1.0.0",
cors: true,
});
const res = await handle({
method: "POST",
path: "/health",
body: {},
});
console.log(res.statusCode, res.body);Project layout (reference app)
my-api/
├── ovenless.config.ts # defineConfig({ router, service, ... })
├── serverless.yml # generated by ovenless build
├── package.json
├── .env
├── .env.development
├── .env.local
├── src/
│ └── router.ts # appRouter + export type AppRouter
└── dist/
└── handler.js # Lambda bundle (after build)Publishing (GitHub Actions + npm Trusted Publishing)
Releases are published from CI using npm Trusted Publishing (OIDC). No long-lived NPM_TOKEN is stored in GitHub Secrets.
One-time: configure Trusted Publisher on npm
Sign in at npmjs.com → open the ovenless package → Settings → Trusted Publisher (or Publishing access → add trusted publisher).
Choose GitHub Actions and enter:
| Field | Value | |-------|--------| | Organization or user |
gbmungunshagai-png| | Repository |ovenless| | Workflow filename |publish.yml| | Environment | (leave empty unless you add a GitHub Environment) |Save. Fields are case-sensitive and must match the workflow file name exactly.
Ensure the package is not set to “Require two-factor authentication and disallow tokens” in a way that blocks CI — Trusted Publishing uses OIDC, not granular tokens.
Release flow
Bump
versioninpackage.json(e.g.0.1.2→0.1.3).Commit and push to
main.Tag and push (tag must match
package.jsonversion):git tag v0.1.3 git push origin v0.1.3The Publish workflow runs tests, builds via
prepublishOnly, and runsnpm publish.Or trigger manually: Actions → Publish → Run workflow.
CI on every push/PR is handled by .github/workflows/ci.yml.
Scripts (developing Ovenless itself)
bun run build # bundle dist/
bun test # run test suite
bun run typecheck # tsc --noEmitRoadmap
- [ ] Middleware and request context (auth, logging)
- [ ] Subscription / streaming procedures
- [ ] First-party project scaffold (
create-ovenless)
License
MIT © G.B.Mungunshagai
