Skip to content

MarcelOlsen/ma

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ma (間)

This is an experiment, not a production framework. Ma exists to explore what best-in-class DX and type safety look like in a TypeScript HTTP framework. It is not published, has no ecosystem, and nobody should depend on it. If you're looking for something to ship with, use Hono or Elysia.

A TypeScript-first HTTP framework for Bun and Deno. The name is the Japanese concept of negative space — a framework that gets out of your way.

Ma prioritizes correct type inference above all else. No internal any, no mangled types, no inference bugs. Every design decision exists to make TypeScript work for you rather than against you.

Quick start

import { Ma, mc } from "@ma/core"
import { serve } from "@ma/core/adapters/bun"
import { Type } from "@sinclair/typebox"

const app = new Ma()
  .get("/hello/:name", {
    params: Type.Object({ name: Type.String() }),
    response: Type.Object({ message: Type.String() }),
  }, (c) => ({
    message: `Hello, ${c.params.name}!`,
  }))

serve(app, { port: 3000 })

Table of contents


Design principles

  1. Correct type inference — including across .then() chains, stored handlers, and plugin composition. No Hono #4765.
  2. Speed — radix tree router, pre-compiled TypeBox validators, minimal allocations.
  3. Honest safety — no internal any in core. unknown + narrowing instead.
  4. Clean architecture — strict separation between the type layer (src/types/) and runtime layer (src/core/).

Routing

All standard HTTP methods are supported. Each has two overloads: with and without a schema options object.

const app = new Ma()
  // Simple handler — output type inferred from return value
  .get("/ping", (c) => c.json({ pong: true }))

  // With schema — validates input, pins output type
  .post("/users", {
    body: Type.Object({ name: Type.String() }),
    response: Type.Object({ id: Type.Number(), name: Type.String() }),
  }, (c) => ({
    id: 1,
    name: c.body.name,
  }))

Supported methods: get, post, put, delete, patch, head, options

Path parameters use :param syntax:

.get("/users/:id/posts/:postId", {
  params: Type.Object({ id: Type.String(), postId: Type.String() }),
}, (c) => {
  c.params.id      // string
  c.params.postId  // string
})

Wildcard routes use *:

.get("/files/*", (c) => { ... })

Validation

Ma uses TypeBox as its validation library. Schemas are compiled once at startup via TypeCompiler.Compile() — zero per-request overhead.

.post("/users", {
  params: Type.Object({ id: Type.String() }),        // path params
  body: Type.Object({ name: Type.String() }),         // request body
  query: Type.Object({ page: Type.Optional(Type.Number()) }),  // query string
  response: Type.Object({ id: Type.Number() }),       // response shape (pins RPC output type)
}, handler)

When validation fails, Ma returns a structured 400 response:

{
  "error": "Validation failed",
  "source": "body",
  "issues": [{ "path": "/name", "message": "Expected string" }]
}

Context

Context carries derived state, parsed inputs, and the raw request. It flows through every handler and hook.

Derived context

.derive() runs a function per request and merges the result into context:

const app = new Ma()
  .derive((c) => ({
    db: new Database(),
    user: getUserFromHeader(c.header("authorization")),
  }))
  .get("/me", (c) => {
    c.get("db")   // Database
    c.get("user") // User — fully typed
  })

Derives are layered — each one sees the previous:

.derive(() => ({ db: new Database() }))
.derive((c) => ({ user: c.get("db").findUser() }))  // sees db

Static decoration

.decorate() sets a value once at startup — zero per-request cost:

.decorate("version", "2.0.0")
.get("/info", (c) => c.json({ version: c.get("version") }))

Parsed inputs

Inside a handler, typed inputs are available directly:

(c) => {
  c.params      // typed from params schema
  c.body        // typed from body schema
  c.query       // typed from query schema
  c.req         // raw Request
  c.method      // string
  c.path        // string
  c.url         // URL
  c.header(name) // string | null
}

Response handling

Handlers can return any value — Ma coerces it to a Response:

Return value Response
Response / TypedResponse<T> Pass through
null / undefined 204 No Content
string text/plain
number Status-only (e.g. return 404)
Uint8Array / ArrayBuffer application/octet-stream
Everything else application/json via JSON.stringify
// All of these work:
.get("/text", () => "hello")
.get("/json", () => ({ id: 1 }))
.get("/empty", () => null)
.get("/status", () => 204)
.get("/explicit", (c) => c.json({ id: 1 }, { status: 201 }))

The c.json(), c.text(), and c.html() methods brand the response for ExtractOutput inference and allow passing custom headers/status:

.get("/users", (c) => c.json(users, { status: 200, headers: { "x-total": "42" } }))

Typed errors

Routes declare their error shapes. The client gets a discriminated union — no more hoping you remembered to check .ok:

