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.

Quick Start
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'.

Lifecycle Phases
1Request arrives
23  ├─ '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.
67  ├─ global middleware chain   (req, res, next)
8  ├─ route handler             response is sent
9  │      └─ on throw: 'error' hooks observe { request, response, error }
1011  └─ '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

app.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.

Error Reporting
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'.

Hook-based Middleware
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.

Choosing
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

Next Steps