localhost
GET
Life Cycle allows us to intercept an important event at the predefined point allowing us to customize the behavior of our server as needed.
Elysia's Life Cycle event can be illustrated as the following.
Below are the request life cycle available in Elysia:
Imagine we want to return some HTML.
We need to set "Content-Type" headers as "text/html" for the browser to render HTML.
Explicitly specifying that the response is HTML could be repetitive if there are a lot of handlers, say ~200 endpoints.
This can lead to a lot of duplicated code just to specify the "text/html" "Content-Type"
But what if after we send a response, we could detect that the response is an HTML string then append the header automatically?
That's when the concept of Life Cycle comes into play.
We refer to each function that intercepts the life cycle event as "hook", as the function hooks into the lifecycle event.
Hooks can be categorized into 2 types:
TIP
The hook will accept the same Context as a handler, you can imagine adding a route handler but at a specific point.
The local hook is executed on a specific route.
To use a local hook, you can inline hook into a route handler:
import { Elysia } from 'elysia'
import { isHtml } from '@elysiajs/html'
new Elysia()
.get('/', () => '<h1>Hello World</h1>', {
afterHandle({ response, set }) {
if (isHtml(response))
set.headers['Content-Type'] = 'text/html; charset=utf8'
}
})
.get('/hi', () => '<h1>Hello World</h1>')
.listen(3000)
The response should be listed as follows:
Path | Content-Type |
---|---|
/ | text/html; charset=utf8 |
/hi | text/plain; charset=utf8 |
Register hook into every handler of the current instance that came after.
To add an interceptor hook, you can use .on
followed by a life cycle event in camelCase:
import { Elysia } from 'elysia'
import { isHtml } from '@elysiajs/html'
new Elysia()
.get('/none', () => '<h1>Hello World</h1>')
.onAfterHandle(({ response, set }) => {
if (isHtml(response))
set.headers['Content-Type'] = 'text/html; charset=utf8'
})
.get('/', () => '<h1>Hello World</h1>')
.get('/hi', () => '<h1>Hello World</h1>')
.listen(3000)
The response should be listed as follows:
Path | Content-Type |
---|---|
/ | text/html; charset=utf8 |
/hi | text/html; charset=utf8 |
/none | text/plain; charset=utf8 |
Events from other plugins are also applied to the route so the order of code is important.
TIP
The code above will only apply to the current instance, not applying to parent.
See scope to find out why
The order of Elysia's life-cycle code is very important.
Elysia's life-cycle event is stored as a queue, aka first-in first-out. So Elysia will always respect the order of code from top-to-bottom followed by the order of life-cycle events.
import { Elysia } from 'elysia'
new Elysia()
.onBeforeHandle(() => {
console.log('1')
})
.onAfterHandle(() => {
console.log('3')
})
.get('/', () => 'hi', {
beforeHandle() {
console.log('2')
}
})
.listen(3000)
Console should log the following:
1
2
3
The first life-cycle event to get executed for every new request is recieved.
As onRequest
is designed to provide only the most crucial context to reduce overhead, it is recommended to use in the following scenario:
Below is a pseudo code to enforce rate-limit on a certain IP address.
import { Elysia } from 'elysia'
new Elysia()
.use(rateLimiter)
.onRequest(({ rateLimiter, ip, set, error }) => {
if (rateLimiter.check(ip)) return error(420, 'Enhance your calm')
})
.get('/', () => 'hi')
.listen(3000)
If a value is returned from onRequest
, it will be used as the response and the rest of the life-cycle will be skipped.
Context's onRequest is typed as PreContext
, a minimal representation of Context
with the attribute on the following: request: Request
Set
Context doesn't provide derived
value because derive is based on onTransform
event.
Parse is an equivalent of body parser in Express.
A function to parse body, the return value will be append to Context.body
, if not, Elysia will continue iterating through additional parser functions assigned by onParse
until either body is assigned or all parsers have been executed.
By default, Elysia will parse the body with content-type of:
text/plain
application/json
multipart/form-data
application/x-www-form-urlencoded
It's recommended to use the onParse
event to provide a custom body parser that Elysia doesn't provide.
Below is an example code to retrieve value based on custom headers.
import { Elysia } from 'elysia'
new Elysia().onParse(({ request, contentType }) => {
if (contentType === 'application/custom-type') return request.text()
})
The returned value will be assigned to Context.body. If not, Elysia will continue iterating through additional parser functions from onParse stack until either body is assigned or all parsers have been executed.
onParse
Context is extends from Context
with additional properties of the following:
All of the context is based on normal context and can be used like normal context in route handler.
By default, Elysia will try to determine body parsing function ahead of time and pick the most suitable function to speed up the process.
Elysia is able to determine that body function by reading body
.
Take a look at this example:
import { Elysia, t } from 'elysia'
new Elysia().post('/', ({ body }) => body, {
body: t.Object({
username: t.String(),
password: t.String()
})
})
Elysia read the body schema and found that, the type is entirely an object, so it's likely that the body will be JSON. Elysia then picks the JSON body parser function ahead of time and tries to parse the body.
Here's a criteria that Elysia uses to pick up type of body parser
application/json
: body typed as t.Object
multipart/form-data
: body typed as t.Object
, and is 1 level deep with t.File
application/x-www-form-urlencoded
: body typed as t.URLEncoded
text/plain
: other primitive typeThis allows Elysia to optimize body parser ahead of time, and reduce overhead in compile time.
However, in some scenario if Elysia fails to pick the correct body parser function, we can explicitly tell Elysia to use a certain function by specifying type
import { Elysia } from 'elysia'
new Elysia().post('/', ({ body }) => body, {
// Short form of application/json
parse: 'json'
})
Allowing us to control Elysia behavior for picking body parser function to fit our needs in a complex scenario.
type
may be one of the following:
type ContentType = |
// Shorthand for 'text/plain'
| 'text'
// Shorthand for 'application/json'
| 'json'
// Shorthand for 'multipart/form-data'
| 'formdata'
// Shorthand for 'application/x-www-form-urlencoded'\
| 'urlencoded'
| 'text/plain'
| 'application/json'
| 'multipart/form-data'
| 'application/x-www-form-urlencoded'
You can provide register a custom parser with parser
:
import { Elysia } from 'elysia'
new Elysia()
.parser('custom', ({ request, contentType }) => {
if (contentType === 'application/elysia') return request.text()
})
.post('/', ({ body }) => body, {
parse: ['custom', 'json']
})
Executed just before Validation process, designed to mutate context to conform with the validation or appending new value.
It's recommended to use transform for the following:
derive
is based on onTransform
with support for providing type.Below is an example of using transform to mutate params to be numeric values.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/id/:id', ({ params: { id } }) => id, {
params: t.Object({
id: t.Number()
}),
transform({ params }) {
const id = +params.id
if (!Number.isNaN(id)) params.id = id
}
})
.listen(3000)
Append new value to context directly before validation. It's stored in the same stack as transform.
Unlike state and decorate that assigned value before the server started. derive assigns a property when each request happens. Allowing us to extract a piece of information into a property instead.
import { Elysia } from 'elysia'
new Elysia()
.derive(({ headers }) => {
const auth = headers['Authorization']
return {
bearer: auth?.startsWith('Bearer ') ? auth.slice(7) : null
}
})
.get('/', ({ bearer }) => bearer)
Because derive is assigned once a new request starts, derive can access Request properties like headers, query, body where store, and decorate can't.
Unlike state, and decorate. Properties which assigned by derive is unique and not shared with another request.
derive
and transform
is stored in the same queue.
import { Elysia } from 'elysia'
new Elysia()
.onTransform(() => {
console.log(1)
})
.derive(() => {
console.log(2)
return {}
})
The console should log as the following:
1
2
Execute after validation and before the main route handler.
Designed to provide a custom validation to provide a specific requirement before running the main handler.
If a value is returned, the route handler will be skipped.
It's recommended to use Before Handle in the following situations:
Below is an example of using the before handle to check for user sign-in.
import { Elysia } from 'elysia'
import { validateSession } from './user'
new Elysia()
.get('/', () => 'hi', {
beforeHandle({ set, cookie: { session }, error }) {
if (!validateSession(session.value)) return error(401)
}
})
.listen(3000)
The response should be listed as follows:
Is signed in | Response |
---|---|
❌ | Unauthorized |
✅ | Hi |
When we need to apply the same before handle to multiple routes, we can use guard
to apply the same before handle to multiple routes.
import { Elysia } from 'elysia'
import { signUp, signIn, validateSession, isUserExists } from './user'
new Elysia()
.guard(
{
beforeHandle({ set, cookie: { session }, error }) {
if (!validateSession(session.value)) return error(401)
}
},
(app) =>
app
.get('/user/:id', ({ body }) => signUp(body))
.post('/profile', ({ body }) => signIn(body), {
beforeHandle: isUserExists
})
)
.get('/', () => 'hi')
.listen(3000)
Append new value to context after validation. It's stored in the same stack as beforeHandle.
Resolve syntax is identical to derive, below is an example of retrieving a bearer header from the Authorization plugin.
import { Elysia, t } from 'elysia'
new Elysia()
.guard(
{
headers: t.Object({
authorization: t.TemplateLiteral('Bearer ${string}')
})
},
(app) =>
app
.resolve(({ headers: { authorization } }) => {
return {
bearer: authorization.split(' ')[1]
}
})
.get('/', ({ bearer }) => bearer)
)
.listen(3000)
Using resolve
and onBeforeHandle
is stored in the same queue.
import { Elysia } from 'elysia'
new Elysia()
.onBeforeHandle(() => {
console.log(1)
})
.resolve(() => {
console.log(2)
return {}
})
.onBeforeHandle(() => {
console.log(3)
})
The console should log as the following:
1
2
3
Same as derive, properties which assigned by resolve is unique and not shared with another request.
As resolve is not available in local hook, it's recommended to use guard to encapsulate the resolve event.
import { Elysia } from 'elysia'
import { isSignIn, findUserById } from './user'
new Elysia()
.guard(
{
beforeHandle: isSignIn
},
(app) =>
app
.resolve(({ cookie: { session } }) => ({
userId: findUserById(session.value)
}))
.get('/profile', ({ userId }) => userId)
)
.listen(3000)
Execute after the main handler, for mapping a returned value of before handle and route handler into a proper response.
It's recommended to use After Handle in the following situations:
Below is an example of using the after handle to add HTML content type to response headers.
import { Elysia } from 'elysia'
import { isHtml } from '@elysiajs/html'
new Elysia()
.get('/', () => '<h1>Hello World</h1>', {
afterHandle({ response, set }) {
if (isHtml(response))
set.headers['content-type'] = 'text/html; charset=utf8'
}
})
.get('/hi', () => '<h1>Hello World</h1>')
.listen(3000)
The response should be listed as follows:
Path | Content-Type |
---|---|
/ | text/html; charset=utf8 |
/hi | text/plain; charset=utf8 |
If a value is returned After Handle will use a return value as a new response value unless the value is undefined
The above example could be rewritten as the following:
import { Elysia } from 'elysia'
import { isHtml } from '@elysiajs/html'
new Elysia()
.get('/', () => '<h1>Hello World</h1>', {
afterHandle({ response, set }) {
if (isHtml(response)) {
set.headers['content-type'] = 'text/html; charset=utf8'
return new Response(response)
}
}
})
.get('/hi', () => '<h1>Hello World</h1>')
.listen(3000)
Unlike beforeHandle, after a value is returned from afterHandle, the iteration of afterHandle will NOT be skipped.
onAfterHandle
context extends from Context
with the additional property of response
, which is the response to return to the client.
The onAfterHandle
context is based on the normal context and can be used like the normal context in route handlers.
Executed just after "afterHandle", designed to provide custom response mapping.
It's recommended to use transform for the following:
Below is an example of using mapResponse to provide Response compression.
import { Elysia } from 'elysia'
const encoder = new TextEncoder()
new Elysia()
.mapResponse(({ response, set }) => {
const isJson = typeof response === 'object'
const text = isJson
? JSON.stringify(response)
: (response?.toString() ?? '')
set.headers['Content-Encoding'] = 'gzip'
return new Response(Bun.gzipSync(encoder.encode(text)), {
headers: {
'Content-Type': `${
isJson ? 'application/json' : 'text/plain'
}; charset=utf-8`
}
})
})
.get('/text', () => 'mapResponse')
.get('/json', () => ({ map: 'response' }))
.listen(3000)
Like parse and beforeHandle, after a value is returned, the next iteration of mapResponse will be skipped.
Elysia will handle the merging process of set.headers from mapResponse automatically. We don't need to worry about appending set.headers to Response manually.
Designed for error-handling. It will be executed when an error is thrown in any life-cycle.
Its recommended to use on Error in the following situation:
Elysia catches all the errors thrown in the handler, classifies the error code, and pipes them to onError
middleware.
import { Elysia } from 'elysia'
new Elysia()
.onError(({ code, error }) => {
return new Response(error.toString())
})
.get('/', () => {
throw new Error('Server is during maintenance')
return 'unreachable'
})
With onError
we can catch and transform the error into a custom error message.
TIP
It's important that onError
must be called before the handler we want to apply it to.
For example, returning custom 404 messages:
import { Elysia, NotFoundError } from 'elysia'
new Elysia()
.onError(({ code, error, set }) => {
if (code === 'NOT_FOUND') return error(404, 'Not Found :(')
})
.post('/', () => {
throw new NotFoundError()
})
.listen(3000)
onError
Context is extends from Context
with additional properties of the following:
Elysia error code consists of:
By default, the thrown error code is UNKNOWN
.
TIP
If no error response is returned, the error will be returned using error.name
.
Elysia.error
is a shorthand for returning an error with a specific HTTP status code.
It could either be return or throw based on your specific needs.
error
is throw, it will be caught by onError
middleware.error
is return, it will be NOT caught by onError
middleware.See the following code:
import { Elysia, file } from 'elysia'
new Elysia()
.onError(({ code, error, path }) => {
if (code === 418) return 'caught'
})
.get('/throw', ({ error }) => {
// This will be caught by onError
throw error(418)
})
.get('/return', () => {
// This will NOT be caught by onError
return error(418)
})
GET
Elysia supports custom error both in the type-level and implementation level.
To provide a custom error code, we can use Elysia.error
to add a custom error code, helping us to easily classify and narrow down the error type for full type safety with auto-complete as the following:
import { Elysia } from 'elysia'
class MyError extends Error {
constructor(public message: string) {
super(message)
}
}
new Elysia()
.error({
MyError
})
.onError(({ code, error }) => {
switch (code) {
// With auto-completion
case 'MyError':
// With type narrowing
// Hover to see error is typed as `CustomError`
return error
}
})
.get('/', () => {
throw new MyError('Hello Error')
})
Properties of error
code is based on the properties of error
, the said properties will be used to classify the error code.
Same as others life-cycle, we provide an error into an scope using guard:
import { Elysia } from 'elysia'
new Elysia()
.get('/', () => 'Hello', {
beforeHandle({ set, request: { headers }, error }) {
if (!isSignIn(headers)) throw error(401)
},
error({ error }) {
return 'Handled'
}
})
.listen(3000)
Executed after the response sent to the client.
It's recommended to use After Response in the following situations:
Below is an example of using the response handle to check for user sign-in.
import { Elysia } from 'elysia'
new Elysia()
.onAfterResponse(() => {
console.log('Response', performance.now())
})
.listen(3000)
Console should log as the following:
Response 0.0000
Response 0.0001
Response 0.0002
Similar to Map Response, afterResponse
also accept a response
value.
import { Elysia } from 'elysia'
new Elysia()
.onAfterResponse(({ response }) => {
console.log(response)
})
.get('/', () => 'Hello')
.listen(3000)
response
from onAfterResponse
, is not a Web-Standard's Response
but is a value that is returned from the handler.
To get a headers, and status returned from the handler, we can access set
from the context.
import { Elysia } from 'elysia'
new Elysia()
.onAfterResponse(({ set }) => {
console.log(set.status, set.headers)
})
.get('/', () => 'Hello')
.listen(3000)