Skip to the content
OrielDocs
0.1.0-alpha.10GitHub

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/index.ts starts the web server, as node --import tsx public/index.ts or bun public/index.ts, 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/app.ts:

public/index.ts
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:

bootstrap/app.ts
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/http.ts (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/providers.ts 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:

  1. the booting() callbacks;
  2. 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;
  3. 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 of withRouting() (the web files in the web group, then the api files in the api group), apply your withMiddleware() callback and, in the console only, find your commands; last, those your providers add in boot().

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/web.ts are in the web group, which gives them their cookies, their session and CSRF protection:

The web groupWhat it does
EncryptCookiesDecrypts the request's cookies and encrypts the response's
AddQueuedCookiesToResponseAdds the cookies queued on the cookie jar to the response
StartSessionLoads the session, and saves it once the response is ready
VerifyCsrfTokenRefuses 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:

  1. terminate() runs on each terminable middleware, the global ones first, then the route's;
  2. the terminating() callbacks run, in the order they were added. One of them is the framework's: it runs the jobs you dispatched with dispatchAfterResponse().

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:

app/Providers/AppServiceProvider.ts
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 SIGINT or SIGTERM. 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 oriel calls 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:

app/Providers/AppServiceProvider.ts
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()
RunsAfter each response, and after each node oriel commandOnce, when the process ends
InsideThe request's scope, after a requestNo request
OrderThe order they were addedNewest first
ForWork that waits until the response is sentClosing 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/Providers. 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/providers.ts. Service providers explains how to write them.

Privacy policy ·

esc