Core Concepts
Hooks
Phase-based request lifecycle callbacks. Hooks run at fixed points around every request — before routing, and after the response is flushed — separate from the (req, res, next) middleware chain.
Overview
MoroJS has two distinct extension systems. Middleware is the standard (req, res, next) chain — it runs in registration order and can end the response. Hooks are lifecycle callbacks the server invokes at fixed phases; they receive a context object and cannot short-circuit the chain with next(). Hooks are how work runs after the response has been sent — session persistence, cache invalidation, audit logging — off the request's critical path.
1import { createApp } from '@morojs/moro';
2
3const app = await createApp();
4
5// app.hooks is the live hook manager
6app.hooks.before('request', ctx => {
7 ctx.request.startedAt = Date.now();
8});
9
10app.hooks.after('response', ctx => {
11 // Runs after the response is flushed — the client is not waiting
12 recordMetrics(ctx.request, ctx.response);
13});
14
15app.get('/hello', () => ({ ok: true }));
16app.listen(3000);Request Lifecycle
Three events fire on every runtime — the long-running servers (Node, native engine, uWebSockets, HTTP/2) and the serverless handler path (Lambda, Vercel Edge, Cloudflare Workers) alike. The HOOK_EVENTS export names them: 'request', 'response', and 'error'.
1Request arrives
2 │
3 ├─ 'request' hooks ← before(), then after() registrations
4 │ body parsed, runs BEFORE global middleware and routing.
5 │ Ending the response here (e.g. CORS preflight) stops the chain.
6 │
7 ├─ global middleware chain (req, res, next)
8 ├─ route handler response is sent
9 │ └─ on throw: 'error' hooks observe { request, response, error }
10 │
11 └─ 'response' hooks ← fire after the response is FLUSHED
12 session persistence, cache invalidation, audit trails.
13 Errors are logged, never sent to the client (it already
14 has its response).One serverless difference: on Lambda/Edge/Workers, 'response' hooks are awaited before the handler returns — work scheduled after the return would be frozen by the platform. On long-running servers they run after the flush, off the request's critical path.
Each hook receives a HookContext: { request, response }. In 'response' hooks the response is already complete — mutating it does nothing; read response.statusCode, request state, and act on your own systems.
Registering Hooks
1// before() runs ahead of after() within the same event
2app.hooks.before('request', async ctx => {
3 ctx.request.traceId = crypto.randomUUID();
4});
5
6app.hooks.after('request', ctx => {
7 // still pre-routing, but after all before('request') hooks
8});
9
10app.hooks.after('response', async ctx => {
11 await auditLog.write({
12 path: ctx.request.path,
13 status: ctx.response.statusCode,
14 });
15});
16
17// Introspection
18app.hooks.hasHooks('response'); // true
19app.hooks.getHooks(); // registered hooks by event
20
21// Removal
22const fn = ctx => {};
23app.hooks.before('request', fn);
24app.hooks.removeHook('request', fn);Hooks are zero-cost when unused: a request only enters the hook path at all when hooks are registered for that phase, and synchronous-only hook chains execute without promise overhead.
Error Observation
'error' hooks observe request failures without owning the error response. Unlike app.setErrorHandler() — a single slot that shapes what the client receives — any number of error hooks can subscribe, and they are fire-and-forget: a slow or throwing observer can never delay or alter the response. This is the integration point for error reporting and audit trails.
1// Multiple observers coexist — neither owns the response
2app.hooks.after('error', ctx => {
3 errorReporter.capture(ctx.error, {
4 path: ctx.request.path,
5 method: ctx.request.method,
6 });
7});
8
9app.hooks.after('error', ctx => {
10 auditLog.write({ kind: 'request-error', error: String(ctx.error) });
11});
12
13// setErrorHandler still shapes the client-facing response
14app.setErrorHandler((err, req, res) => {
15 res.status(500).json({ error: 'Something went wrong' });
16});Hooks in Middleware Packages
Reusable middleware packages register hooks through the MiddlewareInterface pattern — an object with an install(hooks) method that receives the same hook manager as app.hooks. Several built-ins (session, auth, CDN) work exactly this way: attach state on 'request', persist it on 'response'.
1app.use({
2 name: 'request-timing',
3 version: '1.0.0',
4 metadata: { name: 'request-timing', version: '1.0.0' },
5 install(hooks) {
6 hooks.before('request', ctx => {
7 ctx.request.startedAt = Date.now();
8 });
9 hooks.after('response', ctx => {
10 const ms = Date.now() - ctx.request.startedAt;
11 metrics.timing('http.request', ms, {
12 status: ctx.response.statusCode,
13 });
14 });
15 },
16});Hooks vs Middleware
Reach for middleware by default; reach for hooks when the work belongs to a lifecycle phase rather than a position in the chain.
1Use MIDDLEWARE when:
2 - the logic guards or transforms the request/response
3 (auth checks, body handling, compression, per-route logic)
4 - you need to end the response and stop the chain
5 - ordering relative to other middleware matters
6
7Use HOOKS when:
8 - work must run AFTER the response is flushed
9 (persist sessions, invalidate caches, audit, metrics)
10 - a package needs guaranteed pre-routing setup on every
11 request regardless of middleware ordering
12 - you want zero per-request cost when nothing is registered