.get("/users/:id", {
  params: Type.Object({ id: Type.String() }),
  response: Type.Object({ id: Type.Number(), name: Type.String() }),
  errors: {
    404: Type.Object({ message: Type.String() }),
    403: Type.Object({ message: Type.String(), required: Type.String() }),
  },
}, (c) => {
  if (!found) throw new HTTPException(404, { json: { message: "Not found" } })
  return user
})

On the client, TypeScript narrows by ok and status:

const res = await client.users[":id"].get({ id: "1" })

if (res.ok) {
  res.data  // { id: number; name: string }
} else if (res.status === 404) {
  res.data  // { message: string }
} else if (res.status === 403) {
  res.data  // { message: string; required: string }
}

Guards

.guard<TNarrow>() asserts a condition and narrows the context type for all downstream handlers. If the guard throws or returns a Response, the request short-circuits.

const app = new Ma()
  .derive((c) => ({
    user: getUserFromToken(c.header("authorization")),  // User | null
  }))
  .guard<{ user: User }>((c) => {
    if (!c.get("user")) throw new HTTPException(401)
  })
  .get("/me", (c) => {
    c.get("user").name  // User — not User | null. No cast, no `!`
  })

Scoping

Guards only apply to routes registered after them. Routes before the guard are unaffected:

.get("/public", handler)        // no guard
.guard<{ user: User }>(check)
.get("/private", handler)       // guarded

Guards also inherit into .group() calls:

.guard<{ user: User }>(check)
.group("/admin", (g) => g
  .guard<{ user: Admin }>(adminCheck)
  .get("/stats", handler)  // both guards apply
)

Plugins

.use() applies a function that transforms the app. Plugins are plain functions — no special wrapper:

function auth<T extends Record<string, unknown>, R extends Record<string, unknown>>(app: Ma<T, R>) {
  return app
    .derive((c) => ({ user: getUser(c) }))
    .guard<{ user: User }>((c) => {
      if (!c.get("user")) throw new HTTPException(401)
    })
}

const app = new Ma()
  .use(auth)
  .get("/me", (c) => c.json(c.get("user")))  // user is User

Plugins compose naturally:

.use(timing)
.use(auth)
.use(adminOnly)
.get("/dashboard", handler)

Typed client

mc constructs a fully typed RPC client from the app's route map:

const client = mc<typeof app>("http://localhost:3000")

// Navigate by path segments
const res = await client.users.get()
const user = await client.users[":id"].get({ id: "1" })

// POST with body
await client.users.post({ body: { name: "Alice" } })

// Query params
await client.users.get({ query: { page: 2 } })

Client headers

Pass default headers (e.g. auth tokens) or per-request headers:

// Default headers for every request
const client = mc<typeof app>("http://localhost:3000", {
  headers: { authorization: "Bearer token" },
})

// Per-request override
await client.users.get({ headers: { "x-request-id": "abc" } })

Type extraction

import type { InferOutput, InferInput, InferErrors } from "@ma/core"

type User = InferOutput<typeof client.users[":id"]["get"]>
// → { id: number; name: string; email: string }

type CreateArgs = InferInput<typeof client.users["post"]>
// → { body: { name: string; email: string } }

type Errors = InferErrors<typeof client.users[":id"]["get"]>
// → { ok: false; status: 404; data: { message: string } }

Test client

Pass the app instance directly to mc() — no server needed, types inferred automatically:

const client = mc(app)  // no <typeof app>, no URL, no server

const res = await client.users.get()
expect(res.data).toEqual([...])

This calls app.handle() directly. Same typed API, zero network overhead, no port conflicts, no cleanup.

// With headers
const authed = mc(app, { headers: { authorization: "Bearer token" } })
const me = await authed.me.get()

WebSocket

Bidirectional typed WebSocket with receive (client→server) and send (server→client) schemas:

app.ws("/chat", {
  receive: Type.Object({
    type: Type.Literal("message"),
    text: Type.String(),
  }),
  send: Type.Object({
    type: Type.Literal("broadcast"),
    text: Type.String(),
    from: Type.String(),
  }),

  open(ws) {
    ws.send({ type: "broadcast", text: "Connected!", from: "system" })
    // ws.send() auto-stringifies — no JSON.stringify needed
  },

  message(ws, data, ctx) {
    // data.type is "message", data.text is string — typed from receive schema
    ws.send({ type: "broadcast", text: data.text, from: "user" })
  },

  close(ws, code, reason, ctx) { ... },
})
  • receive schema validates incoming messages at runtime. Invalid messages close with code 1008.
  • send schema types ws.send() — objects are auto-stringified.
  • Context (from .derive(), .decorate()) is available in all handlers.
  • Path params work: .ws("/rooms/:id", { ... })

Server-sent events

.get("/events", (c) => c.sse(async (emit) => {
  emit({ count: 1 })
  emit({ count: 2 }, { event: "update", id: "2" })
}))

// With typed schema
.get("/typed-events", (c) => c.sse(
  Type.Object({ count: Type.Number() }),
  async (emit) => {
    emit({ count: 1 })  // typed from schema
  },
))

Lifecycle hooks

