@elysiumjs/core

中文文档
0.24.1发布于 2026/11/12Apache-2.0TypeScriptES Module近 30 天下载 6046

A structured web framework built on ElysiaJS, for Bun, Node.js and Cloudflare Workers.

此软件包已被标记为弃用

has moved into @elysiumjs/olympus: import from @elysiumjs/olympus.

原始使用文档
文档目录17 节
  1. Installation
  2. Features
  3. Usage
  4. Request values
  5. Responses and problems
  6. Results
  7. Environment
  8. Time
  9. Services
  10. Tags
  11. Extension points
  12. Background work
  13. Lazy boot
  14. Runtimes
  15. Documentation
  16. Contributing
  17. License

@elysiumjs/core

Core functionalities for the Elysium.js framework.

NOTE

This package is part of the Elysium.js framework, which is built exclusively for Bun runtime.

Installation

bun install @elysiumjs/core

Features

  • Dependency Injection
  • HTTP Controllers
  • WebSocket support
  • WAMP protocol integration
  • Command-line interface (@elysiumjs/styx), with support for custom commands
  • Lifecycle events
  • Caching (memory and Redis)
  • PostgreSQL database support with Drizzle ORM
  • Model classes and repositories for database interactions
  • Multi-tenancy support enabled
  • OpenAPI documentation (Scalar or Swagger UI)

Usage

Create a controller class:

import { Http } from '@elysiumjs/core';

@Http.controller('/users')
export class UserController {
    @Http.get('/:id')
    public getUser(id: string) {
        return { id };
    }
}

Create a module class. A module is the only way to register anything in an Elysium.js app: controllers, commands, jobs and services are all declared by the module that owns them.

import { Module } from '@elysiumjs/core';

import { UserController } from './controllers/http/user.controller';
import { UserService } from './services/user.service';

@Module.register({
    services: [UserService],
    controllers: [UserController]
})
export class MainModule extends Module {}

Create an application class. Modules that need configuration are bound to it with with():

import { Application, RedisModule } from '@elysiumjs/core';
import { HermesModule } from '@elysiumjs/hermes';

import { MainModule } from './main.module';

@Application.register({
    modules: [
        HermesModule.with({ level: 'debug', format: 'pretty' }),
        RedisModule.with({
            default: 'cache',
            connections: { cache: { url: process.env.REDIS_URL! } }
        }),
        MainModule
    ],
    server: {
        name: 'Elysium',
        port: 3000
    }
})
export class App extends Application {}

Create an entrypoint file:

import { App } from './app';

await App.start();

Start the server:

bun run index.ts serve

Or, if using styx, run:

bun styx serve

Request values

Route parameters receive the parts of the request. Their schemas validate the request before the handler runs and document the route in OpenAPI:

import { Http, t } from '@elysiumjs/core';

@Http.controller('/users')
export class UserController {
    @Http.patch('/:id', { headers: t.Object({ 'if-match': t.Optional(t.String()) }) })
    public update(
        @Http.param('id') id: string,
        @Http.body(t.Object({ name: t.String() })) body: { name: string },
        @Http.query(t.Object({ notify: t.Optional(t.String()) })) query: { notify?: string },
        @Http.header('x-tenant-id') tenant: string
    ) {
        return { id, tenant, ...body };
    }
}
  • @Http.param(), @Http.body() and @Http.query() give the URL parameter, the body and the query. A value that does not match its schema gets a 422 VALIDATION_FAILED problem.
  • @Http.header() gives a header, a required string unless a schema says otherwise. The headers option declares headers the handler does not read itself. Missing or malformed headers get a 400 BAD_REQUEST problem.

Responses and problems

A route declares the schema of its handler's result in response, and the problems it answers with in errors. Both are documented in OpenAPI.

import { DomainError, Http, t } from '@elysiumjs/core';

export class InsufficientFunds extends DomainError.for('INSUFFICIENT_FUNDS', 409, {
    extensions: { missing: t.Integer() }
}) {}

const Wallet = t.Object({ id: t.String(), balance: t.Integer() });

@Http.controller('/wallets')
export class WalletController {
    @Http.post('/:id/pay', { response: Wallet, errors: [InsufficientFunds] })
    public pay(
        @Http.param('id') id: string,
        @Http.body(t.Object({ amount: t.Integer() })) body: { amount: number }
    ) {
        if (body.amount > 100)
            return new InsufficientFunds('Top up first.', { extensions: { missing: body.amount - 100 } });
        return { id, balance: 100 - body.amount };
    }
}
  • A class made with DomainError.for(code, status, { extensions }) has static code, status and schema. schema is the problem details it is sent as, with the code and the status as literals and the extension members; the constructor's extensions are typed from them.
  • The problems of one status are documented as a union. A schema the route gives for that status in response wins over them.
  • The framework adds its own: 422 VALIDATION_FAILED when the body or the query is validated (400 with problems.validationStatus), 400 BAD_REQUEST for a body to parse or declared headers, 413 PAYLOAD_TOO_LARGE under the body limit, 429 RATE_LIMITED with a rate limit, and 500 INTERNAL_ERROR on every route.
  • A middleware, an interceptor or a validator that answers with problems lists their classes in a static errors, and the routes they apply to document them.
  • status sets the status of a successful response: @Http.post('/', { status: 201, response: Wallet }) answers 201 and documents Wallet under it, and @Http.del('/:id', { status: 204 }) answers without a body. A handler that sets ctx.set.status keeps its own.
  • Http.currentContext() gives the context of the request being handled to code outside the handler, such as a service, and undefined outside a request.

An interceptor that changes the shape of results describes it, so that routes declare the result of their handler. TransformInterceptor.with(transform, { schema }) passes the success schemas of the routes it wraps through schema, in the order results go through the interceptors (route, controller, module, then application). The new schema is the one documented, validated and serialized, and schema must leave alone what transform leaves alone:

const Envelope = TransformInterceptor.with(
    (result) => (isEnveloped(result) ? result : { data: result }),
    { schema: (schema) => (isEnvelopedSchema(schema) ? schema : t.Object({ data: schema })) }
);

Results

A check or a call that may fail returns a Result: the value it passed with, or the DomainError it failed with. A handler may return one: a passed result answers its value, a failed one its problem. AsyncResult is the result to come of a promise, with the same methods, and a handler may return it too.

import { AsyncResult, ForbiddenError, Result } from '@elysiumjs/core';

const mayBuy = (account: Account): Result<Buyer, ForbiddenError> =>
    account.adult ? Result.pass({ id: account.id }) : Result.fail(new ForbiddenError('Adults only.'));

const buyer = mayBuy(account);
if (!buyer.check()) return buyer.error;
buyer.result.id;

return mayBuy(account).errorOr((buyer) => view.render(buyer));

const wallet = await AsyncResult.from(wallets.open(userId)).orThrow();
  • check() narrows the result: result is the value on the passed side, error the problem on the failed side.
  • orThrow() gives the value or throws the error, orError() gives the value or the error, and errorOr(transform) gives the error or the transformed value, as a handler returns it.
  • map(transform) transforms the value and andThen(next) chains the next result; a failure skips both.
  • Result.of(value) and AsyncResult.from(promise) make a result of a T | DomainError union, and keep a result as it is. Result.is(value) tells a result from other values.
  • Result is not a thenable, so awaiting one gives the result itself. Awaiting an AsyncResult gives its Result.

Environment

The app and its modules declare the environment variables they read as TypeBox objects. They are validated when the app runs or is built, with every missing or invalid variable listed, and read with Env.get(). A file that declares a schema also augments AppEnv, so that Env.get() knows each name and its type:

// src/env.ts
import type { Env } from '@elysiumjs/core';

import { Secret, t } from '@elysiumjs/core';

export const env = t.Object({
    PORT: t.Number({ default: 3000 }),
    DATABASE_URL: Secret.schema()
});

declare module '@elysiumjs/core' {
    interface AppEnv extends Env.Infer<typeof env> {}
}

The app passes the schema as its env option, and a module as its envSchema option, declared the same way in its own file. Then:

Env.get('PORT'); // number
Env.get('DATABASE_URL').reveal(); // string: a Secret prints as [secret] in logs and JSON
Env.get('PROT'); // compile error: not declared
  • Once any variable is declared, Env.get() only accepts declared names. Without any, it takes any name and returns the raw string.
  • A variable is typed even when its module is inactive in the current environment: declare the variables of such a module with t.Optional().
  • In an app with a schema, reading Env before the environment is validated throws EnvironmentNotReadyError. Read it in onInit, a service, or a with() factory.
  • The runtime's env() stays the raw environment, before validation.

