Skip to content

Plugin

Plugin is a pattern that decouples functionality into smaller parts. Creating reusable components for our web server.

Defining a plugin is to define a separate instance.

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

localhost

GET

The plugin will inherit all properties of the plugin instance, including state, decorate, derive, route, lifecycle, etc.

Elysia will also handle the type inference automatically as well, so you can imagine as if you call all of the other instances on the main one.

TIP

Notice that the plugin doesn't contain .listen, because .listen will allocate a port for the usage, and we only want the main instance to allocate the port.

Plugin

Every Elysia instance can be a plugin.

We can decouple our logic into a new separate Elysia instance and use it as a plugin.

First, we define an instance in a difference file:

typescript
// plugin.ts
import { 
Elysia
} from 'elysia'
export const
plugin
= new
Elysia
()
.
get
('/plugin', () => 'hi')

And then we import the instance into the main file:

typescript
import { Elysia } from 'elysia'
import { plugin } from './plugin'

const app = new Elysia()
    .use(plugin)
    .listen(3000)

Config

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.

typescript
import { Elysia } from 'elysia'

const version = (version = 1) => new Elysia()
        .get('/version', version)

const app = new Elysia()
    .use(version(1))
    .listen(3000)

Functional callback

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.

To define a functional callback, create a function that accepts Elysia as a parameter.

typescript
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)
localhost

GET

Once passed to Elysia.use, functional callback behaves as a normal plugin except the property is assigned directly to

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.

Plugin Deduplication

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:

typescript
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)
localhost

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.

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

Service Locator

When you apply multiple state and decorators plugin to an instance, the instance will gain type safety.

However, you may notice that when you are trying to use the decorated value in another instance without decorator, the type is missing.

typescript
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 | undefined>; params: never; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<string | undefined>>; ... 8 more ...; error: <const Code extends number | keyof StatusMap, const T = Code extends 100 | ... 58 more ... | 511 ? { ...; }[Code] : Code, ...'.
const
main
= new
Elysia
()
.
decorate
('a', 'a')
.
use
(
child
)

This is a TypeScript limitation; Elysia can only refer to the current instance.

Elysia introduces the Service Locator pattern to counteract this.

To put it simply, Elysia will lookup the plugin checksum and get the value or register a new one. Infer the type from the plugin.

Simply put, we need to provide the plugin reference for Elysia to find the service.

typescript
import { 
Elysia
} from 'elysia'
// setup.ts const
setup
= new
Elysia
({
name
: 'setup' })
.
decorate
('a', 'a')
// index.ts const
error
= new
Elysia
()
.
get
('/', ({ a }) =>
a
)
Property 'a' does not exist on type '{ body: unknown; query: Record<string, string | undefined>; params: never; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<string | undefined>>; ... 8 more ...; error: <const Code extends number | keyof StatusMap, const T = Code extends 100 | ... 58 more ... | 511 ? { ...; }[Code] : Code, ...'.
const
main
= new
Elysia
()
.
use
(
setup
)
.
get
('/', ({
a
}) =>
a
)
localhost

GET

Guard

Guard allows us to apply hook and schema into multiple routes all at once.

typescript
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:

PathHas 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:

typescript
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)

Grouped Guard

We can use a group with prefixes by providing 3 parameters to the group.

  1. Prefix - Route prefix
  2. Guard - Schema
  3. Scope - Elysia app callback

With the same API as guard apply to the 2nd parameter, instead of nesting group and guard together.

Consider the following example:

typescript
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:

typescript
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:

typescript
import { 
Elysia
,
t
} from 'elysia'
new
Elysia
()
.
group
(
'/v1', {
body
:
t
.
Literal
('Rikuhachima Aru')
}, (
app
) =>
app
.
post
('/student', ({
body
}) =>
body
)
) .
listen
(3000)
localhost

POST

Scope

By default, hook and schema will apply to current instance only.

Elysia has an encapsulation scope for to prevent unintentional side effects.

Scope type is to specify the scope of hook whether is should be encapsulated or global.

typescript
import { 
Elysia
} from 'elysia'
const
plugin
= new
Elysia
()
.
derive
(() => {
return {
hi
: 'ok' }
}) .
get
('/child', ({
hi
}) =>
hi
)
const
main
= new
Elysia
()
.
use
(
plugin
)
// ⚠️ Hi is missing .
get
('/parent', ({ hi }) =>
hi
)
Property 'hi' does not exist on type '{ body: unknown; query: Record<string, string | undefined>; params: never; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<string | undefined>>; ... 8 more ...; error: <const Code extends number | keyof StatusMap, const T = Code extends 100 | ... 58 more ... | 511 ? { ...; }[Code] : Code, ...'.

From the above code, we can see that hi is missing from the parent instance because the scope is local by default if not specified, and will not apply to parent.

To apply the hook to the parent instance, we can use the as to specify scope of the hook.

typescript
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
)

Scope level

Elysia has 3 levels of scope as the following: Scope type are as the following:

  • local (default) - apply to only current instance and descendant only
  • scoped - apply to parent, current instance and descendants
  • global - apply to all instance that apply the plugin (all parents, current, and descendants)

Let's review what each scope type does by using the following example:

typescript
import { Elysia } from 'elysia'

// ? Value base on table value provided below
const type = 'local'

const child = new Elysia()
    .get('/child', 'hi')

const current = new Elysia()
    .onBeforeHandle({ as: type }, () => { 
        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:

typechildcurrentparentmain
'local'
'scoped'
'global'

Scope cast

To apply hook to parent may use one of the following:

  1. inline as apply only to a single hook
  2. guard as apply to all hook in a guard
  3. instance as apply to all hook in an instance

1. Inline as

Every event listener will accept as parameter to specify the scope of the hook.

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

2. Guard as

Every event listener will accept as parameter to specify the scope of the hook.

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

3. Instance as

as will read all hooks and schema scope of the current instance, modify.

typescript
import { 
Elysia
} from 'elysia'
const
plugin
= new
Elysia
()
.
derive
(() => {
return {
hi
: 'ok' }
}) .
get
('/child', ({
hi
}) =>
hi
)
.
as
('plugin')
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('plugin') to lift it up.

typescript
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<MergeSchema<UnwrapRoute<InputSchema<never>, {}>, MergeSchema<{ body: unknown; headers: unknown; query: unknown; params: unknown; cookie: unknown; response: { 200: string; }; }, MergeSchema<{}, {}>>>, { ...; } & { ...; }, "/not-ok">'. Type '() => number' is not assignable to type '(context: { body: unknown; query: Record<string, string | undefined>; params: never; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<string | undefined>>; ... 8 more ...; error: <const Code extends "OK" | 200, const T extends Code extends 200 ? { ...; }[Code] : Code extends "Continue" | .....'. Type 'number' is not assignable to type 'Response | MaybePromise<string | { 200: string; } | { _type: Record<200, string>; [ELYSIA_RESPONSE]: 200; }>'.
.
as
('plugin')
const
instance
= new
Elysia
()
.
use
(
plugin
)
.
get
('/no-ok-parent', () => 2)
Argument of type '() => number' is not assignable to parameter of type 'InlineHandler<MergeSchema<UnwrapRoute<InputSchema<never>, {}>, MergeSchema<{ body: unknown; headers: unknown; query: unknown; params: unknown; cookie: unknown; response: { 200: string; }; }, MergeSchema<{}, {}>>>, { ...; } & { ...; }, "/no-ok-parent">'. Type '() => number' is not assignable to type '(context: { body: unknown; query: Record<string, string | undefined>; params: never; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<string | undefined>>; ... 8 more ...; error: <const Code extends "OK" | 200, const T extends Code extends 200 ? { ...; }[Code] : Code extends "Continue" | .....'. Type 'number' is not assignable to type 'Response | MaybePromise<string | { 200: string; } | { _type: Record<200, string>; [ELYSIA_RESPONSE]: 200; }>'.
.
as
('plugin')
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<MergeSchema<UnwrapRoute<InputSchema<never>, {}>, MergeSchema<{ body: unknown; headers: unknown; query: unknown; params: unknown; cookie: unknown; response: { 200: string; }; }, MergeSchema<{}, {}>>>, { ...; } & { ...; }, "/ok">'. Type '() => number' is not assignable to type '(context: { body: unknown; query: Record<string, string | undefined>; params: never; headers: Record<string, string | undefined>; cookie: Record<string, Cookie<string | undefined>>; ... 8 more ...; error: <const Code extends "OK" | 200, const T extends Code extends 200 ? { ...; }[Code] : Code extends "Continue" | .....'. Type 'number' is not assignable to type 'Response | MaybePromise<string | { 200: string; } | { _type: Record<200, string>; [ELYSIA_RESPONSE]: 200; }>'.

Descendant

By default plugin will only 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.

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

typescript
import { Elysia } from 'elysia'

const plugin = new Elysia()
    .onBeforeHandle(() => {
        return 'hi'
    })
    .get('/child', 'child')
    .as('plugin')

const main = new Elysia()
    .use(plugin)
    .get('/parent', 'parent')
localhost

GET

Lazy Load

Modules are eagerly loaded by default.

Elysia loads all modules then registers and indexes all of them before starting the server. This enforces that all the modules have loaded before it starts accepting requests.

While this is fine for most applications, it may become a bottleneck for a server running in a serverless environment or an edge function, in which the startup time is important.

Lazy-loading can help decrease startup time by deferring modules to be gradually indexed after the server start.

Lazy-loading modules are a good option when some modules are heavy and importing startup time is crucial.

By default, any async plugin without await is treated as a deferred module and the import statement as a lazy-loading module.

Both will be registered after the server is started.

Deferred Module

The deferred module is an async plugin that can be registered after the server is started.

typescript
// plugin.ts
import { Elysia } from 'elysia'
import { loadAllFiles } from './files'

export const loadStatic = async (app: Elysia) => {
    const files = await loadAllFiles()

    files.forEach((file) => app
        .get(file, () => Bun.file(file))
    )

    return app
}

And in the main file:

typescript
import { Elysia } from 'elysia'
import { loadStatic } from './plugin'

const app = new Elysia()
    .use(loadStatic)

Elysia static plugin is also a deferred module, as it loads files and registers files path asynchronously.

Lazy Load Module

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.

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

Testing

In a test environment, we can use await app.modules to wait for deferred and lazy-loading modules.

typescript
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')
    })
})