Hooks execute in a fixed order. Any hook can short-circuit by returning a Response.

onRequest → onParse → onTransform → onBeforeHandle → [handler]
→ onAfterHandle → onError → onResponse
app
  .onRequest((c) => {
    // Runs first — good for auth, rate limiting
    // Return a Response to short-circuit
  })
  .onBeforeHandle((c) => {
    // Last chance before handler
  })
  .onAfterHandle((c, response) => {
    // Can replace the response
    return new Response("replaced")
  })
  .onError((c, error) => {
    // Catches any thrown error
    return new Response("Error", { status: 500 })
  })
  .onResponse((c, response) => {
    // Post-response — logging. Cannot mutate.
    console.log(c.method, c.path, response.status)
  })

Per-route hooks via the options object:

.get("/special", {
  hooks: {
    onBeforeHandle: [(c) => { ... }],
  },
}, handler)

Route groups

Prefix routes and share context with .group():

app.group("/api/v1", (g) => g
  .get("/users", handler)     // → GET /api/v1/users
  .get("/posts", handler)     // → GET /api/v1/posts
)

Groups inherit parent guards:

app
  .guard<{ user: User }>(authCheck)
  .group("/admin", (g) => g
    .get("/stats", handler)   // guard applies
  )

Cookies

.get("/me", (c) => {
  const session = c.cookie("session")  // string | undefined

  c.setCookie("session", "abc123", {
    httpOnly: true,
    secure: true,
    maxAge: 3600,
    sameSite: "strict",
    path: "/",
  })

  return c.json({ session })
})

Environment validation

Validate process.env at startup with a TypeBox schema. Fails fast with clear error messages:

const app = new Ma()
  .env(Type.Object({
    DATABASE_URL: Type.String(),
    PORT: Type.String(),
    DEBUG: Type.Optional(Type.String()),
  }))
  .get("/config", (c) => {
    c.get("env").DATABASE_URL  // string — typed and validated
  })

OpenAPI generation

Generate an OpenAPI 3.1 spec from your route schemas — TypeBox schemas are the single source of truth:

const spec = app.openapi({
  title: "My API",
  version: "1.0.0",
  description: "Auto-generated from TypeBox schemas",
})

// Serve it
app.get("/openapi.json", () => Response.json(spec))

The spec includes path params, query params, request body, response schemas, and typed error responses — all derived from the same schemas that power your validation and client types.


Adapters

Bun

import { serve } from "@ma/core/adapters/bun"

const server = serve(app, { port: 3000, hostname: "0.0.0.0" })
// server.port, server.stop()

Handles both HTTP and WebSocket upgrades.

Deno

import { serve } from "@ma/core/adapters/deno"

serve(app, { port: 3000 })

Custom 404

app.notFound((c) => c.json({ error: "Not found", path: c.path }, { status: 404 }))

Type utilities

import type {
  InferContext,       // Extract context type: InferContext<typeof app>
  InferOutput,        // Response body type from a client method
  InferInput,         // Request args type from a client method
  InferErrors,        // Error union from a client method
  Client,             // Full client type from a route map
  ClientResponse,     // Response wrapper: { ok, status, data, headers }
  ClientResult,       // Discriminated union for typed errors
  RouteMap,           // Map of "METHOD /path" → RouteDefinition
  MergeCtx,           // Flat merge of context types
  ExtractParams,      // "/users/:id" → { id: string }
  ExtractOutput,      // Handler return type extraction
} from "@ma/core"

Examples

Example What it demonstrates
basic.ts CRUD, schemas, derives, auto-response
typed-context.ts Layered derives, stored handlers
client.ts Typed RPC client, InferOutput/InferInput
client-headers.ts Default and per-request headers
test-client.ts mc(app) — no server needed
typed-errors.ts Discriminated union error responses
guard.ts Type-narrowing guards, scoping, groups
plugin.ts .use() composition
typed-ws.ts Bidirectional typed WebSocket
openapi.ts OpenAPI generation from schemas

Architecture

src/
  types/           ← All generic complexity lives here
    response.ts       TypedResponse brand, ExtractOutput
    route.ts          RouteMap, MergeRoute, path extraction
    context.ts        Context generics, MergeCtx
    schema.ts         TypeBox integration
    client.ts         RPC client type construction
    utils.ts          Equal, Expect, Simplify

  core/            ← Pure runtime, minimal generics
    router.ts         Radix tree router
    context.ts        Context class
    lifecycle.ts      Hook pipeline
    app.ts            Ma class — glue layer
    http-exception.ts HTTPException
    cookies.ts        Cookie parsing/serialization

  adapters/
    bun.ts            Bun.serve() adapter
    deno.ts           Deno.serve() adapter

  client/
    index.ts          mc() RPC client runtime

Hard rules for core/:

  • No any. Use unknown + narrowing.
  • Types come from types/, not defined inline.
  • Each file has one clear responsibility.

License

MIT

About

A TypeScript-first HTTP framework for Bun and Deno.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors