@plinthjs/http
v0.1.0
Published
Mason HTTP layer: router, request/response, middleware, kernel over node:http (the Illuminate\Routing/Http equivalent).
Maintainers
Readme
@plinthjs/http
Mason's HTTP layer, the Illuminate\Http + Illuminate\Routing equivalent. Request and
Response value objects, an async middleware pipeline, a Router (parameters, constraints, groups,
named routes, resource routes, model binding, signed URLs, fallback), an HTTP exception hierarchy
with its ExceptionHandler, the HttpKernel that drives the request lifecycle over the
@plinthjs/core container, and a server adapter over Node's built-in node:http.
Install
npm install @plinthjs/httpUsage
import { gracefulShutdown, HttpKernel, Response, Router, serve } from '@plinthjs/http'
const router = new Router()
router.get('/', () => 'Hello Mason') // strings and objects are coerced to responses
router.get('/users/{id}', (req) => Response.json({ id: req.param('id') })).whereNumber('id')
router.get('/posts/{page?}', (req) => Response.json({ page: req.param('page', '1') }))
router.group({ prefix: '/api', middleware: 'auth' }, (r) => {
r.post('/posts', async (req) => Response.json(req.only(['title', 'body']), 201))
})
router.fallback(() => Response.json({ message: 'Not Found' }, 404))
const kernel = new HttpKernel(router)
const server = await serve(kernel, 3000) // rejects on listen errors such as EADDRINUSE
gracefulShutdown(server, { timeout: 10_000, onShutdown: () => db.close() })createServer(kernel, { maxBodyBytes }) returns the bare node:http server when you want to
listen yourself; oversized bodies are refused with a 413.
Middleware
A middleware is (req, next) => Promise<Response>. Register global middleware on the kernel and
route middleware on routes or groups, by value or by alias / group name:
import { cors, securityHeaders, throttle, type Middleware } from '@plinthjs/http'
const timing: Middleware = async (req, next) => {
const started = Date.now()
const res = await next(req)
return res.header('X-Response-Time', `${Date.now() - started}ms`)
}
router.aliasMiddleware('auth', requireUser)
router.middlewareGroup('api', [timing, 'auth'])
router.get('/me', showProfile).middleware('api')
const kernel = new HttpKernel(router, {
middleware: [securityHeaders({ hsts: true }), cors({ origin: ['https://app.example.com'] })],
})
// Rate limiting, with any limiter exposing hit / tooManyAttempts / availableIn.
router.post('/login', login).middleware(throttle({ limiter, max: 5, decaySeconds: 60 }))A TerminableMiddleware (an object with handle and terminate(req, res)) also gets a callback
after the response is produced.
Requests
req.method() // 'POST'
req.path() // '/api/posts'
req.query('sort', 'desc')
req.input<string>('title')
req.boolean('published') // also integer(), float(), string(), enum()
req.has('tags') && req.filled('title')
req.header('accept')
req.bearerToken()
req.cookie('mason_session')
req.file('avatar') // UploadedFile (multipart bodies are parsed)
req.wantsJson()
req.withAttribute('user', user).attribute('user') // immutable, returns a new RequestBuild a request directly for tests: new Request({ method: 'GET', url: '/users/1', headers: {} }).
Responses
Response.json({ ok: true }, 201)
Response.text('pong')
Response.html('<h1>Hi</h1>')
Response.noContent()
Response.redirectTo('/dashboard').with('status', 'Saved!') // flash data
Response.back('/')
.withErrors({ email: ['Taken'] })
.withInput(req.all())
Response.file('/srv/report.pdf')
Response.download('/srv/report.pdf', 'report.pdf')
Response.streamJson(rowsAsyncIterable)
Response.make('ok').cookie('seen', '1', { httpOnly: true }).withHeaders({ 'X-App': 'mason' })
Response.macro('created', () => Response.json({ created: true }, 201))
Response.callMacro('created')Controllers and binding
import { Container } from '@plinthjs/core'
class UserController {
show(req: Request) {
return Response.json(req.route('user'))
}
}
router.bind('user', (id) => users.find(id)) // a null result renders a 404
router.get('/users/{user}', [UserController, 'show']).name('users.show')
router.resource('photos', PhotoController).only(['index', 'show'])
router.url('users.show', { user: 1 }) // '/users/1'
const kernel = new HttpKernel(router, { container: new Container() })A bare class (or [Class]) dispatches to its __invoke method.
Signed URLs
const router = new Router({ key: appKey }) // or router.setSigningKey(appKey)
const url = router.temporarySignedUrl('unsubscribe', 3600, { user: 1 }) // expires in an hour
router.validateSignature(url) // 'valid' | 'missing' | 'invalid' | 'expired'
router
.get('/unsubscribe/{user}', unsubscribe)
.name('unsubscribe')
.middleware(router.signedMiddleware())Errors
Throw HttpException(status, message, headers), NotFoundHttpException or
MethodNotAllowedHttpException anywhere in the pipeline; the kernel reports and renders them through
its ExceptionHandler:
import { ExceptionHandler, HttpException } from '@plinthjs/http'
const handler = new ExceptionHandler(/* debug */ false)
.reportUsing((error, context) => logger.error(error, context))
.dontReport(HttpException)
.renderable((error) => (error instanceof PaymentError ? Response.json({}, 402) : undefined))
new HttpKernel(router, { exceptionHandler: handler })