Time

Code that decides on time, such as expiries, schedules and the instants it records, reads it from Clock instead of new Date(). Moirai, Heracles schedules and Themis tokens, sessions and signed URLs do. Tests and development can then freeze it or move it:

import { Clock } from '@elysiumjs/core';

Clock.now(); // a Date
Clock.epochMs(); // milliseconds since the epoch

Clock.freeze('2026-01-01T00:00:00Z');
Clock.travel(14 * 24 * 3600 * 1000); // 14 days later, frozen or running
Clock.reset();

freeze() and travel() throw ClockAdjustmentRefusedError outside development and test. Clock.use(source) takes the time from another ClockSource. Durations that only measure the machine, such as request timings and locks, keep the real time.

Services

A service is identified by its class. Give it a name with @Service.register({ name }) to look it up by that string instead. Class names are never used as identifiers, so services resolve the same way after a bundler renames classes (bun run check:minified builds a minified app and checks it).

import { Service, ServiceScope } from '@elysiumjs/core';

@Service.register()
export class UserRepository {}

@Service.register({ name: 'mailer' })
export class MailerService {}

@Service.register()
export class UserService {
    constructor(
        @Service.inject(UserRepository) private readonly users: UserRepository,
        @Service.inject('mailer') private readonly mailer: MailerService
    ) {}
}

@Service.inject() without an argument injects the class of the parameter type, read from emitDecoratorMetadata. Pass the class or the name when the type is an interface or is missing; Elysium throws at declaration time when it cannot tell which service to inject.

A service has one of three scopes:

ScopeInstances
ServiceScope.SINGLETONOne for the app (the default)
ServiceScope.FACTORYA new one each time the service is resolved
ServiceScope.REQUESTOne per HTTP request, shared by its middlewares and handler

A request-scoped service resolves only inside a request: resolving it elsewhere throws OutsideRequestScopeError. Code outside a route, such as a job or a test, opens a scope with Service.runInRequestScope(fn); every request-scoped service resolved inside fn, including after an await, is shared for that run. A singleton, or a controller with the default server scope, cannot depend on a request-scoped service, since it would keep the first request's instance: Elysium throws ScopeMismatchError. Use a request-scoped controller (scope: HttpControllerScope.REQUEST), or inject the service in the route handler's parameters.

Tags

A tag groups services: each service registered with it is injected together with the others. Use it for extension points that modules contribute to, such as health checks or permission rules.

import { Service } from '@elysiumjs/core';

export const HealthChecks = Service.tag<HealthCheck>('health.checks');

@Service.register({ tags: [HealthChecks] })
export class DatabaseCheck implements HealthCheck {}

@Service.register()
export class Health {
    constructor(@Service.injectAll(HealthChecks) private readonly checks: HealthCheck[]) {}
}

@Service.injectAll(tag) and Service.getAll(tag) give the tagged services in registration order, and an empty array when there is none; Service.resolveAll(tag) also waits for their onInit. A module receives the tagged services of every module of the application, including the ones it does not import, while getting one of them by its class still follows the module's imports and exports. Scopes apply to each tagged service as they do to @Service.inject().

Extension points

When each service of a tag carries options of its own, such as a name and a channel, Service.extension() makes the tag, the decorator that registers a service with its options, and the lookups. Names must be unique among the active services: all() throws DuplicateExtensionError otherwise.

type TransportOptions = { name: string; channel: 'email' | 'sms'; devOnly?: boolean };

export const Transports = Service.extension<Transport, TransportOptions>(
    'notifications.transports',
    {
        env: (options) => (options.devOnly ? Environments.local : undefined)
    }
);

@Transports.register({ name: 'resend', channel: 'email' })
export class ResendTransport implements Transport {}

Transports.all(); // [{ service, options }], in registration order
Transports.find('resend'); // the service, or undefined
Transports.optionsOf(ResendTransport); // { name: 'resend', channel: 'email' }

Transports.tag is the tag, for @Service.injectAll(). settings.scope and settings.global apply to every service of the extension point.

Service.invoke(target, method, args) calls a method whose parameters take services with @Service.inject() or @Service.injectAll(): each of them receives its service, resolved in the current module context, and the other parameters receive args in order.

Service.get() answers null for a service that is not visible: keep it for optional services. For a service the code needs, use Service.require(), which returns it at once, or Service.resolve(), which waits for its initialisation: both throw a precise error when it is missing.

Background work

Background scopes wrap every unit of background work (a job, an event handler, an outbox handler), the way interceptors wrap requests: they set what the work needs once, such as a config or an actor scope. A scope is a service registered with @Background.scope(); scopes nest in the order the modules register them, the first one outermost.

import type { BackgroundScope, BackgroundWork } from '@elysiumjs/core';

import { Background } from '@elysiumjs/core';

@Background.scope()
export class SystemActorScope implements BackgroundScope {
    public run<T>(work: BackgroundWork, next: () => Promise<T>): Promise<T> {
        return Actor.run(Actor.system(work.name), next);
    }
}

work.kind says what runs (job, event or outbox), and work.name names it. Background.run(work, fn) runs other work in the scopes.

Lazy boot

A CLI command boots lazily: running app:cmd initialises the command's own module and the modules it imports, not the whole graph. help <cmd> is always lazy, even for commands with static boot = true.

  • Sync helpers are safe (Service.get(), Database.getConnection(), Redis.getConnection(), ...) when the module that owns the service initialises synchronously. Elysium then initialises that module on the spot, even if the command does not import it.
  • Use await Service.resolve(...) when the owning module has an asynchronous onInit. A sync get then throws ServiceNotReadyError, which names the module.
  • Use static boot = true on a command that needs every module initialised before it runs (workers, servers). It initialises every module and builds every service first.
  • Service factories receive a synchronous get; it may only read services whose owning modules initialise synchronously, otherwise it throws ServiceNotReadyError.

Runtimes

Core never calls a host API directly. Environment variables, timers, the HTTP server, process exit and shutdown signals come from a runtime, which ships as its own package:

PackageExportsNotes
@elysiumjs/runtime-bunruntimeBun.env, Bun.sleep, listen, process exit and signals
@elysiumjs/runtime-noderuntimeSame on Node.js; set server: { adapter: node() } from @elysiajs/node
@elysiumjs/runtime-workersruntime, createRuntime({ env })Env from process.env (nodejs_compat) or env; no serve (export the app's fetch), exit or signals

Each package exports runtime (rename it at import if you like). Pass it to the application, or call Runtime.use() before anything needs it:

import { Application } from '@elysiumjs/core';
import { runtime } from '@elysiumjs/runtime-bun';

@Application.register({ runtime, modules: [] })
class App extends Application {}

Without a runtime, Runtime.get() throws RuntimeNotConfiguredError; only the environment name falls back to development. bun run check:runtime bundles core, fails if it touches the Bun global, and boots an application from the bundle under Node.js.

Documentation

TODO

Contributing

This project is built primarily to meet requirements for internal projects at Workbud Technologies Inc.

Feel free to open issues for any bugs or questions you may have. Pull requests are also welcome, but they will be reviewed to ensure they align with our internal project's goals and standards.

License

This project is licensed under the Apache License, Version 2.0. See the LICENSE file for more information.

版本与发布记录

@elysiumjs/core 共发布 39 个版本,首次发布于 2025/04/21;过去 12 个月发布了 24 个版本。

版本发布日期距今
0.24.1latest2026/11/121 天前
0.24.02026/11/121 天前
0.23.12026/11/112 天前
0.23.02026/11/112 天前
0.22.02026/11/112 天前
0.21.02026/11/112 天前

关于 @elysiumjs/core 的常见问题

@elysiumjs/core 是什么?

A structured web framework built on ElysiaJS, for Bun, Node.js and Cloudflare Workers.注意:该包已被作者标记为弃用——has moved into @elysiumjs/olympus: import from @elysiumjs/olympus.

如何安装 @elysiumjs/core?

在项目目录运行 npm install @elysiumjs/core,或使用 pnpm add / yarn add / bun add。

@elysiumjs/core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@elysiumjs/core 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@elysiumjs/core 可以商用吗?

@elysiumjs/core 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@elysiumjs/core 的下载量有多少?

根据 npm 官方下载统计,@elysiumjs/core 在 2026/10/14 – 2026/11/12 这 30 天内被下载 6,046 次,日均约 202 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@elysiumjs/core 还在维护吗?

最新版本 0.24.1 发布于 2026/11/12(1 天前);至今共发布 39 个版本,其中过去 12 个月发布了 24 个;首次发布于 2025/04/21。作者已将其标记为弃用,新项目建议寻找替代方案。