@tyno/tyno
v2.1.3
Published
A lightweight Node.js HTTP framework with onion model middleware, trie router, SSE, and TypeScript support.
Readme
tyno
A zero-dependency lightweight Node.js HTTP framework written in TypeScript, built on native http module with ES Modules. Combines Koa-style onion middleware with ThinkPHP/Laravel-style request accessors.
import { Tyno } from '@tyno/tyno'
const app = new Tyno()
app.use((req) => `Hello ${req.query('name') || 'World'}`)
app.listen(3000)import { Tyno } from '@tyno/tyno'
import { Router } from '@tyno/tyno/router'
const app = new Tyno()
const r = new Router()
r.get('/users/:id', (req) => ({ id: req.params.id }))
app.use(r.routes()).listen(3000)Features
- Onion middleware: auto-normalized return values, async compression
- Trie router: static/param
:id/regex:id(\d+)/wildcard*, HEAD,group(),fallback() - Request accessors:
query()/post()/param()/input() - File uploads: streaming multipart state-machine parser, buffer/disk dual mode
- Response builder: static factories
json/text/empty/redirect/image, chainableset/type/setStatus/setCookie/attachment - Global facades:
req/res(aliases) /request(read inbound) /Cache(cache ops) - Error system:
HttpError/RuntimeError+ error middleware + Symbol marker - Event system:
request/response/response:sent/error/ready - Cache: memory/file/Redis drivers,
get/set/has/delete/clear/remember - Built-in middleware: CORS, async compression, Range static files, request ID
- Testing:
app.inject()without starting a server
Module Structure
Package exports namespace-style, akin to PHP namespaces:
// Main entry
import { Tyno, Response, res, Request, req } from '@tyno/tyno'
// Facades
import { request, Cache } from '@tyno/tyno/facade'
// Middleware
import { cors, compress, serveStatic, requestId } from '@tyno/tyno/middleware'
// Router
import { Router } from '@tyno/tyno/router'
// Errors
import { HttpError, NotFound, RuntimeError } from '@tyno/tyno/errors'
// Cache
import { CacheManager, MemoryDriver } from '@tyno/tyno/cache'Core Modules
Middleware
Signature: async (req, next?) => unknown.
// Onion model — await next() to enter downstream
app.use(async (req, next) => {
const start = Date.now()
const res = await next()
console.log(`${req.method} ${req.path} ${res.status} ${Date.now() - start}ms`)
return res
})
// Terminal (no next)
app.use((req) => ({ hello: 'world' }))
// Error middleware (3 args or Symbol marker)
app.use(async (err, req, next) => {
return Response.json({ error: err.message }, err.status || 500)
})
// Array registration (executed in order)
app.use([logger, auth, compress])
app.middleware([cors, requestId]) // middleware === use aliasReturn value auto-normalization:
| Return Type | Conversion | Content-Type | Status |
|------------|-----------|-------------|--------|
| Response instance | as-is | existing | existing |
| string | Response.text(str) | text/plain; charset=utf-8 | 200 |
| Buffer | Response.text(buf) | text/plain; charset=utf-8 | 200 |
| object (plain) | Response.json(obj) | application/json; charset=utf-8 | 200 |
| number / boolean | Response.text(String(v)) | text/plain; charset=utf-8 | 200 |
| ReadableStream (has pipe) | streamed output | none (passthrough) | 200 |
| AsyncIterable / Generator | SSE stream | text/event-stream | 200 |
| null / undefined | Response.empty() | none | 204 |
| Image Buffer | Response.image(buf, 'png') | image/png | 200 |
Images require explicit
Response.image()— the framework won't guess Buffer type.
Request
Proxy-wrapped IncomingMessage. Built-in props are read-only; custom props stored in DATA_KEY.
// Basic info
req.method // GET / POST
req.path // without query string
req.url // with query string
req.ip // X-Forwarded-For aware
req.protocol // http / https
req.secure // is HTTPS
req.fullUrl // protocol://host/url
req.httpVersion // 1.1 / 2.0
req.host // with port
req.hostname // without port
req.userAgent // User-Agent header
req.isAjax() // X-Requested-With check
req.wantsJSON() // Accept header check
// Query
req.query() // → { id: '1', name: 'Alice' }
req.query('id') // → '1'
req.query('x', 'def') // → 'def' with default
req.get('id') // alias
req.getQuery('id') // alias
// Body (lazy parse, cached)
await req.body() // JSON / urlencoded / text auto-detect
await req.post('title') // single POST field
await req.param('id') // GET priority, fallback POST
await req.input('id', 0, parseInt) // with filter
// Headers / Cookies
req.header('authorization') // case-insensitive
req.cookies // URL-decoded
req.getCookie('sessionId')
// File uploads (streaming multipart)
await req.files() // → { fields, files }
await req.file('avatar') // → single UploadedFile | null
// UploadedFile: { fieldname, filename, mimetype, filepath, size, buffer? }Body config:
const app = new Tyno({
body: {
limit: '2mb', // JSON/urlencoded/text max size
uploadLimit: '20mb', // file upload max size
uploadBufferLimit: '512kb',// memory threshold, exceed → stream to disk
uploadDir: '/tmp',
keepExtensions: true
}
})Response
Construction + static factories + static facade (preset headers/cookies) — one class, three roles.
Aliases: Response (class) = response = res
import { Response, res } from '@tyno/tyno'
// —— Static factories ——
Response.json({ ok: true }) // 200 application/json; charset=utf-8
Response.json(data, 201) // custom status
Response.text('hello') // 200 text/plain; charset=utf-8
Response.empty() // 204 no Content-Type
Response.empty(201) // 201
Response.redirect('/new') // 302 Location: /new
Response.redirect('/new', 301) // 301
Response.image(buf) // 200 image/png
Response.image(buf, 'jpeg') // 200 image/jpeg
Response.image(buf, 'image/webp') // 200 image/webp
Response.image(buf, 'svg', 201) // 201 image/svg+xml
// —— Instance construction ——
new Response(200, { 'X-Custom': 'yes' }, 'body')
new Response(404, {}, 'Not Found')
// —— Chainable modifiers ——
Response.json({ ok: true })
.set('X-Request-Id', 'abc')
.type('json')
.setStatus(201)
.setCookie('token', 'xxx', { httpOnly: true, maxAge: 3600, sameSite: 'Lax' })
.clearCookie('old_session')
.attachment('report.txt') // Content-Disposition: attachment
// —— Static facade (presets, final takes priority) ——
app.use(async (req, next) => {
Response.header('X-Powered-By', 'tyno')
Response.setCookie('track', 'abc', { httpOnly: true })
return next()
})Response output reference:
| Factory | Content-Type | Default Status | Body Type |
|---------|-------------|----------------|-----------|
| json(data, status?) | application/json; charset=utf-8 | 200 | string (JSON) |
| text(data, status?) | text/plain; charset=utf-8 | 200 | string |
| empty(status?) | none | 204 | null |
| redirect(url, status?) | none (Location header) | 302 | null |
| image(data, type?, status?) | image/* | 200 | Buffer \| string |
| new Response(s, h, b) | manual | manual | ResponseBody |
| ResponseBody Type | Output Behavior |
|-------------------|-----------------|
| string | nodeRes.end(body) |
| Buffer | nodeRes.end(body) |
| NodeJS.ReadableStream | body.pipe(nodeRes) |
| AsyncIterable | SSE chunked write |
| null | nodeRes.end() |
Router
Trie-based, with param constraints, wildcards, grouping, and fallback.
import { Router } from '@tyno/tyno/router'
const r = new Router({ prefix: '/api' })
// Router-level middleware (supports array, use/middleware are equivalent)
r.use(async (req, next) => {
req.user = { id: 1 }
return next()
})
r.middleware([auth, adminCheck]) // array registration
// Method registration
r.get('/users/:id(\\d+)', (req) => ({ id: req.params.id }))
r.head('/health', () => Response.empty(200))
r.post('/users', async (req) => ({ created: await req.body() }))
r.put('/users/:id', async (req) => Response.empty(204))
r.delete('/users/:id', () => Response.empty(204))
r.patch('/users/:id', async (req) => 'ok')
r.all('/any', (req) => `${req.method} matched`)
r.get('/*', (req) => ({ wild: req.params['*'] }))
// Grouping
r.group('/admin', (admin) => {
admin.get('/dashboard', () => 'Admin Panel')
admin.get('/users', () => 'User List')
// equivalent to /api/admin/dashboard, /api/admin/users
})
// Fallback — called when no route matches
r.fallback((req) => Response.json({ error: 'Not Found' }, 404))
app.use(r.routes())Path patterns:
| Pattern | Meaning | Match |
|---------|---------|-------|
| users | static segment | /api/users |
| :id | param segment | /api/123 |
| :id(\d+) | regex constraint | /api/123 |
| * | wildcard | /api/a/b/c |
Priority: static > param > wildcard. Methods: get/head/post/put/delete/patch/all.
Errors & Events
Error Handling
import { HttpError, NotFound, RuntimeError, isRuntimeError } from '@tyno/tyno/errors'
// HttpError: 4xx defaults expose=true, 5xx expose=false
throw new NotFound('Resource not found')
throw new HttpError(401, 'Please login')
// RuntimeError: defaults 500, expose=false, with cause chain
throw new RuntimeError('Database connection failed', {
code: 'DB_FAIL',
cause: new Error('ECONNREFUSED')
})Shortcut classes: BadRequest, Unauthorized, Forbidden, NotFound, Conflict, PayloadTooLarge, TooManyRequests, InternalServerError.
Error middleware (3+ args or IS_ERROR_MIDDLEWARE marker, chained in registration order):
import { asErrorMiddleware } from '@tyno/tyno'
app.use(async (err, req, next) => {
if (isRuntimeError(err)) {
return Response.json({ error: 'Service unavailable', code: err.code }, 500)
}
return Response.json({ error: err.message }, err.status || 500)
})Event System
Application extends EventEmitter. Full lifecycle:
| Event | Triggers | Args |
|-------|---------|------|
| request | request arrives, before compose, AsyncLocalStorage ready | (req) |
| response | compose completes, before sending | (req, res) |
| response:sent | after response sent | (req, res) |
| error | error occurs (only with listeners) | (err, req) |
| ready | server listening | () |
import { Tyno } from '@tyno/tyno'
const app = new Tyno({ debug: true })
app.on('request', (req) => console.log(`→ ${req.method} ${req.path}`))
app.on('response', (req, res) => console.log(`← ${req.path} ${res.status}`))
app.on('response:sent', (req, res) => console.log(`✓ ${req.path} ${res.status} done`))
app.on('error', (err, req) => logger.error({ err, path: req.path }))
app.on('ready', () => console.log('Server ready'))Built-in Middleware
import { cors, compress, serveStatic, requestId } from '@tyno/tyno/middleware'
// CORS
app.use(cors())
app.use(cors({ origin: ['https://a.com'], credentials: true, maxAge: 86400 }))
// Async compression (gzip/deflate), supports streaming, skips images/video
app.use(compress())
app.use(compress({ threshold: 2048, level: 6 }))
// Static files: ETag/304, Range/206, path traversal protection
app.use(serveStatic('./public', { prefix: '/static', maxAge: 3600 }))
// Request ID: auto-generate UUID, inject req.requestId, write response header
app.use(requestId())
app.use(requestId({ readFromHeader: false }))
// Dev error page — auto-attached when debug:true
new Tyno({ debug: true }) // shows stack + cause, JSON/HTML dual formatCache
Memory / File / Redis drivers, unified get/set/has/delete/clear/remember API.
Alias: Cache (uppercase) = cache (lowercase)
import { Cache } from '@tyno/tyno/facade'
const app = new Tyno({ cache: { driver: 'memory', prefix: 'my:', ttl: 3600 } })
// via app
await app.cache().set('user:1', { name: 'Alice' }, 60)
const user = await app.cache().get('user:1')
// remember — calls factory on cache miss
const config = await app.cache().remember('config', 3600, loadConfig)
// Global facade (inside middleware)
app.use(async () => {
await Cache.set('key', 'value')
return Response.json({ ok: true })
})File driver: { driver: 'file', file: { path: './storage/cache' } }
Redis driver: requires npm install redis, { driver: 'redis', redis: { host, port } }
Other
Initializers
app.initialize(async (app) => { await db.connect() })
app.listen(4567) // runs all initializers firstHTTPS
app.listen({ port: 443, tls: { key, cert } })Testing
const res = await app.inject({ method: 'GET', url: '/?name=alice' })
res.status // 200
res.json() // { hello: 'alice' }SSE
async function* gen() { yield 'chunk1'; yield 'chunk2' }
return gen()API Reference
Application
| Method | Description |
|--------|-------------|
| new Tyno({ debug?, body?, cache? }) | Create app |
| use(fn\|[...fn]) / middleware(fn\|[...fn]) | Register middleware, supports array |
| initialize(fn) | Register initializer |
| listen(port\|opts) | Start HTTP/HTTPS, emits ready |
| close() | Graceful shutdown |
| inject(opts) | Test request |
| cache() | Cache instance |
| on/emit/once | EventEmitter methods |
Router
| Method | Description |
|--------|-------------|
| new Router({ prefix? }) | Create router |
| use(fn\|[...fn]) / middleware(fn\|[...fn]) | Router-level middleware, supports array |
| get/head/post/put/delete/patch/all(path, ...h) | Register route |
| group(prefix, fn) | Route grouping |
| fallback(handler) | No-match handler |
| routes() | Return middleware function |
Request
| Alias | Export Source |
|-------|--------------|
| Request (class) = req | tyno |
| request (global facade) | tyno/facade |
| Method | Description |
|--------|-------------|
| query()/query(k)/get(k) | Query params |
| body()/post/param/input | Body accessors |
| files()/file(name) | File upload |
| header/cookies/getCookie | Request headers |
Response
| Alias | Export Source |
|-------|--------------|
| Response (class) = response = res | tyno |
| Static Factory | Content-Type | Status |
|---------------|-------------|--------|
| json(data, status?) | application/json | 200 |
| text(data, status?) | text/plain | 200 |
| empty(status?) | — | 204 |
| redirect(url, status?) | Location header | 302 |
| image(data, type?, status?) | image/* | 200 |
| Instance/Static Methods | Description |
|------------------------|-------------|
| set/setHeader/type/setStatus | Chainable modifiers |
| setCookie/clearCookie/attachment | Cookies + download |
| get(name) | Read response header |
| static header(name, value) | Preset header (facade) |
| static setCookie(name, value, options?) | Preset cookie (facade) |
Error Classes
| Class | Description |
|-------|-------------|
| AppError | Base class |
| HttpError(status, msg?, props?) | HTTP error |
| RuntimeError(msg, opts?) | Runtime error |
| NotFound/Forbidden/... | Shortcut subclasses |
Global Facades
| Export | Source | Description |
|--------|--------|-------------|
| req | tyno | Request class alias |
| res | tyno | Response class alias |
| request | tyno/facade | Read current request |
| Cache / cache | tyno/facade | Cache operations |
Project Structure
tyno/
├── src/
│ ├── index.ts # Main barrel
│ ├── application.ts # Application (EventEmitter)
│ ├── compose.ts # Onion model + error handling
│ ├── context.ts # AsyncLocalStorage
│ ├── types.ts # Type definitions
│ ├── response.ts # Response class
│ ├── response/sse.ts # SSEStream
│ ├── request/
│ │ ├── index.ts # Request class
│ │ ├── body-parser.ts # BodyParser
│ │ └── multipart-parser.ts # Streaming multipart
│ ├── router/
│ │ ├── index.ts # Router + group + fallback
│ │ ├── node.ts # TrieNode
│ │ └── parse-path.ts # Path parser
│ ├── errors/
│ │ ├── app-error.ts # AppError
│ │ ├── http-error.ts # HttpError
│ │ └── runtime-error.ts # RuntimeError
│ ├── middlewares/
│ │ ├── index.ts # Subpath barrel
│ │ ├── cors.ts # CORS
│ │ ├── compress.ts # Async compression
│ │ ├── static.ts # Static files + Range
│ │ ├── request-id.ts # Request ID
│ │ └── error-page.ts # Dev error page
│ ├── cache/
│ │ ├── index.ts / manager.ts / types.ts
│ │ └── drivers/ (memory / file / redis)
│ ├── facade/ # Facade subpath barrel
│ ├── request-global.ts # Global request facade
│ ├── cache-facade.ts # Global cache facade
│ └── mime.ts
├── example/app.ts
├── test/functional.test.ts
├── scripts/build.mjs # Dual-format build script
├── package.json
└── tsconfig.jsonLicense
MIT
