Releases: elysiajs/elysia
1.1.15
What's new
Bug fix:
createStaticResponse
unintentionally mutateset.headers
Full Changelog: 1.1.14...1.1.15
1.1.14
What's Changed
Feature:
- add auto-completion to
Content-Type
headers
Bug fix:
- exclude file from Bun native static response until Bun support
- set 'text/plain' for string if no content-type is set for native static response
Full Changelog: 1.1.13...1.1.14
1.1.13
What's Changed
Feature:
- #813 allow UnionEnum to get readonly array by @BleedingDev
Bug fix:
- #830 Incorrect type for ws.publish
- #827 returning a response is forcing application/json content-type
- #821 handle "+" in query with validation
- #820 params in hooks inside prefixed groups are incorrectly typed never
- #819 setting cookie attribute before value cause cookie attribute to not be set
- #810 wrong inference of response in afterResponse, includes status code
New Contributors
- @BleedingDev made their first contribution in #813
Full Changelog: 1.1.12...1.1.13
1.1.12
1.1 - Grown-up's Paradise
Named after a song by Mili, "Grown-up's Paradise", and used as opening for commercial announcement of Arknights TV animation season 3.
As a day one Arknights player and long time Mili's fan, never once I would thought Mili would do a song for Arknights, you should check them out as they are the goat.
Elysia 1.1 focus on several improvement to Developer Experience as follows:
- OpenTelemetry
- Trace v2 (breaking change)
- Normalization
- Data coercion
- Guard as
- Bulk
as
cast - Response status reconcilation
- Optional path parameter
- Generator response stream
OpenTelemetry
Observability is one of an important aspect for production.
It allows us to understand how our server works on production, identifying problems and bottlenecks.
One of the most popular tools for observability is OpenTelemetry. However, we acknowledge that it's hard and take time to setup and instrument your server correctly.
It's hard to integrate OpenTelemetry to most existing framework and library.
Most revolve around hacky solution, monkey patching, prototype pollution, or manual instrumentation as the framework is not designed for observability from the start.
That's why we introduce first party support for OpenTelemetry on Elysia
To start using OpenTelemetry, install @elysiajs/opentelemetry
and apply plugin to any instance.
import { Elysia } from 'elysia'
import { opentelemetry } from '@elysiajs/opentelemetry'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
new Elysia()
.use(
opentelemetry({
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter()
)
]
})
)
Elysia OpenTelemetry is will collect span of any library compatible OpenTelemetry standard, and will apply parent and child span automatically.
In the code above, we apply Prisma
to trace how long each query took.
By applying OpenTelemetry, Elysia will then:
- collect telemetry data
- Grouping relevant lifecycle together
- Measure how long each function took
- Instrument HTTP request and response
- Collect error and exception
You can export telemetry data to Jaeger, Zipkin, New Relic, Axiom or any other OpenTelemetry compatible backend.
Here's an example of exporting telemetry to Axiom
const Bun = {
env: {
AXIOM_TOKEN: '',
AXIOM_DATASET: ''
}
}
// ---cut---
import { Elysia } from 'elysia'
import { opentelemetry } from '@elysiajs/opentelemetry'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
new Elysia()
.use(
opentelemetry({
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: 'https://api.axiom.co/v1/traces', // [!code ++]
headers: { // [!code ++]
Authorization: `Bearer ${Bun.env.AXIOM_TOKEN}`, // [!code ++]
'X-Axiom-Dataset': Bun.env.AXIOM_DATASET // [!code ++]
} // [!code ++]
})
)
]
})
)
Elysia OpenTelemetry is for applying OpenTelemetry to Elysia server only.
You can use OpenTelemetry SDK normally, and the span is run under Elysia's request span, it will be automatically appear in Elysia trace.
However, we also provide a getTracer
, and record
utility to collect span from any part of your application.
const db = {
query(query: string) {
return new Promise<unknown>((resolve) => {
resolve('')
})
}
}
// ---cut---
import { Elysia } from 'elysia'
import { record } from '@elysiajs/opentelemetry'
export const plugin = new Elysia()
.get('', () => {
return record('database.query', () => {
return db.query('SELECT * FROM users')
})
})
record
is an equivalent to OpenTelemetry's startActiveSpan
but it will handle auto-closing and capture exception automatically.
You may think of record
as a label for your code that will be shown in trace.
Prepare your codebase for observability
Elysia OpenTelemetry will group lifecycle and read the function name of each hook as the name of the span.
It's a good time to name your function.
If your hook handler is an arrow function, you may refactor it to named function to understand the trace better otherwise, your trace span will be named as anonymous
.
const bad = new Elysia()
// ⚠️ span name will be anonymous
.derive(async ({ cookie: { session } }) => {
return {
user: await getProfile(session)
}
})
const good = new Elysia()
// ✅ span name will be getProfile
.derive(async function getProfile({ cookie: { session } }) {
return {
user: await getProfile(session)
}
})
Trace v2
Elysia OpenTelemetry is built on Trace v2, replacing Trace v1.
Trace v2 allows us to trace any part of our server with 100% synchronous behavior, instead of relying on parallel event listener bridge (goodbye dead lock)
It's entirely rewritten to not only be faster, but also reliable, and accurate down to microsecond by relying on Elysia's ahead of time compilation and code injection.
Trace v2 use a callback listener instead of Promise to ensure that callback is finished before moving on to the next lifecycle event.
Here's an example usage of Trace v2:
import { Elysia } from 'elysia'
new Elysia()
.trace(({ onBeforeHandle, set }) => {
// Listen to before handle event
onBeforeHandle(({ onEvent }) => {
// Listen to all child event in order
onEvent(({ onStop, name }) => {
// Execute something after a child event is finished
onStop(({ elapsed }) => {
console.log(name, 'took', elapsed, 'ms')
// callback is executed synchronously before next event
set.headers['x-trace'] = 'true'
})
})
})
})
You may also use async
inside trace, Elysia will block and event before proceeding to the next event until the callback is finished.
Trace v2 is a breaking change to Trace v1, please check out trace api documentation for more information.
Normalization
Elysia 1.1 now normalize data before it's being processed.
To ensure that data is consistent and safe, Elysia will try to coerce data into an exact data shape defined in schema, removing additional fields, and normalizing data into a consistent format.
For example if you have a schema like this:
// @errors: 2353
import { Elysia, t } from 'elysia'
import { treaty } from '@elysiajs/eden'
const app = new Elysia()
.post('/', ({ body }) => body, {
body: t.Object({
name: t.String(),
point: t.Number()
}),
response: t.Object({
name: t.String()
})
})
const { data } = await treaty(app).index.post({
name: 'SaltyAom',
point: 9001,
// ⚠️ additional field
title: 'maintainer'
})
// 'point' is removed as defined in response
console.log(data) // { name: 'SaltyAom' }
This code does 2 thing:
- Remove
title
from body before it's being used on the server - Remove
point
from response before it's being sent to the client
This is useful to prevent data inconsistency, and ensure that data is always in the correct format, and not leaking any sensitive information.
Data type coercion
Previously Elysia is using an exact data type without coercion unless explicitly specified to.
For example, to parse a query parameter as a number, you need to explicitly cast it as t.Numeric
instead of t.Number
.
import { Elysia, t } from 'elysia'
const app = new Elysia()
.get('/', ({ query }) => query, {
query: t.Object({
page: t.Numeric()
})
})
However, in Elysia 1.1, we introduce data type coercion, which will automatically coerce data into the correct data type if possible.
Allowing us to simply set t.Number
instead of t.Numeric
to parse a query parameter as a number.
import { Elysia, t } from 'elysia'
const app = new Elysia()
.get('/', ({ query }) => query, {
query: t.Object({
// ✅ page will be coerced into a number automatically
page: t.Number()
})
})
This also apply to t.Boolean
, t.Object
, and t.Array
.
This is done by swapping schema with possible coercion counterpart during compilation phase ahead of time, and has the same as using t.Numeric
or other coercion counterpart.
Guard as
Previously, guard
will only apply to the current instance only.
import { Elysia } from 'elysia'
const plugin = new Elysia()
.guard({
beforeHandle() {
console.log('called')
}
})
.get('/plugin', () => 'ok')
const main = new Elysia()
.use(plugin)
.get('/', () => 'ok')
Using this code, onBeforeHandle
will only be called when accessing /plugin
but not /
.
In Elysia 1.1, we add as
property to guard
allowing us to apply guard as scoped
or global
as same as adding event listener.
import { Elysia } from 'elysia'
const plugin1 = new Elysia()
.guard({
as: 'scoped', // [!code ++]
beforeHandle() {
console.log('called')
}
})
.get('/plugin',...
1.0 - Lament of the Fallen
Elysia 1.0 is the first stable release after development for 1.8 years.
Since started, we have always waiting for a framework that focuses on developer experience, velocity, and how to make writing code for humans, not a machine.
We battle-test Elysia in various situations, simulate medium and large-scale projects, shipping code to clients and this is the first version that we felt confident enough to ship.
Elysia 1.0 introduces significant improvements and contains 1 necessary breaking change.
- Sucrose - Rewritten pattern matching static analysis instead of RegEx
- Improved startup time up to 14x
- Remove ~40 routes/instance TypeScript limitation
- Faster type inference up to ~3.8x
- Treaty 2
- Hook type (breaking changes)
- Inline error for strict error check
It's a tradition that Elysia's release note have a version named after a song or media.
This important version is named after "Lament of the Fallen".
Animated short from "Honkai Impact 3rd" from my favorite arc, and my favorite character, "Raiden Mei" featuring her theme song, "Honkai World Diva".
It's a very good game, and you should check it out.
ー SaltyAom
Also known as Raiden Mei from Gun Girl Z, Honkai Impact 3rd, Honkai Star Rail. And her "variation", Raiden Shogun from Genshin Impact, and possibly Acheron from Honkai Star Rail (since she's likely a bad-end herrscher form mentioned in Star Rail 2.1).
::: tip
Remember, ElysiaJS is an open source library maintain by volunteers, and isn't associate with Mihoyo nor Hoyoverse. But we are a huge fan of Honkai series, alright?
:::
Sucrose
Elysia is optimized to have an excellent performance proven in various benchmarks, one of the main factors is thanks to Bun, and our custom JIT static code analysis.
If you are not aware, Elysia has some sort of "compiler" embedded that reads your code and produces an optimized way to handle functions.
The process is fast and happens on the fly without a need for a build step.
However, it's challenging to maintain as it's written mostly in many complex RegEx, and can be slow at times if recursion happens.
That's why we rewrote our static analysis part to separate the code injection phase using a hybrid approach between partial AST-based and pattern-matching name "Sucrose".
Instead of using full AST-based which is more accurate, we choose to implement only a subset of rules that is needed to improve performance as it needs to be fast on runtime.
Sucrose is good at inferring the recursive property of the handler function accurately with low memory usage, resulting in up to 37% faster inference time and significantly reduced memory usage.
Sucrose is shipped to replace RegEx-based to partial AST, and pattern matching starting from Elysia 1.0.
Improved Startup time
Thanks to Sucrose, and separation from the dynamic injection phase, we can defer the analysis time JIT instead of AOT.
In other words, the "compile" phase can be lazily evaluated.
Offloading the evaluation phase from AOT to JIT when a route is matched for the first time and caching the result to compile on demand instead of all routes before server start.
In a runtime performance, a single compilation is usually fast and takes no longer than 0.01-0.03 ms (millisecond not second).
In a medium-sized application and stress test, we measure up to between ~6.5-14x faster start-up time.
Remove ~40 routes/instance limit
Previously you could only stack up to ~40 routes / 1 Elysia instance since Elysia 0.1.
This is the limitation of TypeScript that each queue that has a limited memory and if exceeded, TypeScript will think that "Type instantiation is excessively deep and possibly infinite".
const main = new Elysia()
.get('/1', () => '1')
.get('/2', () => '2')
.get('/3', () => '3')
// repeat for 40 times
.get('/42', () => '42')
// Type instantiation is excessively deep and possibly infinite
As a workaround, we need to separate an instance into a controller to overcome the limit and remerge the type to offload the queue like this.
const controller1 = new Elysia()
.get('/42', () => '42')
.get('/43', () => '43')
const main = new Elysia()
.get('/1', () => '1')
.get('/2', () => '2')
// repeat for 40 times
.use(controller1)
However, starting from Elysia 1.0, we have overcome the limit after a year after optimizing for type-performance, specifically Tail Call Optimization, and variances.
This means theoretically, we can stack an unlimited amount of routes and methods until TypeScript breaks.
(spoiler: we have done that and it's around 558 routes/instance before TypeScript CLI and language server because of JavaScript memory limit per stack/queue)
const main = new Elysia()
.get('/1', () => '1')
.get('/2', () => '2')
.get('/3', () => '42')
// repeat for n times
.get('/550', () => '550')
So we increase the limit of ~40 routes to JavaScript memory limit instead, so try not to stack more than ~558 routes/instance, and separate into a plugin if necessary.
The blocker that made us feel like Elysia is not ready for production has been finally resolved.
Type Inference improvement
Thanks to the effort we put into optimization, we measure up to ~82% in most Elysia servers.
Thanks to the removed limitation of stack, and improved type performance, we can expect almost instant type check and auto-completion even after 500 routes stacks.
type-demo.mp4
Up to 13x faster for Eden Treaty, type inference performance by precomputing the type instead offload type remap to Eden.
Overall, Elysia, and Eden Treaty performing together would be up to ~3.9x faster.
Here's a comparison between the Elysia + Eden Treaty on 0.8 and 1.0 for 450 routes.
Stress test with 450 routes for Elysia with Eden Treaty, result as follows:
- Elysia 0.8 took ~1500ms
- Elysia 1.0 took ~400ms
And thanks to the removal of stack limitation, and remapping process, it's now possible to stack up to over 1,000 routes for a single Eden Treaty instance.
Treaty 2
We ask you for feedback on Eden Treaty what you like and what could have been improved. and you have given us some flaws in Treaty design and several proposals to improvement.
That's why today, we introduce Eden Treaty 2, an overhaul to a more ergonomic design.
As much as we dislike breaking change, Treaty 2 is a successor to Treaty 1.
What's new in Treaty 2:
- More ergonomic syntax
- End-to-end type safety for Unit Test
- Interceptor
- No "$" prefix and property
Our favorite one is end-to-end type safety for Unit tests.
So instead of starting a mock server and sending a fetch request, we can use Eden Treaty 2 to write unit tests with auto-completion and type safety instead.
// test/index.test.ts
import { describe, expect, it } from 'bun:test'
import { Elysia } from 'elysia'
import { treaty } from '@elysiajs/eden'
const app = new Elysia().get('/hello', () => 'hi')
const api = treaty(app)
describe('Elysia', () => {
it('return a response', async () => {
const { data } = await api.hello.get()
expect(data).toBe('hi')
})
})
The difference between the two is that Treaty 2 is a successor to Treaty 1.
We don't intend to introduce any breaking change to Treaty 1 nor force you to update to Treaty 2.
You can choose to continue using Treaty 1 for your current project without updating to Treaty 2, and we maintain it in a maintenance mode.
- You can import
treaty
to use Treaty 2. - And import
edenTreaty
for Treaty 1.
The documentation for the new Treaty can be found in Treaty overview, and for Treaty 1 in Treaty legacy
Hook type (breaking change)
We hate breaking changes, and this is the first time we do it in large-scale.
We put a lot of effort into API design to reduce changes made to Elysia, but this is necessary to fix a flawed design.
Previously when we added a hook with "on" like onTransform
, or onBeforeHandle
, it would become a global hook.
This is great for creating something like a plugin but is not ideal for a local instance like a controller.
const plugin = new Elysia()
.onBeforeHandle(() => {
console.log('Hi')
})
// log Hi
.get('/hi', () => 'in plugin')
const app = new Elysia()
.use(plugin)
// will also log hi
.get('/no-hi-please', () => 'oh no')
However, we found several problems arise from this behavior.
- We found that many developers have a lot of nested guards even on the new instance. Guard is almost used as a way to start a new instance to avoid side effects.
- global by default may cause unpredictable (side-effect) behavior if not careful, especially in a team with inexperienced developers.
- We asked many developers both familiar and not familiar with Elysia, and found that mo...
0.8 - Gate of Steiners
Named after the ending song of Steins;Gate Zero, "Gate of Steiner".
Gate of Steiner isn't focused on new exciting APIs and features but on API stability and a solid foundation to make sure that the API will be stable once Elysia 1.0 is released.
However, we do bring improvement and new features including:
- Macro API
- New Lifecycle: resolve, mapResponse
- Error Function
- Static Content
- Default Property
- Default Header
- Performance and Notable Improvement
Macro API
Macro allows us to define a custom field to hook and guard by exposing full control of the life cycle event stack.
Allowing us to compose custom logic into a simple configuration with full type safety.
Suppose we have an authentication plugin to restrict access based on role, we can define a custom role field.
import { Elysia } from 'elysia'
import { auth } from '@services/auth'
const app = new Elysia()
.use(auth)
.get('/', ({ user }) => user.profile, {
role: 'admin'
})
Macro has full access to the life cycle stack, allowing us to add, modify, or delete existing events directly for each route.
const plugin = new Elysia({ name: 'plugin' }).macro(({ beforeHandle }) => {
return {
role(type: 'admin' | 'user') {
beforeHandle(
{ insert: 'before' },
async ({ cookie: { session } }) => {
const user = await validateSession(session.value)
await validateRole('admin', user)
}
)
}
}
})
We hope that with this macro API, plugin maintainers will be able to customize Elysia to their heart's content opening a new way to interact better with Elysia, and Elysia users will be able to enjoy even more ergonomic API Elysia could provide.
The documentation of Macro API is now available in pattern section.
The next generation of customizability is now only a reach away from your keyboard and imagination.
New Life Cycle
Elysia introduced a new life cycle to fix an existing problem and highly requested API including Resolve and MapResponse:
resolve: a safe version of derive. Execute in the same queue as beforeHandle
mapResponse: Execute just after afterResponse for providing transform function from primitive value to Web Standard Response
Resolve
A "safe" version of derive.
Designed to append new value to context after validation process storing in the same stack as beforeHandle.
Resolve syntax is identical to derive, below is an example of retrieving a bearer header from Authorization plugin.
import { Elysia } 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)
MapResponse
Executed just after "afterHandle", designed to provide custom response mapping from primitive value into a Web Standard Response.
Below is an example of using mapResponse to provide Response compression.
import { Elysia, mapResponse } from 'elysia'
import { gzipSync } from 'bun'
new Elysia()
.mapResponse(({ response }) => {
return new Response(
gzipSync(
typeof response === 'object'
? JSON.stringify(response)
: response.toString()
)
)
})
.listen(3000)
Why not use afterHandle but introduce a new API?
Because afterHandle is designed to read and modify a primitive value. Storing plugins like HTML, and Compression which depends on creating Web Standard Response.
This means that plugins registered after this type of plugin will be unable to read a value or modify the value making the plugin behavior incorrect.
This is why we introduce a new life-cycle run after afterHandle dedicated to providing a custom response mapping instead of mixing the response mapping and primitive value mutation in the same queue.
Error Function
We can set the status code by using either set.status or returning a new Response.
import { Elysia } from 'elysia'
new Elysia()
.get('/', ({ set }) => {
set.status = 418
return "I'm a teapot"
})
.listen(3000)
This aligns with our goal, to just the literal value to the client instead of worrying about how the server should behave.
However, this is proven to have a challenging integration with Eden. Since we return a literal value, we can't infer the status code from the response making Eden unable to differentiate the response from the status code.
This results in Eden not being able to use its full potential, especially in error handling as it cannot infer type without declaring explicit response type for each status.
Along with many requests from our users wanting to have a more explicit way to return the status code directly with the value, not wanting to rely on set.status, and new Response for verbosity or returning a response from utility function declared outside handler function.
This is why we introduce an error function to return a status code alongside with value back to the client.
import { Elysia, error } from 'elysia' // [!code ++]
new Elysia()
.get('/', () => error(418, "I'm a teapot")) // [!code ++]
.listen(3000)
Which is an equivalent to:
import { Elysia } from 'elysia'
new Elysia()
.get('/', ({ set }) => {
set.status = 418
return "I'm a teapot"
})
.listen(3000)
The difference is that using an error function, Elysia will automatically differentiate from the status code into a dedicated response type, helping Eden to infer a response based on status correctly.
This means that by using error, we don't have to include the explicit response schema to make Eden infers type correctly for each status code.
import { Elysia, error, t } from 'elysia'
new Elysia()
.get('/', ({ set }) => {
set.status = 418
return "I'm a teapot"
}, { // [!code --]
response: { // [!code --]
418: t.String() // [!code --]
} // [!code --]
}) // [!code --]
.listen(3000)
We recommended using error
function to return a response with the status code for the correct type inference, however, we do not intend to remove the usage of set.status from Elysia to keep existing servers working.
Static Content
Static Content refers to a response that almost always returns the same value regardless of the incoming request.
This type of resource on the server is usually something like a public File, video or hardcode value that is rarely changed unless the server is updated.
By far, most content in Elysia is static content. But we also found that many cases like serving a static file or serving an HTML page using a template engine are usually static content.
This is why Elysia introduced a new API to optimize static content by determining the Response Ahead of Time.
new Elysia()
.get('/', () => Bun.file('video/kyuukurarin.mp4')) // [!code --]
.get('/', Bun.file('video/kyuukurarin.mp4')) // [!code ++]
.listen(3000)
Notice that the handler now isn't a function but is an inline value instead.
This will improve the performance by around 20-25% by compiling the response ahead of time.
Default Property
Elysia 0.8 updates to TypeBox to 0.32 which introduces many new features including dedicated RegEx, Deref but most importantly the most requested feature in Elysia, default field support.
Now defining a default field in Type Builder, Elysia will provide a default value if the value is not provided, supporting schema types from type to body.
import { Elysia } from 'elysia'
new Elysia()
.get('/', ({ query: { name } }) => name, {
query: t.Object({
name: t.String({
default: 'Elysia'
})
})
})
.listen(3000)
This allows us to provide a default value from schema directly, especially useful when using reference schema.
Default Header
We can set headers using set.headers, which Elysia always creates a default empty object for every request.
Previously we could use onRequest to append desired values into set.headers, but this will always have some overhead because a function is called.
Stacking functions to mutate an object can be a little slower than having the desired value set in the first hand if the value is always the same for every request like CORS or cache header.
This is why we now support setting default headers out of the box instead of creating an empty object for every new request.
new Elysia()
.headers({
'X-Powered-By': 'Elysia'
})
Elysia CORS plugin also has an update to use this new API to improve this performance.
Performance and notable improvement
As usual, we found a way to optimize El...
0.7 - Stellar Stellar
Name after our never giving up spirit, our beloved Virtual YouTuber, Suicopath Hoshimachi Suisei, and her brilliance voice: 「Stellar Stellar」from her first album:「Still Still Stellar」
For once being forgotten, she really is a star that truly shine in the dark.
Stellar Stellar brings many exciting new update to help Elysia solid the foundation, and handle complexity with ease, featuring:
- Entirely rewrite type, up to 13x faster type inference.
- "Trace" for declarative telemetry and better performance audit.
- Reactive Cookie model and cookie valiation to simplify cookie handling.
- TypeBox 0.31 with a custom decoder support.
- Rewritten Web Socket for even better support.
- Definitions remapping, and declarative affix for preventing name collision.
- Text-based status
Rewritten Type
Core feature of Elysia about developer experience.
Type is one of the most important aspect of Elysia, as it allows us to do many amazing thing like unified type, syncing your business logic, typing, documentation and frontend.
We want you to have an outstanding experience with Elysia, focusing on your business logic part, and let's Elysia handle the rest whether it's type-inference with unified type, and Eden connector for syncing type with backend.
To achieve that, we put our effort on creating a unified type system for to synchronize all of the type, but as the feature grow, we found that our type inference might not be fast enough from our lack of TypeScript experience we have year ago.
With our experience we made along the way of handling complex type system, various optimization and many project like Mobius. We challenge our self to speed up our type system once again, making this a second type rewrite for Elysia.
We delete and rewrite every Elysia type from ground up to make Elysia type to be magnitude faster.
Here's a comparison between 0.6 and 0.7 on a simple Elysia.get
code:
With our new found experience, and newer TypeScript feature like const generic, we are able to simplify a lot of our code, reducing our codebase over a thousand line in type.
Allowing us to refine our type system to be even faster, and even more stable.
Using Perfetto and TypeScript CLI to generate trace on a large-scale and complex app, we measure up to 13x inference speed.
And if you might wonder if we might break type inference with 0.6 or not, we do have a unit test in type-level to make sure most of the case, there's no breaking change for type.
We hope this improvement will help you with even faster type inference like faster auto-completion, and load time from your IDE to be near instant to help your development to be even more faster and more fluent than ever before.
Trace
Performance is another one of important aspect for Elysia.
We don't want to be fast for benchmarking purpose, we want you to have a real fast server in real-world scenario, not just benchmarking.
There are many factor that can slow down your app, and it's hard to identifying one, that's why we introduce "Trace".
Trace allow us to take tap into a life-cycle event and identifying performance bottleneck for our app.
This example code allow us tap into all beforeHandle event, and extract the execution time one-by-one before setting the Server-Timing API to inspect the performance bottleneck.
And this is not limited to only beforeHandle
, and event can be trace even the handler
itself. The naming convention is name after life-cycle event you are already familiar with.
This API allows us to effortlessly auditing performance bottleneck of your Elysia server and integrate with the report tools of your choice.
By default, Trace use AoT compilation and Dynamic Code injection to conditionally report and even that you actually use automatically, which means there's no performance impact at all.
Reactive Cookie
We merged our cookie plugin into Elysia core.
Same as Trace, Reactive Cookie use AoT compilation and Dynamic Code injection to conditionally inject the cookie usage code, leading to no performance impact if you don't use one.
Reactive Cookie take a more modern approach like signal to handle cookie with an ergonomic API.
There's no getCookie
, setCookie
, everything is just a cookie object.
When you want to use cookie, you just extract the name get/set its value like:
app.get('/', ({ cookie: { name } }) => {
// Get
name.value
// Set
name.value = "New Value"
})
Then cookie will be automatically sync the value with headers, and the cookie jar, making the cookie
object a single source of truth for handling cookie.
The Cookie Jar is reactive, which means that if you don't set the new value for the cookie, the Set-Cookie
header will not be send to keep the same cookie value and reduce performance bottleneck.
Cookie Schema
With the merge of cookie into the core of Elysia, we introduce a new Cookie Schema for validating cookie value.
This is useful when you have to strictly validate cookie session or want to have a strict type or type inference for handling cookie.
app.get('/', ({ cookie: { name } }) => {
// Set
name.value = {
id: 617,
name: 'Summoning 101'
}
}, {
cookie: t.Cookie({
value: t.Object({
id: t.Numeric(),
name: t.String()
})
})
})
Elysia encode and decode cookie value for you automatically, so if you want to store JSON in a cookie like decoded JWT value, or just want to make sure if the value is a numeric string, you can do that effortlessly.
Cookie Signature
And lastly, with an introduction of Cookie Schema, and t.Cookie
type. We are able to create a unified type for handling sign/verify cookie signature automatically.
Cookie signature is a cryptographic hash appended to a cookie's value, generated using a secret key and the content of the cookie to enhance security by adding a signature to the cookie.
This make sure that the cookie value is not modified by malicious actor, helps in verifying the authenticity and integrity of the cookie data.
To handle cookie signature in Elysia, it's a simple as providing a secert
and sign
property:
new Elysia({
cookie: {
secret: 'Fischl von Luftschloss Narfidort'
}
})
.get('/', ({ cookie: { profile } }) => {
profile.value = {
id: 617,
name: 'Summoning 101'
}
}, {
cookie: t.Cookie({
profile: t.Object({
id: t.Numeric(),
name: t.String()
})
}, {
sign: ['profile']
})
})
By provide a cookie secret, and sign
property to indicate which cookie should have a signature verification.
Elysia then sign and unsign cookie value automatically, eliminate the need of sign / unsign function manually.
Elysia handle Cookie's secret rotation automatically, so if you have to migrate to a new cookie secret, you can just append the secret, and Elysia will use the first value to sign a new cookie, while trying to unsign cookie with the rest of the secret if match.
new Elysia({
cookie: {
secret: ['Vengeance will be mine', 'Fischl von Luftschloss Narfidort']
}
})
The Reactive Cookie API is declarative and straigth forward, and there's some magical thing about the ergonomic it provide, and we really looking forward for you to try it.
TypeBox 0.31
With the release of 0.7, we are updating to TypeBox 0.31 to brings even more feature to Elysia.
This brings new exciting feature like support for TypeBox's Decode
in Elysia natively.
Previously, a custom type like Numeric
require a dyanmic code injection to convert numeric string to number, but with the use of TypeBox's decode, we are allow to define a custom function to encode and decode the value of a type automatically.
Allowing us to simplify type to:
Numeric: (property?: NumericOptions<number>) =>
Type.Transform(Type.Union([Type.String(), Type.Number(property)]))
.Decode((value) => {
const number = +value
if (isNaN(number)) return value
return number
})
.Encode((value) => value) as any as TNumber,
Instead of relying on an extensive check and code injection, it's simplified by a Decode
function in TypeBox.
We have rewrite all type that require Dynamic Code Injection to use Transform
for easier code maintainance.
Not only limited to that, with t.Transform
you can now also define a custom type to with a custom function to Encode and Decode manually, allowing you to write a more expressive code than ever before.
We can't wait to see what you will brings with the introduction of t.Transform
.
New Type
With an introduction Transform, we have add a new type like t.ObjectString
to automatically decode a value of Object in request.
This is useful when you have to u...
0.6 - This Game
Named after the opening of the legendary anime, "No Game No Life", 「This Game」composed by Konomi Suzuki.
This Game push the boundary of medium-size project to large-scale app with re-imagined plugin model, dynamic mode, pushing developer experience with declarative custom error, collecting more metric with 'onResponse', customizable loose and strict path mapping, TypeBox 0.30 and WinterCG framework interlop.
(We are still waiting for No Game No Life season 2)
0.5 - Radiant
Named after Arknights' original music, 「Radiant」composed by Monster Sirent Records.
Radiant push the boundary of performance with more stability improvement especially types, and dynamic routes.
Static Code Analysis
With Elysia 0.4 introducing Ahead of Time compliation, allowing Elysia to optimize function calls, and eliminate many overhead we previously had.
Today we are expanding Ahead of Time compliation to be even faster wtih Static Code Analysis, to be the fastest Bun web framework.
Static Code Analysis allow Elysia to read your function, handlers, life-cycle and schema, then try to adjust fetch handler compile the handler ahead of time, and eliminating any unused code and optimize where possible.
For example, if you're using schema
with body type of Object, Elysia expect that this route is JSON first, and will parse the body as JSON instead of relying on dynamic checking with Content-Type header:
app.post('/sign-in', ({ body }) => signIn(body), {
schema: {
body: t.Object({
username: t.String(),
password: t.String()
})
}
})
This allows us to improve performance of body parsing by almost 2.5x.
With Static Code Analysis, instead of relying on betting if you will use expensive properties or not.
Elysia can read your code and detect what you will be using, and adjust itself ahead of time to fits your need.
This means that if you're not using expensive property like query
, or body
, Elysia will skip the parsing entirely to improve the performance.
// Body is not used, skip body parsing
app.post('/id/:id', ({ params: { id } }) => id, {
schema: {
body: t.Object({
username: t.String(),
password: t.String()
})
}
})
With Static Code Analysis, and Ahead of Time compilation, you can rest assure that Elysia is very good at reading your code and adjust itself to maximize the performance automatically.
Static Code Analysis allows us to improve Elysia performance beyond we have imagined, here's a notable mention:
- overall improvement by ~15%
- static router fast ~33%
- empty query parsing ~50%
- strict type body parsing faster by ~100%
- empty body parsing faster by ~150%
With this improvement, we are able to surpass Stricjs in term of performance, compared using Elysia 0.5.0-beta.0 and Stricjs 2.0.4
We intent to explain this in more detail with our research paper to explain this topic and how we improve the performance with Static Code Analysis to be published in the future.
New Router, "Memoirist"
Since 0.2, we have been building our own Router, "Raikiri".
Raikiri served it purposed, it's build on the ground up to be fast with our custom Radix Tree implementation.
But as we progress, we found that Raikiri doesn't perform well complex recoliation with of Radix Tree, which cause developers to report many bugs especially with dynamic route which usually solved by re-ordering routees.
We understand, and patched many area in Raikiri's Radix Tree reconcilation algorithm, however our algorithm is complex, and getting more expensive as we patch the router until it doesn't fits our purpose anymore.
That's why we introduce a new router, "Memoirist".
Memoirist is a stable Raix Tree router to fastly handle dynamic path based on Medley Router's algorithm, while on the static side take advantage of Ahead of Time compilation.
With this release, we will be migrating from Raikiri to Memoirist for stability improvement and even faster path mapping than Raikiri.
We hope that any problems you have encountered with Raikiri will be solved with Memoirist and we encourage you to give it a try.
TypeBox 0.28
TypeBox is a core library that powered Elysia's strict type system known as Elysia.t.
In this update, we update TypeBox from 0.26 to 0.28 to make even more fine-grained Type System near strictly typed language.
We update Typebox to improve Elysia typing system to match new TypeBox feature with newer version of TypeScript like Constant Generic
new Elysia()
.decorate('version', 'Elysia Radiant')
.model(
'name',
Type.TemplateLiteral([
Type.Literal('Elysia '),
Type.Union([
Type.Literal('The Blessing'),
Type.Literal('Radiant')
])
])
)
// Strictly check for template literal
.get('/', ({ version }) => version)
This allows us to strictly check for template literal, or a pattern of string/number to validate for your on both runtime and development process all at once.
Ahead of Time & Type System
And with Ahead of Time compilation, Elysia can adjust itself to optimize and match schema defined type to reduce overhead.
That's why we introduced a new Type, URLEncoded.
As we previously mentioned before, Elysia now can take an advantage of schema and optimize itself Ahead of Time, body parsing is one of more expensive area in Elysia, that's why we introduce a dedicated type for parsing body like URLEncoded.
By default, Elysia will parse body based on body's schema type as the following:
- t.URLEncoded ->
application/x-www-form-urlencoded
- t.Object ->
application/json
- t.File ->
multipart/form-data
- the rest ->
text/plain
However, you can explictly tells Elysia to parse body with the specific method using type
as the following:
app.post('/', ({ body }) => body, {
type: 'json'
})
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 find more detail at explicity body page in concept.
Numeric Type
We found that one of the redundant task our developers found using Elysia is to parse numeric string.
That's we introduce a new Numeric Type.
Previously on Elysia 0.4, to parse numeric string, we need to use transform
to manually parse the string ourself.
app.get('/id/:id', ({ params: { id } }) => id, {
schema: {
params: t.Object({
id: t.Number()
})
},
transform({ params }) {
const id = +params.id
if(!Number.isNan(id))
params.id = id
}
})
We found that this step is redundant, and full of boiler-plate, we want to tap into this problem and solve it in a declarative way.
Thanks to Static Code Analysis, Numeric type allow you to defined a numeric string and parse it to number automatically.
Once validated, a numeric type will be parsed as number automatically both on runtime and type level to fits our need.
app.get('/id/:id', ({ params: { id } }) => id, {
params: t.Object({
id: t.Numeric()
})
})
You can use numeric type on any property that support schema typing, including:
- params
- query
- headers
- body
- response
We hope that you will find this new Numeric type useful in your server.
You can find more detail at numeric type page in concept.
With TypeBox 0.28, we are making Elysia type system we more complete, and we excited to see how it play out on your end.
Inline Schema
You might have notice already that our example are not using a schema.type
to create a type anymore, because we are making a breaking change to move schema and inline it to hook statement instead.
// ? From
app.get('/id/:id', ({ params: { id } }) => id, {
schema: {
params: t.Object({
id: t.Number()
})
},
})
// ? To
app.get('/id/:id', ({ params: { id } }) => id, {
params: t.Object({
id: t.Number()
})
})
We think a lot when making a breaking change especially to one of the most important part of Elysia.
Based on a lot of tinkering and real-world usage, we try to suggest this new change for our community with a vote, and found that around 60% of Elysia developer are happy with migrating to the inline schema.
But we also listen the the rest of our community, and try to get around with the argument against this decision:
Clear separation
With the old syntax, you have to explicitly tells Elysia that the part you are creating are a schema using Elysia.t
.
Creating a clear separation between life-cycle and schema are more clear and has a better readability.
But from our intense test, we found that most people don't have any problem struggling reading a new syntax, separating life-cycle hook from schema type, we found that it still has clear separation with t.Type
and function, and a different syntax highlight when reviewing the code, although not as good as clear as explicit schema, but people can get used to the new syntax very quickly especially if they are familiar the Elysia.
Auto completion
One of the other area that people are concerned about are reading auto-completion.
Merging schema and life-cycle hook caused the auto-completion to have around 10 properties for auto-complete to suggest, and based on many proven general User Experience research, it can be frastating for user to that many options to choose from, and can cause a steeper learning curve.
However, we found that the schema property name of Elysia is quite predictable to get over this problem once developer are used to Elysia type.
For example, if you want to access a ...