localhost
GET
Plugin is a pattern that decouples functionality into smaller parts. Creating reusable components for our web server.
To create a plugin is to create a separate instance.
import { Elysia } from 'elysia'
const plugin = new Elysia()
.decorate('plugin', 'hi')
.get('/plugin', ({ plugin }) => plugin)
const app = new Elysia()
.use(plugin)
.get('/', ({ plugin }) => plugin)
.listen(3000)
We can use the plugin by passing an instance to Elysia.use.
GET
The plugin will inherit all properties of the plugin instance like state
, decorate
but WILL NOT inherit plugin lifecycle as it's isolated by default.
Elysia will also handle the type inference automatically as well.
Every Elysia instance can be a plugin.
We decouple our logic into a separate Elysia instance and reuse it across multiple instances.
To create a plugin, simply define an instance in a separate file:
// plugin.ts
import { Elysia } from 'elysia'
export const plugin = new Elysia()
.get('/plugin', () => 'hi')
And then we import the instance into the main file:
import { Elysia } from 'elysia'
import { plugin } from './plugin'
const app = new Elysia()
.use(plugin)
.listen(3000)
Elysia lifecycle methods are encapsulated to its own instance only.
Which means if you create a new instance, it will not share the lifecycle methods with others.
import { Elysia } from 'elysia'
const profile = new Elysia()
.onBeforeHandle(({ cookie }) => {
throwIfNotSignIn(cookie)
})
.get('/profile', () => 'Hi there!')
const app = new Elysia()
.use(profile)
// ⚠️ This will NOT have sign in check
.patch('/rename', ({ body }) => updateProfile(body))
In this example, the isSignIn
check will only apply to profile
but not app
.
GET
Try changing the path in the URL bar to /rename and see the result
Elysia isolate lifecycle by default unless explicitly stated. This is similar to export in JavaScript, where you need to export the function to make it available outside the module.
To "export" the lifecycle to other instances, you must add specify the scope.
import { Elysia } from 'elysia'
const profile = new Elysia()
.onBeforeHandle(
{ as: 'global' },
({ cookie }) => {
throwIfNotSignIn(cookie)
}
)
.get('/profile', () => 'Hi there!')
const app = new Elysia()
.use(profile)
// This has sign in check
.patch('/rename', ({ body }) => updateProfile(body))
GET
Casting lifecycle to "global" will export lifecycle to every instance.
Elysia has 3 levels of scope as the following:
Scope type are as the following:
Let's review what each scope type does by using the following example:
import { Elysia } from 'elysia'
const child = new Elysia()
.get('/child', 'hi')
const current = new Elysia()
// ? Value based on table value provided below
.onBeforeHandle({ as: 'local' }, () => {
console.log('hi')
})
.use(child)
.get('/current', 'hi')
const parent = new Elysia()
.use(current)
.get('/parent', 'hi')
const main = new Elysia()
.use(parent)
.get('/main', 'hi')
By changing the type
value, the result should be as follows:
type | child | current | parent | main |
---|---|---|---|---|
local | ✅ | ✅ | ❌ | ❌ |
scoped | ✅ | ✅ | ✅ | ❌ |
global | ✅ | ✅ | ✅ | ✅ |
By default plugin will apply hook to itself and descendants only.
If the hook is registered in a plugin, instances that inherit the plugin will NOT inherit hooks and schema.
import { Elysia } from 'elysia'
const plugin = new Elysia()
.onBeforeHandle(() => {
console.log('hi')
})
.get('/child', 'log hi')
const main = new Elysia()
.use(plugin)
.get('/parent', 'not log hi')
To apply hook to globally, we need to specify hook as global.
import { Elysia } from 'elysia'
const plugin = new Elysia()
.onBeforeHandle(() => {
return 'hi'
})
.get('/child', 'child')
.as('scoped')
const main = new Elysia()
.use(plugin)
.get('/parent', 'parent')
GET
To make the plugin more useful, allowing customization via config is recommended.
You can create a function that accepts parameters that may change the behavior of the plugin to make it more reusable.
import { Elysia } from 'elysia'
const version = (version = 1) => new Elysia()
.get('/version', version)
const app = new Elysia()
.use(version(1))
.listen(3000)
It's recommended to define a new plugin instance instead of using a function callback.
Functional callback allows us to access the existing property of the main instance. For example, checking if specific routes or stores existed but harder to handle encapsulation and scope correctly.
To define a functional callback, create a function that accepts Elysia as a parameter.
import { Elysia } from 'elysia'
const plugin = (app: Elysia) => app
.state('counter', 0)
.get('/plugin', () => 'Hi')
const app = new Elysia()
.use(plugin)
.get('/counter', ({ store: { counter } }) => counter)
.listen(3000)
GET
Once passed to Elysia.use
, functional callback behaves as a normal plugin except the property is assigned directly to the main instance.
TIP
You shall not worry about the performance difference between a functional callback and creating an instance.
Elysia can create 10k instances in a matter of milliseconds, the new Elysia instance has even better type inference performance than the functional callback.
By default, Elysia will register any plugin and handle type definitions.
Some plugins may be used multiple times to provide type inference, resulting in duplication of setting initial values or routes.
Elysia avoids this by differentiating the instance by using name and optional seeds to help Elysia identify instance duplication:
import { Elysia } from 'elysia'
const plugin = <T extends string>(config: { prefix: T }) =>
new Elysia({
name: 'my-plugin',
seed: config,
})
.get(`${config.prefix}/hi`, () => 'Hi')
const app = new Elysia()
.use(
plugin({
prefix: '/v2'
})
)
.listen(3000)
GET
Elysia will use name and seed to create a checksum to identify if the instance has been registered previously or not, if so, Elysia will skip the registration of the plugin.
If seed is not provided, Elysia will only use name to differentiate the instance. This means that the plugin is only registered once even if you registered it multiple times.
import { Elysia } from 'elysia'
const plugin = new Elysia({ name: 'plugin' })
const app = new Elysia()
.use(plugin)
.use(plugin)
.use(plugin)
.use(plugin)
.listen(3000)
This allows Elysia to improve performance by reusing the registered plugins instead of processing the plugin over and over again.
TIP
Seed could be anything, varying from a string to a complex object or class.
If the provided value is class, Elysia will then try to use the .toString
method to generate a checksum.
When you apply a plugin with state/decorators to an instance, the instance will gain type safety.
But if you don't apply the plugin to another instance, it will not be able to infer the type.
import { Elysia } from 'elysia'
const child = new Elysia()
// ❌ 'a' is missing
.get('/', ({ a }) => a)Property 'a' does not exist on type '{ body: unknown; query: Record<string, string>; params: {}; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<unknown>>; server: Server | null; ... 6 more ...; status: <const Code extends number | keyof StatusMap, const T = Code extends 100 | ... 59 more ... | 511 ? { ...; }[Code] : Code>(co...'.
const main = new Elysia()
.decorate('a', 'a')
.use(child)
Elysia introduces the Service Locator pattern to counteract this.
Elysia will lookup the plugin checksum and get the value or register a new one. Infer the type from the plugin.
So we have to provide the plugin reference for Elysia to find the service to add type safety.
import { Elysia } from 'elysia'
const setup = new Elysia({ name: 'setup' })
.decorate('a', 'a')
// Without 'setup', type will be missing
const error = new Elysia()
.get('/', ({ a }) => a)Property 'a' does not exist on type '{ body: unknown; query: Record<string, string>; params: {}; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<unknown>>; server: Server | null; ... 6 more ...; status: <const Code extends number | keyof StatusMap, const T = Code extends 100 | ... 59 more ... | 511 ? { ...; }[Code] : Code>(co...'.
// With `setup`, type will be inferred
const child = new Elysia()
.use(setup)
.get('/', ({ a }) => a)
const main = new Elysia()
.use(child)
GET
Guard allows us to apply hook and schema into multiple routes all at once.
import { Elysia, t } from 'elysia'
new Elysia()
.guard(
{
body: t.Object({
username: t.String(),
password: t.String()
})
},
(app) =>
app
.post('/sign-up', ({ body }) => signUp(body))
.post('/sign-in', ({ body }) => signIn(body), {
beforeHandle: isUserExists
})
)
.get('/', 'hi')
.listen(3000)
This code applies validation for body
to both '/sign-in' and '/sign-up' instead of inlining the schema one by one but applies not to '/'.
We can summarize the route validation as the following:
Path | Has validation |
---|---|
/sign-up | ✅ |
/sign-in | ✅ |
/ | ❌ |
Guard accepts the same parameter as inline hook, the only difference is that you can apply hook to multiple routes in the scope.
This means that the code above is translated into:
import { Elysia, t } from 'elysia'
new Elysia()
.post('/sign-up', ({ body }) => signUp(body), {
body: t.Object({
username: t.String(),
password: t.String()
})
})
.post('/sign-in', ({ body }) => body, {
beforeHandle: isUserExists,
body: t.Object({
username: t.String(),
password: t.String()
})
})
.get('/', () => 'hi')
.listen(3000)
We can use a group with prefixes by providing 3 parameters to the group.
With the same API as guard apply to the 2nd parameter, instead of nesting group and guard together.
Consider the following example:
import { Elysia, t } from 'elysia'
new Elysia()
.group('/v1', (app) =>
app.guard(
{
body: t.Literal('Rikuhachima Aru')
},
(app) => app.post('/student', ({ body }) => body)
)
)
.listen(3000)
From nested groupped guard, we may merge group and guard together by providing guard scope to 2nd parameter of group:
import { Elysia, t } from 'elysia'
new Elysia()
.group(
'/v1',
(app) => app.guard(
{
body: t.Literal('Rikuhachima Aru')
},
(app) => app.post('/student', ({ body }) => body)
)
)
.listen(3000)
Which results in the follows syntax:
import { Elysia, t } from 'elysia'
new Elysia()
.group(
'/v1',
{
body: t.Literal('Rikuhachima Aru')
},
(app) => app.post('/student', ({ body }) => body)
)
.listen(3000)
POST
To apply hook to parent may use one of the following:
Every event listener will accept as
parameter to specify the scope of the hook.
import { Elysia } from 'elysia'
const plugin = new Elysia()
.derive({ as: 'scoped' }, () => {
return { hi: 'ok' }
})
.get('/child', ({ hi }) => hi)
const main = new Elysia()
.use(plugin)
// ✅ Hi is now available
.get('/parent', ({ hi }) => hi)
However, this method is apply to only a single hook, and may not be suitable for multiple hooks.
Every event listener will accept as
parameter to specify the scope of the hook.
import { Elysia, t } from 'elysia'
const plugin = new Elysia()
.guard({
as: 'scoped',
response: t.String(),
beforeHandle() {
console.log('ok')
}
})
.get('/child', 'ok')
const main = new Elysia()
.use(plugin)
.get('/parent', 'hello')
Guard alllowing us to apply schema
and hook
to multiple routes all at once while specifying the scope.
However, it doesn't support derive
and resolve
method.
as
will read all hooks and schema scope of the current instance, modify.
import { Elysia } from 'elysia'
const plugin = new Elysia()
.derive(() => {
return { hi: 'ok' }
})
.get('/child', ({ hi }) => hi)
.as('scoped')
const main = new Elysia()
.use(plugin)
// ✅ Hi is now available
.get('/parent', ({ hi }) => hi)
Sometimes we want to reapply plugin to parent instance as well but as it's limited by scoped
mechanism, it's limited to 1 parent only.
To apply to the parent instance, we need to lift the scope up to the parent instance, and as
is the perfect method to do so.
Which means if you have local
scope, and want to apply it to the parent instance, you can use as('scoped')
to lift it up.
import { Elysia, t } from 'elysia'
const plugin = new Elysia()
.guard({
response: t.String()
})
.onBeforeHandle(() => { console.log('called') })
.get('/ok', () => 'ok')
.get('/not-ok', () => 1)Argument of type '() => number' is not assignable to parameter of type 'InlineHandler<NoInfer<IntersectIfObjectSchema<{ body: unknown; headers: unknown; query: unknown; params: {}; cookie: unknown; response: { 200: string; }; }, {}>>, { decorator: {}; store: {}; derive: {}; resolve: {}; } & { ...; }, {}>'.
Type '() => number' is not assignable to type '(context: { body: unknown; query: Record<string, string>; params: {}; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<unknown>>; server: Server | null; ... 6 more ...; status: <const Code extends 200 | "OK", T extends Code extends 200 ? { ...; }[Code] : Code extends "Continue" | ... 59 mor...'.
Type 'number' is not assignable to type 'Response | MaybePromise<string | ElysiaCustomStatusResponse<200, string, 200>>'. .as('scoped')
const instance = new Elysia()
.use(plugin)
.get('/no-ok-parent', () => 2)Argument of type '() => number' is not assignable to parameter of type 'InlineHandler<NoInfer<IntersectIfObjectSchema<{ body: unknown; headers: unknown; query: unknown; params: {}; cookie: unknown; response: { 200: string; }; }, {} & {}>>, { decorator: {}; store: {}; derive: {}; resolve: {}; } & { ...; }, {}>'.
Type '() => number' is not assignable to type '(context: { body: unknown; query: Record<string, string>; params: {}; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<unknown>>; server: Server | null; ... 6 more ...; status: <const Code extends 200 | "OK", T extends Code extends 200 ? { ...; }[Code] : Code extends "Continue" | ... 59 mor...'.
Type 'number' is not assignable to type 'Response | MaybePromise<string | ElysiaCustomStatusResponse<200, string, 200>>'. .as('scoped')
const parent = new Elysia()
.use(instance)
// This now error because `scoped` is lifted up to parent
.get('/ok', () => 3)Argument of type '() => number' is not assignable to parameter of type 'InlineHandler<NoInfer<IntersectIfObjectSchema<{ body: unknown; headers: unknown; query: unknown; params: {}; cookie: unknown; response: { 200: string; }; }, {} & {}>>, { decorator: {}; store: {}; derive: {}; resolve: {}; } & { ...; }, {}>'.
Type '() => number' is not assignable to type '(context: { body: unknown; query: Record<string, string>; params: {}; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<unknown>>; server: Server | null; ... 6 more ...; status: <const Code extends 200 | "OK", T extends Code extends 200 ? { ...; }[Code] : Code extends "Continue" | ... 59 mor...'.
Type 'number' is not assignable to type 'Response | MaybePromise<string | ElysiaCustomStatusResponse<200, string, 200>>'.
Modules are eagerly loaded by default.
Elysia will make sure that all modules are registered before the server starts.
However, some modules may be computationally heavy or blocking, making the server startup slow.
To solve this, Elysia allows you to provide an async plugin that will not block the server startup.
The deferred module is an async plugin that can be registered after the server is started.
// plugin.ts
import { Elysia, file } from 'elysia'
import { loadAllFiles } from './files'
export const loadStatic = async (app: Elysia) => {
const files = await loadAllFiles()
files.forEach((asset) => app
.get(asset, file(file))
)
return app
}
And in the main file:
import { Elysia } from 'elysia'
import { loadStatic } from './plugin'
const app = new Elysia()
.use(loadStatic)
Same as the async plugin, the lazy-load module will be registered after the server is started.
A lazy-load module can be both sync or async function, as long as the module is used with import
the module will be lazy-loaded.
import { Elysia } from 'elysia'
const app = new Elysia()
.use(import('./plugin'))
Using module lazy-loading is recommended when the module is computationally heavy and/or blocking.
To ensure module registration before the server starts, we can use await
on the deferred module.
In a test environment, we can use await app.modules
to wait for deferred and lazy-loading modules.
import { describe, expect, it } from 'bun:test'
import { Elysia } from 'elysia'
describe('Modules', () => {
it('inline async', async () => {
const app = new Elysia()
.use(async (app) =>
app.get('/async', () => 'async')
)
await app.modules
const res = await app
.handle(new Request('http://localhost/async'))
.then((r) => r.text())
expect(res).toBe('async')
})
})