Architecture
Request lifecycle
Oriel builds your app once, when its process starts, and answers every request from it, until the process ends and the app shuts down.
Introduction#
You can build an app without knowing how Oriel works inside. Knowing the order in which it does things tells you where your own code goes: what a service provider can rely on, when your routes exist, and what lasts longer than one request.
Lifecycle overview#
First steps#
Your app has two entry points. public/ starts the web server, as node --import tsx public/ or
bun public/, and node oriel serve starts the same server while you work. The oriel file runs a command,
as node oriel migrate. Both get the application from createApplication() in bootstrap/:
import { serve } from '@orieljs/framework'import { createApplication } from '../bootstrap/app'// Production entry: `node --import tsx public/index.ts`, or `bun public/index.ts`.await serve(await createApplication())Application.configure() creates the application, which is also the service container. The methods
after it record what you give them, and create() puts the app together:
import { dirname } from 'node:path'import { Application } from '@orieljs/framework'import providers from './providers'/** Builds the application. Each call returns a fresh app (tests need one per test). */export function createApplication(): Promise<Application> { return Application.configure({ basePath: dirname(import.meta.dirname) }) .withRouting({ web: 'routes/web.ts', console: 'routes/console.ts', health: '/up' }) .withProviders(providers) .create()}create() loads your .env file and every file in config/ (Configuration covers both), then
registers the service providers. Each call makes a new application, so your tests can have a fresh one for every test.
HTTP / console kernels#
A request goes to the HTTP kernel, and a command to the console kernel.
serve() answers a GET or HEAD request for a file in public/ itself, and hands every other request to
app.handle(), which passes it to the HTTP kernel and returns the response. The kernel takes a web-standard Request
and returns a Response: think of it as a black box that stands for your whole app. It wraps the request in Oriel's
request object, applies the trusted proxies and the body limits of config/ (see
Requests), reads the body, and sends the request through the global middleware to the
router.
node oriel gives its arguments to the console kernel. The command runs, the app terminates as it does after a
request, then it shuts down, and the process exits with the command's exit code.
Service providers#
Service providers set up every part of the framework: the database, the queue, validation, routing and the rest.
create() calls each provider's register(), in order. The framework has 22, registered first, in this order:
Pipeline, Encryption, Cookie, Session, Hashing, Log, Event, Cache, Filesystem, Database, Queue, Bus, Mail,
Notifications, Validation, Auth, View, Inertia, Live, Routing, Http and Console (each one's class is its name followed
by ServiceProvider). Each may rely on the ones before it. Yours come next, in the order bootstrap/
lists them.
create() registers the providers but doesn't boot them. The app boots once, the first time something needs it:
serve() before it starts listening, node oriel before it runs its command, your tests before each test, or the
first request the app handles. Booting runs, in this order:
- the
booting()callbacks; - each provider's
boot(), in the order they were registered. A provider registered while the app boots is booted in the same pass, and one registered after it has booted is booted at once; - the
booted()callbacks, in the order they were added: those you give the builder first, then the framework's, which add the health route, load the route files ofwithRouting()(thewebfiles in thewebgroup, then theapifiles in theapigroup), apply yourwithMiddleware()callback and, in the console only, find your commands; last, those your providers add inboot().
Your route files run once every provider has booted, so a route pattern, a binding or a gate that a provider sets up
in boot() is ready for them. The builder takes callbacks of its own:
Application.configure({ basePath: '.' }) .booting((app) => { // Before the first provider boots... }) .booted((app) => { // Once every provider has booted, before your routes load... })Routing#
app.handle() opens a request scope, and the kernel binds the request in it: everything resolved for this request,
such as the request, its session and its cookie jar, lives in the scope and is dropped with it. The global middleware
run first: TrimStrings, ConvertEmptyStringsToNull, and those you add with
withMiddleware().
The router then finds the route. A path no route matches is a 404, and a path whose routes don't take the request's
method is a 405 with an Allow header. The router records the route as the current one, then runs the route's
middleware: those of its groups, outer group first, then its own. The routes in routes/ are in the web group,
which gives them their cookies, their session and CSRF protection:
The web group | What it does |
|---|---|
EncryptCookies | Decrypts the request's cookies and encrypts the response's |
AddQueuedCookiesToResponse | Adds the cookies queued on the cookie jar to the response |
StartSession | Loads the session, and saves it once the response is ready |
VerifyCsrfToken | Refuses a form without the CSRF token |
The web and api groups' middleware, auth and guest run before the route's other middleware, then route model
binding replaces the parameters that name a model, then the rest run (Sorting
middleware has the rule). Last, the action runs: a closure is called with the
route's parameters, in URI order, and a controller is built by the container for this request. Whatever the action
returns becomes the response, as Responses describes.
Finishing up#
The response goes back out through the route's middleware, then the global middleware, each of which can examine or
change it. An error thrown at any stage becomes a response at that stage, rendered by the exception handler (see
Error handling), so a middleware further out always receives a response. The response to a HEAD
request loses its body here.
The server writes the response, and then the app terminates the request, still inside its scope:
terminate()runs on each terminable middleware, the global ones first, then the route's;- the
terminating()callbacks run, in the order they were added. One of them is the framework's: it runs the jobs you dispatched withdispatchAfterResponse().
On Node, this starts once the response is written; on Bun, right after the response is handed to the server.
Register a terminating callback in a service provider. It receives no arguments:
import { ServiceProvider } from '@orieljs/framework'import { Metrics } from '../Services/Metrics'export class AppServiceProvider extends ServiceProvider { override register(): void { this.app.singleton(Metrics) } override boot(): void { this.app.terminating(async () => { await this.app.make(Metrics).flush() }) }}Shutting down#
A Node or Bun process keeps its database pools, its Redis clients and its log files open from one request to the next.
When the process ends, app.shutdown() closes them: it runs the callbacks registered with app.onShutdown(), once.
- The server calls it when it receives
SIGINTorSIGTERM. It stops taking new connections, waits for the requests in flight (closing the connections that are still open after 10 seconds), shuts the app down, and exits with 0. node orielcalls it after its command, also when the command throws.- A script that makes the app itself must call it before it exits, or the last log entries are lost (see Flushing the log).
The callbacks run newest first. Yours were registered after the framework's, so they run before the framework closes what they might use. Then the framework closes the queue's Redis connections, the database pools, the cache's Redis clients, the log files (writing out what is waiting) and the session's Redis connection, each only if your app opened it:
import { ServiceProvider } from '@orieljs/framework'import { Metrics } from '../Services/Metrics'export class AppServiceProvider extends ServiceProvider { override register(): void { this.app.singleton(Metrics) this.app.onShutdown(async () => { if (this.app.resolved(Metrics)) await this.app.make(Metrics).close() }) }}A callback that throws is printed with console.error, and the others still run. If the callbacks haven't finished
after 10 seconds, the rest are given up on, with "The shutdown callbacks did not finish within 10 seconds; carrying on
with the exit.", so the process can still exit. shutdown() never rejects, and calling it again returns the same
promise.
terminating() | onShutdown() | |
|---|---|---|
| Runs | After each response, and after each node oriel command | Once, when the process ends |
| Inside | The request's scope, after a request | No request |
| Order | The order they were added | Newest first |
| For | Work that waits until the response is sent | Closing pools, clients and files |
Focus on service providers#
Service providers are the key to how an Oriel app starts: the application is created, the providers are registered and
booted, and the requests are handed to the booted app. Your own providers live in app/. A new app's
AppServiceProvider is empty: it is the place for your app's bindings and its setup at boot. A larger app can split
them over several providers, each listed in bootstrap/. Service providers explains how
to write them.