@torajs/tora
v0.1.6
Published
A lightweight web framework with a declarative map() API for Deno, Bun and Node.js
Maintainers
Readme
🐯 Tora
⚠️ Experimental. Tora is a personal framework built to explore the
map()idea — a declarative, tree-shaped route API that pre-computes middleware chains at startup. It powers my own projects and evolves alongside them. Expect breaking changes as the design matures. Currently written in TypeScript with opt-in typed handlers — full end-to-end type safety is planned.
Tora (虎) means tiger in Japanese. It is a minimalist and ultrafast web framework built on Web Standards, compatible with Deno, Bun, and Node.js. Its unique declarative map() API pre-computes middleware chains at startup — zero per-request overhead.
Why Tora?
Pre-computed middleware chains
Most frameworks resolve middleware at request time. Express walks a global stack, Koa composes closures lazily. Tora does none of that.
Because map() declares the entire route tree upfront, Tora fully resolves which middlewares apply to each route at registration time. Every route gets a flat, pre-built [...globalMw, ...scopedMw, ...routeMw] array baked in. At request time fetch() does a single linear iteration over that array — no path matching, no closure composition, no runtime middleware resolution. The cost is paid once at startup.
Scoped middleware that reads like your structure
The use key cascades middleware down the tree. The shape of your middleware config mirrors the shape of your API — no separate router.use('/api', auth) calls scattered around.
app.map({
'/api': {
use: [auth()], // applies to everything under /api
'/admin': {
use: [adminOnly()], // stacks on top for /admin routes
get: dashboard,
},
},
})Composable by default
Route subtrees are plain objects. Split them across files and compose with spread — no framework-specific API needed.
import { usersRoutes } from './routes/users.ts'
import { adminRoutes } from './routes/admin.ts'
app.map({
'/api': {
use: [auth()],
...usersRoutes,
'/admin': adminRoutes,
},
})Installation
Deno / JSR
import { Tora } from 'jsr:@tora/tora'Bun / Node.js (npm)
npm install @torajs/toraRequires Node 18+ or Bun 1+.
Quick start
Deno
import { Tora } from 'jsr:@tora/tora'
const app = new Tora()
app.map({ '/': { get: (ctx) => ctx.text('Hello world') } })
Deno.serve(app.fetch)Bun
import { Tora } from '@torajs/tora'
const app = new Tora()
app.map({ '/': { get: (ctx) => ctx.text('Hello world') } })
Bun.serve({ fetch: app.fetch })Node.js
import { Tora } from '@torajs/tora'
import { serve } from '@torajs/tora/node'
const app = new Tora()
app.map({ '/': { get: (ctx) => ctx.text('Hello world') } })
serve(app, 3000)The map() API
map() is the core of Tora. Your entire API is a single nested object. Keys are path segments, HTTP method names, or use for middleware.
Basic routes
app.map({
'/': {
get: (ctx) => ctx.text('Home'),
post: (ctx) => ctx.json({ created: true }, 201),
},
'/about': {
get: (ctx) => ctx.text('About'),
},
})Nested paths
app.map({
'/api': {
'/users': {
get: (ctx) => ctx.text('User list'), // GET /api/users
post: (ctx) => ctx.text('Create'), // POST /api/users
'/:id': {
get: (ctx) => ctx.text('One user'), // GET /api/users/:id
delete: (ctx) => ctx.text('Delete'), // DELETE /api/users/:id
},
},
},
})URL parameters
Parameters are prefixed with : and available on ctx.params:
'/:id': {
get: (ctx) => ctx.json({ id: ctx.params.id }),
}Supported HTTP methods
get, post, put, delete, patch, head
Multiple map() calls
map() calls are additive — each one registers more routes into the same radix tree. Splitting by domain is a natural way to organise larger apps:
app.map({ '/api/users': usersRoutes })
app.map({ '/api/orders': ordersRoutes })
app.map({ '/api/products': productsRoutes })Writing middleware
A middleware is a function that receives ctx and next. Call next() to pass control to the next middleware or handler, and use the returned Response to do work after the route has responded.
import type { Context, Next } from 'jsr:@tora/tora'
const myMiddleware = async (ctx: Context, next: Next) => {
// --- before the handler runs ---
console.log('incoming:', ctx.req.method, new URL(ctx.req.url).pathname)
const response = await next() // run the next middleware / handler
// --- after the handler responds ---
console.log('outgoing:', response.status)
return response
}Register it globally or scope it to a subtree:
app.use(myMiddleware) // global — runs on every request
app.map({
'/api': {
use: [myMiddleware], // scoped — runs only under /api
'/users': {
get: route(myMiddleware, listUsers), // route-level — this route only
},
},
})Execution order
For a request to GET /api/users with the setup above, the full execution order is:
global middlewares → scoped middlewares → route middlewares → handler
↑ ↑ ↑ ↑
app.use() use: [...] route(mw, ...) handler fnThe "before" code in each middleware runs top-down, and the "after" code runs bottom-up as the call stack unwinds:
→ globalMw (before)
→ scopedMw (before)
→ routeMw (before)
→ handler
← routeMw (after)
← scopedMw (after)
← globalMw (after)Short-circuiting
Return a response without calling next() to stop the chain — useful for auth guards:
const auth = async (ctx: Context, next: Next) => {
const token = ctx.header('authorization')
if (!token) return ctx.json({ error: 'Unauthorized' }, 401) // chain stops here
ctx.set('user', await verifyToken(token))
return next()
}Passing data downstream
Use ctx.set() / ctx.get() to share data between middlewares and handlers:
const auth = async (ctx: Context, next: Next) => {
ctx.set('user', await verifyToken(ctx.header('authorization') ?? ''))
return next()
}
const getProfile = (ctx: Context) => {
const user = ctx.get<User>('user')
return ctx.json(user)
}Route-level middleware with route()
Use the route() helper to attach middleware to a single route. It enforces at the type level that the last argument is a Handler and all preceding arguments are Middleware — a plain array gives you no such guarantee.
import { Tora, route } from 'jsr:@tora/tora'
app.map({
'/api/users': {
post: route(auth, validate(schema), createUser),
// ^^^^^ TypeScript enforces: [...Middleware[], Handler]
},
})Without route(), a plain array works at runtime but the types are loose — putting the handler in the wrong position is a silent runtime bug. route() makes that a compile error.
Scoped middleware with use
Add a use key at any level. Middleware cascades down to all routes at that level and below.
app.map({
'/api': {
use: [auth],
'/users': {
get: listUsers, // auth runs here
'/:id': {
get: getUser, // and here
},
},
'/admin': {
use: [adminOnly],
get: adminDashboard, // auth + adminOnly run here
},
},
})Execution order for GET /api/admin: auth → adminOnly → adminDashboard
Global middleware
app.use() registers middleware that runs on every request, before any route-level middleware:
app.use(requestId(), rateLimiter(), logger(), cors())Error handling
app.onError() registers error handlers. Called whenever a handler or middleware throws. Multiple handlers chain via next(). If none handle it, a default 500 JSON response is returned.
app.onError((err, ctx, next) => {
console.error(err)
return ctx.json({ error: err.message }, err.status ?? 500)
})Attach a status property to errors for custom HTTP codes:
const err = new Error('Forbidden')
err.status = 403
throw errRequest context (ctx)
Every handler and middleware receives ctx:
| Property / Method | Description |
| ----------------------------------- | ------------------------------------------------ |
| ctx.req | Raw Request object |
| ctx.params | URL parameters e.g. { id: '42' } |
| ctx.query | Parsed query string (lazy) e.g. { page: '1' } |
| ctx.header(name) | Get a request header |
| ctx.cookie(name) | Read a request cookie |
| ctx.setCookie(name, value, opts) | Set a response cookie |
| ctx.body<T>() | Parse request body as JSON |
| ctx.bodyText() | Parse request body as plain text |
| ctx.bodyForm() | Parse request body as FormData |
| ctx.set(key, value) | Store a value for the lifetime of the request |
| ctx.get<T>(key) | Retrieve a stored value |
| ctx.text(content, status?) | Return a text response |
| ctx.json(data, status?) | Return a JSON response |
| ctx.html(content, status?) | Return an HTML response |
| ctx.redirect(url, status?) | Return a redirect (default 302) |
Cookies
// Read
const token = ctx.cookie('session')
// Write
ctx.setCookie('session', 'abc123', {
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: 3600,
path: '/',
})
return ctx.json({ ok: true })Bundled middlewares
logger()
Logs method, path, status, and response time to stdout.
import { logger } from 'jsr:@tora/tora/logger'
app.use(logger())
// [2026-04-10T12:00:00.000Z] GET /api/users 200 - 3mscors(options?)
Handles CORS headers and preflight OPTIONS requests.
import { cors } from 'jsr:@tora/tora/cors'
app.use(cors({
origin: ['https://myapp.com', 'https://staging.myapp.com'],
credentials: true,
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['X-Request-Id'],
maxAge: 86400,
}))Defaults to origin: '*' with no credentials.
rateLimiter(options?)
In-memory token bucket rate limiter. Expired buckets are swept automatically on each window interval so memory stays bounded.
import { rateLimiter } from 'jsr:@tora/tora'
app.use(rateLimiter({
limit: 100, // requests per window
window: 60_000, // window in ms (default: 1 min)
keyBy: (ctx) => ctx.header('x-forwarded-for') ?? 'unknown',
}))Returns 429 Too Many Requests when the limit is exceeded.
requestId()
Stamps each request with a unique ID. Reuses X-Request-Id if already present (e.g. from a proxy). Echoed back in the X-Request-Id response header.
import { requestId } from 'jsr:@tora/tora'
app.use(requestId())
// Access in handlers/middleware:
const id = ctx.get('requestId')errorHandler()
Structured JSON error responses with console logging.
import { errorHandler } from 'jsr:@tora/tora'
app.onError(errorHandler())
// [ERROR] Something went wrong
// { "error": "Something went wrong" } — 500Validation
Tora doesn't include a built-in validator — use whatever you prefer. The route() helper makes attaching a validation middleware clean and type-safe:
import { z } from 'zod'
import type { Context, Next } from 'jsr:@tora/tora'
const validate = (schema: z.ZodType) => async (ctx: Context, next: Next) => {
const result = schema.safeParse(await ctx.body())
if (!result.success) return ctx.json({ error: result.error.flatten() }, 400)
ctx.set('body', result.data)
return next()
}
app.map({
'/api/users': {
post: route(validate(z.object({ name: z.string(), age: z.number() })), createUser),
},
})Works with Zod, Valibot, TypeBox, or anything else.
Route tree visualiser
Two visualiser functions are available as standalone imports — tree-shakeable and optional.
tree(app) — full chain
Shows the complete middleware → handler chain for every route.
import { tree } from 'jsr:@tora/tora'
console.log(tree(app))🐯 Tora
/
├── GET: showUrl
└── api
└── users
├── GET: auth → listUsers
└── POST: auth → validate → createUserNamed functions show their name. Anonymous arrows show as <anonymous> — a useful nudge to name your handlers.
treeCompact(app) — method + count
Shows methods inline with the middleware count in parentheses. Useful for a quick overview of large APIs.
import { treeCompact } from 'jsr:@tora/tora'
console.log(treeCompact(app))🐯 Tora
/ [GET]
├── api
│ ├── users [GET (1), POST (2)]
│ └── :id [GET (1), DELETE (1)]
└── admin [GET (2)]Node.js
Use the included adapter to run on Node 18+:
import { Tora } from '@torajs/tora'
import { serve } from '@torajs/tora/node'
const app = new Tora()
app.map({ ... })
serve(app, 3000, () => console.log('http://localhost:3000'))Or use createNodeAdapter directly if you need the raw http.Server:
import http from 'node:http'
import { createNodeAdapter } from '@torajs/tora/node'
const server = http.createServer(createNodeAdapter(app))
server.listen(3000)Full example
import { Tora, route, tree } from 'jsr:@tora/tora'
import { logger, cors, rateLimiter, requestId, errorHandler } from 'jsr:@tora/tora'
import type { Context, Next } from 'jsr:@tora/tora'
const app = new Tora()
app.onError(errorHandler())
app.use(requestId(), rateLimiter(), logger(), cors())
const auth = async (ctx: Context, next: Next) => {
const token = ctx.header('authorization')
if (!token) return ctx.json({ error: 'Unauthorized' }, 401)
ctx.set('token', token)
return next()
}
app.map({
'/': {
get: (ctx) => ctx.text('Hello'),
},
'/api': {
use: [auth],
'/users': {
get: (ctx) => ctx.json({ users: [] }),
post: route(auth, (ctx) => ctx.json({ created: true }, 201)),
'/:id': {
get: (ctx) => ctx.json({ id: ctx.params.id }),
delete: (ctx) => ctx.json({ deleted: ctx.params.id }),
},
},
},
})
console.log(tree(app))
Deno.serve(app.fetch)