--- url: 'https://elysia.cndocs.org/plugins/graphql-apollo.md' --- # GraphQL Apollo 插件 用于 [elysia](https://github.com/elysiajs/elysia) 的插件,可以使用 GraphQL Apollo。 使用以下命令安装: ```bash bun add graphql @elysiajs/apollo @apollo/server ``` 然后使用它: ```typescript import { Elysia } from 'elysia' import { apollo, gql } from '@elysiajs/apollo' const app = new Elysia() .use( apollo({ typeDefs: gql` type Book { title: String author: String } type Query { books: [Book] } `, resolvers: { Query: { books: () => { return [ { title: 'Elysia', author: 'saltyAom' } ] } } } }) ) .listen(3000) ``` 访问 `/graphql` 应该会显示 Apollo GraphQL playground 工作情况。 ## 背景 由于 Elysia 基于 Web 标准请求和响应,这与 Express 使用的 Node 的 `HttpRequest` 和 `HttpResponse` 不同,导致 `req, res` 在上下文中为未定义。 因此,Elysia 用 `context` 替代两者,类似于路由参数。 ```typescript const app = new Elysia() .use( apollo({ typeDefs, resolvers, context: async ({ request }) => { const authorization = request.headers.get('Authorization') return { authorization } } }) ) .listen(3000) ``` ## 配置 该插件扩展了 Apollo 的 [ServerRegistration](https://www.apollographql.com/docs/apollo-server/api/apollo-server/#options)(即 `ApolloServer` 的构造参数)。 以下是用于使用 Elysia 配置 Apollo Server 的扩展参数。 ### path @default `"/graphql"` 暴露 Apollo Server 的路径。 ### enablePlayground @default `process.env.ENV !== 'production'` 确定 Apollo 是否应提供 Apollo Playground。 --- --- url: 'https://elysia.cndocs.org/plugins/bearer.md' --- # Bearer 插件 用于 [elysia](https://github.com/elysiajs/elysia) 的插件,用于获取 Bearer 令牌。 通过以下命令安装: ```bash bun add @elysiajs/bearer ``` 然后使用它: ```typescript twoslash import { Elysia } from 'elysia' import { bearer } from '@elysiajs/bearer' const app = new Elysia() .use(bearer()) .get('/sign', ({ bearer }) => bearer, { beforeHandle({ bearer, set, status }) { if (!bearer) { set.headers[ 'WWW-Authenticate' ] = `Bearer realm='sign', error="invalid_request"` return status(400, 'Unauthorized') } } }) .listen(3000) ``` 该插件用于获取在 [RFC6750](https://www.rfc-editor.org/rfc/rfc6750#section-2) 中指定的 Bearer 令牌。 该插件不处理您的服务器的身份验证验证。相反,该插件将决定权留给开发人员,以便他们自己应用验证检查的逻辑。 --- --- url: 'https://elysia.cndocs.org/integrations/better-auth.md' --- # 更好的身份验证 更好的身份验证是一个与框架无关的 TypeScript 身份验证(和授权)框架。 它提供了一整套全面的功能,并包括一个插件生态系统,可以简化添加高级功能。 我们建议在访问此页面之前先查看 [Better Auth 基本设置](https://www.better-auth.com/docs/installation)。 我们基本的设置看起来如下: ```ts [auth.ts] import { betterAuth } from 'better-auth' import { Pool } from 'pg' export const auth = betterAuth({ database: new Pool() }) ``` ## 处理程序 在设置了更好的身份验证实例后,我们可以通过 [mount](/patterns/mount.html) 将其挂载到 Elysia。 我们需要将处理程序挂载到 Elysia 端点。 ```ts [index.ts] import { Elysia } from 'elysia' import { auth } from './auth' const app = new Elysia() .mount(auth.handler) // [!code ++] .listen(3000) console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ) ``` 然后我们可以通过 `http://localhost:3000/api/auth` 访问更好的身份验证。 ### 自定义端点 我们建议在使用 [mount](/patterns/mount.html) 时设置一个前缀路径。 ```ts [index.ts] import { Elysia } from 'elysia' const app = new Elysia() .mount('/auth', auth.handler) // [!code ++] .listen(3000) console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ) ``` 然后我们可以通过 `http://localhost:3000/auth/api/auth` 访问更好的身份验证。 但是这个 URL 看起来有些冗余,我们可以在更好的身份验证实例中将 `/api/auth` 前缀自定义为其他内容。 ```ts import { betterAuth } from 'better-auth' import { openAPI } from 'better-auth/plugins' import { passkey } from 'better-auth/plugins/passkey' import { Pool } from 'pg' export const auth = betterAuth({ basePath: '/api' // [!code ++] }) ``` 然后我们可以通过 `http://localhost:3000/auth/api` 访问 Better Auth。 不幸的是,我们不能将更好的身份验证实例的 `basePath` 设置为为空或 `/`。 ## OpenAPI 更好的身份验证支持使用 `better-auth/plugins` 的 `openapi`。 然而,如果我们使用 [@elysiajs/openapi](/plugins/openapi),您可能希望从更好的身份验证实例中提取文档。 我们可以通过以下代码实现: ```ts import { openAPI } from 'better-auth/plugins' let _schema: ReturnType const getSchema = async () => (_schema ??= auth.api.generateOpenAPISchema()) export const OpenAPI = { getPaths: (prefix = '/auth/api') => getSchema().then(({ paths }) => { const reference: typeof paths = Object.create(null) for (const path of Object.keys(paths)) { const key = prefix + path reference[key] = paths[path] for (const method of Object.keys(paths[path])) { const operation = (reference[key] as any)[method] operation.tags = ['Better Auth'] } } return reference }) as Promise, components: getSchema().then(({ components }) => components) as Promise } as const ``` 然后在我们使用 `@elysiajs/swagger` 的 Elysia 实例中。 ```ts import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' import { OpenAPI } from './auth' const app = new Elysia().use( openapi({ documentation: { components: await OpenAPI.components, paths: await OpenAPI.getPaths() } }) ) ``` ## CORS 要配置 CORS,您可以使用 `@elysiajs/cors` 中的 `cors` 插件。 ```ts import { Elysia } from 'elysia' import { cors } from '@elysiajs/cors' import { auth } from './auth' const app = new Elysia() .use( cors({ origin: 'http://localhost:3001', methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], credentials: true, allowedHeaders: ['Content-Type', 'Authorization'] }) ) .mount(auth.handler) .listen(3000) console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ) ``` ## 宏 您可以结合使用 [macro](https://elysiajs.com/patterns/macro.html#macro) 和 [resolve](https://elysiajs.com/essential/handler.html#resolve) 来在传递给视图之前提供会话和用户信息。 ```ts import { Elysia } from 'elysia' import { auth } from './auth' // 用户中间件(计算用户和会话并传递给路由) const betterAuth = new Elysia({ name: 'better-auth' }) .mount(auth.handler) .macro({ auth: { async resolve({ status, request: { headers } }) { const session = await auth.api.getSession({ headers }) if (!session) return status(401) return { user: session.user, session: session.session } } } }) const app = new Elysia() .use(betterAuth) .get('/user', ({ user }) => user, { auth: true }) .listen(3000) console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ) ``` 这将允许您在所有路由中访问 `user` 和 `session` 对象。 --- --- url: 'https://elysia.cndocs.org/plugins/cors.md' --- # CORS 插件 这个插件为自定义 [跨源资源共享](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) 行为提供支持。 安装命令: ```bash bun add @elysiajs/cors ``` 然后使用它: ```typescript twoslash import { Elysia } from 'elysia' import { cors } from '@elysiajs/cors' new Elysia().use(cors()).listen(3000) ``` 这样将使 Elysia 接受来自任何源的请求。 ## 配置 以下是该插件接受的配置 ### origin @默认 `true` 指示是否可以与来自给定来源的请求代码共享响应。 值可以是以下之一: * **字符串** - 源的名称,会直接分配给 [Access-Control-Allow-Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) 头部。 * **布尔值** - 如果设置为 true, [Access-Control-Allow-Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) 将设置为 `*`(任何来源)。 * **RegExp** - 匹配请求 URL 的模式,如果匹配则允许。 * **函数** - 自定义逻辑以允许资源共享,如果返回 true 则允许。 * 预期具有以下类型: ```typescript cors(context: Context) => boolean | void ``` * **Array\** - 按顺序迭代上述所有情况,只要有任何一个值为 `true` 则允许。 *** ### methods @默认 `*` 允许的跨源请求方法。 分配 [Access-Control-Allow-Methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods) 头部。 值可以是以下之一: * **undefined | null | ''** - 忽略所有方法。 * **\*** - 允许所有方法。 * **字符串** - 期望单个方法或逗号分隔的字符串 * (例如: `'GET, PUT, POST'`) * **string\[]** - 允许多个 HTTP 方法。 * 例如: `['GET', 'PUT', 'POST']` *** ### allowedHeaders @默认 `*` 允许的传入请求头。 分配 [Access-Control-Allow-Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) 头部。 值可以是以下之一: * **字符串** - 期望单个头或逗号分隔的字符串 * 例如: `'Content-Type, Authorization'`。 * **string\[]** - 允许多个 HTTP 头。 * 例如: `['Content-Type', 'Authorization']` *** ### exposeHeaders @默认 `*` 响应 CORS 中包含指定的头部。 分配 [Access-Control-Expose-Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers) 头部。 值可以是以下之一: * **字符串** - 期望单个头或逗号分隔的字符串。 * 例如: `'Content-Type, X-Powered-By'`。 * **string\[]** - 允许多个 HTTP 头。 * 例如: `['Content-Type', 'X-Powered-By']` *** ### credentials @默认 `true` Access-Control-Allow-Credentials 响应头告诉浏览器在请求的凭证模式 [Request.credentials](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials) 为 `include` 时,是否将响应暴露给前端 JavaScript 代码。 当请求的凭证模式 [Request.credentials](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials) 为 `include` 时,浏览器仅在 Access-Control-Allow-Credentials 值为 true 的情况下,将响应暴露给前端 JavaScript 代码。 凭证包括 cookies、授权头或 TLS 客户端证书。 分配 [Access-Control-Allow-Credentials](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials) 头部。 *** ### maxAge @默认 `5` 指示 [预检请求](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) 的结果(即包含在 [Access-Control-Allow-Methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods) 和 [Access-Control-Allow-Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) 头部中的信息)可以缓存多久。 分配 [Access-Control-Max-Age](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age) 头部。 *** ### preflight 预检请求是用来检查 CORS 协议是否被理解以及服务器是否知道如何使用特定方法和头部的请求。 使用 **OPTIONS** 请求的响应中包含 3 个 HTTP 请求头: * **Access-Control-Request-Method** * **Access-Control-Request-Headers** * **Origin** 此配置指示服务器是否应该响应预检请求。 ## 示例 以下是使用该插件的常见模式。 ## 按顶级域名允许 CORS ```typescript twoslash import { Elysia } from 'elysia' import { cors } from '@elysiajs/cors' const app = new Elysia() .use( cors({ origin: /.*\.saltyaom\.com$/ }) ) .get('/', () => '你好') .listen(3000) ``` 这将允许来自顶级域名 `saltyaom.com` 的请求。 --- --- url: 'https://elysia.cndocs.org/plugins/cron.md' --- # Cron 插件 此插件为 Elysia 服务器添加了运行 cronjob 的支持。 通过以下方式安装: ```bash bun add @elysiajs/cron ``` 然后使用它: ```typescript twoslash import { Elysia } from 'elysia' import { cron } from '@elysiajs/cron' new Elysia() .use( cron({ name: 'heartbeat', pattern: '*/10 * * * * *', run() { console.log('Heartbeat') } }) ) .listen(3000) ``` 上述代码将每 10 秒记录一次 `heartbeat`。 ## cron 为 Elysia 服务器创建一个 cronjob。 类型: ``` cron(config: CronConfig, callback: (Instance['store']) => void): this ``` `CronConfig` 接受以下参数: ### name 注册到 `store` 的作业名称。 这将以指定的名称将 cron 实例注册到 `store`,可供后续过程引用,例如停止作业。 ### pattern 根据下面的 [cron 语法](https://en.wikipedia.org/wiki/Cron) 指定作业运行时间: ``` ┌────────────── 秒(可选) │ ┌──────────── 分钟 │ │ ┌────────── 小时 │ │ │ ┌──────── 每月的日期 │ │ │ │ ┌────── 月 │ │ │ │ │ ┌──── 星期几 │ │ │ │ │ │ * * * * * * ``` 可以使用 [Crontab Guru](https://crontab.guru/) 等工具生成。 *** 此插件通过 [cronner](https://github.com/hexagon/croner) 扩展了 Elysia 的 cron 方法。 以下是 cronner 接受的配置。 ### timezone 以欧洲/斯德哥尔摩格式表示的时区。 ### startAt 作业的调度开始时间。 ### stopAt 作业的调度停止时间。 ### maxRuns 最大执行次数。 ### catch 即使触发的函数抛出未处理错误,也继续执行。 ### interval 执行之间的最小间隔(秒)。 ## 模式 下面是使用该插件的常用模式。 ## 停止 cronjob 您可以通过访问注册到 `store` 的 cronjob 名称手动停止 cronjob。 ```typescript import { Elysia } from 'elysia' import { cron } from '@elysiajs/cron' const app = new Elysia() .use( cron({ name: 'heartbeat', pattern: '*/1 * * * * *', run() { console.log('Heartbeat') } }) ) .get( '/stop', ({ store: { cron: { heartbeat } } }) => { heartbeat.stop() return 'Stop heartbeat' } ) .listen(3000) ``` ## 预定义模式 您可以使用 `@elysiajs/cron/schedule` 中的预定义模式。 ```typescript import { Elysia } from 'elysia' import { cron, Patterns } from '@elysiajs/cron' const app = new Elysia() .use( cron({ name: 'heartbeat', pattern: Patterns.everySecond(), run() { console.log('Heartbeat') } }) ) .get( '/stop', ({ store: { cron: { heartbeat } } }) => { heartbeat.stop() return 'Stop heartbeat' } ) .listen(3000) ``` ### 函数 | 函数 | 描述 | | -------------------------------------- | --------------------------------------------------- | | `.everySeconds(2)` | 每 2 秒运行一次任务 | | `.everyMinutes(5)` | 每 5 分钟运行一次任务 | | `.everyHours(3)` | 每 3 小时运行一次任务 | | `.everyHoursAt(3, 15)` | 每 3 小时在 15 分钟时运行一次任务 | | `.everyDayAt('04:19')` | 每天在 04:19 运行一次任务 | | `.everyWeekOn(Patterns.MONDAY, '19:30')` | 每周一在 19:30 运行一次任务 | | `.everyWeekdayAt('17:00')` | 每个工作日的 17:00 运行一次任务 | | `.everyWeekendAt('11:00')` | 每周六和周日在 11:00 运行一次任务 | ### 函数别名到常量 | 函数 | 常量 | | ----------------- | ------------------------------ | | `.everySecond()` | EVERY\_SECOND | | `.everyMinute()` | EVERY\_MINUTE | | `.hourly()` | EVERY\_HOUR | | `.daily()` | EVERY\_DAY\_AT\_MIDNIGHT | | `.everyWeekday()` | EVERY\_WEEKDAY | | `.everyWeekend()` | EVERY\_WEEKEND | | `.weekly()` | EVERY\_WEEK | | `.monthly()` | EVERY\_1ST\_DAY\_OF\_MONTH\_AT\_MIDNIGHT | | `.everyQuarter()` | EVERY\_QUARTER | | `.yearly()` | EVERY\_YEAR | ### 常量 | 常量 | 模式 | | --------------------------------------- | ----------------------- | | `.EVERY_SECOND` | `* * * * * *` | | `.EVERY_5_SECONDS` | `*/5 * * * * *` | | `.EVERY_10_SECONDS` | `*/10 * * * * *` | | `.EVERY_30_SECONDS` | `*/30 * * * * *` | | `.EVERY_MINUTE` | `*/1 * * * *` | | `.EVERY_5_MINUTES` | `0 */5 * * * *` | | `.EVERY_10_MINUTES` | `0 */10 * * * *` | | `.EVERY_30_MINUTES` | `0 */30 * * * *` | | `.EVERY_HOUR` | `0 0-23/1 * * *` | | `.EVERY_2_HOURS` | `0 0-23/2 * * *` | | `.EVERY_3_HOURS` | `0 0-23/3 * * *` | | `.EVERY_4_HOURS` | `0 0-23/4 * * *` | | `.EVERY_5_HOURS` | `0 0-23/5 * * *` | | `.EVERY_6_HOURS` | `0 0-23/6 * * *` | | `.EVERY_7_HOURS` | `0 0-23/7 * * *` | | `.EVERY_8_HOURS` | `0 0-23/8 * * *` | | `.EVERY_9_HOURS` | `0 0-23/9 * * *` | | `.EVERY_10_HOURS` | `0 0-23/10 * * *` | | `.EVERY_11_HOURS` | `0 0-23/11 * * *` | | `.EVERY_12_HOURS` | `0 0-23/12 * * *` | | `.EVERY_DAY_AT_1AM` | `0 01 * * *` | | `.EVERY_DAY_AT_2AM` | `0 02 * * *` | | `.EVERY_DAY_AT_3AM` | `0 03 * * *` | | `.EVERY_DAY_AT_4AM` | `0 04 * * *` | | `.EVERY_DAY_AT_5AM` | `0 05 * * *` | | `.EVERY_DAY_AT_6AM` | `0 06 * * *` | | `.EVERY_DAY_AT_7AM` | `0 07 * * *` | | `.EVERY_DAY_AT_8AM` | `0 08 * * *` | | `.EVERY_DAY_AT_9AM` | `0 09 * * *` | | `.EVERY_DAY_AT_10AM` | `0 10 * * *` | | `.EVERY_DAY_AT_11AM` | `0 11 * * *` | | `.EVERY_DAY_AT_NOON` | `0 12 * * *` | | `.EVERY_DAY_AT_1PM` | `0 13 * * *` | | `.EVERY_DAY_AT_2PM` | `0 14 * * *` | | `.EVERY_DAY_AT_3PM` | `0 15 * * *` | | `.EVERY_DAY_AT_4PM` | `0 16 * * *` | | `.EVERY_DAY_AT_5PM` | `0 17 * * *` | | `.EVERY_DAY_AT_6PM` | `0 18 * * *` | | `.EVERY_DAY_AT_7PM` | `0 19 * * *` | | `.EVERY_DAY_AT_8PM` | `0 20 * * *` | | `.EVERY_DAY_AT_9PM` | `0 21 * * *` | | `.EVERY_DAY_AT_10PM` | `0 22 * * *` | | `.EVERY_DAY_AT_11PM` | `0 23 * * *` | | `.EVERY_DAY_AT_MIDNIGHT` | `0 0 * * *` | | `.EVERY_WEEK` | `0 0 * * 0` | | `.EVERY_WEEKDAY` | `0 0 * * 1-5` | | `.EVERY_WEEKEND` | `0 0 * * 6,0` | | `.EVERY_1ST_DAY_OF_MONTH_AT_MIDNIGHT` | `0 0 1 * *` | | `.EVERY_1ST_DAY_OF_MONTH_AT_NOON` | `0 12 1 * *` | | `.EVERY_2ND_HOUR` | `0 */2 * * *` | | `.EVERY_2ND_HOUR_FROM_1AM_THROUGH_11PM` | `0 1-23/2 * * *` | | `.EVERY_2ND_MONTH` | `0 0 1 */2 *` | | `.EVERY_QUARTER` | `0 0 1 */3 *` | | `.EVERY_6_MONTHS` | `0 0 1 */6 *` | | `.EVERY_YEAR` | `0 0 1 1 *` | | `.EVERY_30_MINUTES_BETWEEN_9AM_AND_5PM` | `0 */30 9-17 * * *` | | `.EVERY_30_MINUTES_BETWEEN_9AM_AND_6PM` | `0 */30 9-18 * * *` | | `.EVERY_30_MINUTES_BETWEEN_10AM_AND_7PM`| `0 */30 10-19 * * *` | --- --- url: 'https://elysia.cndocs.org/eden/fetch.md' --- # Eden Fetch 一个像 Fetch 的替代品,与 Eden Treaty 相比。 使用 Eden Fetch,可以使用 Fetch API 以类型安全的方式与 Elysia 服务器交互。 *** 首先导出您现有的 Elysia 服务器类型: ```typescript // server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/hi', () => 'Hi Elysia') .get('/id/:id', ({ params: { id } }) => id) .post('/mirror', ({ body }) => body, { body: t.Object({ id: t.Number(), name: t.String() }) }) .listen(3000) export type App = typeof app ``` 然后导入服务器类型,并在客户端使用 Elysia API: ```typescript import { edenFetch } from '@elysiajs/eden' import type { App } from './server' const fetch = edenFetch('http://localhost:3000') // 响应类型: 'Hi Elysia' const pong = await fetch('/hi', {}) // 响应类型: 1895 const id = await fetch('/id/:id', { params: { id: '1895' } }) // 响应类型: { id: 1895, name: 'Skadi' } const nendoroid = await fetch('/mirror', { method: 'POST', body: { id: 1895, name: 'Skadi' } }) ``` ## 错误处理 您可以像处理 Eden Treaty 一样处理错误: ```typescript import { edenFetch } from '@elysiajs/eden' import type { App } from './server' const fetch = edenFetch('http://localhost:3000') // 响应类型: { id: 1895, name: 'Skadi' } const { data: nendoroid, error } = await fetch('/mirror', { method: 'POST', body: { id: 1895, name: 'Skadi' } }) if(error) { switch(error.status) { case 400: case 401: throw error.value break case 500: case 502: throw error.value break default: throw error.value break } } const { id, name } = nendoroid ``` ## 何时应该使用 Eden Fetch 而不是 Eden Treaty 与 Elysia < 1.0 不同,Eden Fetch 现在并不比 Eden Treaty 更快。 选择取决于您和您的团队的协议,然而我们建议使用 [Eden Treaty](/eden/treaty/overview)。 对于 Elysia < 1.0: 使用 Eden Treaty 需要大量的降级迭代来一次性映射所有可能的类型,而相反,Eden Fetch 可以延迟执行,直到您选择一个路由。 对于复杂的类型和大量的服务器路由,在低端开发设备上使用 Eden Treaty 可能导致类型推断和自动补全变慢。 但随着 Elysia 调整和优化了很多类型和推断,Eden Treaty 在大量路由中表现得非常好。 如果您的单个进程包含 **超过 500 个路由**,而您需要在 **单个前端代码库中使用所有路由**,那么您可能想要使用 Eden Fetch,因为它的 TypeScript 性能显著优于 Eden Treaty。 --- --- url: 'https://elysia.cndocs.org/eden/treaty/parameters.md' --- # 参数 我们最终需要向服务器发送一个有效载荷。 为此,Eden Treaty 的方法接受 2 个参数来发送数据到服务器。 这两个参数都是类型安全的,并将由 TypeScript 自动指导: 1. body 2. 其他参数 * query * headers * fetch ```typescript import { Elysia, t } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .post('/user', ({ body }) => body, { body: t.Object({ name: t.String() }) }) .listen(3000) const api = treaty('localhost:3000') // ✅ 有效 api.user.post({ name: 'Elysia' }) // ✅ 也有效 api.user.post({ name: 'Elysia' }, { // 在模式中未指定,这是可选的 headers: { authorization: 'Bearer 12345' }, query: { id: 2 } }) ``` 除非方法不接受 body,否则将省略 body,仅保留一个参数。 如果方法为 **"GET"** 或 **"HEAD"**: 1. 其他参数 * query * headers * fetch ```typescript import { Elysia } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .get('/hello', () => 'hi') .listen(3000) const api = treaty('localhost:3000') // ✅ 有效 api.hello.get({ // 在模式中未指定,这是可选的 headers: { hello: 'world' } }) ``` ## 空的 body 如果 body 可选或不需要,但 query 或 headers 是必需的,则可以将 body 传递为 `null` 或 `undefined`。 ```typescript import { Elysia, t } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .post('/user', () => 'hi', { query: t.Object({ name: t.String() }) }) .listen(3000) const api = treaty('localhost:3000') api.user.post(null, { query: { name: 'Ely' } }) ``` ## Fetch 参数 Eden Treaty 是一个 fetch 封装,我们可以通过将有效的 [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) 参数传递给 `$fetch` 来添加到 Eden 中: ```typescript import { Elysia, t } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .get('/hello', () => 'hi') .listen(3000) const api = treaty('localhost:3000') const controller = new AbortController() const cancelRequest = setTimeout(() => { controller.abort() }, 5000) await api.hello.get({ fetch: { signal: controller.signal } }) clearTimeout(cancelRequest) ``` ## 文件上传 我们可以传递以下任一项来附加文件: * **File** * **File\[]** * **FileList** * **Blob** 附加文件将使 **content-type** 变为 **multipart/form-data** 假设我们有如下的服务器: ```typescript import { Elysia, t } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .post('/image', ({ body: { image, title } }) => title, { body: t.Object({ title: t.String(), image: t.Files() }) }) .listen(3000) export const api = treaty('localhost:3000') const images = document.getElementById('images') as HTMLInputElement const { data } = await api.image.post({ title: "Misono Mika", image: images.files!, }) ``` --- --- url: 'https://elysia.cndocs.org/eden/treaty/response.md' --- # 响应 一旦调用 fetch 方法,Eden Treaty 会返回一个包含以下属性的对象的 `Promise`: * data - 响应返回的值(2xx) * error - 响应返回的错误值(>= 3xx) * response `Response` - Web 标准的 Response 类 * status `number` - HTTP 状态码 * headers `FetchRequestInit['headers']` - 响应头信息 返回后,您必须进行错误处理以确保响应数据值被解包,否则该值将为可空。Elysia 提供了 `error()` 辅助函数来处理错误,Eden 会为错误值提供类型收窄。 ```typescript import { Elysia, t } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .post('/user', ({ body: { name }, status }) => { if(name === 'Otto') return status(400) return name }, { body: t.Object({ name: t.String() }) }) .listen(3000) const api = treaty('localhost:3000') const submit = async (name: string) => { const { data, error } = await api.user.post({ name }) // 类型: string | null console.log(data) if (error) switch(error.status) { case 400: // 错误类型将被收窄 throw error.value default: throw error.value } // 一旦错误被处理,类型将被解包 // 类型: string return data } ``` 默认情况下,Elysia 会自动推断 `error` 和 `response` 的类型到 TypeScript,Eden 将提供自动补全和类型收窄以实现准确的行为。 ::: tip 如果服务器响应的 HTTP 状态码 >= 300,则值始终为 `null`,而 `error` 会包含返回的值。 否则,响应值将传递给 `data`。 ::: ## 流响应 Eden 会将流响应或 [服务器发送事件 (Server-Sent Events)](/essential/handler.html#server-sent-events-sse) 解释为 `AsyncGenerator`,允许我们使用 `for await` 循环来消费该流。 ::: code-group ```typescript twoslash [流] import { Elysia } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .get('/ok', function* () { yield 1 yield 2 yield 3 }) const { data, error } = await treaty(app).ok.get() if (error) throw error for await (const chunk of data) console.log(chunk) // ^? ``` ```typescript twoslash [服务器发送事件] import { Elysia, sse } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .get('/ok', function* () { yield sse({ event: 'message', data: 1 }) yield sse({ event: 'message', data: 2 }) yield sse({ event: 'end' }) }) const { data, error } = await treaty(app).ok.get() if (error) throw error for await (const chunk of data) console.log(chunk) // ^? // ``` ::: ## 工具类型 Eden Treaty 提供了工具类型 `Treaty.Data` 和 `Treaty.Error` 来提取响应中的 `data` 和 `error` 类型。 ```typescript twoslash import { Elysia, t } from 'elysia' import { treaty, Treaty } from '@elysiajs/eden' const app = new Elysia() .post('/user', ({ body: { name }, status }) => { if(name === 'Otto') return status(400) return name }, { body: t.Object({ name: t.String() }) }) .listen(3000) const api = treaty('localhost:3000') type UserData = Treaty.Data // ^? // 或者你也可以传入一个响应对象 const response = await api.user.post({ name: 'Saltyaom' }) type UserDataFromResponse = Treaty.Data // ^? type UserError = Treaty.Error // ^? // ``` --- --- url: 'https://elysia.cndocs.org/eden/treaty/config.md' --- # 配置 Eden Treaty 接受 2 个参数: * **urlOrInstance** - URL 终端或 Elysia 实例 * **options**(可选) - 自定义获取行为 ## urlOrInstance 接受 URL 终端作为字符串或字面量 Elysia 实例。 Eden 会根据类型改变行为如下: ### URL 终端 (字符串) 如果传入 URL 终端,Eden Treaty 将使用 `fetch` 或 `config.fetcher` 创建对 Elysia 实例的网络请求。 ```typescript import { treaty } from '@elysiajs/eden' import type { App } from './server' const api = treaty('localhost:3000') ``` 你可以选择是否为 URL 终端指定协议。 Elysia 将自动附加终端如下: 1. 如果指定了协议,直接使用该 URL 2. 如果 URL 是 localhost 并且 ENV 不是生产环境,使用 http 3. 否则使用 https 这同样适用于 Web Socket,以确定使用 **ws://** 还是 **wss://**。 *** ### Elysia 实例 如果传入 Elysia 实例,Eden Treaty 将创建一个 `Request` 类,并直接传递到 `Elysia.handle`,而无需创建网络请求。 这使我们能够直接与 Elysia 服务器交互,而无需请求开销或启动服务器。 ```typescript import { Elysia } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .get('/hi', 'Hi Elysia') .listen(3000) const api = treaty(app) ``` 如果传入实例,则不需要传递泛型,因为 Eden Treaty 可以直接从参数中推断类型。 这种模式推荐用于执行单元测试,或创建类型安全的反向代理服务器或微服务。 ## 选项 Eden Treaty 的第二个可选参数用于自定义获取行为,接受以下参数: * [fetch](#fetch) - 添加默认参数到获取初始化(RequestInit) * [headers](#headers) - 定义默认头部 * [fetcher](#fetcher) - 自定义获取函数,例如 Axios,unfetch * [onRequest](#onrequest) - 在发送请求前拦截并修改获取请求 * [onResponse](#onresponse) - 在获取响应后拦截并修改响应 ## 获取 默认参数附加到 fetch 的第二个参数,扩展类型为 **Fetch.RequestInit**。 ```typescript export type App = typeof app // [!code ++] import { treaty } from '@elysiajs/eden' // ---cut--- treaty('localhost:3000', { fetch: { credentials: 'include' } }) ``` 所有传递给 fetch 的参数,将作为等价传递给 fetcher: ```typescript fetch('http://localhost:3000', { credentials: 'include' }) ``` ## 头部 提供额外的默认头部到 fetch,为 `options.fetch.headers` 的简写。 ```typescript treaty('localhost:3000', { headers: { 'X-Custom': 'Griseo' } }) ``` 所有传递给 fetch 的参数,将作为等价传递给 fetcher: ```typescript twoslash fetch('localhost:3000', { headers: { 'X-Custom': 'Griseo' } }) ``` 头部可以接受以下参数: * 对象 * 函数 ### 头部对象 如果传入对象,则将直接传递到 fetch ```typescript treaty('localhost:3000', { headers: { 'X-Custom': 'Griseo' } }) ``` ### 函数 你可以将头部指定为函数,以根据条件返回自定义头部。 ```typescript treaty('localhost:3000', { headers(path, options) { if(path.startsWith('user')) return { authorization: 'Bearer 12345' } } }) ``` 你可以返回对象以将其值追加到 fetch 头部。 头部函数接受 2 个参数: * path `string` - 将发送到参数的路径 * 注意:主机名将被 **排除**,例如(/user/griseo) * options `RequestInit`: 通过 fetch 的第二个参数传入的参数 ### 数组 如果需要多个条件,您可以将 headers 函数定义为数组。 ```typescript treaty('localhost:3000', { headers: [ (path, options) => { if(path.startsWith('user')) return { authorization: 'Bearer 12345' } } ] }) ``` Eden Treaty 将 **运行所有函数**,即使值已经返回。 ## 头部优先级 Eden Treaty 将优先考虑头部的顺序,如果重复如下: 1. 内联方法 - 直接传递的方法函数 2. headers - 传递给 `config.headers` * 如果 `config.headers` 是数组,则后来的参数将被优先考虑 3. fetch - 传递给 `config.fetch.headers` 例如,对于以下示例: ```typescript const api = treaty('localhost:3000', { headers: { authorization: 'Bearer Aponia' } }) api.profile.get({ headers: { authorization: 'Bearer Griseo' } }) ``` 这将导致以下结果: ```typescript fetch('http://localhost:3000', { headers: { authorization: 'Bearer Griseo' } }) ``` 如果内联函数未指定头部,则结果将是 "**Bearer Aponia**"。 ## Fetcher 提供一个自定义的获取函数,而不是使用环境的默认 fetch。 ```typescript treaty('localhost:3000', { fetcher(url, options) { return fetch(url, options) } }) ``` 如果你想使用其他客户端而不是 fetch,建议替换 fetch,例如 Axios,unfetch。 ## OnRequest 在发送请求前拦截并修改获取请求。 你可以返回对象以将值追加到 **RequestInit**。 ```typescript treaty('localhost:3000', { onRequest(path, options) { if(path.startsWith('user')) return { headers: { authorization: 'Bearer 12345' } } } }) ``` 如果返回了值,Eden Treaty 将对返回的值和 `value.headers` 进行 **浅合并**。 **onRequest** 接受 2 个参数: * path `string` - 将发送到参数的路径 * 注意:主机名将被 **排除**,例如(/user/griseo) * options `RequestInit`: 通过 fetch 的第二个参数传入的参数 ### 数组 如果需要多个条件,你可以将 onRequest 函数定义为数组。 ```typescript treaty('localhost:3000', { onRequest: [ (path, options) => { if(path.startsWith('user')) return { headers: { authorization: 'Bearer 12345' } } } ] }) ``` Eden Treaty 将 **运行所有函数**,即使值已经返回。 ## onResponse 拦截并修改 fetch 的响应或返回新值。 ```typescript treaty('localhost:3000', { onResponse(response) { if(response.ok) return response.json() } }) ``` **onResponse** 接受 1 个参数: * response `Response` - 通常从 `fetch` 返回的 Web 标准响应 ### 数组 如果需要多个条件,你可以将 onResponse 函数定义为数组。 ```typescript treaty('localhost:3000', { onResponse: [ (response) => { if(response.ok) return response.json() } ] }) ``` 与 [headers](#headers) 和 [onRequest](#onrequest) 不同,Eden Treaty 将循环执行函数,直到找到返回的值或抛出错误,返回的值将用作新响应。 --- --- url: 'https://elysia.cndocs.org/eden/installation.md' --- # Eden 安装 首先在前端安装 Eden: ```bash bun add @elysiajs/eden bun add -d elysia ``` ::: tip Eden 需要 Elysia 来推断实用工具类型。 请确保安装的 Elysia 版本与服务器匹配。 ::: 首先,导出您现有的 Elysia 服务器类型: ```typescript // server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/', () => 'Hi Elysia') .get('/id/:id', ({ params: { id } }) => id) .post('/mirror', ({ body }) => body, { body: t.Object({ id: t.Number(), name: t.String() }) }) .listen(3000) export type App = typeof app // [!code ++] ``` 然后在客户端消费 Elysia API: ```typescript twoslash // @filename: server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/', 'Hi Elysia') .get('/id/:id', ({ params: { id } }) => id) .post('/mirror', ({ body }) => body, { body: t.Object({ id: t.Number(), name: t.String() }) }) .listen(3000) export type App = typeof app // [!code ++] // @filename: index.ts // ---cut--- // client.ts import { treaty } from '@elysiajs/eden' import type { App } from './server' // [!code ++] const client = treaty('localhost:3000') // [!code ++] // response: Hi Elysia const { data: index } = await client.get() // response: 1895 const { data: id } = await client.id({ id: 1895 }).get() // response: { id: 1895, name: 'Skadi' } const { data: nendoroid } = await client.mirror.post({ id: 1895, name: 'Skadi' }) // @noErrors client. // ^| ``` ## 注意事项 有时,Eden 可能无法正确从 Elysia 推断类型,以下是最常见的解决 Eden 类型推断问题的变通方法。 ### 类型严格模式 请确保在 **tsconfig.json** 中启用严格模式 ```json { "compilerOptions": { "strict": true // [!code ++] } } ``` ### Elysia 版本不匹配 Eden 依赖 Elysia 类来导入 Elysia 实例并正确推断类型。 请确保客户端和服务器具有匹配的 Elysia 版本。 您可以使用 [`npm why`](https://docs.npmjs.com/cli/v10/commands/npm-explain) 命令检查它: ```bash npm why elysia ``` 输出应仅包含一个顶级 elysia 版本: ``` elysia@1.1.12 node_modules/elysia elysia@"1.1.25" from the root project peer elysia@">= 1.1.0" from @elysiajs/html@1.1.0 node_modules/@elysiajs/html dev @elysiajs/html@"1.1.1" from the root project peer elysia@">= 1.1.0" from @elysiajs/opentelemetry@1.1.2 node_modules/@elysiajs/opentelemetry dev @elysiajs/opentelemetry@"1.1.7" from the root project peer elysia@">= 1.1.0" from @elysiajs/swagger@1.1.0 node_modules/@elysiajs/swagger dev @elysiajs/swagger@"1.1.6" from the root project peer elysia@">= 1.1.0" from @elysiajs/eden@1.1.2 node_modules/@elysiajs/eden dev @elysiajs/eden@"1.1.3" from the root project ``` ### TypeScript 版本 Elysia 使用 TypeScript 的较新功能和语法,以最性能的方式推断类型。诸如 Const Generic 和 Template Literal 等功能被大量使用。 请确保您的客户端具有 **最低 TypeScript 版本 >= 5.0** ### 方法链 为了使 Eden 工作,Elysia 必须使用 **方法链** Elysia 的类型系统很复杂,方法通常会为实例引入新类型。 使用方法链将有助于保存该新类型引用。 例如: ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .state('build', 1) // Store is strictly typed // [!code ++] .get('/', ({ store: { build } }) => build) .listen(3000) ``` 使用此方法,**state** 现在返回一个新的 **ElysiaInstance** 类型,将 **build** 引入 store 以替换当前类型。 不使用方法链时,Elysia 在引入新类型时不会保存它,导致无法进行类型推断。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' const app = new Elysia() app.state('build', 1) app.get('/', ({ store: { build } }) => build) app.listen(3000) ``` ### 类型定义 如果您使用 Bun 特定功能,如 `Bun.file` 或类似 API 并从处理程序中返回它,您可能需要将 Bun 类型定义安装到客户端。 ```bash bun add -d @types/bun ``` ### 路径别名(monorepo) 如果您在 monorepo 中使用路径别名,请确保前端能够像后端一样解析路径。 ::: tip 在 monorepo 中设置路径别名有点棘手,您可以 fork 我们的示例模板:[Kozeki Template](https://github.com/SaltyAom/kozeki-template) 并根据您的需求修改它。 ::: 例如,如果您的后端在 **tsconfig.json** 中有以下路径别名: ```json { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } } ``` 并且您的后端代码如下: ```typescript import { Elysia } from 'elysia' import { a, b } from '@/controllers' const app = new Elysia() .use(a) .use(b) .listen(3000) export type app = typeof app ``` 您 **必须** 确保您的前端代码能够解析相同的路径别名。否则,类型推断将被解析为 any。 ```typescript import { treaty } from '@elysiajs/eden' import type { app } from '@/index' const client = treaty('localhost:3000') // 这应该能够在前端和后端解析相同的模块,而不是 `any` import { a, b } from '@/controllers' // [!code ++] ``` 要修复此问题,您必须确保路径别名在前端和后端解析到相同的文件。 因此,您必须将 **tsconfig.json** 中的路径别名更改为: ```json { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["../apps/backend/src/*"] } } } ``` 如果配置正确,您应该能够在前端和后端解析相同的模块。 ```typescript // 这应该能够在前端和后端解析相同的模块,而不是 `any` import { a, b } from '@/controllers' ``` #### 命名空间 我们推荐为 monorepo 中的每个模块添加 **命名空间** 前缀,以避免可能发生的任何混淆和冲突。 ```json { "compilerOptions": { "baseUrl": ".", "paths": { "@frontend/*": ["./apps/frontend/src/*"], "@backend/*": ["./apps/backend/src/*"] } } } ``` 然后,您可以像这样导入模块: ```typescript // 应该在前端和后端都能工作,并且不返回 `any` import { a, b } from '@backend/controllers' ``` 我们推荐创建一个 **单一的 tsconfig.json**,它将 `baseUrl` 定义为您的 repo 根目录,根据模块位置提供路径,并为每个模块创建一个 **tsconfig.json**,它继承根 **tsconfig.json**,其中包含路径别名。 您可以在此 [路径别名示例 repo](https://github.com/SaltyAom/elysia-monorepo-path-alias) 或 [Kozeki Template](https://github.com/SaltyAom/kozeki-template) 中找到一个工作示例。 --- --- url: 'https://elysia.cndocs.org/eden/test.md' --- # Eden 测试 使用 Eden,我们可以创建一个具有端到端类型安全和自动补全的集成测试。 > 使用 Eden Treaty 创建测试,由 [irvilerodrigues 在 Twitter 上](https://twitter.com/irvilerodrigues/status/1724836632300265926) ## 设置 我们可以使用 [Bun test](https://bun.sh/guides/test/watch-mode) 来创建测试。 在项目目录的根部创建 **test/index.test.ts**,内容如下: ```typescript // test/index.test.ts import { describe, expect, it } from 'bun:test' import { edenTreaty } from '@elysiajs/eden' const app = new Elysia() .get('/', () => 'hi') .listen(3000) const api = edenTreaty('http://localhost:3000') describe('Elysia', () => { it('返回响应', async () => { const { data } = await api.get() expect(data).toBe('hi') }) }) ``` 然后,我们可以通过运行 **bun test** 来执行测试。 ```bash bun test ``` 这使我们能够以编程方式执行集成测试,而不是手动获取,同时自动支持类型检查。 --- --- url: 'https://elysia.cndocs.org/plugins/graphql-yoga.md' --- # GraphQL Yoga 插件 此插件将 GraphQL Yoga 集成到 Elysia 中 安装方法: ```bash bun add @elysiajs/graphql-yoga ``` 然后使用它: ```typescript import { Elysia } from 'elysia' import { yoga } from '@elysiajs/graphql-yoga' const app = new Elysia() .use( yoga({ typeDefs: /* GraphQL */ ` type Query { hi: String } `, resolvers: { Query: { hi: () => 'Hello from Elysia' } } }) ) .listen(3000) ``` 在浏览器中访问 `/graphql`(GET 请求)将显示一个 GraphiQL 实例,用于支持 GraphQL 的 Elysia 服务器。 可选:您还可以安装自定义版本的可选对等依赖项: ```bash bun add graphql graphql-yoga ``` ## 解析器 Elysia 使用 [Mobius](https://github.com/saltyaom/mobius) 自动从 **typeDefs** 字段推断类型,允许您在输入 **resolver** 类型时获得完全的类型安全和自动完成。 ## 上下文 您可以通过添加 **context** 为解析器函数添加自定义上下文 ```ts import { Elysia } from 'elysia' import { yoga } from '@elysiajs/graphql-yoga' const app = new Elysia() .use( yoga({ typeDefs: /* GraphQL */ ` type Query { hi: String } `, context: { name: 'Mobius' }, // 如果上下文是一个函数,它不出现在这里 // 由于某种原因,它不会推断上下文类型 useContext(_) {}, resolvers: { Query: { hi: async (parent, args, context) => context.name } } }) ) .listen(3000) ``` ## 配置 此插件扩展了 [GraphQL Yoga 的 createYoga 选项,请参考 GraphQL Yoga 文档](https://the-guild.dev/graphql/yoga-server/docs),并将 `schema` 配置内联到根部。 以下是插件接受的配置 ### path @default `/graphql` 公开 GraphQL 处理程序的端点 --- --- url: 'https://elysia.cndocs.org/plugins/html.md' --- # HTML 插件 允许您在 Elysia 服务器中使用 [JSX](#jsx) 和 HTML,并提供适当的头部和支持。 安装方法: ```bash bun add @elysiajs/html ``` 然后使用它: ```tsx twoslash import React from 'react' // ---cut--- import { Elysia } from 'elysia' import { html, Html } from '@elysiajs/html' new Elysia() .use(html()) .get( '/html', () => ` Hello World

Hello World

` ) .get('/jsx', () => ( Hello World

Hello World

)) .listen(3000) ``` 该插件将自动在响应中添加 `Content-Type: text/html; charset=utf8` 头部,添加 ``,并将其转换为一个响应对象。 ## JSX Elysia HTML 基于 [@kitajs/html](https://github.com/kitajs/html),允许我们在编译时将 JSX 定义为字符串,以实现高性能。 需要使用 JSX 的文件名称应以后缀 **"x"** 结尾: * .js -> .jsx * .ts -> .tsx 要注册 TypeScript 类型,请将以下内容添加到 **tsconfig.json**: ```jsonc // tsconfig.json { "compilerOptions": { "jsx": "react", "jsxFactory": "Html.createElement", "jsxFragmentFactory": "Html.Fragment" } } ``` 就是这样,现在您可以将 JSX 用作模板引擎: ```tsx twoslash import React from 'react' // ---cut--- import { Elysia } from 'elysia' import { html, Html } from '@elysiajs/html' // [!code ++] new Elysia() .use(html()) // [!code ++] .get('/', () => ( Hello World

Hello World

)) .listen(3000) ``` 如果出现错误 `Cannot find name 'Html'. Did you mean 'html'?`,则必须将此导入添加到 JSX 模板中: ```tsx import { Html } from '@elysiajs/html' ``` 务必以大写字母书写。 ## XSS Elysia HTML 基于 Kita HTML 插件,在编译时检测可能的 XSS 攻击。 您可以使用专用的 `safe` 属性来清理用户值,以防止 XSS 漏洞。 ```tsx import { Elysia, t } from 'elysia' import { html, Html } from '@elysiajs/html' new Elysia() .use(html()) .post( '/', ({ body }) => ( Hello World

{body}

), { body: t.String() } ) .listen(3000) ``` 然而,在构建大型应用时,最好有类型提醒以检测代码库中可能的 XSS 漏洞。 要添加类型安全提醒,请安装: ```sh bun add @kitajs/ts-html-plugin ``` 然后在 **tsconfig.json** 中添加以下内容: ```jsonc // tsconfig.json { "compilerOptions": { "jsx": "react", "jsxFactory": "Html.createElement", "jsxFragmentFactory": "Html.Fragment", "plugins": [{ "name": "@kitajs/ts-html-plugin" }] } } ``` ## 选项 ### contentType * 类型: `string` * 默认值: `'text/html; charset=utf8'` 响应的内容类型。 ### autoDetect * 类型: `boolean` * 默认值: `true` 是否自动检测 HTML 内容并设置内容类型。 ### autoDoctype * 类型: `boolean | 'full'` * 默认值: `true` 是否在响应开头是 `` 时自动添加 ``,如果未找到。 使用 `full` 还可以在没有此插件的响应中自动添加文档类型。 ```ts // 没有插件 app.get('/', () => '') // 有插件 app.get('/', ({ html }) => html('')) ``` ### isHtml * 类型: `(value: string) => boolean` * 默认: `isHtml` (导出的函数) 该函数用于检测一个字符串是否为 HTML。默认实现是如果长度大于 7,且以 `<` 开头并以 `>` 结尾。 请注意,没有真正的方法来验证 HTML,因此默认实现只是一个最佳猜测。 --- --- url: 'https://elysia.cndocs.org/integrations/drizzle.md' --- # Drizzle Drizzle ORM 是一个无头 TypeScript ORM,专注于类型安全和开发者体验。 我们可以使用 `drizzle-typebox` 将 Drizzle 模式转换为 Elysia 验证模型。 ### Drizzle Typebox [Elysia.t](/essential/validation.html#elysia-type) 是 TypeBox 的一个分支,允许我们直接在 Elysia 中使用任何 TypeBox 类型。 我们可以使用 ["drizzle-typebox"](https://npmjs.org/package/drizzle-typebox) 将 Drizzle 模式转换为 TypeBox 模式,并直接在 Elysia 的模式验证中使用。 ### 其工作原理如下: 1. 在 Drizzle 中定义你的数据库模式。 2. 使用 `drizzle-typebox` 将 Drizzle 模式转换为 Elysia 验证模型。 3. 使用转换后的 Elysia 验证模型来确保类型验证。 4. 从 Elysia 验证模型生成 OpenAPI 模式。 5. 添加 [Eden Treaty](/eden/overview) 以增强前端的类型安全。 ``` * ——————————————— * | | | -> | 文档 | * ————————— * * ———————— * OpenAPI | | | | | drizzle- | | ——————— | * ——————————————— * | Drizzle | —————————-> | Elysia | | | -typebox | | ——————— | * ——————————————— * * ————————— * * ———————— * Eden | | | | -> | 前端代码 | | | * ——————————————— * ``` ## 安装 要安装 Drizzle,请运行以下命令: ```bash bun add drizzle-orm drizzle-typebox ``` 然后你需要固定 `@sinclair/typebox` 的版本,因为 `drizzle-typebox` 和 `Elysia` 之间可能存在版本不匹配,这可能会导致两个版本之间的符号冲突。 我们建议使用以下命令固定 `@sinclair/typebox` 的版本为 `elysia` 使用的 **最低版本**: ```bash grep "@sinclair/typebox" node_modules/elysia/package.json ``` 我们可以在 `package.json` 中使用 `overrides` 字段来固定 `@sinclair/typebox` 的版本: ```json { "overrides": { "@sinclair/typebox": "0.32.4" } } ``` ## Drizzle 模式 假设我们在代码库中有一个 `user` 表,如下所示: ::: code-group ```ts [src/database/schema.ts] import { relations } from 'drizzle-orm' import { pgTable, varchar, timestamp } from 'drizzle-orm/pg-core' import { createId } from '@paralleldrive/cuid2' export const user = pgTable( 'user', { id: varchar('id') .$defaultFn(() => createId()) .primaryKey(), username: varchar('username').notNull().unique(), password: varchar('password').notNull(), email: varchar('email').notNull().unique(), salt: varchar('salt', { length: 64 }).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), } ) export const table = { user } as const export type Table = typeof table ``` ::: ## drizzle-typebox 我们可以使用 `drizzle-typebox` 将 `user` 表转换为 TypeBox 模型: ::: code-group ```ts [src/index.ts] import { createInsertSchema } from 'drizzle-typebox' import { table } from './database/schema' const _createUser = createInsertSchema(table.user, { // 使用 Elysia 的 email 类型替换电子邮件 email: t.String({ format: 'email' }) }) new Elysia() .post('/sign-up', ({ body }) => { // 创建新用户 }, { body: t.Omit( _createUser, ['id', 'salt', 'createdAt'] ) }) ``` ::: 这使我们可以在 Elysia 验证模型中重复使用数据库模式。 ## 类型实例化可能是无限的 如果你遇到错误 **类型实例化可能是无限的**,这可能是因为 `drizzle-typebox` 和 `Elysia` 之间存在循环引用。 如果我们将来自 drizzle-typebox 的类型嵌套到 Elysia 模式中,它将导致类型实例化的无限循环。 为了避免这种情况,我们需要 **在 `drizzle-typebox` 和 `Elysia` 模式之间显式定义一个类型**: ```ts import { t } from 'elysia' import { createInsertSchema } from 'drizzle-typebox' import { table } from './database/schema' const _createUser = createInsertSchema(table.user, { email: t.String({ format: 'email' }) }) // ✅ 这样做有效,通过引用来自 `drizzle-typebox` 的类型 const createUser = t.Omit( _createUser, ['id', 'salt', 'createdAt'] ) // ❌ 这样做会导致类型实例化的无限循环 const createUser = t.Omit( createInsertSchema(table.user, { email: t.String({ format: 'email' }) }), ['id', 'salt', 'createdAt'] ) ``` 如果你想使用 Elysia 类型,始终为 `drizzle-typebox` 声明一个变量并引用它。 ## 实用工具 由于我们很可能会使用 `t.Pick` 和 `t.Omit` 来排除或包括某些字段,重复这个过程可能会很繁琐: 我们建议使用以下实用函数 **(按原样复制)** 来简化这个过程: ::: code-group ```ts [src/database/utils.ts] /** * @lastModified 2025-02-04 * @see https://elysiajs.com/recipe/drizzle.html#utility */ import { Kind, type TObject } from '@sinclair/typebox' import { createInsertSchema, createSelectSchema, BuildSchema, } from 'drizzle-typebox' import { table } from './schema' import type { Table } from 'drizzle-orm' type Spread< T extends TObject | Table, Mode extends 'select' | 'insert' | undefined, > = T extends TObject ? { [K in keyof Fields]: Fields[K] } : T extends Table ? Mode extends 'select' ? BuildSchema< 'select', T['_']['columns'], undefined >['properties'] : Mode extends 'insert' ? BuildSchema< 'insert', T['_']['columns'], undefined >['properties'] : {} : {} /** * 将 Drizzle 模式展开为一个普通对象 */ export const spread = < T extends TObject | Table, Mode extends 'select' | 'insert' | undefined, >( schema: T, mode?: Mode, ): Spread => { const newSchema: Record = {} let table switch (mode) { case 'insert': case 'select': if (Kind in schema) { table = schema break } table = mode === 'insert' ? createInsertSchema(schema) : createSelectSchema(schema) break default: if (!(Kind in schema)) throw new Error('期望是一个模式') table = schema } for (const key of Object.keys(table.properties)) newSchema[key] = table.properties[key] return newSchema as any } /** * 将 Drizzle 表展开为一个普通对象 * * 如果 `mode` 是 'insert',则模式将经过插入优化 * 如果 `mode` 是 'select',则模式将经过选择优化 * 如果 `mode` 是未定义,模式将按原样展开,模型需要手动优化 */ export const spreads = < T extends Record, Mode extends 'select' | 'insert' | undefined, >( models: T, mode?: Mode, ): { [K in keyof T]: Spread } => { const newSchema: Record = {} const keys = Object.keys(models) for (const key of keys) newSchema[key] = spread(models[key], mode) return newSchema as any } ``` ::: 这个实用函数将把 Drizzle 模式转换为一个普通对象,可以通过属性名称作为普通对象进行选择: ```ts // ✅ 使用展开实用函数 const user = spread(table.user, 'insert') const createUser = t.Object({ id: user.id, // { type: 'string' } username: user.username, // { type: 'string' } password: user.password // { type: 'string' } }) // ⚠️ 使用 t.Pick const _createUser = createInsertSchema(table.user) const createUser = t.Pick( _createUser, ['id', 'username', 'password'] ) ``` ### 表单例 我们建议使用单例模式来存储表模式,这将使我们能够在代码库的任何地方访问表模式: ::: code-group ```ts [src/database/model.ts] import { table } from './schema' import { spreads } from './utils' export const db = { insert: spreads({ user: table.user, }, 'insert'), select: spreads({ user: table.user, }, 'select') } as const ``` ::: 这样我们就能在代码库的任何地方访问表模式: ::: code-group ```ts [src/index.ts] import { Elysia } from 'elysia' import { db } from './database/model' const { user } = db.insert new Elysia() .post('/sign-up', ({ body }) => { // 创建新用户 }, { body: t.Object({ id: user.username, username: user.username, password: user.password }) }) ``` ::: ### 精细化 如果需要类型精细化,你可以直接使用 `createInsertSchema` 和 `createSelectSchema` 来精细化模式。 ::: code-group ```ts [src/database/model.ts] import { t } from 'elysia' import { createInsertSchema, createSelectSchema } from 'drizzle-typebox' import { table } from './schema' import { spreads } from './utils' export const db = { insert: spreads({ user: createInsertSchema(table.user, { email: t.String({ format: 'email' }) }), }, 'insert'), select: spreads({ user: createSelectSchema(table.user, { email: t.String({ format: 'email' }) }) }, 'select') } as const ``` ::: 在上述代码中,我们精细化了 `user.email` 模式以包括一个 `format` 属性。 `spread` 实用函数将跳过优化的模式,因此你可以按原样使用它。 *** 有关更多信息,请参考 [Drizzle ORM](https://orm.drizzle.team) 和 [Drizzle TypeBox](https://orm.drizzle.team/docs/typebox) 文档。 --- --- url: 'https://elysia.cndocs.org/plugins/jwt.md' --- # JWT 插件 该插件增强了在 Elysia 处理程序中使用 JWT 的支持。 安装命令: ```bash bun add @elysiajs/jwt ``` 然后使用它: ::: code-group ```typescript [cookie] import { Elysia } from 'elysia' import { jwt } from '@elysiajs/jwt' const app = new Elysia() .use( jwt({ name: 'jwt', secret: 'Fischl von Luftschloss Narfidort' }) ) .get('/sign/:name', async ({ jwt, params: { name }, cookie: { auth } }) => { const value = await jwt.sign({ name }) auth.set({ value, httpOnly: true, maxAge: 7 * 86400, path: '/profile', }) return `以 ${value} 登入` }) .get('/profile', async ({ jwt, status, cookie: { auth } }) => { const profile = await jwt.verify(auth.value) if (!profile) return status(401, '未授权') return `你好 ${profile.name}` }) .listen(3000) ``` ```typescript [headers] import { Elysia } from 'elysia' import { jwt } from '@elysiajs/jwt' const app = new Elysia() .use( jwt({ name: 'jwt', secret: 'Fischl von Luftschloss Narfidort' }) ) .get('/sign/:name', ({ jwt, params: { name } }) => { return jwt.sign({ name }) }) .get('/profile', async ({ jwt, error, headers: { authorization } }) => { const profile = await jwt.verify(authorization) if (!profile) return status(401, '未授权') return `你好 ${profile.name}` }) .listen(3000) ``` ::: ## 配置 该插件扩展了 [jose](https://github.com/panva/jose) 的配置。 以下是插件接受的配置。 ### name 注册 `jwt` 函数的名称。 例如,`jwt` 函数将以自定义名称注册。 ```typescript app .use( jwt({ name: 'myJWTNamespace', secret: process.env.JWT_SECRETS! }) ) .get('/sign/:name', ({ myJWTNamespace, params }) => { return myJWTNamespace.sign(params) }) ``` 因为有些人可能需要在同一服务器中使用多个具有不同配置的 `jwt`,因此显式使用不同名称注册 JWT 函数是必要的。 ### secret 用于签署 JWT 负载的私钥。 ### schema 对 JWT 负载进行严格的类型验证。 *** 以下是扩展自 [cookie](https://npmjs.com/package/cookie) 的配置 ### alg @default `HS256` 用于签署 JWT 负载的签名算法。 可供 jose 使用的属性有: HS256 HS384 HS512 PS256 PS384 PS512 RS256 RS384 RS512 ES256 ES256K ES384 ES512 EdDSA ### iss 发行者声明标识根据 [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.1) 签发 JWT 的主体。 简而言之:通常是签名者(域名)的名称。 ### sub 主体声明标识 JWT 的主题。 JWT 中的声明通常是关于主题的语句,根据 [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.2) ### aud 受众声明标识 JWT 预期的接收者。 每个预期处理 JWT 的主体必须在受众声明中以一个值标识自己,根据 [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3) ### jti JWT ID 声明提供 JWT 的唯一标识符,根据 [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.7) ### nbf “未生效”声明标识 JWT 在处理之前不得被接受的时间,根据 [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5) ### exp 过期时间声明标识在该时间之后不得被接受处理的 JWT,根据 [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.4) ### iat “签发时间”声明标识 JWT 被签发的时间。 该声明可用于确定 JWT 的年龄,根据 [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6) ### b64 此 JWS 扩展头参数修改 JWS 负载表示和 JWS 签名输入计算,根据 [RFC7797](https://www.rfc-editor.org/rfc/rfc7797)。 ### kid 指示用于保护 JWS 的密钥的提示。 该参数允许创建者显式信号向接收方变化密钥,根据 [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.4) ### x5t (X.509 证书 SHA-1 指纹) 头参数是 X.509 证书的 DER 编码的 base64url 编码 SHA-1 摘要 [RFC5280](https://www.rfc-editor.org/rfc/rfc5280),与用于数字签名 JWS 的密钥对应,根据 [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.7) ### x5c (X.509 证书链) 头参数包含与用于数字签名 JWS 的密钥对应的 X.509 公钥证书或证书链 [RFC5280](https://www.rfc-editor.org/rfc/rfc5280),根据 [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.6) ### x5u (X.509 URL) 头参数是指向 X.509 公钥证书或证书链的 URI [RFC3986](https://www.rfc-editor.org/rfc/rfc3986),其对应于用于数字签名 JWS 的密钥,根据 [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.5) ### jwk “jku”(JWK 集 URL)头参数是一个 URI \[RFC3986],指向 JSON 编码公钥集合的资源,其中之一与用于数字签名 JWS 的密钥对应。 这些密钥必须作为 JWK 集 \[JWK] 编码,根据 [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.2) ### typ `typ`(类型)头参数由 JWS 应用程序用于声明该完整 JWS 的媒体类型 \[IANA.MediaTypes]。 当应用程序中可能出现多种对象时,可以使用此内容,根据 [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.9) ### ctr Content-Type 参数由 JWS 应用程序用于声明被保护内容(负载)的媒体类型 \[IANA.MediaTypes]。 当 JWS 负载中可能存在多种对象时,这一内容用于该应用程序,根据 [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.9) ## 处理程序 以下是添加到处理程序中的值。 ### jwt.sign 与 JWT 使用相关的动态对象集合,由 JWT 插件注册。 类型: ```typescript sign: (payload: JWTPayloadSpec): Promise ``` `JWTPayloadSpec` 接受与 [JWT 配置](#config) 相同的值。 ### jwt.verify 使用提供的 JWT 配置验证负载。 类型: ```typescript verify(payload: string) => Promise ``` `JWTPayloadSpec` 接受与 [JWT 配置](#config) 相同的值。 ## 模式 以下是使用该插件的常见模式。 ## 设置 JWT 过期时间 默认情况下,配置会传递给 `setCookie` 并继承其值。 ```typescript const app = new Elysia() .use( jwt({ name: 'jwt', secret: 'kunikuzushi', exp: '7d' }) ) .get('/sign/:name', async ({ jwt, params }) => jwt.sign(params)) ``` 这将签署一个过期时间为接下来的 7 天的 JWT。 --- --- url: 'https://elysia.cndocs.org/patterns/mount.md' --- # Mount WinterCG 是一个用于网页互操作运行时的标准。它得到了 Cloudflare、Deno、Vercel Edge Runtime、Netlify Function 和其他多种支持,允许网页服务器在使用 Web 标准定义(如 `Fetch`、`Request` 和 `Response`)的运行时之间互操作。 Elysia 遵循 WinterCG 标准。我们经过优化以在 Bun 上运行,但也开放支持其他运行时。 理论上,这允许任何符合 WinterCG 标准的框架或代码一起运行,使得像 Elysia、Hono、Remix、Itty Router 等框架可以简单地在一个函数中共同运行。 遵循这一点,我们为 Elysia 引入了 `.mount` 方法,以便与任何符合 WinterCG 标准的框架或代码一起运行。 ## Mount 要使用 **.mount**,[只需传递一个 `fetch` 函数](https://twitter.com/saltyAom/status/1684786233594290176): ```ts import { Elysia } from 'elysia' const app = new Elysia() .get('/', () => 'Hello from Elysia') .mount('/hono', hono.fetch) ``` 一个 **fetch** 函数是一个接受 Web 标准请求并返回 Web 标准响应的函数,其定义为: ```ts // Web 标准请求类对象 // Web 标准响应 type fetch = (request: RequestLike) => Response ``` 默认情况下,以下声明被使用: * Bun * Deno * Vercel Edge Runtime * Cloudflare Worker * Netlify Edge Function * Remix Function Handler * 等等。 这使您可以在单个服务器环境中执行上述所有代码,并使与 Elysia 的无缝交互成为可能。您还可以在单个部署中重用现有功能,从而消除管理多个服务器所需的反向代理。 如果框架也支持 **.mount** 函数,您可以深层嵌套一个支持该功能的框架。 ```ts import { Elysia } from 'elysia' import { Hono } from 'hono' const elysia = new Elysia() .get('/', () => 'Hello from Elysia inside Hono inside Elysia') const hono = new Hono() .get('/', (c) => c.text('Hello from Hono!')) .mount('/elysia', elysia.fetch) const main = new Elysia() .get('/', () => 'Hello from Elysia') .mount('/hono', hono.fetch) .listen(3000) ``` ## 重用 Elysia 此外,您可以在服务器上重用多个现有的 Elysia 项目。 ```ts import { Elysia } from 'elysia' import A from 'project-a/elysia' import B from 'project-b/elysia' import C from 'project-c/elysia' new Elysia() .mount(A) .mount(B) .mount(C) ``` 如果传递给 `mount` 的实例是一个 Elysia 实例,它将通过 `use` 自动解析,默认提供类型安全和 Eden 支持。 这使得互操作框架和运行时的可能性成为现实。 --- --- url: 'https://elysia.cndocs.org/patterns/openapi.md' --- # OpenAPI Elysia 默认提供一流的支持并遵循 OpenAPI 架构。 Elysia 可以使用 OpenAPI 插件自动生成 API 文档页面。 要生成 Swagger 页面,请安装插件: ```bash bun add @elysiajs/openapi ``` 并将插件注册到服务器: ```typescript import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' // [!code ++] new Elysia() .use(openapi()) // [!code ++] ``` 默认情况下,Elysia 使用 OpenAPI V3 架构和 [Scalar UI](http://scalar.com)。 有关 OpenAPI 插件配置,请参阅 [OpenAPI 插件页面](/plugins/openapi)。 ## 从类型生成 OpenAPI > 这是可选的,但我们强烈推荐它以获得更好的文档体验。 默认情况下,Elysia 依赖运行时架构来生成 OpenAPI 文档。 但是,您也可以使用 OpenAPI 插件的生成器从类型生成 OpenAPI 文档,如下所示: 1. 指定 Elysia 根文件(如果未指定,Elysia 将使用 `src/index.ts`),并导出实例 2. 导入生成器并提供**从项目根目录的文件路径**给类型生成器 ```ts import { Elysia, t } from 'elysia' import { openapi, fromTypes } from '@elysiajs/openapi' // [!code ++] export const app = new Elysia() // [!code ++] .use( openapi({ references: fromTypes() // [!code ++] }) ) .get('/', { test: 'hello' as const }) .post('/json', ({ body, status }) => body, { body: t.Object({ hello: t.String() }) }) .listen(3000) ``` Elysia 将尝试通过读取导出的实例类型来生成 OpenAPI 文档。 这将与运行时架构共存,并且运行时架构将优先于类型定义。 ### 生产环境 在生产环境中,您可能会将 Elysia 编译为 [使用 Bun 的单一可执行文件](/patterns/deploy.html) 或 [打包成单一 JavaScript 文件](https://elysiajs.com/patterns/deploy.html#compile-to-javascript)。 建议您预先生成声明文件(**.d.ts**)以向生成器提供类型声明。 ```ts import { Elysia, t } from 'elysia' import { openapi, fromTypes } from '@elysiajs/openapi' const app = new Elysia() .use( openapi({ references: fromTypes( process.env.NODE_ENV === 'production' // [!code ++] ? 'dist/index.d.ts' // [!code ++] : 'src/index.ts' // [!code ++] ) }) ) ``` ### 注意事项:根路径 由于猜测项目根目录不可靠,建议提供项目根目录的路径以允许生成器正确运行,尤其是在使用 monorepo 时。 ```ts import { Elysia, t } from 'elysia' import { openapi, fromTypes } from '@elysiajs/openapi' export const app = new Elysia() .use( openapi({ references: fromTypes('src/index.ts', { projectRoot: path.join('..', import.meta.dir) // [!code ++] }) }) ) .get('/', { test: 'hello' as const }) .post('/json', ({ body, status }) => body, { body: t.Object({ hello: t.String() }) }) .listen(3000) ``` ### 自定义 tsconfig.json 如果您有多个 `tsconfig.json` 文件,则必须指定用于类型生成的正确 `tsconfig.json` 文件。 ```ts import { Elysia, t } from 'elysia' import { openapi, fromTypes } from '@elysiajs/openapi' export const app = new Elysia() .use( openapi({ references: fromTypes('src/index.ts', { // 这是从项目根目录的引用 tsconfigPath: 'tsconfig.dts.json' // [!code ++] }) }) ) .get('/', { test: 'hello' as const }) .post('/json', ({ body, status }) => body, { body: t.Object({ hello: t.String() }) }) .listen(3000) ``` ## 使用 OpenAPI 的标准架构 Elysia 将尝试使用每个架构的原生方法将其转换为 OpenAPI 架构。 但是,如果架构没有提供原生方法,您可以通过提供 `mapJsonSchema` 来自定义将架构转换为 OpenAPI 的方式,如下所示: \ ### Zod OpenAPI 由于 Zod 在架构上没有 `toJSONSchema` 方法,我们需要提供自定义映射器来将 Zod 架构转换为 OpenAPI 架构。 ::: code-group ```typescript [Zod 4] import openapi from '@elysiajs/openapi' import * as z from 'zod' openapi({ mapJsonSchema: { zod: z.toJSONSchema } }) ``` ```typescript [Zod 3] import openapi from '@elysiajs/openapi' import { zodToJsonSchema } from 'zod-to-json-schema' openapi({ mapJsonSchema: { zod: zodToJsonSchema } }) ``` ::: ### Valibot OpenAPI Valibot 使用单独的包(`@valibot/to-json-schema`)将 Valibot 架构转换为 JSON Schema。 ```typescript import openapi from '@elysiajs/openapi' import { toJsonSchema } from '@valibot/to-json-schema' openapi({ mapJsonSchema: { valibot: toJsonSchema } }) ``` ### Effect OpenAPI 由于 Effect 在架构上没有 `toJSONSchema` 方法,我们需要提供自定义映射器来将 Effect 架构转换为 OpenAPI 架构。 ```typescript import openapi from '@elysiajs/openapi' import { JSONSchema } from 'effect' openapi({ mapJsonSchema: { effect: JSONSchema.make } }) ``` ## 描述路由 我们可以通过提供架构类型来添加路由信息。 但是,有时仅定义类型并不能清楚说明路由的作用。您可以使用 [detail](/plugins/openapi#detail) 字段来明确描述路由。 ```typescript import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' new Elysia() .use(openapi()) .post( '/sign-in', ({ body }) => body, { body: t.Object( { username: t.String(), password: t.String({ minLength: 8, description: 'User password (at least 8 characters)' // [!code ++] }) }, { // [!code ++] description: 'Expected a username and password' // [!code ++] } // [!code ++] ), detail: { // [!code ++] summary: 'Sign in the user', // [!code ++] tags: ['authentication'] // [!code ++] } // [!code ++] }) ``` detail 字段默认遵循 OpenAPI V3 定义,并提供自动补全和类型安全。 然后,detail 被传递给 OpenAPI 以将描述放入 OpenAPI 路由中。 ## 响应头 我们可以通过使用 `withHeader` 包装架构来添加响应头: ```typescript import { Elysia, t } from 'elysia' import { openapi, withHeader } from '@elysiajs/openapi' // [!code ++] new Elysia() .use(openapi()) .get( '/thing', ({ body, set }) => { set.headers['x-powered-by'] = 'Elysia' return body }, { response: withHeader( // [!code ++] t.Literal('Hi'), // [!code ++] { // [!code ++] 'x-powered-by': t.Literal('Elysia') // [!code ++] } // [!code ++] ) // [!code ++] } ) ``` 请注意,`withHeader` 仅是一个注解,并不强制执行或验证实际的响应头。您需要手动设置头。 ### 隐藏路由 您可以通过将 `detail.hide` 设置为 `true` 来从 Swagger 页面隐藏路由 ```typescript import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' new Elysia() .use(openapi()) .post( '/sign-in', ({ body }) => body, { body: t.Object( { username: t.String(), password: t.String() }, { description: 'Expected a username and password' } ), detail: { // [!code ++] hide: true // [!code ++] } // [!code ++] } ) ``` ## 标签 Elysia 可以使用 Swagger 的标签系统将端点分离成组 首先在 swagger 配置对象中定义可用的标签 ```typescript new Elysia().use( openapi({ documentation: { tags: [ { name: 'App', description: 'General endpoints' }, { name: 'Auth', description: 'Authentication endpoints' } ] } }) ) ``` 然后在端点配置部分的 details 属性中使用该标签将端点分配到组 ```typescript new Elysia() .get('/', () => 'Hello Elysia', { detail: { tags: ['App'] } }) .group('/auth', (app) => app.post( '/sign-up', ({ body }) => db.user.create({ data: body, select: { id: true, username: true } }), { detail: { tags: ['Auth'] } } ) ) ``` 这将生成如下所示的 swagger 页面 ### 标签组 Elysia 可以接受标签来将整个实例或一组路由添加到特定标签。 ```typescript import { Elysia, t } from 'elysia' new Elysia({ tags: ['user'] }) .get('/user', 'user') .get('/admin', 'admin') ``` ## 模型 通过使用 [引用模型](/essential/validation.html#reference-model),Elysia 将自动处理架构生成。 将模型分离到专用部分并通过引用链接。 ```typescript new Elysia() .model({ User: t.Object({ id: t.Number(), username: t.String() }) }) .get('/user', () => ({ id: 1, username: 'saltyaom' }), { response: { 200: 'User' }, detail: { tags: ['User'] } }) ``` ## 守卫 或者,Elysia 可以接受守卫来将整个实例或一组路由添加到特定守卫。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .guard({ detail: { description: 'Require user to be logged in' } }) .get('/user', 'user') .get('/admin', 'admin') ``` ## 更改 OpenAPI 端点 您可以通过在插件配置中设置 [path](#path) 来更改 OpenAPI 端点。 ```typescript twoslash import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' new Elysia() .use( openapi({ path: '/v2/openapi' }) ) .listen(3000) ``` ## 自定义 OpenAPI 信息 我们可以通过在插件配置中设置 [documentation.info](#documentationinfo) 来自定义 OpenAPI 信息。 ```typescript twoslash import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' new Elysia() .use( openapi({ documentation: { info: { title: 'Elysia Documentation', version: '1.0.0' } } }) ) .listen(3000) ``` 这可能有用 * 添加标题 * 设置 API 版本 * 添加描述解释我们的 API 是关于什么的 * 解释可用的标签,每个标签的含义 ## 安全配置 要保护您的 API 端点,您可以在 Swagger 配置中定义安全方案。下面的示例演示了如何使用 Bearer 认证(JWT)来保护您的端点: ```typescript new Elysia().use( openapi({ documentation: { components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } } } }) ) export const addressController = new Elysia({ prefix: '/address', detail: { tags: ['Address'], security: [ { bearerAuth: [] } ] } }) ``` 这将确保 `/address` 前缀下的所有端点都需要有效的 JWT 令牌才能访问。 --- --- url: 'https://elysia.cndocs.org/plugins/openapi.md' --- # OpenAPI 插件 [elysia](https://github.com/elysiajs/elysia) 的插件,用于自动生成 API 文档页面。 使用以下命令安装: ```bash bun add @elysiajs/openapi ``` 然后使用它: ```typescript twoslash import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' new Elysia() .use(openapi()) .get('/', () => 'hello') .post('/hello', () => 'OpenAPI') .listen(3000) ``` 访问 `/openapi` 将显示带有从 Elysia 服务器生成的端点文档的 Scalar UI。您也可以在 `/openapi/json` 访问原始 OpenAPI 规范。 ::: tip 此页面是插件配置参考。 如果您正在寻找 OpenAPI 的常见模式或高级用法,请查看 [Patterns: OpenAPI](/patterns/openapi) ::: ## 详细信息 `detail` 扩展了 [OpenAPI Operation Object](https://spec.openapis.org/oas/v3.0.3.html#operation-object) `detail` 字段是一个对象,可用于描述路由的 API 文档信息。 它可以包含以下字段: ## detail.hide 通过将 `detail.hide` 设置为 `true`,您可以从 Swagger 页面隐藏路由 ```typescript import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' new Elysia().use(openapi()).post('/sign-in', ({ body }) => body, { body: t.Object( { username: t.String(), password: t.String() }, { description: 'Expected a username and password' } ), detail: { // [!code ++] hide: true // [!code ++] } // [!code ++] }) ``` ### detail.deprecated 声明此操作已弃用。消费者应避免使用声明的操作。默认值为 `false`。 ### detail.description 操作行为的详细解释。 ### detail.summary 操作功能的简短摘要。 ## 配置 以下是插件接受的配置 ## enabled @default true 启用/禁用插件 ## documentation OpenAPI 文档信息 @see https://spec.openapis.org/oas/v3.0.3.html ## exclude 从文档中排除路径或方法的配置 ## exclude.methods 从文档中排除的方法列表 ## exclude.paths 从文档中排除的路径列表 ## exclude.staticFile @default true 从文档中排除静态文件路由 ## exclude.tags 从文档中排除的标签列表 ## mapJsonSchema 从标准 schema 到 OpenAPI schema 的自定义映射函数 ### 示例 ```typescript import { openapi } from '@elysiajs/openapi' import { toJsonSchema } from '@valibot/to-json-schema' openapi({ mapJsonSchema: { valibot: toJsonSchema } }) ``` ## path @default '/openapi' 暴露 OpenAPI 文档前端的端点 ## provider @default 'scalar' OpenAPI 文档前端选项: * [Scalar](https://github.com/scalar/scalar) * [SwaggerUI](https://github.com/swagger-api/swagger-ui) * null: 禁用前端 ## references 每个端点的附加 OpenAPI 引用 ## scalar Scalar 配置,参考 [Scalar config](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) ## specPath @default '/${path}/json' 暴露 JSON 格式 OpenAPI 规范的端点 ## swagger Swagger 配置,参考 [Swagger config](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/) 以下是使用该插件的常见模式。 --- --- url: 'https://elysia.cndocs.org/integrations/opentelemetry.md' --- # OpenTelemetry 要开始使用 OpenTelemetry,请安装 `@elysiajs/opentelemetry` 并将插件应用于任何实例。 ```typescript 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())] }) ) ``` ![jaeger 显示收集到的跟踪信息](/blog/elysia-11/jaeger.webp) Elysia OpenTelemetry 将 **收集与 OpenTelemetry 标准兼容的任何库的 span**,并会自动应用父子 span。 在上面的代码中,我们应用 `Prisma` 来跟踪每个查询所花费的时间。 通过应用 OpenTelemetry,Elysia 将: * 收集遥测数据 * 将相关生命周期分组 * 测量每个函数所花费的时间 * 对 HTTP 请求和响应进行仪器化 * 收集错误和异常 您可以将遥测数据导出到 Jaeger、Zipkin、New Relic、Axiom 或任何其他与 OpenTelemetry 兼容的后端。 ![axiom 显示收集到的 OpenTelemetry 跟踪信息](/blog/elysia-11/axiom.webp) 以下是将遥测数据导出到 [Axiom](https://axiom.co) 的示例 ```typescript 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 ++] }) ) ] }) ) ``` ## 仪器化 许多仪器化库要求 SDK **必须** 在导入模块之前运行。 例如,要使用 `PgInstrumentation`,`OpenTelemetry SDK` 必须在导入 `pg` 模块之前运行。 要在 Bun 中实现这一点,我们可以 1. 将 OpenTelemetry 设置分成一个不同的文件 2. 创建 `bunfig.toml` 以预加载 OpenTelemetry 设置文件 让我们在 `src/instrumentation.ts` 中创建一个新文件 ```ts [src/instrumentation.ts] import { opentelemetry } from '@elysiajs/opentelemetry' import { PgInstrumentation } from '@opentelemetry/instrumentation-pg' export const instrumentation = opentelemetry({ instrumentations: [new PgInstrumentation()] }) ``` 然后我们可以将此 `instrumentaiton` 插件应用于 `src/index.ts` 中的主实例 ```ts [src/index.ts] import { Elysia } from 'elysia' import { instrumentation } from './instrumentation.ts' new Elysia().use(instrumentation).listen(3000) ``` 然后创建一个 `bunfig.toml`,内容如下: ```toml [bunfig.toml] preload = ["./src/instrumentation.ts"] ``` 这将告诉 Bun 在运行 `src/index.ts` 之前加载并设置 `instrumentation`,以允许 OpenTelemetry 按需设置。 ### 部署到生产环境 如果您使用 `bun build` 或其他打包工具。 由于 OpenTelemetry 依赖于猴子补丁 `node_modules/`。为了确保仪器化正常工作,我们需要指定要被仪器化的库作为外部模块,以将其排除在打包之外。 例如,如果您使用 `@opentelemetry/instrumentation-pg` 来对 `pg` 库进行仪器化。我们需要将 `pg` 排除在打包之外,并确保它从 `node_modules/pg` 导入。 要使其正常工作,我们可以通过 `--external pg` 将 `pg` 指定为外部模块 ```bash bun build --compile --external pg --outfile server src/index.ts ``` 这告诉 bun 不要将 `pg` 打包到最终输出文件中,并将在运行时从 **node\_modules** 目录导入。所以在生产服务器上,您还必须保留 **node\_modules** 目录。 建议在 **package.json** 中将应在生产服务器上可用的包指定为 **dependencies**,并使用 `bun install --production` 仅安装生产依赖项。 ```json { "dependencies": { "pg": "^8.15.6" }, "devDependencies": { "@elysiajs/opentelemetry": "^1.2.0", "@opentelemetry/instrumentation-pg": "^0.52.0", "@types/pg": "^8.11.14", "elysia": "^1.2.25" } } ``` 然后在运行构建命令后,在生产服务器上 ```bash bun install --production ``` 如果 node\_modules 目录仍包含开发依赖项,您可以删除 node\_modules 目录并再次安装生产依赖项。 ## OpenTelemetry SDK Elysia OpenTelemetry 仅用于将 OpenTelemetry 应用到 Elysia 服务器。 您可以正常使用 OpenTelemetry SDK,并且 span 在 Elysia 的请求 span 下运行,它将自动出现在 Elysia 的跟踪中。 然而,我们也提供 `getTracer` 和 `record` 实用工具,以便从您应用的任何部分收集 span。 ```typescript 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 实用工具 `record` 相当于 OpenTelemetry 的 `startActiveSpan`,但它将自动处理关闭并捕获异常。 您可以将 `record` 看作是您的代码的标签,这将在跟踪中显示。 ### 为可观察性准备您的代码库 Elysia OpenTelemetry 将分组生命周期并读取每个钩子的 **函数名称** 作为 span 的名称。 现在是 **命名您的函数** 的好时机。 如果您的钩子处理程序是一个箭头函数,您可以将其重构为命名函数,以便更好地理解跟踪,否则,您的跟踪 span 将被命名为 `anonymous`。 ```typescript const bad = new Elysia() // ⚠️ span 名称将是匿名的 .derive(async ({ cookie: { session } }) => { return { user: await getProfile(session) } }) const good = new Elysia() // ✅ span 名称将是 getProfile .derive(async function getProfile({ cookie: { session } }) { return { user: await getProfile(session) } }) ``` ## getCurrentSpan `getCurrentSpan` 是一个实用工具,用于在处理程序外部获取当前请求的当前 span。 ```typescript import { getCurrentSpan } from '@elysiajs/opentelemetry' function utility() { const span = getCurrentSpan() span.setAttributes({ 'custom.attribute': 'value' }) } ``` 这在处理程序外部通过从 `AsyncLocalStorage` 获取当前 span 而工作。 ## setAttributes `setAttribute` 是一个用于将属性设置为当前 span 的实用工具。 ```typescript import { setAttributes } from '@elysiajs/opentelemetry' function utility() { setAttributes({ 'custom.attribute': 'value' }) } ``` 这是 `getCurrentSpan().setAttributes` 的语法糖。 ## 配置 请查看 [opentelemetry 插件](/plugins/opentelemetry) 以获取配置选项和定义。 --- --- url: 'https://elysia.cndocs.org/plugins/opentelemetry.md' --- # OpenTelemetry ::: tip 此页面是 **OpenTelemetry** 的 **配置参考**,如果您想要设置和集成 OpenTelemetry,我们建议您查看 [与 OpenTelemetry 集成](/integrations/opentelemetry)。 ::: 要开始使用 OpenTelemetry,请安装 `@elysiajs/opentelemetry` 并将插件应用于任意实例。 ```typescript twoslash 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() ) ] }) ) ``` ![jaeger 显示自动收集的追踪](/blog/elysia-11/jaeger.webp) Elysia OpenTelemetry 将 **收集任何与 OpenTelemetry 标准兼容的库的跨度**,并将自动应用父子跨度。 ## 使用 请参见 [opentelemetry](/integrations/opentelemetry) 以获取用法和实用工具 ## 配置 此插件扩展 OpenTelemetry SDK 参数选项。 以下是插件接受的配置 ### autoDetectResources - 布尔值 使用默认资源探测器自动检测环境中的资源。 默认值:`true` ### contextManager - ContextManager 使用自定义上下文管理器。 默认值:`AsyncHooksContextManager` ### textMapPropagator - TextMapPropagator 使用自定义传播器。 默认值:`CompositePropagator`,使用 W3C Trace Context 和 Baggage ### metricReader - MetricReader 添加一个将被传递给 MeterProvider 的 MetricReader。 ### views - View\[] 要传递给 MeterProvider 的视图列表。 接受 View 实例的数组。此参数可用于配置直方图指标的显式桶大小。 ### instrumentations - (Instrumentation | Instrumentation\[])\[] 配置仪器。 默认情况下启用 `getNodeAutoInstrumentations`,如果您希望启用它们,您可以使用元包或单独配置每个仪器。 默认值:`getNodeAutoInstrumentations()` ### resource - IResource 配置资源。 资源也可以通过使用 SDK 的 autoDetectResources 方法来检测。 ### resourceDetectors - Array\ 配置资源探测器。默认情况下,资源探测器为 \[envDetector, processDetector, hostDetector]。 注意:为了启用探测,参数 autoDetectResources 必须为 true。 如果没有设置 resourceDetectors,您还可以使用环境变量 OTEL\_NODE\_RESOURCE\_DETECTORS 来启用特定探测器或完全禁用它们: * env * host * os * process * serviceinstance (实验性) * all - 启用上述所有资源探测器 * none - 禁用资源探测 例如,只启用 env 和 host 探测器: ```bash export OTEL_NODE_RESOURCE_DETECTORS="env,host" ``` ### sampler - Sampler 配置自定义采样器。默认情况下,所有追踪将被采样。 ### serviceName - 字符串 要标识的命名空间。 ### spanProcessors - SpanProcessor\[] 要注册到追踪器提供程序的跨度处理器数组。 ### traceExporter - SpanExporter 配置追踪导出器。如果配置了导出器,则将与 `BatchSpanProcessor` 一起使用。 如果没有以编程方式配置导出器或跨度处理器,该软件包将自动设置使用 http/protobuf 协议的默认 otlp 导出器和一个 BatchSpanProcessor。 ### spanLimits - SpanLimits 配置追踪参数。这些与配置追踪器使用的相同追踪参数。 --- --- url: 'https://elysia.cndocs.org/integrations/react-email.md' --- # React Email React Email 是一个库,允许您使用 React 组件创建电子邮件。 由于 Elysia 使用 Bun 作为运行环境,我们可以直接编写一个 React Email 组件,并将 JSX 直接导入到我们的代码中以发送电子邮件。 ## 安装 要安装 React Email,请运行以下命令: ```bash bun add -d react-email bun add @react-email/components react react-dom ``` 然后在 `package.json` 中添加以下脚本: ```json { "scripts": { "email": "email dev --dir src/emails" } } ``` 我们建议将电子邮件模板添加到 `src/emails` 目录中,因为我们可以直接导入 JSX 文件。 ### TypeScript 如果您使用 TypeScript,可能需要在 `tsconfig.json` 中添加以下内容: ```json { "compilerOptions": { "jsx": "react" } } ``` ## 您的第一封电子邮件 创建文件 `src/emails/otp.tsx`,并输入以下代码: ```tsx import * as React from 'react' import { Tailwind, Section, Text } from '@react-email/components' export default function OTPEmail({ otp }: { otp: number }) { return (
验证您的电子邮件地址 使用以下代码验证您的电子邮件地址 {otp} 此代码在 10 分钟内有效 感谢加入我们
) } OTPEmail.PreviewProps = { otp: 123456 } ``` 您可能会注意到我们使用了 `@react-email/components` 来创建电子邮件模板。 该库提供了一组与邮件客户端(例如 Gmail、Outlook 等)兼容的组件,包括 **使用 Tailwind 进行样式设置**。 我们还向 `OTPEmail` 函数添加了 `PreviewProps`。这仅在我们在 PLAYGROUND 上预览电子邮件时适用。 ## 预览您的电子邮件 要预览您的电子邮件,请运行以下命令: ```bash bun email ``` 这将打开一个浏览器窗口,显示您的电子邮件预览。 ![React Email playground showing an OTP email we have just written](/recipe/react-email/email-preview.webp) ## 发送电子邮件 要发送电子邮件,我们可以使用 `react-dom/server` 来渲染电子邮件,然后使用首选提供商进行发送: ::: code-group ```tsx [Nodemailer] import { Elysia, t } from 'elysia' import * as React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import OTPEmail from './emails/otp' import nodemailer from 'nodemailer' // [!code ++] const transporter = nodemailer.createTransport({ // [!code ++] host: 'smtp.gehenna.sh', // [!code ++] port: 465, // [!code ++] auth: { // [!code ++] user: 'makoto', // [!code ++] pass: '12345678' // [!code ++] } // [!code ++] }) // [!code ++] new Elysia() .get('/otp', async async ({ body }) => { // 随机生成 100,000 到 999,999 之间的数字 const otp = ~~(Math.random() * (900_000 - 1)) + 100_000 const html = renderToStaticMarkup() await transporter.sendMail({ // [!code ++] from: 'ibuki@gehenna.sh', // [!code ++] to: body, // [!code ++] subject: '验证您的电子邮件地址', // [!code ++] html, // [!code ++] }) // [!code ++] return { success: true } }, { body: t.String({ format: 'email' }) }) .listen(3000) ``` ```tsx [Resend] import { Elysia, t } from 'elysia' import OTPEmail from './emails/otp' import Resend from 'resend' // [!code ++] const resend = new Resend('re_123456789') // [!code ++] new Elysia() .get('/otp', async ({ body }) => { // 随机生成 100,000 到 999,999 之间的数字 const otp = ~~(Math.random() * (900_000 - 1)) + 100_000 await resend.emails.send({ // [!code ++] from: 'ibuki@gehenna.sh', // [!code ++] to: body, // [!code ++] subject: '验证您的电子邮件地址', // [!code ++] html: , // [!code ++] }) // [!code ++] return { success: true } }, { body: t.String({ format: 'email' }) }) .listen(3000) ``` ```tsx [AWS SES] import { Elysia, t } from 'elysia' import * as React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import OTPEmail from './emails/otp' import { type SendEmailCommandInput, SES } from '@aws-sdk/client-ses' // [!code ++] import { fromEnv } from '@aws-sdk/credential-providers' // [!code ++] const ses = new SES({ // [!code ++] credentials: // [!code ++] process.env.NODE_ENV === 'production' ? fromEnv() : undefined // [!code ++] }) // [!code ++] new Elysia() .get('/otp', async ({ body }) => { // 随机生成 100,000 到 999,999 之间的数字 const otp = ~~(Math.random() * (900_000 - 1)) + 100_000 const html = renderToStaticMarkup() await ses.sendEmail({ // [!code ++] Source: 'ibuki@gehenna.sh', // [!code ++] Destination: { // [!code ++] ToAddresses: [body] // [!code ++] }, // [!code ++] Message: { // [!code ++] Body: { // [!code ++] Html: { // [!code ++] Charset: 'UTF-8', // [!code ++] Data: html // [!code ++] } // [!code ++] }, // [!code ++] Subject: { // [!code ++] Charset: 'UTF-8', // [!code ++] Data: '验证您的电子邮件地址' // [!code ++] } // [!code ++] } // [!code ++] } satisfies SendEmailCommandInput) // [!code ++] return { success: true } }, { body: t.String({ format: 'email' }) }) .listen(3000) ``` ```tsx [Sendgrid] import { Elysia, t } from 'elysia' import OTPEmail from './emails/otp' import sendgrid from "@sendgrid/mail" // [!code ++] sendgrid.setApiKey(process.env.SENDGRID_API_KEY) // [!code ++] new Elysia() .get('/otp', async ({ body }) => { // 随机生成 100,000 到 999,999 之间的数字 const otp = ~~(Math.random() * (900_000 - 1)) + 100_000 const html = renderToStaticMarkup() await sendgrid.send({ // [!code ++] from: 'ibuki@gehenna.sh', // [!code ++] to: body, // [!code ++] subject: '验证您的电子邮件地址', // [!code ++] html // [!code ++] }) // [!code ++] return { success: true } }, { body: t.String({ format: 'email' }) }) .listen(3000) ``` ::: ::: tip 注意,我们可以直接导入电子邮件组件,这要归功于 Bun ::: 您可以在 [React Email Integration](https://react.email/docs/integrations/overview) 中查看所有可用的 React Email 集成,并在 [React Email documentation](https://react.email/docs) 中了解更多信息。 --- --- url: 'https://elysia.cndocs.org/plugins/swagger.md' --- ::: warning Swagger 插件已弃用且不再维护。请使用 [OpenAPI 插件](/plugins/openapi) 替代。 ::: # Swagger 插件 该插件为 Elysia 服务器生成一个 Swagger 端点。 安装命令: ```bash bun add @elysiajs/swagger ``` 然后这样使用它: ```typescript import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' new Elysia() .use(swagger()) .get('/', () => 'hi') .post('/hello', () => 'world') .listen(3000) ``` 访问 `/swagger` 将展示一个 Scalar UI,显示从 Elysia 服务器生成的端点文档。您还可以在 `/swagger/json` 访问原始的 OpenAPI 规范。 ## 配置 以下是插件接受的配置项: ### provider @default `scalar` 文档 UI 的提供者,默认是 Scalar。 ### scalar 自定义 Scalar 的配置。 请参考 [Scalar 配置](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) ### swagger 自定义 Swagger 的配置。 请参考 [Swagger 规范](https://swagger.io/specification/v2/)。 ### excludeStaticFile @default `true` 确定 Swagger 是否应排除静态文件。 ### path @default `/swagger` 暴露 Swagger 的端点路径。 ### exclude 需要从 Swagger 文档中排除的路径。 支持以下类型的值: * **字符串** * **正则表达式(RegExp)** * **字符串或正则表达式数组** ## 使用模式 以下是该插件的一些常见使用模式。 ## 更改 Swagger 端点 您可以通过插件配置中的 [path](#path) 属性更改 Swagger 端点位置。 ```typescript import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' new Elysia() .use( swagger({ path: '/v2/swagger' }) ) .listen(3000) ``` ## 自定义 Swagger 信息 ```typescript import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' new Elysia() .use( swagger({ documentation: { info: { title: 'Elysia 文档', version: '1.0.0' } } }) ) .listen(3000) ``` ## 使用标签 Elysia 可以利用 Swagger 的标签系统对端点进行分组。 首先,在 Swagger 配置对象中定义可用标签: ```typescript app.use( swagger({ documentation: { tags: [ { name: 'App', description: '通用端点' }, { name: 'Auth', description: '认证端点' } ] } }) ) ``` 然后在端点配置的 `detail` 属性中为该端点分配标签组: ```typescript app.get('/', () => 'Hello Elysia', { detail: { tags: ['App'] } }) app.group('/auth', (app) => app.post( '/sign-up', async ({ body }) => db.user.create({ data: body, select: { id: true, username: true } }), { detail: { tags: ['Auth'] } } ) ) ``` 这样将生成类似如下的 Swagger 页面 ## 安全配置 为了保护您的 API 端点,您可以在 Swagger 配置中定义安全方案。下面示例展示了如何用 Bearer 认证(JWT)来保护端点: ```typescript app.use( swagger({ documentation: { components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } } } }) ) export const addressController = new Elysia({ prefix: '/address', detail: { tags: ['Address'], security: [ { bearerAuth: [] } ] } }) ``` 此配置确保所有以 `/address` 前缀的端点都需要有效的 JWT 令牌才能访问。 --- --- url: 'https://elysia.cndocs.org/patterns/trace.md' --- # Trace 性能是 Elysia 一个重要的方面。 我们不仅希望在基准测试中快速运行,我们希望您在真实场景中拥有一个真正快速的服务器。 有许多因素可能会减慢我们的应用程序 - 并且很难识别它们,但 **trace** 可以通过在每个生命周期中注入开始和停止代码来帮助解决这个问题。 Trace 允许我们在每个生命周期事件的前后注入代码,从而阻止并与函数的执行进行交互。 ## Trace Trace 使用回调监听器以确保回调函数在移动到下一个生命周期事件之前完成。 要使用 `trace`,您需要在 Elysia 实例上调用 `trace` 方法,并传递一个将在每个生命周期事件中执行的回调函数。 您可以通过在生命周期名称前添加 `on` 前缀来监听每个生命周期,例如 `onHandle` 以监听 `handle` 事件。 ```ts twoslash import { Elysia } from 'elysia' const app = new Elysia() .trace(async ({ onHandle }) => { onHandle(({ begin, onStop }) => { onStop(({ end }) => { console.log('handle took', end - begin, 'ms') }) }) }) .get('/', () => 'Hi') .listen(3000) ``` 有关更多信息,请参见 [生命周期事件](/essential/life-cycle#events): ![Elysia 生命周期](/assets/lifecycle-chart.svg) ## 子事件 每个事件除了 `handle` 之外都有一个子事件,这是在每个生命周期事件内部执行的事件数组。 您可以使用 `onEvent` 来按顺序监听每个子事件。 ```ts twoslash import { Elysia } from 'elysia' const sleep = (time = 1000) => new Promise((resolve) => setTimeout(resolve, time)) const app = new Elysia() .trace(async ({ onBeforeHandle }) => { onBeforeHandle(({ total, onEvent }) => { console.log('总子事件:', total) onEvent(({ onStop }) => { onStop(({ elapsed }) => { console.log('子事件耗时', elapsed, 'ms') }) }) }) }) .get('/', () => 'Hi', { beforeHandle: [ function setup() {}, async function delay() { await sleep() } ] }) .listen(3000) ``` 在此示例中,总子事件将为 `2`,因为在 `beforeHandle` 事件中有 2 个子事件。 然后,我们使用 `onEvent` 监听每个子事件,并打印每个子事件的持续时间。 ## Trace 参数 每个生命周期被调用时 ```ts twoslash import { Elysia } from 'elysia' const app = new Elysia() // 这是 trace 参数 // 悬停以查看类型 .trace((parameter) => { }) .get('/', () => 'Hi') .listen(3000) ``` `trace` 接受以下参数: ### id - `number` 为每个请求随机生成的唯一 id ### context - `Context` Elysia 的 [上下文](/essential/handler.html#context),例如 `set`、`store`、`query``、`params\` ### set - `Context.set` `context.set` 的快捷方式,用于设置上下文的头部或状态 ### store - `Singleton.store` `context.store` 的快捷方式,用于访问上下文中的数据 ### time - `number` 请求被调用的时间戳 ### on\[Event] - `TraceListener` 每个生命周期事件的事件监听器。 您可以监听以下生命周期: * **onRequest** - 通知每个新请求 * **onParse** - 用于解析主体的函数数组 * **onTransform** - 在验证之前转换请求和上下文 * **onBeforeHandle** - 在主处理器之前检查的自定义要求,可以在返回响应时跳过主处理器。 * **onHandle** - 分配给路径的函数 * **onAfterHandle** - 在将响应发回客户端之前与响应进行交互 * **onMapResponse** - 将返回值映射到 Web 标准响应 * **onError** - 处理在处理请求期间抛出的错误 * **onAfterResponse** - 在响应发送之后的清理函数 ## Trace 监听器 每个生命周期事件的监听器 ```ts twoslash import { Elysia } from 'elysia' const app = new Elysia() .trace(({ onBeforeHandle }) => { // 这是 trace 监听器 // 悬停以查看类型 onBeforeHandle((parameter) => { }) }) .get('/', () => 'Hi') .listen(3000) ``` 每个生命周期监听器接受以下内容 ### name - `string` 函数的名称,如果函数是匿名的,则名称将为 `anonymous` ### begin - `number` 函数开始执行的时间 ### end - `Promise` 函数结束时的时间,当函数结束时将解析 ### error - `Promise` 在生命周期中抛出的错误,将在函数结束时解析 ### onStop - `callback?: (detail: TraceEndDetail) => any` 在生命周期结束时将执行的回调 ```ts twoslash import { Elysia } from 'elysia' const app = new Elysia() .trace(({ onBeforeHandle, set }) => { onBeforeHandle(({ onStop }) => { onStop(({ elapsed }) => { set.headers['X-Elapsed'] = elapsed.toString() }) }) }) .get('/', () => 'Hi') .listen(3000) ``` 建议在此函数中修改上下文,因为有一个锁机制以确保上下文在移动到下一个生命周期事件之前成功修改。 ## TraceEndDetail 传递给 `onStop` 回调的参数 ### end - `number` 函数结束时的时间 ### error - `Error | null` 在生命周期中抛出的错误 ### elapsed - `number` 生命周期的经过时间或 `end - begin` --- --- url: 'https://elysia.cndocs.org/patterns/websocket.md' --- # WebSocket WebSocket 是一种用于客户端与服务器之间通信的实时协议。 与 HTTP 不同,客户端一次又一次地询问网站信息并等待每次的回复,WebSocket 建立了一条直接的通道,使我们的客户端和服务器可以直接来回发送消息,从而使对话更快、更流畅,而无需每条消息都重新开始。 SocketIO 是一个流行的 WebSocket 库,但并不是唯一的。Elysia 使用 [uWebSocket](https://github.com/uNetworking/uWebSockets),它与 Bun 在底层使用相同的 API。 要使用 WebSocket,只需调用 `Elysia.ws()`: ```typescript import { Elysia } from 'elysia' new Elysia() .ws('/ws', { message(ws, message) { ws.send(message) } }) .listen(3000) ``` ## WebSocket 消息验证: 与普通路由相同,WebSocket 也接受一个 **schema** 对象来严格类型化和验证请求。 ```typescript import { Elysia, t } from 'elysia' const app = new Elysia() .ws('/ws', { // 验证传入消息 body: t.Object({ message: t.String() }), query: t.Object({ id: t.String() }), message(ws, { message }) { // 从 `ws.data` 获取 schema const { id } = ws.data.query ws.send({ id, message, time: Date.now() }) } }) .listen(3000) ``` WebSocket schema 可以验证如下内容: * **message** - 传入消息。 * **query** - 查询字符串或 URL 参数。 * **params** - 路径参数。 * **header** - 请求的头部。 * **cookie** - 请求的 cookie。 * **response** - 从处理器返回的值。 默认情况下,Elysia 将解析传入的字符串化 JSON 消息为对象以供验证。 ## 配置 您可以通过 Elysia 构造函数设置 WebSocket 值。 ```ts import { Elysia } from 'elysia' new Elysia({ websocket: { idleTimeout: 30 } }) ``` Elysia 的 WebSocket 实现扩展了 Bun 的 WebSocket 配置,更多信息请参见 [Bun 的 WebSocket 文档](https://bun.sh/docs/api/websockets)。 以下是 [Bun WebSocket](https://bun.sh/docs/api/websockets#create-a-websocket-server) 的简要配置: ### perMessageDeflate @default `false` 为支持的客户端启用压缩。 默认情况下,压缩是禁用的。 ### maxPayloadLength 消息的最大大小。 ### idleTimeout @default `120` 在连接未接收到消息后,经过这一秒数将关闭连接。 ### backpressureLimit @default `16777216` (16MB) 单个连接可以缓冲的最大字节数。 ### closeOnBackpressureLimit @default `false` 如果超过背压限制,关闭连接。 ## 方法 以下是可用于 WebSocket 路由的新方法。 ## ws 创建 WebSocket 处理程序。 示例: ```typescript import { Elysia } from 'elysia' const app = new Elysia() .ws('/ws', { message(ws, message) { ws.send(message) } }) .listen(3000) ``` 类型: ```typescript .ws(endpoint: path, options: Partial>): this ``` * **endpoint** - 作为 WebSocket 处理程序暴露的路径 * **options** - 自定义 WebSocket 处理程序行为 ## WebSocketHandler WebSocketHandler 扩展自 [config](#configuration) 的配置。 以下是 `ws` 接受的配置。 ## open 新的 WebSocket 连接的回调函数。 类型: ```typescript open(ws: ServerWebSocket<{ // 每个连接的 uid id: string data: Context }>): this ``` ## message 传入 WebSocket 消息的回调函数。 类型: ```typescript message( ws: ServerWebSocket<{ // 每个连接的 uid id: string data: Context }>, message: Message ): this ``` `Message` 类型基于 `schema.message`。默认是 `string`。 ## close 关闭 WebSocket 连接的回调函数。 类型: ```typescript close(ws: ServerWebSocket<{ // 每个连接的 uid id: string data: Context }>): this ``` ## drain 服务器准备好接受更多数据的回调函数。 类型: ```typescript drain( ws: ServerWebSocket<{ // 每个连接的 uid id: string data: Context }>, code: number, reason: string ): this ``` ## parse `Parse` 中间件在将 HTTP 连接升级到 WebSocket 之前解析请求。 ## beforeHandle `Before Handle` 中间件在将 HTTP 连接升级到 WebSocket 之前执行。 理想的验证位置。 ## transform `Transform` 中间件在验证之前执行。 ## transformMessage 类似于 `transform`,但在验证 WebSocket 消息之前执行。 ## header 在将连接升级到 WebSocket 之前添加的附加头。 --- --- url: 'https://elysia.cndocs.org/at-glance.md' --- # 一览 Elysia 是一个用于使用 Bun 构建后端服务器的人体工学 Web 框架。 Elysia 以简洁性和类型安全为设计理念,提供熟悉的 API,并广泛支持 TypeScript,同时针对 Bun 进行了优化。 这是一个简单的 Elysia Hello World 示例。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', 'Hello Elysia') .get('/user/:id', ({ params: { id }}) => id) .post('/form', ({ body }) => body) .listen(3000) ``` 导航到 [localhost:3000](http://localhost:3000/),您应该会看到 'Hello Elysia' 作为结果。 ::: tip 将鼠标悬停在代码片段上以查看类型定义。 在模拟浏览器中,点击蓝色高亮的路径以更改路径并预览响应。 Elysia 可以在浏览器中运行,您看到的结果实际上是使用 Elysia 执行的。 ::: ## 性能 基于 Bun 和广泛的优化(如静态代码分析),Elysia 能够在运行时生成优化的代码。 Elysia 可以超越当今大多数可用的 Web 框架\[1],甚至匹配 Golang 和 Rust 框架的性能\[2]。 | Framework | Runtime | Average | Plain Text | Dynamic Parameters | JSON Body | | ------------- | ------- | ----------- | ---------- | ------------------ | ---------- | | bun | bun | 262,660.433 | 326,375.76 | 237,083.18 | 224,522.36 | | elysia | bun | 255,574.717 | 313,073.64 | 241,891.57 | 211,758.94 | | hyper-express | node | 234,395.837 | 311,775.43 | 249,675 | 141,737.08 | | hono | bun | 203,937.883 | 239,229.82 | 201,663.43 | 170,920.4 | | h3 | node | 96,515.027 | 114,971.87 | 87,935.94 | 86,637.27 | | oak | deno | 46,569.853 | 55,174.24 | 48,260.36 | 36,274.96 | | fastify | bun | 65,897.043 | 92,856.71 | 81,604.66 | 23,229.76 | | fastify | node | 60,322.413 | 71,150.57 | 62,060.26 | 47,756.41 | | koa | node | 39,594.14 | 46,219.64 | 40,961.72 | 31,601.06 | | express | bun | 29,715.537 | 39,455.46 | 34,700.85 | 14,990.3 | | express | node | 15,913.153 | 17,736.92 | 17,128.7 | 12,873.84 | ## TypeScript Elysia 的设计旨在帮助您编写更少的 TypeScript 代码。 Elysia 的类型系统经过仔细调整,能够从您的代码中自动推断类型,而无需编写显式的 TypeScript,从而在运行时和编译时提供类型安全,实现最出色的人体工学开发体验。 看看这个示例: ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/user/:id', ({ params: { id } }) => id) // ^? .listen(3000) ``` 上述代码创建了一个路径参数 "id"。`:id` 的值将在运行时和类型中传递到 `params.id`,无需手动类型声明。 Elysia 的目标是帮助您编写更少的 TypeScript 代码,并更多地关注业务逻辑。让框架处理复杂的类型。 使用 Elysia 不需要 TypeScript,但推荐使用。 ## 类型完整性 进一步来说,Elysia 提供了 **Elysia.t**,这是一个模式构建器,用于在运行时和编译时验证类型和值,从而为您的数据类型创建一个单一真相来源。 让我们修改之前的代码,使其只接受数字值而不是字符串。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/user/:id', ({ params: { id } }) => id, { // ^? params: t.Object({ id: t.Number() }) }) .listen(3000) ``` 这段代码确保我们的路径参数 **id** 在运行时和编译时(类型级别)始终为数字。 ::: tip 将鼠标悬停在上述代码片段中的 "id" 上以查看类型定义。 ::: 使用 Elysia 的模式构建器,我们可以像强类型语言一样确保类型安全,并拥有单一真相来源。 ## 标准模式 Elysia 支持 [Standard Schema](https://github.com/standard-schema/standard-schema),允许您使用您喜欢的验证库: * Zod * Valibot * ArkType * Effect Schema * Yup * Joi * [以及更多](https://github.com/standard-schema/standard-schema) ```typescript twoslash import { Elysia } from 'elysia' import { z } from 'zod' import * as v from 'valibot' new Elysia() .get('/id/:id', ({ params: { id }, query: { name } }) => id, { // ^? params: z.object({ id: z.coerce.number() }), query: v.object({ name: v.literal('Lilith') }) }) .listen(3000) ``` Elysia 将自动从模式中推断类型,允许您使用您喜欢的验证库,同时保持类型安全。 ## OpenAPI Elysia 默认采用了许多标准,如 OpenAPI、WinterTC 合规性和 Standard Schema。这允许您与大多数行业标准工具集成,或者至少轻松集成您熟悉的工具。 例如,因为 Elysia 默认采用 OpenAPI,生成 API 文档就像添加一行代码一样简单: ```typescript import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' new Elysia() .use(openapi()) // [!code ++] .get('/user/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number() }) }) .listen(3000) ``` 使用 OpenAPI 插件,您可以无缝生成 API 文档页面,而无需额外代码或特定配置,并轻松与您的团队分享。 ## 从类型生成 OpenAPI Elysia 对 OpenAPI 的支持出色,使用模式进行数据验证、类型推断和从单一真相来源的 OpenAPI 注解。 Elysia 还支持**直接从类型生成 OpenAPI 模式,仅需 1 行代码**,允许您拥有完整且准确的 API 文档,而无需任何手动注解。 ```typescript import { Elysia, t } from 'elysia' import { openapi, fromTypes } from '@elysiajs/openapi' export const app = new Elysia() .use(openapi({ references: fromTypes() // [!code ++] })) .get('/user/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number() }) }) .listen(3000) ``` ## 端到端类型安全 使用 Elysia,类型安全不仅限于服务器端。 使用 Elysia,您可以自动与前端团队同步类型,类似于 tRPC,使用 Elysia 的客户端库 "Eden"。 ```typescript twoslash import { Elysia, t } from 'elysia' import { openapi, fromTypes } from '@elysiajs/openapi' export const app = new Elysia() .use(openapi({ references: fromTypes() })) .get('/user/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number() }) }) .listen(3000) export type App = typeof app ``` 而在客户端: ```typescript twoslash // @filename: server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/user/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number() }) }) .listen(3000) export type App = typeof app // @filename: client.ts // ---cut--- // client.ts import { treaty } from '@elysiajs/eden' import type { App } from './server' const app = treaty('localhost:3000') // Get data from /user/617 const { data } = await app.user({ id: 617 }).get() // ^? console.log(data) ``` 使用 Eden,您可以使用现有的 Elysia 类型查询 Elysia 服务器**无需代码生成**,并自动同步前端和后端的类型。 Elysia 不仅仅是帮助您创建自信的后端,而是为了这个世界上所有美好的事物。 ## 平台无关 Elysia 是为 Bun 设计的,但**不限于 Bun**。由于 [WinterTC 合规](https://wintertc.org/),您可以将 Elysia 服务器部署到 Cloudflare Workers、Vercel Edge Functions 和大多数支持 Web 标准请求的其他运行时。 ## 我们的社区 如果您有问题或在使用 Elysia 时遇到困难,请随时在 GitHub Discussions、Discord 或 Twitter 上向我们的社区提问。 *** 1\. 以请求/秒为单位测量。在 Debian 11 上,Intel i7-13700K 测试于 Bun 0.7.2,2023 年 8 月 6 日。解析查询、路径参数并设置响应头。查看基准条件 [here](https://github.com/SaltyAom/bun-http-framework-benchmark/tree/c7e26fe3f1bfee7ffbd721dbade10ad72a0a14ab#results)。 2\. 基于 [TechEmpower Benchmark round 22](https://www.techempower.com/benchmarks/#section=data-r22\&hw=ph\&test=composite)。 --- --- url: 'https://elysia.cndocs.org/integrations/ai-sdk.md' --- # 与 AI SDK 集成 Elysia 提供了对响应流的支持,使您能够无缝集成 [Vercel AI SDKs](https://vercel.com/docs/ai)。 ## 响应流 Elysia 支持对 `ReadableStream` 和 `Response` 的持续流式处理,允许您直接从 AI SDK 返回流。 ```ts import { Elysia } from 'elysia' import { streamText } from 'ai' import { openai } from '@ai-sdk/openai' new Elysia().get('/', () => { const stream = streamText({ model: openai('gpt-5'), system: '你是《原神》中的八重神子', prompt: '嗨!你近来怎么样?' }) // 直接返回一个 ReadableStream return result.textStream // [!code ++] // 也支持 UI 消息流 return result.toUIMessageStream() // [!code ++] }) ``` Elysia 会自动处理流,允许您以多种方式使用它。 ## 服务器发送事件(Server Sent Event) Elysia 还支持通过简单地将 `ReadableStream` 包裹在 `sse` 函数中,实现流式响应的服务器发送事件。 ```ts import { Elysia, sse } from 'elysia' // [!code ++] import { streamText } from 'ai' import { openai } from '@ai-sdk/openai' new Elysia().get('/', () => { const stream = streamText({ model: openai('gpt-5'), system: '你是《原神》中的八重神子', prompt: '嗨!你近来怎么样?' }) // 每个数据块都会作为服务器发送事件发送 return sse(result.textStream) // [!code ++] // 也支持 UI 消息流 return sse(result.toUIMessageStream()) // [!code ++] }) ``` ## 作为响应 如果您不需要后续使用 [Eden](/eden/overview) 的流类型安全,可以直接将流作为响应返回。 ```ts import { Elysia } from 'elysia' import { ai } from 'ai' import { openai } from '@ai-sdk/openai' new Elysia().get('/', () => { const stream = streamText({ model: openai('gpt-5'), system: '你是《原神》中的八重神子', prompt: '嗨!你近来怎么样?' }) return result.toTextStreamResponse() // [!code ++] // UI 消息流响应将使用 SSE return result.toUIMessageStreamResponse() // [!code ++] }) ``` ## 手动流式处理 如果您想对流过程有更多控制,可以使用生成器函数手动产出数据块。 ```ts import { Elysia, sse } from 'elysia' import { ai } from 'ai' import { openai } from '@ai-sdk/openai' new Elysia().get('/', async function* () { const stream = streamText({ model: openai('gpt-5'), system: '你是《原神》中的八重神子', prompt: '嗨!你近来怎么样?' }) for await (const data of result.textStream) // [!code ++] yield sse({ // [!code ++] data, // [!code ++] event: 'message' // [!code ++] }) // [!code ++] yield sse({ event: 'done' }) }) ``` ## 使用 Fetch 如果 AI SDK 不支持您使用的模型,您仍然可以使用 `fetch` 函数向 AI SDK 发起请求并直接流式传输响应。 ```ts import { Elysia, fetch } from 'elysia' new Elysia().get('/', () => { return fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.OPENAI_API_KEY}` }, body: JSON.stringify({ model: 'gpt-5', stream: true, messages: [ { role: 'system', content: '你是《原神》中的八重神子' }, { role: 'user', content: '嗨!你近来怎么样?' } ] }) }) }) ``` Elysia 会自动代理带有流支持的 fetch 响应。 *** 更多信息请参考 [AI SDK 文档](https://ai-sdk.dev/docs/introduction) --- --- url: 'https://elysia.cndocs.org/integrations/astro.md' --- # 与 Astro 的集成 使用 [Astro Endpoint](https://docs.astro.build/en/core-concepts/endpoints/),我们可以直接在 Astro 上运行 Elysia。 1. 在 **astro.config.mjs** 中将 **output** 设置为 **server** ```javascript // astro.config.mjs import { defineConfig } from 'astro/config' // https://astro.build/config export default defineConfig({ output: 'server' // [!code ++] }) ``` 2. 创建 **pages/\[...slugs].ts** 3. 在 **\[...slugs].ts** 中创建或导入一个现有的 Elysia 服务器 4. 用您想要公开的方法名称导出处理器 ```typescript // pages/[...slugs].ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/api', () => 'hi') .post('/api', ({ body }) => body, { body: t.Object({ name: t.String() }) }) const handle = ({ request }: { request: Request }) => app.handle(request) // [!code ++] export const GET = handle // [!code ++] export const POST = handle // [!code ++] ``` Elysia 能够正常工作,因为遵循了 WinterCG。 我们推荐在 [Bun 上运行 Astro](https://docs.astro.build/en/recipes/bun),因为 Elysia 设计是为了在 Bun 上运行。 ::: tip 您可以在不使用 Bun 运行 Astro 的情况下运行 Elysia 服务器,这得益于 WinterCG 的支持。 但是如果您在 Node 上运行 Astro,某些插件如 **Elysia Static** 可能无法正常工作。 ::: 通过这种方式,您可以在单个代码库中共同拥有前端和后端,并且与 Eden 实现端到端的类型安全。 有关更多信息,请参阅 [Astro Endpoint](https://docs.astro.build/en/core-concepts/endpoints/)。 ## 前缀 如果您将 Elysia 服务器放在应用路由的根目录之外,您需要为 Elysia 服务器注释前缀。 例如,如果您将 Elysia 服务器放在 **pages/api/\[...slugs].ts**,则需要将前缀注释为 **/api**。 ```typescript // pages/api/[...slugs].ts import { Elysia, t } from 'elysia' const app = new Elysia({ prefix: '/api' }) // [!code ++] .get('/', () => 'hi') .post('/', ({ body }) => body, { body: t.Object({ name: t.String() }) }) const handle = ({ request }: { request: Request }) => app.handle(request) // [!code ++] export const GET = handle // [!code ++] export const POST = handle // [!code ++] ``` 这将确保 Elysia 路由在您放置的位置上能够正常工作。 --- --- url: 'https://elysia.cndocs.org/integrations/cloudflare-worker.md' --- # Cloudflare Worker 实验性 Elysia 现在通过 **实验性** 的 Cloudflare Worker 适配器支持 Cloudflare Worker。 1. 您需要 [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update) 来设置并启动开发服务器。 ```bash wrangler init elysia-on-cloudflare ``` 2. 然后将 Cloudflare 适配器添加到您的 Elysia 应用中,并确保在导出应用之前调用 `.compile()`。 ```ts import { Elysia } from 'elysia' import { CloudflareAdapter } from 'elysia/adapter/cloudflare' // [!code ++] export default new Elysia({ adapter: CloudflareAdapter // [!code ++] }) .get('/', () => 'Hello Cloudflare Worker!') // 这是在 Cloudflare Worker 上使 Elysia 工作的必需步骤 .compile() // [!code ++] ``` 3. 确保在您的 wrangler 配置中将 `compatability_date` 设置为至少 `2025-06-01` ::: code-group ```jsonc [wrangler.jsonc] { "$schema": "node_modules/wrangler/config-schema.json", "name": "elysia-on-cloudflare", "main": "src/index.ts", "compatibility_date": "2025-06-01" // [!code ++] } ``` ```toml [wrangler.toml] main = "src/index.ts" name = "elysia-on-cloudflare" compatibility_date = "2025-06-01" # [!code ++] ``` ::: 4. 现在您可以使用以下命令启动开发服务器: ```bash wrangler dev ``` 这应该会在 `http://localhost:8787` 启动开发服务器 您不需要 `nodejs_compat` 标志,因为 Elysia 不使用任何 Node.js 内置模块(或者我们使用的模块尚未支持 Cloudflare Worker) ## 限制 以下是在 Cloudflare Worker 上使用 Elysia 的已知限制: 1. `Elysia.file` 和 [Static Plugin](/plugins/static) 不起作用,因为 [缺少 `fs` 模块](https://developers.cloudflare.com/workers/runtime-apis/nodejs/#supported-nodejs-apis) 2. [OpenAPI Type Gen](/blog/openapi-type-gen) 不起作用,因为 [缺少 `fs` 模块](https://developers.cloudflare.com/workers/runtime-apis/nodejs/#supported-nodejs-apis) 3. 您无法在服务器启动前定义 [**Response**](https://x.com/saltyAom/status/1966602691754553832),或者使用会这样做的插件。 ## 静态文件 [Static Plugin](/plugins/static) 不起作用,您仍然可以使用 [Cloudflare 的内置静态文件服务](https://developers.cloudflare.com/workers/static-assets/) 来提供静态文件。 在您的 wrangler 配置中添加以下内容: ::: code-group ```jsonc [wrangler.jsonc] { "$schema": "node_modules/wrangler/config-schema.json", "name": "elysia-on-cloudflare", "main": "src/index.ts", "compatibility_date": "2025-06-01", "assets": { "directory": "public" } // [!code ++] } ``` ```toml [wrangler.toml] name = "elysia-on-cloudflare" main = "src/index.ts" compatibility_date = "2025-06-01" assets = { directory = "public" } # [!code ++] ``` ::: 在 `public` 文件夹中创建并放置您的静态文件。 假设您有如下文件夹结构: ``` │ ├─ public │ ├─ kyuukurarin.mp4 │ └─ static │ └─ mika.webp ├─ src │ └── index.ts └─ wrangler.toml ``` 然后您应该能够从以下路径访问您的静态文件: * **http://localhost:8787/kyuukurarin.mp4** * **http://localhost:8787/static/mika.webp** ## AoT 编译 以前,要在 Cloudflare Worker 上使用 Elysia,您必须在 Elysia 构造函数中传递 `aot: false`。 现在这不再必要,因为 [Cloudflare 现在支持在启动期间进行函数编译](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-eval-during-startup) 从 Elysia 1.4.7 开始,您现在可以使用 Ahead of Time Compilation 与 Cloudflare Worker 一起使用,并删除 `aot: false` 标志。 ```ts import { Elysia } from 'elysia' import { CloudflareAdapter } from 'elysia/adapter/cloudflare' // [!code ++] export default new Elysia({ aot: false, // [!code --] adapter: CloudflareAdapter // [!code ++] }) ``` 否则,如果您不想使用 Ahead of Time Compilation,您仍然可以使用 `aot: false`,但我们推荐您使用它以获得更好的性能和准确的插件封装。 --- --- url: 'https://elysia.cndocs.org/integrations/expo.md' --- # 与 Expo 集成 从 Expo SDK 50 和 App Router v3 开始,Expo 允许我们直接在 Expo 应用中创建 API 路由。 1. 如果尚不存在,请创建一个 Expo 应用: ```typescript bun create expo-app --template tabs ``` 2. 创建 **app/\[...slugs]+api.ts** 3. 在 **\[...slugs]+api.ts** 中创建或导入一个现有的 Elysia 服务器 4. 以您想要暴露的方法名称导出处理器 ```typescript // app/[...slugs]+api.ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/', () => 'hello Next') .post('/', ({ body }) => body, { body: t.Object({ name: t.String() }) }) export const GET = app.handle // [!code ++] export const POST = app.handle // [!code ++] ``` Elysia 将正常运行,因为得益于 WinterCG 的兼容性,然而,某些插件如 **Elysia Static** 可能在您在 Node 上运行 Expo 时无法正常工作。 您可以像对待普通的 Expo API 路由那样对待 Elysia 服务器。 通过这种方式,您可以将前端和后端共同放置在一个仓库中,并实现 [Eden 的端到端类型安全](https://elysiajs.com/eden/overview.html),同时支持客户端和服务器操作。 有关更多信息,请参考 [API 路由](https://docs.expo.dev/router/reference/api-routes/)。 ## 前缀 如果您将 Elysia 服务器放置在应用路由的根目录之外,您需要为 Elysia 服务器注释前缀。 例如,如果您将 Elysia 服务器放在 **app/api/\[...slugs]+api.ts** 中,您需要将前缀注释为 **/api**。 ```typescript // app/api/[...slugs]+api.ts import { Elysia, t } from 'elysia' const app = new Elysia({ prefix: '/api' }) .get('/', () => 'hi') .post('/', ({ body }) => body, { body: t.Object({ name: t.String() }) }) export const GET = app.handle export const POST = app.handle ``` 这样可以确保无论您将其放置在何处,Elysia 路由都会正常工作。 ## 部署 您可以直接使用 Elysia 的 API 路由,根据需要部署为正常的 Elysia 应用,或使用 [实验性的 Expo 服务器运行时](https://docs.expo.dev/router/reference/api-routes/#deployment)。 如果您使用 Expo 服务器运行时,可以使用 `expo export` 命令为您的 Expo 应用创建优化构建,这将包括一个使用 Elysia 的 Expo 函数,位于 **dist/server/\_expo/functions/\[...slugs]+api.js** ::: tip 请注意,Expo 函数被视为边缘函数,而不是普通服务器,因此直接运行边缘函数不会分配任何端口。 ::: 您可以使用 Expo 提供的 Expo 函数适配器来部署您的边缘函数。 目前 Expo 支持以下适配器: * [Express](https://docs.expo.dev/router/reference/api-routes/#express) * [Netlify](https://docs.expo.dev/router/reference/api-routes/#netlify) * [Vercel](https://docs.expo.dev/router/reference/api-routes/#vercel) --- --- url: 'https://elysia.cndocs.org/integrations/nextjs.md' --- # 与 Next.js 的集成 使用 Next.js App Router,我们可以在 Next.js 路由上运行 Elysia。 1. 在 app router 中创建 **api/\[\[...slugs]]/route.ts** 2. 在 **route.ts** 中,创建或导入现有的 Elysia 服务器 3. 使用 `Elysia.handle` 导出您想要使用的 HTTP 方法 ```typescript [app/api/[[...slugs]]/route.ts] // app/api/[[...slugs]]/route.ts import { Elysia, t } from 'elysia' const app = new Elysia({ prefix: '/api' }) .get('/', () => 'hello Next') .post('/', ({ body }) => body, { body: t.Object({ name: t.String() }) }) export const GET = app.handle export const POST = app.handle ``` 由于符合 WinterCG 标准,Elysia 将按预期正常工作,但是,如果您在 Node 上运行 Next.js,某些插件如 **Elysia Static** 可能无法工作。 您可以将 Elysia 服务器视为普通的 Next.js API 路由。 通过这种方法,您可以在单个仓库中实现前端和后端的共置,并使用 [Eden 实现端到端类型安全](https://elysiajs.com/eden/overview.html),适用于客户端和服务器操作。 有关更多信息,请参阅 [Next.js 路由处理程序](https://nextjs.org/docs/app/building-your-application/routing/route-handlers#static-route-handlers)。 ## 前缀 由于我们的 Elysia 服务器不在 app router 的根目录中,您需要为 Elysia 服务器标注前缀。 例如,如果您将 Elysia 服务器放置在 **app/user/\[\[...slugs]]/route.ts** 中,您需要将前缀标注为 **/user**。 ```typescript // app/user/[[...slugs]]/route.ts import { Elysia, t } from 'elysia' export default new Elysia({ prefix: '/user' }) // [!code ++] .get('/', () => 'hi') .post('/', ({ body }) => body, { body: t.Object({ name: t.String() }) }) ``` 这将确保 Elysia 路由在您放置它的任何位置都能正常工作。 --- --- url: 'https://elysia.cndocs.org/integrations/nuxt.md' --- # 与 Nuxt 集成 我们可以使用 [nuxt-elysia](https://github.com/tkesgar/nuxt-elysia)(一个 Nuxt 社区插件)在 Nuxt API 路由上设置 Elysia 并配置 Eden Treaty。 1. 使用以下命令安装插件: ```bash bun add elysia @elysiajs/eden bun add -d nuxt-elysia ``` 2. 将 `nuxt-elysia` 添加到你的 Nuxt 配置中: ```ts export default defineNuxtConfig({ modules: [ // [!code ++] 'nuxt-elysia' // [!code ++] ] // [!code ++] }) ``` 3. 在项目根目录创建 `api.ts`: ```typescript [api.ts] export default () => new Elysia() // [!code ++] .get('/hello', () => ({ message: 'Hello world!' })) // [!code ++] ``` 4. 在你的 Nuxt 应用中使用 Eden Treaty: ```vue ``` 这将自动设置 Elysia 在 Nuxt API 路由上运行。 ## 前缀 默认情况下,Elysia 将挂载在 **/\_api** 路径下,但我们可以通过 `nuxt-elysia` 配置来自定义。 ```ts export default defineNuxtConfig({ nuxtElysia: { path: '/api' // [!code ++] } }) ``` 这将把 Elysia 挂载到 **/api** 而不是 **/\_api**。 有关更多配置,请参考 [nuxt-elysia](https://github.com/tkesgar/nuxt-elysia)。 --- --- url: 'https://elysia.cndocs.org/integrations/prisma.md' --- # Prisma [Prisma](https://prisma.io) 是一个 ORM,允许我们以类型安全的方式与数据库交互。 它提供了一种使用 Prisma schema 文件定义数据库模式的方法,然后基于该模式生成 TypeScript 类型。 ### Prismabox [Prismabox](https://github.com/m1212e/prismabox) 是一个可以从 Prisma 模式生成 TypeBox 或 Elysia 验证模型的库。 我们可以使用 Prismabox 将 Prisma 模式转换为 Elysia 验证模型,然后用于确保 Elysia 中的类型验证。 ### 工作原理: 1. 在 Prisma Schema 中定义数据库模式。 2. 添加 `prismabox` 生成器以生成 Elysia 模式。 3. 使用转换后的 Elysia 验证模型确保类型验证。 4. 从 Elysia 验证模型生成 OpenAPI 模式。 5. 添加 [Eden Treaty](/eden/overview) 为前端添加类型安全。 ``` * ——————————————— * | | | -> | 文档生成 | * ————————— * * ———————— * OpenAPI | | | | | prismabox | | ——————— | * ——————————————— * | Prisma | —————————-> | Elysia | | | | | ——————— | * ——————————————— * * ————————— * * ———————— * Eden | | | | -> | 前端代码 | | | * ——————————————— * ``` ## 安装 要安装 Prisma,请运行以下命令: ```bash bun add @prisma/client prismabox && \ bun add -d prisma ``` ## Prisma 模式 假设您已经有一个 `prisma/schema.prisma` 文件。 我们可以将 `prismabox` 生成器添加到 Prisma 模式文件中,如下所示: ::: code-group ```ts [prisma/schema.prisma] generator client { provider = "prisma-client-js" output = "../generated/prisma" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } generator prismabox { // [!code ++] provider = "prismabox" // [!code ++] typeboxImportDependencyName = "elysia" // [!code ++] typeboxImportVariableName = "t" // [!code ++] inputModel = true // [!code ++] output = "../generated/prismabox" // [!code ++] } // [!code ++] model User { id String @id @default(cuid()) email String @unique name String? posts Post[] } model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String } ``` ::: 这将在 `generated/prismabox` 目录中生成 Elysia 验证模型。 每个模型将有自己的文件,模型名称将基于 Prisma 模型名称。 例如: * `User` 模型将生成到 `generated/prismabox/User.ts` * `Post` 模型将生成到 `generated/prismabox/Post.ts` ## 使用生成的模型 然后我们可以在 Elysia 应用程序中导入生成的模型: ::: code-group ```ts [src/index.ts] import { Elysia, t } from 'elysia' import { PrismaClient } from '../generated/prisma' // [!code ++] import { UserPlain, UserPlainInputCreate } from '../generated/prismabox/User' // [!code ++] const prisma = new PrismaClient() const app = new Elysia() .put( '/', async ({ body }) => prisma.user.create({ data: body }), { body: UserPlainInputCreate, // [!code ++] response: UserPlain // [!code ++] } ) .get( '/id/:id', async ({ params: { id }, status }) => { const user = await prisma.user.findUnique({ where: { id } }) if (!user) return status(404, 'User not found') return user }, { response: { 200: UserPlain, // [!code ++] 404: t.String() // [!code ++] } } ) .listen(3000) console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ) ``` ::: 这使我们能够在 Elysia 验证模型中重用数据库模式。 *** 更多信息,请参考 [Prisma](https://prisma.io) 和 [Prismabox](https://github.com/m1212e/prismabox) 文档。 --- --- url: 'https://elysia.cndocs.org/integrations/sveltekit.md' --- # 与 SvelteKit 的集成 使用 SvelteKit,您可以在服务器路由上运行 Elysia。 1. 创建 **src/routes/\[...slugs]/+server.ts**。 2. 在 **+server.ts** 中创建或导入一个现有的 Elysia 服务器 3. 导出您想要公开的方法的处理程序,您也可以使用 `fallback` 让 Elysia 处理所有方法。 ```typescript // src/routes/[...slugs]/+server.ts import { Elysia, t } from 'elysia'; const app = new Elysia() .get('/', () => 'hello SvelteKit') .post('/', ({ body }) => body, { body: t.Object({ name: t.String() }) }) type RequestHandler = (v: { request: Request }) => Response | Promise export const GET: RequestHandler = ({ request }) => app.handle(request) export const POST: RequestHandler = ({ request }) => app.handle(request) // or simply export const fallback: RequestHandler = ({ request }) => app.handle(request) ``` 您可以将 Elysia 服务器视为普通的 SvelteKit 服务器路由。 通过这种方法,您可以在单个代码库中共同定位前端和后端,并且可以实现 [Eden 的端到端类型安全](https://elysiajs.com/eden/overview.html),支持客户端和服务器的操作。 有关更多信息,请参考 [SvelteKit 路由](https://kit.svelte.dev/docs/routing#server)。 ## 前缀 如果您将 Elysia 服务器放在应用路由的根目录以外的位置,您需要为 Elysia 服务器注释前缀。 例如,如果您将 Elysia 服务器放在 **src/routes/api/\[...slugs]/+server.ts** 中,您需要将前缀注释为 **/api**。 ```typescript twoslash // src/routes/api/[...slugs]/+server.ts import { Elysia, t } from 'elysia'; const app = new Elysia({ prefix: '/api' }) // [!code ++] .get('/', () => 'hi') .post('/', ({ body }) => body, { body: t.Object({ name: t.String() }) }) type RequestHandler = (v: { request: Request }) => Response | Promise export const fallback: RequestHandler = ({ request }) => app.handle(request) ``` 这样可以确保 Elysia 路由在您放置它的任何位置都能正常工作。 --- --- url: 'https://elysia.cndocs.org/integrations/vercel.md' --- # 与 Vercel Function 集成 Vercel Function 默认支持 Web Standard Framework,因此您可以在 Vercel Function 上运行 Elysia 而无需任何额外配置。 1. 在 **src/index.ts** 创建一个文件 2. 在 **src/index.ts** 中,创建或导入现有的 Elysia 服务器 3. 将 Elysia 服务器作为默认导出 ```typescript import { Elysia, t } from 'elysia' export default new Elysia() .get('/', () => 'Hello Vercel Function') .post('/', ({ body }) => body, { body: t.Object({ name: t.String() }) }) ``` 4. 添加构建脚本,使用 `tsdown` 或类似工具将代码打包成单个文件 ```json { "scripts": { "build": "tsdown src/index.ts -d api --dts" } } ``` 5. 创建 **vercel.json** 以将所有端点重写到 Elysia 服务器 ```json { "$schema": "https://openapi.vercel.sh/vercel.json", "rewrites": [ { "source": "/(.*)", "destination": "/api" } ] } ``` 此配置将所有请求重写到 `/api` 路由,这是 Elysia 服务器定义的位置。 Elysia 与 Vercel Function 配合使用无需额外配置,因为它默认支持 Web Standard Framework。 ## 如果此方法无效 确保将 Elysia 服务器作为默认导出,并且构建输出是一个位于 `/api/index.js` 的单个文件。 您还可以使用 Elysia 的内置功能,如验证、错误处理、[OpenAPI](/plugins/openapi.html) 等,就像在其他环境一样。 有关更多信息,请参阅 [Vercel Function 文档](https://vercel.com/docs/functions?framework=other)。 --- --- url: 'https://elysia.cndocs.org/migrate/from-express.md' --- # 从 Express 迁移到 Elysia 本指南面向 Express 用户,旨在展示 Express 与 Elysia 的语法差异,并通过示例说明如何将应用从 Express 迁移至 Elysia。 **Express** 是 Node.js 中流行的 Web 框架,广泛用于构建 Web 应用和 API。它以简洁性和灵活性著称。 **Elysia** 是为 Bun、Node.js 及支持 Web 标准 API 的运行时而设计的人体工学 Web 框架。注重**健全的类型安全**和性能,旨在提供符合人体工学的开发者友好体验。 ## 性能 得益于原生 Bun 实现和静态代码分析,Elysia 相比 Express 有显著的性能提升。 ## 路由 Express 和 Elysia 拥有相似的路由语法,使用 `app.get()` 和 `app.post()` 方法定义路由,以及相似的路径参数语法。 ::: code-group ```ts [Express] import express from 'express' const app = express() app.get('/', (req, res) => { res.send('Hello World') }) app.post('/id/:id', (req, res) => { res.status(201).send(req.params.id) }) app.listen(3000) ``` ::: > Express 使用 `req` 和 `res` 作为请求和响应对象 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() .get('/', 'Hello World') .post( '/id/:id', ({ status, params: { id } }) => { return status(201, id) } ) .listen(3000) ``` ::: > Elysia 使用单一的 `context` 并直接返回响应 风格指南略有不同,Elysia 推荐使用方法链式和对象解构。 如果你不需要使用上下文,Elysia 还支持内联值作为响应。 ## 处理程序 两者都拥有相似的属性来访问输入参数,如 `headers`、`query`、`params` 和 `body`。 ::: code-group ```ts [Express] import express from 'express' const app = express() app.use(express.json()) app.post('/user', (req, res) => { const limit = req.query.limit const name = req.body.name const auth = req.headers.authorization res.json({ limit, name, auth }) }) ``` ::: > Express 需要 `express.json()` 中间件来解析 JSON 请求体 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() .post('/user', (ctx) => { const limit = ctx.query.limit const name = ctx.body.name const auth = ctx.headers.authorization return { limit, name, auth } }) ``` ::: > Elysia 默认解析 JSON、URL 编码数据和表单数据 ## 子路由 Express 使用专用的 `express.Router()` 来声明子路由,而 Elysia 将每个实例视为可即插即用的组件。 ::: code-group ```ts [Express] import express from 'express' const subRouter = express.Router() subRouter.get('/user', (req, res) => { res.send('Hello User') }) const app = express() app.use('/api', subRouter) ``` ::: > Express 使用 `express.Router()` 创建子路由 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia({ prefix: '/api' }) .get('/user', 'Hello User') const app = new Elysia() .use(subRouter) ``` ::: > Elysia 将每个实例视为组件 ## 验证 Elysia 内置了对使用 TypeBox 进行请求验证的支持,提供健全的类型安全,并支持开箱即用的标准模式,允许你使用喜欢的库如 Zod、Valibot、ArkType、Effect Schema 等。而 Express 不提供内置验证,需要根据每个验证库手动声明类型。 ::: code-group ```ts [Express] import express from 'express' import { z } from 'zod' const app = express() app.use(express.json()) const paramSchema = z.object({ id: z.coerce.number() }) const bodySchema = z.object({ name: z.string() }) app.patch('/user/:id', (req, res) => { const params = paramSchema.safeParse(req.params) if (!params.success) return res.status(422).json(result.error) const body = bodySchema.safeParse(req.body) if (!body.success) return res.status(422).json(result.error) res.json({ params: params.id.data, body: body.data }) }) ``` ::: > Express 需要外部验证库如 `zod` 或 `joi` 来验证请求体 ::: code-group ```ts twoslash [Elysia TypeBox] import { Elysia, t } from 'elysia' const app = new Elysia() .patch('/user/:id', ({ params, body }) => ({ // ^? params, body // ^? }), { params: t.Object({ id: t.Number() }), body: t.Object({ name: t.String() }) }) ``` ```ts twoslash [Elysia Zod] import { Elysia } from 'elysia' import { z } from 'zod' const app = new Elysia() .patch('/user/:id', ({ params, body }) => ({ // ^? params, body // ^? }), { params: z.object({ id: z.number() }), body: z.object({ name: z.string() }) }) ``` ```ts twoslash [Elysia Valibot] import { Elysia } from 'elysia' import * as v from 'valibot' const app = new Elysia() .patch('/user/:id', ({ params, body }) => ({ // ^? params, body // ^? }), { params: v.object({ id: v.number() }), body: v.object({ name: v.string() }) }) ``` ::: > Elysia 使用 TypeBox 进行验证,并自动强制类型转换。同时支持多种验证库如 Zod、Valibot,语法相同。 ## 文件上传 Express 使用外部库 `multer` 处理文件上传,而 Elysia 内置了对文件和表单数据的支持,使用声明式 API 进行 MIME 类型验证。 ::: code-group ```ts [Express] import express from 'express' import multer from 'multer' import { fileTypeFromFile } from 'file-type' import path from 'path' const app = express() const upload = multer({ dest: 'uploads/' }) app.post('/upload', upload.single('image'), async (req, res) => { const file = req.file if (!file) return res .status(422) .send('No file uploaded') const type = await fileTypeFromFile(file.path) if (!type || !type.mime.startsWith('image/')) return res .status(422) .send('File is not a valid image') const filePath = path.resolve(file.path) res.sendFile(filePath) }) ``` ::: > Express 需要 `express.json()` 中间件来解析 JSON 请求体 ::: code-group ```ts [Elysia] import { Elysia, t } from 'elysia' const app = new Elysia() .post('/upload', ({ body }) => body.file, { body: t.Object({ file: t.File({ type: 'image' }) }) }) ``` ::: > Elysia 以声明式方式处理文件和 MIME 类型验证 由于 **multer** 不验证 MIME 类型,你需要使用 **file-type** 或类似库手动验证 MIME 类型。 Elysia 验证文件上传,并使用 **file-type** 自动验证 MIME 类型。 ## 中间件 Express 中间件使用单一基于队列的顺序,而 Elysia 通过**基于事件**的生命周期提供更细粒度的控制。 Elysia 的生命周期事件可图示如下: ![Elysia 生命周期图](/assets/lifecycle-chart.svg) > 点击图片放大 虽然 Express 拥有单一顺序的请求管道流,但 Elysia 可以拦截请求管道中的每个事件。 ::: code-group ```ts [Express] import express from 'express' const app = express() // 全局中间件 app.use((req, res, next) => { console.log(`${req.method} ${req.url}`) next() }) app.get( '/protected', // 路由特定中间件 (req, res, next) => { const token = req.headers.authorization if (!token) return res.status(401).send('Unauthorized') next() }, (req, res) => { res.send('Protected route') } ) ``` ::: > Express 使用单一基于队列的顺序执行中间件 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() // 全局中间件 .onRequest(({ method, path }) => { console.log(`${method} ${path}`) }) // 路由特定中间件 .get('/protected', () => 'protected', { beforeHandle({ status, headers }) { if (!headers.authorizaton) return status(401) } }) ``` ::: > Elysia 为请求管道中的每个点使用特定事件拦截器 虽然 Hono 有 `next` 函数来调用下一个中间件,但 Elysia 没有。 ## 健全的类型安全 Elysia 设计为具备健全的类型安全。 例如,你可以使用 [derive](/essential/life-cycle.html#derive) 和 [resolve](/essential/life-cycle.html#resolve) 以**类型安全**的方式自定义上下文,而 Express 则不行。 ::: code-group ```ts twoslash [Express] // @errors: 2339 import express from 'express' import type { Request, Response } from 'express' const app = express() const getVersion = (req: Request, res: Response, next: Function) => { // @ts-ignore req.version = 2 next() } app.get('/version', getVersion, (req, res) => { res.send(req.version) // ^? }) const authenticate = (req: Request, res: Response, next: Function) => { const token = req.headers.authorization if (!token) return res.status(401).send('Unauthorized') // @ts-ignore req.token = token.split(' ')[1] next() } app.get('/token', getVersion, authenticate, (req, res) => { req.version // ^? res.send(req.token) // ^? }) ``` ::: > Express 使用单一基于队列的顺序执行中间件 ::: code-group ```ts twoslash [Elysia] import { Elysia } from 'elysia' const app = new Elysia() .decorate('version', 2) .get('/version', ({ version }) => version) .resolve(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) return { token: authorization.split(' ')[1] } }) .get('/token', ({ token, version }) => { version // ^? return token // ^? }) ``` ::: > Elysia 为请求管道中的每个点使用特定事件拦截器 虽然 Express 可以使用 `declare module` 扩展 `Request` 接口,但它是全局可用的,不具备健全的类型安全,并且不保证该属性在所有请求处理程序中可用。 ```ts declare module 'express' { interface Request { version: number token: string } } ``` > 这是上述 Express 示例工作所必需的,但它不提供健全的类型安全 ## 中间件参数 Express 使用函数返回插件来定义可重用的路由特定中间件,而 Elysia 使用 [macro](/patterns/macro) 定义自定义钩子。 ::: code-group ```ts twoslash [Express] const findUser = (authorization?: string) => { return { name: 'Jane Doe', role: 'admin' as const } } // ---cut--- // @errors: 2339 import express from 'express' import type { Request, Response } from 'express' const app = express() const role = (role: 'user' | 'admin') => (req: Request, res: Response, next: Function) => { const user = findUser(req.headers.authorization) if (user.role !== role) return res.status(401).send('Unauthorized') // @ts-ignore req.user = user next() } app.get('/token', role('admin'), (req, res) => { res.send(req.user) // ^? }) ``` ::: > Express 使用函数回调接受中间件的自定义参数 ::: code-group ```ts twoslash [Elysia] const findUser = (authorization?: string) => { return { name: 'Jane Doe', role: 'admin' as const } } // ---cut--- import { Elysia } from 'elysia' const app = new Elysia() .macro({ role: (role: 'user' | 'admin') => ({ resolve({ status, headers: { authorization } }) { const user = findUser(authorization) if(user.role !== role) return status(401) return { user } } }) }) .get('/token', ({ user }) => user, { // ^? role: 'admin' }) ``` ::: > Elysia 使用宏将自定义参数传递给自定义中间件 ## 错误处理 Express 对所有路由使用单一错误处理程序,而 Elysia 提供更细粒度的错误控制。 ::: code-group ```ts import express from 'express' const app = express() class CustomError extends Error { constructor(message: string) { super(message) this.name = 'CustomError' } } // 全局错误处理程序 app.use((error, req, res, next) => { if(error instanceof CustomError) { res.status(500).json({ message: 'Something went wrong!', error }) } }) // 路由特定错误处理程序 app.get('/error', (req, res) => { throw new CustomError('oh uh') }) ``` ::: > Express 使用中间件处理错误,对所有路由使用单一错误处理程序 ::: code-group ```ts twoslash [Elysia] import { Elysia } from 'elysia' class CustomError extends Error { // 可选:自定义 HTTP 状态码 status = 500 constructor(message: string) { super(message) this.name = 'CustomError' } // 可选:应发送给客户端的内容 toResponse() { return { message: "If you're seeing this, our dev forgot to handle this error", error: this } } } const app = new Elysia() // 可选:注册自定义错误类 .error({ CUSTOM: CustomError, }) // 全局错误处理程序 .onError(({ error, code }) => { if(code === 'CUSTOM') // ^? return { message: 'Something went wrong!', error } }) .get('/error', () => { throw new CustomError('oh uh') }, { // 可选:路由特定错误处理程序 error({ error }) { return { message: 'Only for this route!', error } } }) ``` ::: > Elysia 提供更细粒度的错误控制和作用域机制 虽然 Express 提供使用中间件的错误处理,但 Elysia 提供: 1. 全局和路由特定的错误处理程序 2. 映射 HTTP 状态和 `toResponse` 的简写方式,用于将错误映射到响应 3. 为每个错误提供自定义错误代码 错误代码对日志记录和调试非常有用,并且在区分扩展自同一类的不同错误类型时非常重要。 ## 封装 Express 中间件是全局注册的,而 Elysia 通过显式的作用域机制和代码顺序控制插件的副作用。 ::: code-group ```ts [Express] import express from 'express' const app = express() app.get('/', (req, res) => { res.send('Hello World') }) const subRouter = express.Router() subRouter.use((req, res, next) => { const token = req.headers.authorization if (!token) return res.status(401).send('Unauthorized') next() }) app.use(subRouter) // 受 subRouter 的副作用影响 app.get('/side-effect', (req, res) => { res.send('hi') }) ``` ::: > Express 不处理中间件的副作用,需要前缀来分隔副作用 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia() .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) const app = new Elysia() .get('/', 'Hello World') .use(subRouter) // 不受 subRouter 的副作用影响 .get('/side-effect', () => 'hi') ``` ::: > Elysia 将副作用封装到插件中 默认情况下,Elysia 会将生命周期事件和上下文封装到所使用的实例中,因此插件的副作用不会影响父实例,除非明确声明。 ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia() .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) // 作用域限定于父实例但不超出 .as('scoped') // [!code ++] const app = new Elysia() .get('/', 'Hello World') .use(subRouter) // [!code ++] // 现在受 subRouter 的副作用影响 .get('/side-effect', () => 'hi') ``` Elysia 提供 3 种作用域机制: 1. **local** - 仅应用于当前实例,无副作用(默认) 2. **scoped** - 副作用作用域限定于父实例但不超出 3. **global** - 影响所有实例 虽然 Express 可以通过添加前缀来限定中间件的副作用范围,但这并不是真正的封装。副作用仍然存在,但被分隔到任何以该前缀开头的路由中,增加了开发者记忆哪些前缀有副作用的心智负担。 你可以执行以下操作: 1. 移动代码顺序,但前提是只有一个具有副作用的实例。 2. 添加前缀,但副作用仍然存在。如果其他实例具有相同的前缀,那么它也会受到副作用的影响。 这可能导致调试噩梦,因为 Express 不提供真正的封装。 ## Cookie Express 使用外部库 `cookie-parser` 解析 cookie,而 Elysia 内置了对 cookie 的支持,并使用基于信号的方法处理 cookie。 ::: code-group ```ts [Express] import express from 'express' import cookieParser from 'cookie-parser' const app = express() app.use(cookieParser('secret')) app.get('/', function (req, res) { req.cookies.name req.signedCookies.name res.cookie('name', 'value', { signed: true, maxAge: 1000 * 60 * 60 * 24 }) }) ``` ::: > Express 使用 `cookie-parser` 解析 cookie ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia({ cookie: { secret: 'secret' } }) .get('/', ({ cookie: { name } }) => { // 签名验证自动处理 name.value // cookie 签名自动签名 name.value = 'value' name.maxAge = 1000 * 60 * 60 * 24 }) ``` ::: > Elysia 使用基于信号的方法处理 cookie ## OpenAPI Express 需要单独的配置来处理 OpenAPI、验证和类型安全,而 Elysia 内置了对 OpenAPI 的支持,使用模式作为**单一事实来源**。 ::: code-group ```ts [Express] import express from 'express' import swaggerUi from 'swagger-ui-express' const app = express() app.use(express.json()) app.post('/users', (req, res) => { // TODO: 验证请求体 res.status(201).json(req.body) }) const swaggerSpec = { openapi: '3.0.0', info: { title: 'My API', version: '1.0.0' }, paths: { '/users': { post: { summary: 'Create user', requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { name: { type: 'string', description: 'First name only' }, age: { type: 'integer' } }, required: ['name', 'age'] } } } }, responses: { '201': { description: 'User created' } } } } } } app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)) ``` ::: > Express 需要单独的配置来处理 OpenAPI、验证和类型安全 ::: code-group ```ts twoslash [Elysia] import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' // [!code ++] const app = new Elysia() .use(openapi()) // [!code ++] .model({ user: t.Array( t.Object({ name: t.String(), age: t.Number() }) ) }) .post('/users', ({ body }) => body, { // ^? body: 'user', response: { 201: 'user' }, detail: { summary: 'Create user' } }) ``` ::: > Elysia 使用模式作为单一事实来源 Elysia 将根据你提供的模式生成 OpenAPI 规范,并根据模式验证请求和响应,并自动推断类型。 Elysia 还将 `model` 中注册的模式附加到 OpenAPI 规范中,允许你在 Swagger 或 Scalar UI 的专用部分引用该模型。 ## 测试 Express 使用单一的 `supertest` 库测试应用,而 Elysia 构建在 Web 标准 API 之上,允许与任何测试库一起使用。 ::: code-group ```ts [Express] import express from 'express' import request from 'supertest' import { describe, it, expect } from 'vitest' const app = express() app.get('/', (req, res) => { res.send('Hello World') }) describe('GET /', () => { it('should return Hello World', async () => { const res = await request(app).get('/') expect(res.status).toBe(200) expect(res.text).toBe('Hello World') }) }) ``` ::: > Express 使用 `supertest` 库测试应用 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' import { describe, it, expect } from 'vitest' const app = new Elysia() .get('/', 'Hello World') describe('GET /', () => { it('should return Hello World', async () => { const res = await app.handle( new Request('http://localhost') ) expect(res.status).toBe(200) expect(await res.text()).toBe('Hello World') }) }) ``` ::: > Elysia 使用 Web 标准 API 处理请求和响应 或者,Elysia 还提供了一个名为 [Eden](/eden/overview) 的辅助库,用于端到端类型安全,允许我们进行自动完成和完全类型安全的测试。 ```ts twoslash [Elysia] import { Elysia } from 'elysia' import { treaty } from '@elysiajs/eden' import { describe, expect, it } from 'bun:test' const app = new Elysia().get('/hello', 'Hello World') const api = treaty(app) describe('GET /', () => { it('should return Hello World', async () => { const { data, error, status } = await api.hello.get() expect(status).toBe(200) expect(data).toBe('Hello World') // ^? }) }) ``` ## 端到端类型安全 Elysia 提供了内置的**端到端类型安全**支持,无需代码生成,使用 [Eden](/eden/overview),而 Express 不提供。 ::: code-group ```ts twoslash [Elysia] import { Elysia, t } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .post('/mirror', ({ body }) => body, { body: t.Object({ message: t.String() }) }) const api = treaty(app) const { data, error } = await api.mirror.post({ message: 'Hello World' }) if(error) throw error // ^? console.log(data) // ^? // ---cut-after--- console.log('ok') ``` ::: 如果端到端类型安全对你很重要,那么 Elysia 是正确的选择。 *** Elysia 提供了更符合人体工学和开发者友好的体验,注重性能、类型安全和简洁性,而 Express 是 Node.js 中流行的 Web 框架,但在性能和简洁性方面存在一些限制。 如果你正在寻找一个易于使用、具有出色开发者体验并构建在 Web 标准 API 之上的框架,Elysia 是你的正确选择。 或者,如果你来自不同的框架,可以查看: --- --- url: 'https://elysia.cndocs.org/migrate/from-fastify.md' --- # 从 Fastify 迁移至 Elysia 本指南面向 Fastify 用户,旨在展示与 Fastify 的差异,包括语法差异,并通过示例说明如何将应用从 Fastify 迁移至 Elysia。 **Fastify** 是一个快速且低开销的 Node.js Web 框架,设计简洁易用。它构建在 HTTP 模块之上,提供了一系列功能,便于构建 Web 应用。 **Elysia** 是一个符合人体工程学的 Web 框架,适用于 Bun、Node.js 以及支持 Web 标准 API 的运行时。设计注重人体工程学和开发者友好性,重点关注**健全的类型安全**和性能。 ## 性能 得益于原生的 Bun 实现和静态代码分析,Elysia 相比 Fastify 有显著的性能提升。 ## 路由 Fastify 和 Elysia 具有相似的路由语法,使用 `app.get()` 和 `app.post()` 方法定义路由,以及相似的路径参数语法。 ::: code-group ```ts [Fastify] import fastify from 'fastify' const app = fastify() app.get('/', (request, reply) => { res.send('Hello World') }) app.post('/id/:id', (request, reply) => { reply.status(201).send(req.params.id) }) app.listen({ port: 3000 }) ``` ::: > Fastify 使用 `request` 和 `reply` 作为请求和响应对象 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() .get('/', 'Hello World') .post( '/id/:id', ({ status, params: { id } }) => { return status(201, id) } ) .listen(3000) ``` ::: > Elysia 使用单一的 `context` 并直接返回响应 在风格指南上略有不同,Elysia 推荐使用方法链和对象解构。 如果你不需要使用上下文,Elysia 还支持内联值作为响应。 ## 处理程序 两者都具有相似的属性来访问输入参数,如 `headers`、`query`、`params` 和 `body`,并自动将请求体解析为 JSON、URL 编码数据和表单数据。 ::: code-group ```ts [Fastify] import fastify from 'fastify' const app = fastify() app.post('/user', (request, reply) => { const limit = request.query.limit const name = request.body.name const auth = request.headers.authorization reply.send({ limit, name, auth }) }) ``` ::: > Fastify 解析数据并将其放入 `request` 对象 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() .post('/user', (ctx) => { const limit = ctx.query.limit const name = ctx.body.name const auth = ctx.headers.authorization return { limit, name, auth } }) ``` ::: > Elysia 解析数据并将其放入 `context` 对象 ## 子路由 Fastify 使用函数回调定义子路由,而 Elysia 将每个实例视为可以即插即用的组件。 ::: code-group ```ts [Fastify] import fastify, { FastifyPluginCallback } from 'fastify' const subRouter: FastifyPluginCallback = (app, opts, done) => { app.get('/user', (request, reply) => { reply.send('Hello User') }) } const app = fastify() app.register(subRouter, { prefix: '/api' }) ``` ::: > Fastify 使用函数回调声明子路由 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia({ prefix: '/api' }) .get('/user', 'Hello User') const app = new Elysia() .use(subRouter) ``` ::: > Elysia 将每个实例视为组件 Elysia 在构造函数中设置前缀,而 Fastify 要求你在选项中设置前缀。 ## 验证 Elysia 内置支持请求验证,使用 **TypeBox** 提供开箱即用的健全类型安全,而 Fastify 使用 JSON Schema 声明模式,并使用 **ajv** 进行验证。 然而,Fastify 不会自动推断类型,你需要使用类型提供者如 `@fastify/type-provider-json-schema-to-ts` 来推断类型。 ::: code-group ```ts [Fastify] import fastify from 'fastify' import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts' const app = fastify().withTypeProvider() app.patch( '/user/:id', { schema: { params: { type: 'object', properties: { id: { type: 'string', pattern: '^[0-9]+$' } }, required: ['id'] }, body: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, } }, (request, reply) => { // 将字符串映射为数字 request.params.id = +request.params.id reply.send({ params: request.params, body: request.body }) } }) ``` ::: > Fastify 使用 JSON Schema 进行验证 ::: code-group ```ts twoslash [Elysia TypeBox] import { Elysia, t } from 'elysia' const app = new Elysia() .patch('/user/:id', ({ params, body }) => ({ // ^? params, body // ^? }), { params: t.Object({ id: t.Number() }), body: t.Object({ name: t.String() }) }) ``` ```ts twoslash [Elysia Zod] import { Elysia } from 'elysia' import { z } from 'zod' const app = new Elysia() .patch('/user/:id', ({ params, body }) => ({ // ^? params, body // ^? }), { params: z.object({ id: z.number() }), body: z.object({ name: z.string() }) }) ``` ```ts twoslash [Elysia Valibot] import { Elysia } from 'elysia' import * as v from 'zod' const app = new Elysia() .patch('/user/:id', ({ params, body }) => ({ // ^? params, body // ^? }), { params: v.object({ id: v.number() }), body: v.object({ name: v.string() }) }) ``` ::: > Elysia 使用 TypeBox 进行验证,并自动强制类型转换。同时支持各种验证库,如 Zod、Valibot,语法相同。 或者,Fastify 也可以使用 **TypeBox** 或 **Zod** 进行验证,使用 `@fastify/type-provider-typebox` 自动推断类型。 虽然 Elysia **偏好 TypeBox** 进行验证,但 Elysia 也支持标准模式,允许你开箱即用地使用 Zod、Valibot、ArkType、Effect Schema 等库。 ## 文件上传 Fastify 使用 `fastify-multipart` 处理文件上传,它在底层使用 `Busboy`,而 Elysia 使用 Web 标准 API 处理表单数据,使用声明式 API 进行 MIME 类型验证。 然而,Fastify 没有提供直接的文件验证方式,例如文件大小和 MIME 类型,需要一些变通方法来验证文件。 ::: code-group ```ts [Fastify] import fastify from 'fastify' import multipart from '@fastify/multipart' import { fileTypeFromBuffer } from 'file-type' const app = fastify() app.register(multipart, { attachFieldsToBody: 'keyValues' }) app.post( '/upload', { schema: { body: { type: 'object', properties: { file: { type: 'object' } }, required: ['file'] } } }, async (req, res) => { const file = req.body.file if (!file) return res.status(422).send('No file uploaded') const type = await fileTypeFromBuffer(file) if (!type || !type.mime.startsWith('image/')) return res.status(422).send('File is not a valid image') res.header('Content-Type', type.mime) res.send(file) } ) ``` ::: > Fastify 使用 `fastify-multipart` 处理文件上传,并伪造 `type: object` 以允许 Buffer ::: code-group ```ts [Elysia] import { Elysia, t } from 'elysia' const app = new Elysia() .post('/upload', ({ body }) => body.file, { body: t.Object({ file: t.File({ type: 'image' }) }) }) ``` ::: > Elysia 使用 `t.File` 处理文件和 MIME 类型验证 由于 **multer** 不验证 MIME 类型,你需要使用 **file-type** 或类似库手动验证 MIME 类型。 而 Elysia 验证文件上传,并使用 **file-type** 自动验证 MIME 类型。 ## 生命周期事件 Fastify 和 Elysia 都有类似的生命周期事件,采用基于事件的方法。 ### Elysia 生命周期 Elysia 的生命周期事件可以如下图所示。 ![Elysia 生命周期图表](/assets/lifecycle-chart.svg) > 点击图片放大 ### Fastify 生命周期 Fastify 的生命周期事件可以如下图所示。 ``` 传入请求 │ └─▶ 路由 │ └─▶ 实例日志记录器 │ 4**/5** ◀─┴─▶ onRequest 钩子 │ 4**/5** ◀─┴─▶ preParsing 钩子 │ 4**/5** ◀─┴─▶ 解析 │ 4**/5** ◀─┴─▶ preValidation 钩子 │ 400 ◀─┴─▶ 验证 │ 4**/5** ◀─┴─▶ preHandler 钩子 │ 4**/5** ◀─┴─▶ 用户处理程序 │ └─▶ 响应 │ 4**/5** ◀─┴─▶ preSerialization 钩子 │ └─▶ onSend 钩子 │ 4**/5** ◀─┴─▶ 传出响应 │ └─▶ onResponse 钩子 ``` 两者都有类似的语法来拦截请求和响应生命周期事件,但 Elysia 不需要你调用 `done` 来继续生命周期事件。 ::: code-group ```ts [Fastify] import fastify from 'fastify' const app = fastify() // 全局中间件 app.addHook('onRequest', (request, reply, done) => { console.log(`${request.method} ${request.url}`) done() }) app.get( '/protected', { // 路由特定中间件 preHandler(request, reply, done) { const token = request.headers.authorization if (!token) reply.status(401).send('Unauthorized') done() } }, (request, reply) => { reply.send('Protected route') } ) ``` ::: > Fastify 使用 `addHook` 注册中间件,并要求调用 `done` 以继续生命周期事件 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() // 全局中间件 .onRequest(({ method, path }) => { console.log(`${method} ${path}`) }) // 路由特定中间件 .get('/protected', () => 'protected', { beforeHandle({ status, headers }) { if (!headers.authorizaton) return status(401) } }) ``` ::: > Elysia 自动检测生命周期事件,不需要调用 `done` 来继续生命周期事件 ## 健全的类型安全 Elysia 设计为健全的类型安全。 例如,你可以使用 [derive](/essential/life-cycle.html#derive) 和 [resolve](/essential/life-cycle.html#resolve) 以**类型安全**的方式自定义上下文,而 Fastify 则不能。 ::: code-group ```ts twoslash [Fastify] // @errors: 2339 import fastify from 'fastify' const app = fastify() app.decorateRequest('version', 2) app.get('/version', (req, res) => { res.send(req.version) // ^? }) app.get( '/token', { preHandler(req, res, done) { const token = req.headers.authorization if (!token) return res.status(401).send('Unauthorized') // @ts-ignore req.token = token.split(' ')[1] done() } }, (req, res) => { req.version // ^? res.send(req.token) // ^? } ) app.listen({ port: 3000 }) ``` ::: > Fastify 使用 `decorateRequest` 但不提供健全的类型安全 ::: code-group ```ts twoslash [Elysia] import { Elysia } from 'elysia' const app = new Elysia() .decorate('version', 2) .get('/version', ({ version }) => version) .resolve(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) return { token: authorization.split(' ')[1] } }) .get('/token', ({ token, version }) => { version // ^? return token // ^? }) ``` ::: > Elysia 使用 `decorate` 扩展上下文,并使用 `resolve` 向上下文添加自定义属性 虽然 Fastify 可以使用 `declare module` 扩展 `FastifyRequest` 接口,但它是全局可用的,不具备健全的类型安全,并且不能保证该属性在所有请求处理程序中可用。 ```ts declare module 'fastify' { interface FastifyRequest { version: number token: string } } ``` > 上述 Fastify 示例需要此代码才能工作,但它不提供健全的类型安全 ## 中间件参数 Fastify 使用函数返回 Fastify 插件来定义命名中间件,而 Elysia 使用 [macro](/patterns/macro) 定义自定义钩子。 ::: code-group ```ts twoslash [Fastify] const findUser = (authorization?: string) => { return { name: 'Jane Doe', role: 'admin' as const } } // ---cut--- // @errors: 2339 import fastify from 'fastify' import type { FastifyRequest, FastifyReply } from 'fastify' const app = fastify() const role = (role: 'user' | 'admin') => (request: FastifyRequest, reply: FastifyReply, next: Function) => { const user = findUser(request.headers.authorization) if (user.role !== role) return reply.status(401).send('Unauthorized') // @ts-ignore request.user = user next() } app.get( '/token', { preHandler: role('admin') }, (request, reply) => { reply.send(request.user) // ^? } ) ``` ::: > Fastify 使用函数回调接受中间件的自定义参数 ::: code-group ```ts twoslash [Elysia] const findUser = (authorization?: string) => { return { name: 'Jane Doe', role: 'admin' as const } } // ---cut--- import { Elysia } from 'elysia' const app = new Elysia() .macro({ role: (role: 'user' | 'admin') => ({ resolve({ status, headers: { authorization } }) { const user = findUser(authorization) if(user.role !== role) return status(401) return { user } } }) }) .get('/token', ({ user }) => user, { // ^? role: 'admin' }) ``` ::: > Elysia 使用宏将自定义参数传递给自定义中间件 虽然 Fastify 使用函数回调,但它需要返回一个函数以放置在事件处理程序中,或返回一个表示钩子的对象,当需要多个自定义函数时,这可能难以处理,因为你需要将它们协调到单个对象中。 ## 错误处理 Fastify 和 Elysia 都提供了生命周期事件来处理错误。 ::: code-group ```ts import fastify from 'fastify' const app = fastify() class CustomError extends Error { constructor(message: string) { super(message) this.name = 'CustomError' } } // 全局错误处理程序 app.setErrorHandler((error, request, reply) => { if (error instanceof CustomError) reply.status(500).send({ message: 'Something went wrong!', error }) }) app.get( '/error', { // 路由特定错误处理程序 errorHandler(error, request, reply) { reply.send({ message: 'Only for this route!', error }) } }, (request, reply) => { throw new CustomError('oh uh') } ) ``` ::: > Fastify 使用 `setErrorHandler` 作为全局错误处理程序,使用 `errorHandler` 作为路由特定错误处理程序 ::: code-group ```ts twoslash [Elysia] import { Elysia } from 'elysia' class CustomError extends Error { // 可选:自定义 HTTP 状态码 status = 500 constructor(message: string) { super(message) this.name = 'CustomError' } // 可选:应发送给客户端的内容 toResponse() { return { message: "If you're seeing this, our dev forgot to handle this error", error: this } } } const app = new Elysia() // 可选:注册自定义错误类 .error({ CUSTOM: CustomError, }) // 全局错误处理程序 .onError(({ error, code }) => { if(code === 'CUSTOM') // ^? return { message: 'Something went wrong!', error } }) .get('/error', () => { throw new CustomError('oh uh') }, { // 可选:路由特定错误处理程序 error({ error }) { return { message: 'Only for this route!', error } } }) ``` ::: > Elysia 提供自定义错误代码、状态码的简写以及 `toResponse` 用于将错误映射到响应。 虽然两者都使用生命周期事件提供错误处理,但 Elysia 还提供: 1. 自定义错误代码 2. 映射 HTTP 状态和 `toResponse` 的简写,用于将错误映射到响应 错误代码对于日志记录和调试非常有用,并且在区分扩展相同类的不同错误类型时非常重要。 ## 封装 Fastify 封装插件的副作用,而 Elysia 通过显式的作用域机制和代码顺序让你控制插件的副作用。 ::: code-group ```ts [Fastify] import fastify from 'fastify' import type { FastifyPluginCallback } from 'fastify' const subRouter: FastifyPluginCallback = (app, opts, done) => { app.addHook('preHandler', (request, reply) => { if (!request.headers.authorization?.startsWith('Bearer ')) reply.code(401).send({ error: 'Unauthorized' }) }) done() } const app = fastify() .get('/', (request, reply) => { reply.send('Hello World') }) .register(subRouter) // 不受 subRouter 的副作用影响 .get('/side-effect', () => 'hi') ``` ::: > Fastify 封装插件的副作用 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia() .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) const app = new Elysia() .get('/', 'Hello World') .use(subRouter) // 不受 subRouter 的副作用影响 .get('/side-effect', () => 'hi') ``` ::: > Elysia 封装插件的副作用,除非明确声明 两者都有插件的封装机制以防止副作用。 然而,Elysia 可以通过声明作用域来明确哪些插件应该有副作用,而 Fastify 总是封装它。 ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia() .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) // 作用域限定于父实例但不超出 .as('scoped') // [!code ++] const app = new Elysia() .get('/', 'Hello World') .use(subRouter) // [!code ++] // 现在受 subRouter 的副作用影响 .get('/side-effect', () => 'hi') ``` Elysia 提供 3 种作用域机制: 1. **local** - 仅应用于当前实例,无副作用(默认) 2. **scoped** - 副作用作用域限定于父实例但不超出 3. **global** - 影响所有实例 *** 由于 Fastify 不提供作用域机制,我们需要: 1. 为每个钩子创建函数并手动附加它们 2. 使用高阶函数,并将其应用于需要该效果的实例 然而,如果不小心处理,这可能会导致重复的副作用。 ```ts import fastify from 'fastify' import type { FastifyRequest, FastifyReply, FastifyPluginCallback } from 'fastify' const log = (request: FastifyRequest, reply: FastifyReply, done: Function) => { console.log('Middleware executed') done() } const app = fastify() app.addHook('onRequest', log) app.get('/main', (request, reply) => { reply.send('Hello from main!') }) const subRouter: FastifyPluginCallback = (app, opts, done) => { app.addHook('onRequest', log) // 这会记录两次 app.get('/sub', (request, reply) => { return reply.send('Hello from sub router!') }) done() } app.register(subRouter, { prefix: '/sub' }) app.listen({ port: 3000 }) ``` 在这种情况下,Elysia 提供了插件去重机制以防止重复的副作用。 ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia({ name: 'subRouter' }) // [!code ++] .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) .as('scoped') const app = new Elysia() .get('/', 'Hello World') .use(subRouter) .use(subRouter) // [!code ++] .use(subRouter) // [!code ++] .use(subRouter) // [!code ++] // 副作用仅调用一次 .get('/side-effect', () => 'hi') ``` 通过使用唯一的 `name`,Elysia 将仅应用该插件一次,并且不会导致重复的副作用。 ## Cookie Fastify 使用 `@fastify/cookie` 解析 cookie,而 Elysia 内置支持 cookie,并使用基于信号的方法处理 cookie。 ::: code-group ```ts [Fastify] import fastify from 'fastify' import cookie from '@fastify/cookie' const app = fastify() app.use(cookie, { secret: 'secret', hook: 'onRequest' }) app.get('/', function (request, reply) { request.unsignCookie(request.cookies.name) reply.setCookie('name', 'value', { path: '/', signed: true }) }) ``` ::: > Fastify 使用 `unsignCookie` 验证 cookie 签名,并使用 `setCookie` 设置 cookie ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia({ cookie: { secret: 'secret' } }) .get('/', ({ cookie: { name } }) => { // 签名验证自动处理 name.value // cookie 签名自动签名 name.value = 'value' name.maxAge = 1000 * 60 * 60 * 24 }) ``` ::: > Elysia 使用基于信号的方法处理 cookie,签名验证自动处理 ## OpenAPI 两者都提供使用 Swagger 的 OpenAPI 文档,但 Elysia 默认使用 Scalar UI,这是一个更现代、用户友好的 OpenAPI 文档界面。 ::: code-group ```ts [Fastify] import fastify from 'fastify' import swagger from '@fastify/swagger' const app = fastify() app.register(swagger, { openapi: '3.0.0', info: { title: 'My API', version: '1.0.0' } }) app.addSchema({ $id: 'user', type: 'object', properties: { name: { type: 'string', description: 'First name only' }, age: { type: 'integer' } }, required: ['name', 'age'] }) app.post( '/users', { schema: { summary: 'Create user', body: { $ref: 'user#' }, response: { '201': { $ref: 'user#' } } } }, (req, res) => { res.status(201).send(req.body) } ) await fastify.ready() fastify.swagger() ``` ::: > Fastify 使用 `@fastify/swagger` 进行 OpenAPI 文档,使用 Swagger ::: code-group ```ts twoslash [Elysia] import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' // [!code ++] const app = new Elysia() .use(openapi()) // [!code ++] .model({ user: t.Array( t.Object({ name: t.String(), age: t.Number() }) ) }) .post('/users', ({ body }) => body, { // ^? body: 'user', response: { 201: 'user' }, detail: { summary: 'Create user' } }) ``` ::: > Elysia 默认使用 `@elysiajs/swagger` 进行 OpenAPI 文档,使用 Scalar,或可选 Swagger 两者都提供使用 `$ref` 的模型引用用于 OpenAPI 文档,但 Fastify 不提供类型安全和自动完成来指定模型名称,而 Elysia 提供。 ## 测试 Fastify 内置支持测试,使用 `fastify.inject()` **模拟**网络请求,而 Elysia 使用 Web 标准 API 进行**实际**请求。 ::: code-group ```ts [Fastify] import fastify from 'fastify' import request from 'supertest' import { describe, it, expect } from 'vitest' function build(opts = {}) { const app = fastify(opts) app.get('/', async function (request, reply) { reply.send({ hello: 'world' }) }) return app } describe('GET /', () => { it('should return Hello World', async () => { const app = build() const response = await app.inject({ url: '/', method: 'GET', }) expect(res.status).toBe(200) expect(res.text).toBe('Hello World') }) }) ``` ::: > Fastify 使用 `fastify.inject()` 模拟网络请求 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' import { describe, it, expect } from 'vitest' const app = new Elysia() .get('/', 'Hello World') describe('GET /', () => { it('should return Hello World', async () => { const res = await app.handle( new Request('http://localhost') ) expect(res.status).toBe(200) expect(await res.text()).toBe('Hello World') }) }) ``` ::: > Elysia 使用 Web 标准 API 处理**实际**请求 或者,Elysia 还提供了一个名为 [Eden](/eden/overview) 的辅助库,用于端到端类型安全,允许我们进行自动完成和完全类型安全的测试。 ```ts twoslash [Elysia] import { Elysia } from 'elysia' import { treaty } from '@elysiajs/eden' import { describe, expect, it } from 'bun:test' const app = new Elysia().get('/hello', 'Hello World') const api = treaty(app) describe('GET /', () => { it('should return Hello World', async () => { const { data, error, status } = await api.hello.get() expect(status).toBe(200) expect(data).toBe('Hello World') // ^? }) }) ``` ## 端到端类型安全 Elysia 内置支持**端到端类型安全**,无需代码生成,使用 [Eden](/eden/overview),而 Fastify 不提供。 ::: code-group ```ts twoslash [Elysia] import { Elysia, t } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .post('/mirror', ({ body }) => body, { body: t.Object({ message: t.String() }) }) const api = treaty(app) const { data, error } = await api.mirror.post({ message: 'Hello World' }) if(error) throw error // ^? console.log(data) // ^? // ---cut-after--- console.log('ok') ``` ::: 如果端到端类型安全对你很重要,那么 Elysia 是正确的选择。 *** Elysia 提供了更符合人体工程学和开发者友好的体验,注重性能、类型安全和简单性,而 Fastify 是 Node.js 的成熟框架之一,但不具备下一代框架提供的**健全类型安全**和**端到端类型安全**。 如果你正在寻找一个易于使用、具有出色开发者体验并构建在 Web 标准 API 之上的框架,Elysia 是你的正确选择。 或者,如果你来自不同的框架,可以查看: --- --- url: 'https://elysia.cndocs.org/migrate/from-hono.md' --- # 从 Hono 迁移到 Elysia 本指南面向希望了解 Elysia 与 Hono 差异(包括语法)的 Hono 用户,并通过示例说明如何将应用从 Hono 迁移至 Elysia。 **Hono** 是一个基于 Web 标准构建的快速轻量级框架。它对多种运行时(如 Deno、Bun、Cloudflare Workers 和 Node.js)具有广泛的兼容性。 **Elysia** 是一个符合人体工学的 Web 框架。设计注重**健全的类型安全**和性能,旨在提供符合人体工学和开发者友好的体验。 两个框架都构建在 Web 标准 API 之上,语法略有不同。Hono 提供与多种运行时的更广泛兼容性,而 Elysia 专注于特定的一组运行时。 ## 性能 得益于静态代码分析,Elysia 相比 Hono 有显著的性能提升。 ## 路由 Hono 和 Elysia 具有相似的路由语法,使用 `app.get()` 和 `app.post()` 方法定义路由,以及相似的路径参数语法。 两者都使用单一的 `Context` 参数处理请求和响应,并直接返回响应。 ::: code-group ```ts [Hono] import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => { return c.text('Hello World') }) app.post('/id/:id', (c) => { c.status(201) return c.text(req.params.id) }) export default app ``` ::: > Hono 使用辅助函数 `c.text`、`c.json` 返回响应 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() .get('/', 'Hello World') .post( '/id/:id', ({ status, params: { id } }) => { return status(201, id) } ) .listen(3000) ``` ::: > Elysia 使用单一的 `context` 并直接返回响应 Hono 使用 `c.text` 和 `c.json` 包装响应,而 Elysia 自动将值映射为响应。 风格指南略有不同,Elysia 推荐使用方法链和对象解构。 Hono 的端口分配取决于运行时和适配器,而 Elysia 使用单一的 `listen` 方法启动服务器。 ## 处理程序 Hono 使用函数手动解析查询、头部和请求体,而 Elysia 自动解析属性。 ::: code-group ```ts [Hono] import { Hono } from 'hono' const app = new Hono() app.post('/user', async (c) => { const limit = c.req.query('limit') const { name } = await c.body() const auth = c.req.header('authorization') return c.json({ limit, name, auth }) }) ``` ::: > Hono 自动解析请求体,但不适用于查询和头部 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() .post('/user', (ctx) => { const limit = ctx.query.limit const name = ctx.body.name const auth = ctx.headers.authorization return { limit, name, auth } }) ``` ::: > Elysia 使用静态代码分析来确定需要解析的内容 Elysia 使用**静态代码分析**来确定需要解析的内容,并且只解析必需的属性。 这对性能和类型安全都有好处。 ## 子路由 两者都可以继承另一个实例作为路由,但 Elysia 将每个实例视为一个组件,可用作子路由。 ::: code-group ```ts [Hono] import { Hono } from 'hono' const subRouter = new Hono() subRouter.get('/user', (c) => { return c.text('Hello User') }) const app = new Hono() app.route('/api', subRouter) ``` ::: > Hono **需要**前缀来分隔子路由 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia({ prefix: '/api' }) .get('/user', 'Hello User') const app = new Elysia() .use(subRouter) ``` ::: > Elysia 使用可选的前缀构造函数来定义 Hono 需要前缀来分隔子路由,而 Elysia 不需要前缀来分隔子路由。 ## 验证 Hono 通过外部包支持各种验证器,而 Elysia 内置了使用 **TypeBox** 的验证,并支持开箱即用的标准模式,允许您使用喜欢的库(如 Zod、Valibot、ArkType、Effect Schema 等)而无需额外库。Elysia 还提供与 OpenAPI 的无缝集成,并在幕后进行类型推断。 ::: code-group ```ts [Hono] import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const app = new Hono() app.patch( '/user/:id', zValidator( 'param', z.object({ id: z.coerce.number() }) ), zValidator( 'json', z.object({ name: z.string() }) ), (c) => { return c.json({ params: c.req.param(), body: c.req.json() }) } ) ``` ::: > Hono 使用基于管道的验证 ::: code-group ```ts twoslash [Elysia TypeBox] import { Elysia, t } from 'elysia' const app = new Elysia() .patch('/user/:id', ({ params, body }) => ({ // ^? params, body // ^? }), { params: t.Object({ id: t.Number() }), body: t.Object({ name: t.String() }) }) ``` ```ts twoslash [Elysia Zod] import { Elysia } from 'elysia' import { z } from 'zod' const app = new Elysia() .patch('/user/:id', ({ params, body }) => ({ // ^? params, body // ^? }), { params: z.object({ id: z.number() }), body: z.object({ name: z.string() }) }) ``` ```ts twoslash [Elysia Valibot] import { Elysia } from 'elysia' import * as v from 'zod' const app = new Elysia() .patch('/user/:id', ({ params, body }) => ({ // ^? params, body // ^? }), { params: v.object({ id: v.number() }), body: v.object({ name: v.string() }) }) ``` ::: > Elysia 使用 TypeBox 进行验证,并自动强制类型转换。同时支持各种验证库,如 Zod、Valibot,语法相同。 两者都提供从模式到上下文的自动类型推断。 ## 文件上传 Hono 和 Elysia 都使用 Web 标准 API 处理文件上传,但 Elysia 内置了使用 **file-type** 验证 mimetype 的声明式文件验证支持。 ::: code-group ```ts [Hono] import { Hono } from 'hono' import { z } from 'zod' import { zValidator } from '@hono/zod-validator' import { fileTypeFromBlob } from 'file-type' const app = new Hono() app.post( '/upload', zValidator( 'form', z.object({ file: z.instanceof(File) }) ), async (c) => { const body = await c.req.parseBody() const type = await fileTypeFromBlob(body.image as File) if (!type || !type.mime.startsWith('image/')) { c.status(422) return c.text('File is not a valid image') } return new Response(body.image) } ) ``` ::: > Hono 需要单独的 `file-type` 库来验证 mimetype ::: code-group ```ts [Elysia] import { Elysia, t } from 'elysia' const app = new Elysia() .post('/upload', ({ body }) => body.file, { body: t.Object({ file: t.File({ type: 'image' }) }) }) ``` ::: > Elysia 以声明方式处理文件和 mimetype 验证 由于 Web 标准 API 不验证 mimetype,信任客户端提供的 `content-type` 存在安全风险,因此 Hono 需要外部库,而 Elysia 使用 `file-type` 自动验证 mimetype。 ## 中间件 Hono 中间件使用类似于 Express 的单一基于队列的顺序,而 Elysia 使用**基于事件**的生命周期为您提供更精细的控制。 Elysia 的生命周期事件可以如下图所示。 ![Elysia 生命周期图](/assets/lifecycle-chart.svg) > 点击图片放大 Hono 具有单一顺序的请求管道流程,而 Elysia 可以拦截请求管道中的每个事件。 ::: code-group ```ts [Hono] import { Hono } from 'hono' const app = new Hono() // 全局中间件 app.use(async (c, next) => { console.log(`${c.method} ${c.url}`) await next() }) app.get( '/protected', // 路由特定中间件 async (c, next) => { const token = c.headers.authorization if (!token) { c.status(401) return c.text('Unauthorized') } await next() }, (req, res) => { res.send('Protected route') } ) ``` ::: > Hono 使用单一基于队列的顺序执行中间件 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() // 全局中间件 .onRequest(({ method, path }) => { console.log(`${method} ${path}`) }) // 路由特定中间件 .get('/protected', () => 'protected', { beforeHandle({ status, headers }) { if (!headers.authorizaton) return status(401) } }) ``` ::: > Elysia 使用特定事件拦截器处理请求管道中的每个点 Hono 有 `next` 函数调用下一个中间件,而 Elysia 没有。 ## 健全的类型安全 Elysia 设计为具有健全的类型安全。 例如,您可以使用 [derive](/essential/life-cycle.html#derive) 和 [resolve](/essential/life-cycle.html#resolve) 以**类型安全**的方式自定义上下文,而 Hono 不能。 ::: code-group ```ts twoslash [Hono] // @errors: 2339, 2769 import { Hono } from 'hono' import { createMiddleware } from 'hono/factory' const app = new Hono() const getVersion = createMiddleware(async (c, next) => { c.set('version', 2) await next() }) app.use(getVersion) app.get('/version', getVersion, (c) => { return c.text(c.get('version') + '') }) const authenticate = createMiddleware(async (c, next) => { const token = c.req.header('authorization') if (!token) { c.status(401) return c.text('Unauthorized') } c.set('token', token.split(' ')[1]) await next() }) app.post('/user', authenticate, async (c) => { c.get('version') return c.text(c.get('token')) }) ``` ::: > Hono 使用中间件扩展上下文,但不是类型安全的 ::: code-group ```ts twoslash [Elysia] import { Elysia } from 'elysia' const app = new Elysia() .decorate('version', 2) .get('/version', ({ version }) => version) .resolve(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) return { token: authorization.split(' ')[1] } }) .get('/token', ({ token, version }) => { version // ^? return token // ^? }) ``` ::: > Elysia 使用特定事件拦截器处理请求管道中的每个点 虽然 Hono 可以使用 `declare module` 扩展 `ContextVariableMap` 接口,但它是全局可用的,不具备健全的类型安全,并且不保证属性在所有请求处理程序中可用。 ```ts declare module 'hono' { interface ContextVariableMap { version: number token: string } } ``` > 上述 Hono 示例需要此声明才能工作,但这不提供健全的类型安全 ## 中间件参数 Hono 使用回调函数定义可重用的路由特定中间件,而 Elysia 使用 [macro](/patterns/macro) 定义自定义钩子。 ::: code-group ```ts twoslash [Hono] const findUser = (authorization?: string) => { return { name: 'Jane Doe', role: 'admin' as const } } // ---cut--- // @errors: 2339 2589 2769 import { Hono } from 'hono' import { createMiddleware } from 'hono/factory' const app = new Hono() const role = (role: 'user' | 'admin') => createMiddleware(async (c, next) => { const user = findUser(c.req.header('Authorization')) if(user.role !== role) { c.status(401) return c.text('Unauthorized') } c.set('user', user) await next() }) app.get('/user/:id', role('admin'), (c) => { return c.json(c.get('user')) }) ``` ::: > Hono 使用回调返回 `createMiddleware` 创建可重用中间件,但不是类型安全的 ::: code-group ```ts twoslash [Elysia] const findUser = (authorization?: string) => { return { name: 'Jane Doe', role: 'admin' as const } } // ---cut--- import { Elysia } from 'elysia' const app = new Elysia() .macro({ role: (role: 'user' | 'admin') => ({ resolve({ status, headers: { authorization } }) { const user = findUser(authorization) if(user.role !== role) return status(401) return { user } } }) }) .get('/token', ({ user }) => user, { // ^? role: 'admin' }) ``` ::: > Elysia 使用宏将自定义参数传递给自定义中间件 ## 错误处理 Hono 提供一个适用于所有路由的 `onError` 函数,而 Elysia 提供更精细的错误处理控制。 ::: code-group ```ts import { Hono } from 'hono' const app = new Hono() class CustomError extends Error { constructor(message: string) { super(message) this.name = 'CustomError' } } // 全局错误处理程序 app.onError((error, c) => { if(error instanceof CustomError) { c.status(500) return c.json({ message: 'Something went wrong!', error }) } }) // 路由特定错误处理程序 app.get('/error', (req, res) => { throw new CustomError('oh uh') }) ``` ::: > Hono 使用 `onError` 函数处理错误,所有路由共用单一错误处理程序 ::: code-group ```ts twoslash [Elysia] import { Elysia } from 'elysia' class CustomError extends Error { // 可选:自定义 HTTP 状态码 status = 500 constructor(message: string) { super(message) this.name = 'CustomError' } // 可选:发送给客户端的内容 toResponse() { return { message: "If you're seeing this, our dev forgot to handle this error", error: this } } } const app = new Elysia() // 可选:注册自定义错误类 .error({ CUSTOM: CustomError, }) // 全局错误处理程序 .onError(({ error, code }) => { if(code === 'CUSTOM') // ^? return { message: 'Something went wrong!', error } }) .get('/error', () => { throw new CustomError('oh uh') }, { // 可选:路由特定错误处理程序 error({ error }) { return { message: 'Only for this route!', error } } }) ``` ::: > Elysia 提供更精细的错误控制和作用域机制 Hono 提供类似中间件的错误处理,而 Elysia 提供: 1. 全局和路由特定的错误处理程序 2. 映射 HTTP 状态和 `toResponse` 的简写方式,用于将错误映射到响应 3. 为每个错误提供自定义错误代码 错误代码对日志记录和调试很有用,并且在区分扩展自同一类的不同错误类型时很重要。 ## 封装 Hono 封装插件的副作用,而 Elysia 通过显式的作用域机制和代码顺序控制插件的副作用。 ::: code-group ```ts [Hono] import { Hono } from 'hono' const subRouter = new Hono() subRouter.get('/user', (c) => { return c.text('Hello User') }) const app = new Hono() app.route('/api', subRouter) ``` ::: > Hono 封装插件的副作用 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia() .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) const app = new Elysia() .get('/', 'Hello World') .use(subRouter) // 没有来自 subRouter 的副作用 .get('/side-effect', () => 'hi') ``` ::: > Elysia 封装插件的副作用,除非明确声明 两者都有插件的封装机制以防止副作用。 然而,Elysia 可以通过声明作用域来明确哪些插件应该有副作用,而 Fastify 总是封装它。 ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia() .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) // 作用域限定于父实例但不超出范围 .as('scoped') // [!code ++] const app = new Elysia() .get('/', 'Hello World') .use(subRouter) // [!code ++] // 现在有来自 subRouter 的副作用 .get('/side-effect', () => 'hi') ``` Elysia 提供 3 种作用域机制: 1. **local** - 仅应用于当前实例,无副作用(默认) 2. **scoped** - 副作用作用域限定于父实例但不超出范围 3. **global** - 影响所有实例 *** 由于 Hono 不提供作用域机制,我们需要: 1. 为每个钩子创建函数并手动附加 2. 使用高阶函数,并将其应用于需要效果的实例 但是,如果不小心处理,可能会导致重复的副作用。 ```ts [Hono] import { Hono } from 'hono' import { createMiddleware } from 'hono/factory' const middleware = createMiddleware(async (c, next) => { console.log('called') await next() }) const app = new Hono() const subRouter = new Hono() app.use(middleware) app.get('/main', (c) => c.text('Hello from main!')) subRouter.use(middleware) // 这会记录两次 subRouter.get('/sub', (c) => c.text('Hello from sub router!')) app.route('/sub', subRouter) export default app ``` 在这种情况下,Elysia 提供插件去重机制以防止重复的副作用。 ```ts [Elysia] import { Elysia } from 'elysia' const subRouter = new Elysia({ name: 'subRouter' }) // [!code ++] .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) .as('scoped') const app = new Elysia() .get('/', 'Hello World') .use(subRouter) .use(subRouter) // [!code ++] .use(subRouter) // [!code ++] .use(subRouter) // [!code ++] // 副作用只调用一次 .get('/side-effect', () => 'hi') ``` 通过使用唯一的 `name`,Elysia 只会应用插件一次,不会导致重复的副作用。 ## Cookie Hono 在 `hono/cookie` 下有内置的 cookie 实用函数,而 Elysia 使用基于信号的方法处理 cookie。 ::: code-group ```ts [Hono] import { Hono } from 'hono' import { getSignedCookie, setSignedCookie } from 'hono/cookie' const app = new Hono() app.get('/', async (c) => { const name = await getSignedCookie(c, 'secret', 'name') await setSignedCookie( c, 'name', 'value', 'secret', { maxAge: 1000, } ) }) ``` ::: > Hono 使用实用函数处理 cookie ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia({ cookie: { secret: 'secret' } }) .get('/', ({ cookie: { name } }) => { // 签名验证自动处理 name.value // cookie 签名自动签名 name.value = 'value' name.maxAge = 1000 * 60 * 60 * 24 }) ``` ::: > Elysia 使用基于信号的方法处理 cookie ## OpenAPI Hono 需要额外努力来描述规范,而 Elysia 将规范无缝集成到模式中。 ::: code-group ```ts [Hono] import { Hono } from 'hono' import { describeRoute, openAPISpecs } from 'hono-openapi' import { resolver, validator as zodValidator } from 'hono-openapi/zod' import { swaggerUI } from '@hono/swagger-ui' import { z } from '@hono/zod-openapi' const app = new Hono() const model = z.array( z.object({ name: z.string().openapi({ description: 'first name only' }), age: z.number() }) ) const detail = await resolver(model).builder() console.log(detail) app.post( '/', zodValidator('json', model), describeRoute({ validateResponse: true, summary: 'Create user', requestBody: { content: { 'application/json': { schema: detail.schema } } }, responses: { 201: { description: 'User created', content: { 'application/json': { schema: resolver(model) } } } } }), (c) => { c.status(201) return c.json(c.req.valid('json')) } ) app.get('/ui', swaggerUI({ url: '/doc' })) app.get( '/doc', openAPISpecs(app, { documentation: { info: { title: 'Hono API', version: '1.0.0', description: 'Greeting API' }, components: { ...detail.components } } }) ) export default app ``` ::: > Hono 需要额外努力来描述规范 ::: code-group ```ts twoslash [Elysia] import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' // [!code ++] const app = new Elysia() .use(openapi()) // [!code ++] .model({ user: t.Array( t.Object({ name: t.String(), age: t.Number() }) ) }) .post('/users', ({ body }) => body, { // ^? body: 'user', response: { 201: 'user' }, detail: { summary: 'Create user' } }) ``` ::: > Elysia 将规范无缝集成到模式中 Hono 有单独的函数来描述路由规范、验证,并且需要一些努力来正确设置。 Elysia 使用您提供的模式生成 OpenAPI 规范,并验证请求/响应,并从**单一事实来源**自动推断类型。 Elysia 还将 `model` 中注册的模式附加到 OpenAPI 规范中,允许您在 Swagger 或 Scalar UI 的专用部分引用模型,而 Hono 将模式内联到路由中。 ## 测试 两者都构建在 Web 标准 API 之上,允许与任何测试库一起使用。 ::: code-group ```ts [Hono] import { Hono } from 'hono' import { describe, it, expect } from 'vitest' const app = new Hono() .get('/', (c) => c.text('Hello World')) describe('GET /', () => { it('should return Hello World', async () => { const res = await app.request('/') expect(res.status).toBe(200) expect(await res.text()).toBe('Hello World') }) }) ``` ::: > Hono 有内置的 `request` 方法来运行请求 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' import { describe, it, expect } from 'vitest' const app = new Elysia() .get('/', 'Hello World') describe('GET /', () => { it('should return Hello World', async () => { const res = await app.handle( new Request('http://localhost') ) expect(res.status).toBe(200) expect(await res.text()).toBe('Hello World') }) }) ``` ::: > Elysia 使用 Web 标准 API 处理请求和响应 或者,Elysia 还提供了一个名为 [Eden](/eden/overview) 的辅助库,用于端到端类型安全,允许我们进行自动完成和完全类型安全的测试。 ```ts twoslash [Elysia] import { Elysia } from 'elysia' import { treaty } from '@elysiajs/eden' import { describe, expect, it } from 'bun:test' const app = new Elysia().get('/hello', 'Hello World') const api = treaty(app) describe('GET /', () => { it('should return Hello World', async () => { const { data, error, status } = await api.hello.get() expect(status).toBe(200) expect(data).toBe('Hello World') // ^? }) }) ``` ## 端到端类型安全 两者都提供端到端类型安全,但 Hono 似乎不提供基于状态码的类型安全错误处理。 ::: code-group ```ts twoslash [Hono] import { Hono } from 'hono' import { hc } from 'hono/client' import { z } from 'zod' import { zValidator } from '@hono/zod-validator' const app = new Hono() .post( '/mirror', zValidator( 'json', z.object({ message: z.string() }) ), (c) => c.json(c.req.valid('json')) ) const client = hc('/') const response = await client.mirror.$post({ json: { message: 'Hello, world!' } }) const data = await response.json() // ^? console.log(data) ``` ::: > Hono 使用 `hc` 运行请求,并提供端到端类型安全 ::: code-group ```ts twoslash [Elysia] import { Elysia, t } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .post('/mirror', ({ body }) => body, { body: t.Object({ message: t.String() }) }) const api = treaty(app) const { data, error } = await api.mirror.post({ message: 'Hello World' }) if(error) throw error // ^? console.log(data) // ^? // ---cut-after--- console.log('ok') ``` ::: > Elysia 使用 `treaty` 运行请求,并提供端到端类型安全 虽然两者都提供端到端类型安全,但 Elysia 提供基于状态码的更类型安全的错误处理,而 Hono 没有。 使用相同目的的代码测量每个框架的类型推断速度,Elysia 的类型检查速度比 Hono 快 2.3 倍。 ![Elysia eden 类型推断性能](/migrate/elysia-type-infer.webp) > Elysia 推断 Elysia 和 Eden 耗时 536ms(点击放大) ![Hono HC 类型推断性能](/migrate/hono-type-infer.webp) > Hono 推断 Hono 和 HC 耗时 1.27 秒,出现错误(中止)(点击放大) 1.27 秒并不反映推断的整个持续时间,而是从开始到因错误中止的持续时间 **"Type instantiation is excessively deep and possibly infinite."**,当模式过大时会发生此错误。 ![Hono HC 代码显示 excessively deep 错误](/migrate/hono-hc-infer.webp) > Hono HC 显示 excessively deep 错误 这是由于大型模式导致的,Hono 不支持超过 100 个具有复杂请求体和响应验证的路由,而 Elysia 没有这个问题。 ![Elysia Eden 代码显示无错误的类型推断](/migrate/elysia-eden-infer.webp) > Elysia Eden 代码显示无错误的类型推断 Elysia 具有更快的类型推断性能,并且**至少**在 2,000 个具有复杂请求体和响应验证的路由上不会出现 **"Type instantiation is excessively deep and possibly infinite."** 错误。 如果端到端类型安全对您很重要,那么 Elysia 是正确的选择。 *** 两者都是构建在 Web 标准 API 之上的下一代 Web 框架,略有不同。 Elysia 设计为符合人体工学和开发者友好,注重**健全的类型安全**,并且比 Hono 有更好的性能。 而 Hono 提供与多种运行时的广泛兼容性,尤其是 Cloudflare Workers,并且用户群更大。 或者,如果您来自不同的框架,可以查看: --- --- url: 'https://elysia.cndocs.org/eden/treaty/unit-test.md' --- # 单元测试 根据 [伊甸条约配置](/eden/treaty/config.html#urlorinstance) 和 [单元测试](/patterns/unit-test),我们可以直接将一个 Elysia 实例传递给伊甸条约,从而直接与 Elysia 服务器进行交互,而无需发送网络请求。 我们可以使用这种模式创建一个具有端到端类型安全性和类型级别测试的单元测试。 ```typescript twoslash // 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('返回响应', async () => { const { data } = await api.hello.get() expect(data).toBe('hi') // ^? }) }) ``` ## 类型安全测试 要执行类型安全测试,只需运行 **tsc** 来测试文件夹。 ```bash tsc --noEmit test/**/*.ts ``` 这对于确保客户端和服务器的类型完整性非常有用,特别是在迁移期间。 --- --- url: 'https://elysia.cndocs.org/eden/treaty/legacy.md' --- # 伊甸条约遗产 ::: tip 注意 这是针对伊甸条约 1 或 (edenTreaty) 的文档。 对于新项目,建议使用伊甸条约 2 (treaty) 而不是。 ::: 伊甸条约是 Elysia 服务器的对象类似表示。 提供类似普通对象的访问器,直接从服务器获取类型,帮助我们更快地工作,并确保不会发生错误。 *** 要使用伊甸条约,首先导出您现有的 Elysia 服务器类型: ```typescript // server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/', () => '嗨,Elysia') .get('/id/:id', ({ params: { id } }) => id) .post('/mirror', ({ body }) => body, { body: t.Object({ id: t.Number(), name: t.String() }) }) .listen(3000) export type App = typeof app // [!代码 ++] ``` 然后导入服务器类型,并在客户端使用 Elysia API: ```typescript // client.ts import { edenTreaty } from '@elysiajs/eden' import type { App } from './server' // [!代码 ++] const app = edenTreaty('http://localhost:') // 响应类型: '嗨,Elysia' const { data: pong, error } = app.get() // 响应类型: 1895 const { data: id, error } = app.id['1895'].get() // 响应类型: { id: 1895, name: 'Skadi' } const { data: nendoroid, error } = app.mirror.post({ id: 1895, name: 'Skadi' }) ``` ::: tip 伊甸条约具有完全的类型安全和自动补全支持。 ::: ## 构造 伊甸条约会将所有现有路径转换为对象类似表示,可以描述为: ```typescript EdenTreaty.<1>.<2>..({ ...body, $query?: {}, $fetch?: RequestInit }) ``` ### 路径 伊甸会将 `/` 转换为 `.`,可以用已注册的 `method` 调用,例如: * **/path** -> .path * **/nested/path** -> .nested.path ### 路径参数 路径参数会根据它们在 URL 中的名称自动映射。 * **/id/:id** -> .id.`<任何东西>` * 例如: .id.hi * 例如: .id\['123'] ::: tip 如果路径不支持路径参数,TypeScript 会显示错误。 ::: ### 查询 您可以使用 `$query` 将查询附加到路径: ```typescript app.get({ $query: { name: '伊甸', code: '金' } }) ``` ### 获取 伊甸条约是一个获取封装器,您可以通过将其传递给 `$fetch` 来为伊甸添加任何有效的 [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) 参数: ```typescript app.post({ $fetch: { headers: { 'x-organization': 'MANTIS' } } }) ``` ## 错误处理 伊甸条约将返回一个 `data` 和 `error` 的值作为结果,均为完全类型。 ```typescript // 响应类型: { id: 1895, name: 'Skadi' } const { data: nendoroid, error } = app.mirror.post({ id: 1895, name: 'Skadi' }) if(error) { switch(error.status) { case 400: case 401: warnUser(error.value) break case 500: case 502: emergencyCallDev(error.value) break default: reportError(error.value) break } throw error } const { id, name } = nendoroid ``` **data** 和 **error** 的类型在您确认其状态之前都是可为空的。 简单来说,如果获取成功,data 将有值而 error 将为 null,反之亦然。 ::: tip 错误被包装在一个 `Error` 中,其值从服务器返回,可以从 `Error.value` 中检索 ::: ### 基于状态的错误类型 如果您在 Elysia 服务器中明确提供了错误类型,伊甸条约和伊甸获取可以根据状态码缩小错误类型。 ```typescript // server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .model({ nendoroid: t.Object({ id: t.Number(), name: t.String() }), error: t.Object({ message: t.String() }) }) .get('/', () => '嗨,Elysia') .get('/id/:id', ({ params: { id } }) => id) .post('/mirror', ({ body }) => body, { body: 'nendoroid', response: { 200: 'nendoroid', // [!代码 ++] 400: 'error', // [!代码 ++] 401: 'error' // [!代码 ++] } }) .listen(3000) export type App = typeof app ``` 在客户端: ```typescript const { data: nendoroid, error } = app.mirror.post({ id: 1895, name: 'Skadi' }) if(error) { switch(error.status) { case 400: case 401: // 缩小到服务器中描述的类型 'error' warnUser(error.value) break default: // 类型为 unknown reportError(error.value) break } throw error } ``` ## WebSocket 伊甸支持 WebSocket,使用与普通路由相同的 API。 ```typescript // 服务器 import { Elysia, t } from 'elysia' const app = new Elysia() .ws('/chat', { message(ws, message) { ws.send(message) }, body: t.String(), response: t.String() }) .listen(3000) type App = typeof app ``` 要开始监听实时数据,调用 `.subscribe` 方法: ```typescript // 客户端 import { edenTreaty } from '@elysiajs/eden' const app = edenTreaty('http://localhost:') const chat = app.chat.subscribe() chat.subscribe((message) => { console.log('接收到', message) }) chat.send('客户端发送的你好') ``` 我们可以使用 [schema](/integrations/cheat-sheet#schema) 来强制 WebSocket 的类型安全,正如普通路由一样。 *** **Eden.subscribe** 返回 **EdenWebSocket**,它扩展了 [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket) 类,具备类型安全。语法与 WebSocket 相同。 如果需要更多控制,可以访问 **EdenWebSocket.raw** 与原生 WebSocket API 交互。 ## 文件上传 您可以将以下之一传递到字段中以附加文件: * **File** * **FileList** * **Blob** 附加文件将导致 **content-type** 为 **multipart/form-data**。 假设我们有如下服务器: ```typescript // server.ts import { Elysia } from 'elysia' const app = new Elysia() .post('/image', ({ body: { image, title } }) => title, { body: t.Object({ title: t.String(), image: t.Files(), }) }) .listen(3000) export type App = typeof app ``` 我们可以如下使用客户端: ```typescript // client.ts import { edenTreaty } from '@elysia/eden' import type { Server } from './server' export const client = edenTreaty('http://localhost:3000') const id = (id: string) => document.getElementById(id)! as T const { data } = await client.image.post({ title: "Misono Mika", image: id('picture').files!, }) ``` --- --- url: 'https://elysia.cndocs.org/key-concept.md' --- # 关键概念 Elysia 有一些非常重要的概念,您需要理解才能使用它。 本页涵盖了您在开始使用前应该了解的大多数概念。 ## 封装 Elysia 的生命周期方法仅**封装**在其自己的实例中。 这意味着如果您创建一个新实例,它不会与其他实例共享生命周期方法。 ```ts import { Elysia } from 'elysia' const profile = new Elysia() .onBeforeHandle(({ cookie }) => { throwIfNotSignIn(cookie) }) .get('/profile', () => 'Hi there!') const app = new Elysia() .use(profile) // ⚠️ 这将没有登录检查 .patch('/rename', ({ body }) => updateProfile(body)) ``` 在这个示例中,`isSignIn` 检查仅适用于 `profile`,而不适用于 `app`。 > 尝试在 URL 栏中更改路径为 **/rename** 并查看结果 **Elysia 默认隔离生命周期**,除非明确指定。这类似于 JavaScript 中的 **export**,您需要导出函数才能使其在模块外部可用。 要将生命周期\*\*“导出”\*\*到其他实例,您必须指定作用域。 ```ts import { Elysia } from 'elysia' const profile = new Elysia() .onBeforeHandle( { as: 'global' }, // [!code ++] ({ cookie }) => { throwIfNotSignIn(cookie) } ) .get('/profile', () => 'Hi there!') const app = new Elysia() .use(profile) // 这有登录检查 .patch('/rename', ({ body }) => updateProfile(body)) ``` 将生命周期转换为 **"global"** 将将其导出到**每个实例**。 了解更多信息,请参阅 [scope](/essential/plugin.html#scope-level)。 ## 方法链 Elysia 代码应**始终**使用方法链。 这对于**确保类型安全**非常重要。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .state('build', 1) // Store is strictly typed // [!code ++] .get('/', ({ store: { build } }) => build) // ^? .listen(3000) ``` 在上面的代码中,**state** 返回一个新的 **ElysiaInstance** 类型,添加了一个类型化的 `build` 属性。 ### 没有方法链的情况 由于 Elysia 类型系统复杂,Elysia 中的每个方法都会返回一个新的类型引用。 如果不使用方法链,Elysia 不会保存这些新类型,导致没有类型推断。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' const app = new Elysia() app.state('build', 1) app.get('/', ({ store: { build } }) => build) app.listen(3000) ``` 我们推荐**始终使用方法链** 以提供准确的类型推断。 ## 依赖 每个插件在应用到另一个实例时都会被**重新执行**。 如果插件被多次应用,它会导致不必要的重复。 重要的是,某些方法如**生命周期**或**路由**,应该只调用一次。 为了防止这种情况,Elysia 可以使用**唯一标识符**来去重生命周期。 ```ts twoslash import { Elysia } from 'elysia' // `name` 是一个唯一标识符 const ip = new Elysia({ name: 'ip' }) // [!code ++] .derive( { as: 'global' }, ({ server, request }) => ({ ip: server?.requestIP(request) }) ) .get('/ip', ({ ip }) => ip) const router1 = new Elysia() .use(ip) .get('/ip-1', ({ ip }) => ip) const router2 = new Elysia() .use(ip) .get('/ip-2', ({ ip }) => ip) const server = new Elysia() .use(router1) .use(router2) ``` 将 `name` 属性添加到实例将使其成为唯一标识符,防止其被多次调用。 了解更多信息,请参阅 [plugin deduplication](/essential/plugin.html#plugin-deduplication)。 ### 服务定位器 当您将插件应用到一个实例时,该实例将获得类型安全。 但如果您不将插件应用到另一个实例,它将无法推断类型。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' const child = new Elysia() // ❌ 'a' is missing .get('/', ({ a }) => a) const main = new Elysia() .decorate('a', 'a') .use(child) ``` Elysia 引入了**服务定位器**模式来抵消这种情况。 只需为 Elysia 提供插件引用,即可找到服务以添加类型安全。 ```typescript twoslash // @errors: 2339 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) // With `setup`, type will be inferred const child = new Elysia() .use(setup) // [!code ++] .get('/', ({ a }) => a) // ^? // ---cut-after--- console.log() ``` 这相当于 TypeScript 的 **type import**,您导入类型而不实际导入要运行的代码。 如前所述,Elysia 已经处理了去重,因此这不会有任何性能损失或生命周期重复。 ## 代码顺序 Elysia 生命周期代码的顺序非常重要。 因为事件仅适用于**注册之后**的路由。 如果您将 onError 放在插件之前,插件将无法继承 onError 事件。 ```typescript import { Elysia } from 'elysia' new Elysia() .onBeforeHandle(() => { console.log('1') }) .get('/', () => 'hi') .onBeforeHandle(() => { console.log('2') }) .listen(3000) ``` 控制台应记录以下内容: ```bash 1 ``` 请注意,它没有记录 **2**,因为事件在路由之后注册,因此不适用于该路由。 了解更多信息,请参阅 [order of code](/essential/life-cycle.html#order-of-code)。 ## 类型推断 Elysia 有一个复杂类型系统,可以从实例推断类型。 ```ts twoslash import { Elysia, t } from 'elysia' const app = new Elysia() .post('/', ({ body }) => body, { // ^? body: t.Object({ name: t.String() }) }) ``` 您应该**始终使用内联函数**来提供准确的类型推断。 如果需要应用单独的函数,例如 MVC 的控制器模式,建议从内联函数中解构属性,以防止不必要的类型推断,如下所示: ```ts twoslash import { Elysia, t } from 'elysia' abstract class Controller { static greet({ name }: { name: string }) { return 'hello ' + name } } const app = new Elysia() .post('/', ({ body }) => Controller.greet(body), { body: t.Object({ name: t.String() }) }) ``` 参阅 [最佳实践:MVC Controller](/essential/best-practice.html#controller)。 ### TypeScript 我们可以通过访问 `static` 属性来获取 Elysia/TypeBox 的每个类型的类型定义,如下所示: ```ts twoslash import { t } from 'elysia' const MyType = t.Object({ hello: t.Literal('Elysia') }) type MyType = typeof MyType.static // ^? ``` 这允许 Elysia 自动推断并提供类型,减少声明重复 schema 的需求。 单个 Elysia/TypeBox schema 可以用于: * 运行时验证 * 数据强制转换 * TypeScript 类型 * OpenAPI schema 这使我们可以将 schema 作为**单一真相来源**。 ``` ``` --- --- url: 'https://elysia.cndocs.org/essential/best-practice.md' --- # 最佳实践 Elysia 是一个模式无关的框架,将使用哪种编码模式的决定留给您和您的团队。 然而,在尝试将 MVC 模式[(Model-View-Controller)](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) 与 Elysia 结合时,有几个问题,我们发现很难解耦和处理类型。 本页是关于如何遵循 Elysia 结构最佳实践结合 MVC 模式的指南,但它可以适应您喜欢的任何编码模式。 ## 文件夹结构 Elysia 对文件夹结构没有特定意见,让您**决定**如何组织您的代码。 然而,**如果您没有特定的结构想法**,我们推荐基于功能的文件夹结构,其中每个功能都有自己的文件夹,包含控制器、服务和模型。 ``` | src | modules | auth | index.ts (Elysia controller) | service.ts (service) | model.ts (model) | user | index.ts (Elysia controller) | service.ts (service) | model.ts (model) | utils | a | index.ts | b | index.ts ``` 这种结构让您更容易找到和管理代码,并将相关代码保持在一起。 以下是如何将您的代码分布到基于功能的文件夹结构的示例代码: ::: code-group ```typescript [auth/index.ts] // Controller handle HTTP related eg. routing, request validation import { Elysia } from 'elysia' import { Auth } from './service' import { AuthModel } from './model' export const auth = new Elysia({ prefix: '/auth' }) .get( '/sign-in', async ({ body, cookie: { session } }) => { const response = await Auth.signIn(body) // Set session cookie session.value = response.token return response }, { body: AuthModel.signInBody, response: { 200: AuthModel.signInResponse, 400: AuthModel.signInInvalid } } ) ``` ```typescript [auth/service.ts] // Service handle business logic, decoupled from Elysia controller import { status } from 'elysia' import type { AuthModel } from './model' // If the class doesn't need to store a property, // you may use `abstract class` to avoid class allocation export abstract class Auth { static async signIn({ username, password }: AuthModel.signInBody) { const user = await sql` SELECT password FROM users WHERE username = ${username} LIMIT 1` if (await Bun.password.verify(password, user.password)) // You can throw an HTTP error directly throw status( 400, 'Invalid username or password' satisfies AuthModel.signInInvalid ) return { username, token: await generateAndSaveTokenToDB(user.id) } } } ``` ```typescript [auth/model.ts] // Model define the data structure and validation for the request and response import { t } from 'elysia' export namespace AuthModel { // Define a DTO for Elysia validation export const signInBody = t.Object({ username: t.String(), password: t.String(), }) // Define it as TypeScript type export type signInBody = typeof signInBody.static // Repeat for other models export const signInResponse = t.Object({ username: t.String(), token: t.String(), }) export type signInResponse = typeof signInResponse.static export const signInInvalid = t.Literal('Invalid username or password') export type signInInvalid = typeof signInInvalid.static } ``` ::: 每个文件都有其自身责任如下: * **Controller**:处理 HTTP 路由、请求验证和 cookie。 * **Service**:处理业务逻辑,如果可能,则与 Elysia 控制器解耦。 * **Model**:为请求和响应定义数据结构和验证。 请随意根据您的需求调整此结构,并使用您喜欢的任何编码模式。 ## Controller > 1 Elysia 实例 = 1 controller Elysia 做了很多工作来确保类型完整性,如果您将整个 `Context` 类型传递给控制器,这些可能是问题: 1. Elysia 类型复杂且高度依赖插件和多级链式调用。 2. 难以类型化,Elysia 类型可能随时改变,特别是使用装饰器和 store 时。 3. 类型转换可能导致类型完整性丢失或无法确保类型与运行时代码之间的一致性。 4. 这会使 [Sucrose](/blog/elysia-10#sucrose) *(Elysia 的“某种”编译器)* 更难静态分析您的代码。 ### ❌ 不要:创建单独的控制器 不要创建单独的控制器,而是将 Elysia 本身用作控制器: ```typescript import { Elysia, t, type Context } from 'elysia' abstract class Controller { static root(context: Context) { return Service.doStuff(context.stuff) } } // ❌ Don't new Elysia() .get('/', Controller.hi) ``` 将整个 `Controller.method` 传递给 Elysia 相当于有两个控制器来回传递数据。这违背了框架和 MVC 模式本身的设计。 ### ✅ 可以:将 Elysia 用作控制器 相反,将 Elysia 实例本身视为控制器。 ```typescript import { Elysia } from 'elysia' import { Service } from './service' new Elysia() .get('/', ({ stuff }) => { Service.doStuff(stuff) }) ``` 否则,如果您确实想分离控制器,您可以创建一个与 HTTP 请求无关的控制器类。 ```typescript import { Elysia } from 'elysia' abstract class Controller { static doStuff(stuff: string) { return Service.doStuff(stuff) } } new Elysia() .get('/', ({ stuff }) => Controller.doStuff(stuff)) ``` ### 测试 您可以使用 `handle` 测试您的控制器,直接调用函数(及其生命周期)。 ```typescript import { Elysia } from 'elysia' import { Service } from './service' import { describe, it, expect } from 'bun:test' const app = new Elysia() .get('/', ({ stuff }) => { Service.doStuff(stuff) return 'ok' }) describe('Controller', () => { it('should work', async () => { const response = await app .handle(new Request('http://localhost/')) .then((x) => x.text()) expect(response).toBe('ok') }) }) ``` 您可以在 [Unit Test](/patterns/unit-test.html) 中找到更多关于测试的信息。 ## Service Service 是一组与模块/控制器(在我们的情况下,是 Elysia 实例)解耦的实用程序/辅助函数,作为业务逻辑使用。 任何可以从控制器解耦的技术逻辑都可以存在于 **Service** 中。 在 Elysia 中有两种类型的 service: 1. 非请求依赖的服务 2. 请求依赖的服务 ### ✅ 可以:抽象非请求依赖的服务 我们推荐将服务类/函数从 Elysia 中抽象出来。 如果服务或函数不与 HTTP 请求绑定或不访问 `Context`,推荐将其实现为静态类或函数。 ```typescript import { Elysia, t } from 'elysia' abstract class Service { static fibo(number: number): number { if(number < 2) return number return Service.fibo(number - 1) + Service.fibo(number - 2) } } new Elysia() .get('/fibo', ({ body }) => { return Service.fibo(body) }, { body: t.Numeric() }) ``` 如果您的服务不需要存储属性,您可以使用 `abstract class` 和 `static` 来避免分配类实例。 ### ✅ 可以:请求依赖的服务作为 Elysia 实例 **如果服务是请求依赖的服务** 或需要处理 HTTP 请求,我们推荐将其抽象为 Elysia 实例,以确保类型完整性和推断: ```typescript import { Elysia } from 'elysia' // ✅ Do const AuthService = new Elysia({ name: 'Auth.Service' }) .macro({ isSignIn: { resolve({ cookie, status }) { if (!cookie.session.value) return status(401) return { session: cookie.session.value, } } } }) const UserController = new Elysia() .use(AuthService) .get('/profile', ({ Auth: { user } }) => user, { isSignIn: true }) ``` ::: tip Elysia 默认处理 [插件去重](/essential/plugin.html#plugin-deduplication),因此您不必担心性能,如果您指定 **"name"** 属性,它将是单例。 ::: ### ✅ 可以:仅装饰请求依赖的属性 推荐仅 `decorate` 请求依赖的属性,例如 `requestIP`、`requestTime` 或 `session`。 过度使用装饰器可能会将您的代码绑定到 Elysia,使其更难测试和重用。 ```typescript import { Elysia } from 'elysia' new Elysia() .decorate('requestIP', ({ request }) => request.headers.get('x-forwarded-for') || request.ip) .decorate('requestTime', () => Date.now()) .decorate('session', ({ cookie }) => cookie.session.value) .get('/', ({ requestIP, requestTime, session }) => { return { requestIP, requestTime, session } }) ``` ### ❌ 不要:将整个 `Context` 传递给服务 **Context 是一个高度动态的类型**,可以从 Elysia 实例推断。 不要将整个 `Context` 传递给服务,而是使用对象解构提取您需要的内容并传递给服务。 ```typescript import type { Context } from 'elysia' class AuthService { constructor() {} // ❌ Don't do this isSignIn({ status, cookie: { session } }: Context) { if (session.value) return status(401) } } ``` 由于 Elysia 类型复杂且高度依赖插件和多级链式调用,手动类型化可能具有挑战性,因为它是高度动态的。 ### ⚠️ 从 Elysia 实例推断 Context 在**绝对必要**的情况下,您可以从 Elysia 实例本身推断 `Context` 类型: ```typescript import { Elysia, type InferContext } from 'elysia' const setup = new Elysia() .state('a', 'a') .decorate('b', 'b') class AuthService { constructor() {} // ✅ Do isSignIn({ status, cookie: { session } }: InferContext) { if (session.value) return status(401) } } ``` 然而,我们推荐尽量避免这样做,而是使用 [Elysia 作为服务](#✅-do-use-elysia-as-a-controller)。 您可以在 [Essential: Handler](/essential/handler) 中找到更多关于 [InferContext](/essential/handler#infercontext) 的信息。 ## Model Model 或 [DTO (Data Transfer Object)](https://en.wikipedia.org/wiki/Data_transfer_object) 由 [Elysia.t (Validation)](/essential/validation.html#elysia-type) 处理。 Elysia 内置了一个验证系统,可以从您的代码推断类型并在运行时验证它。 ### ❌ 不要:将类实例声明为模型 不要将类实例声明为模型: ```typescript // ❌ Don't class CustomBody { username: string password: string constructor(username: string, password: string) { this.username = username this.password = password } } // ❌ Don't interface ICustomBody { username: string password: string } ``` ### ✅ 可以:使用 Elysia 的验证系统 不要声明类或接口,而是使用 Elysia 的验证系统来定义模型: ```typescript twoslash // ✅ Do import { Elysia, t } from 'elysia' const customBody = t.Object({ username: t.String(), password: t.String() }) // Optional if you want to get the type of the model // Usually if we didn't use the type, as it's already inferred by Elysia type CustomBody = typeof customBody.static // ^? export { customBody } ``` 我们可以通过使用 `typeof` 与模型的 `.static` 属性来获取模型的类型。 然后,您可以使用 `CustomBody` 类型来推断请求体类型。 ```typescript twoslash import { Elysia, t } from 'elysia' const customBody = t.Object({ username: t.String(), password: t.String() }) // ---cut--- // ✅ Do new Elysia() .post('/login', ({ body }) => { // ^? return body }, { body: customBody }) ``` ### ❌ 不要:将类型与模型分开声明 不要将类型与模型分开声明,而是使用 `typeof` 与 `.static` 属性来获取模型的类型。 ```typescript // ❌ Don't import { Elysia, t } from 'elysia' const customBody = t.Object({ username: t.String(), password: t.String() }) type CustomBody = { username: string password: string } // ✅ Do const customBody = t.Object({ username: t.String(), password: t.String() }) type CustomBody = typeof customBody.static ``` ### Group 您可以将多个模型分组到一个对象中,以使其更组织化。 ```typescript import { Elysia, t } from 'elysia' export const AuthModel = { sign: t.Object({ username: t.String(), password: t.String() }) } const models = AuthModel.models ``` ### Model Injection 虽然这是可选的,如果您严格遵循 MVC 模式,您可能想像服务一样注入到控制器中。我们推荐使用 [Elysia 引用模型](/essential/validation#reference-model)。 使用 Elysia 的模型引用 ```typescript twoslash import { Elysia, t } from 'elysia' const customBody = t.Object({ username: t.String(), password: t.String() }) const AuthModel = new Elysia() .model({ 'auth.sign': customBody }) const models = AuthModel.models const UserController = new Elysia({ prefix: '/auth' }) .use(AuthModel) .post('/sign-in', async ({ body, cookie: { session } }) => { // ^? return true }, { body: 'auth.sign' }) ``` 这种方法提供了几个好处: 1. 允许我们命名模型并提供自动完成。 2. 为后续使用修改 schema,或执行 [remap](/essential/handler.html#remap)。 3. 在 OpenAPI 合规客户端中显示为“models”,例如 OpenAPI。 4. 提高 TypeScript 推断速度,因为模型类型将在注册期间缓存。 ## 重复使用插件 重复使用插件多次以提供类型推断是可以的。 Elysia 默认自动处理插件去重,性能影响可以忽略不计。 要创建唯一插件,您可以为 Elysia 实例提供 **name** 或可选 **seed**。 ```typescript import { Elysia } from 'elysia' const plugin = new Elysia({ name: 'my-plugin' }) .decorate("type", "plugin") const app = new Elysia() .use(plugin) .use(plugin) .use(plugin) .use(plugin) .listen(3000) ``` 这允许 Elysia 通过重用已注册的插件而不是反复处理插件来提高性能。 --- --- url: 'https://elysia.cndocs.org/patterns/cookie.md' --- # Cookie Elysia 提供了一个可变的 signal 用于与 Cookie 交互。 没有 get/set,你可以直接提取 cookie 名称并检索或更新其值。 ```ts import { Elysia } from 'elysia' new Elysia() .get('/', ({ cookie: { name } }) => { // Get name.value // Set name.value = "New Value" }) ``` 默认情况下,Reactive Cookie 可以自动编码/解码对象类型,这允许我们将 cookies 视为对象,而无需担心编码/解码。**它就是能用**。 ## Reactivity Elysia 的 cookie 是响应式的。这意味着当你更改 cookie 值时,cookie 将基于类似于 signals 的方法自动更新。 Elysia cookie 提供了一个单一真相来源来处理 cookies,它具有自动设置标头和同步 cookie 值的能力。 由于 cookies 默认是依赖 Proxy 的对象,提取的值永远不会是 **undefined**;相反,它将始终是 `Cookie` 的值,可以通过调用 **.value** 属性获取。 我们可以将 cookie jar 视为常规对象,对其迭代只会迭代已存在的 cookie 值。 ## Cookie 属性 要使用 Cookie 属性,你可以使用以下任一方法: 1. 直接设置属性 2. 使用 `set` 或 `add` 更新 cookie 属性。 有关更多信息,请参阅 [cookie 属性配置](/patterns/cookie.html#config)。 ### 分配属性 你可以像操作任何普通对象一样获取/设置 cookie 的属性,响应式模型会自动同步 cookie 值。 ```ts import { Elysia } from 'elysia' new Elysia() .get('/', ({ cookie: { name } }) => { // get name.domain // set name.domain = 'millennium.sh' name.httpOnly = true }) ``` ## set **set** 允许通过 **重置所有属性** 和用新值覆盖属性,一次更新多个 cookie 属性。 ```ts import { Elysia } from 'elysia' new Elysia() .get('/', ({ cookie: { name } }) => { name.set({ domain: 'millennium.sh', httpOnly: true }) }) ``` ## add 与 **set** 类似,**add** 允许我们一次更新多个 cookie 属性,但它只会覆盖定义的属性,而不是重置。 ## remove 要移除 cookie,你可以使用以下任一方法: 1. name.remove 2. delete cookie.name ```ts import { Elysia } from 'elysia' new Elysia() .get('/', ({ cookie, cookie: { name } }) => { name.remove() delete cookie.name }) ``` ## Cookie Schema 你可以使用 `t.Cookie` 的 cookie schema 严格验证 cookie 类型并提供 cookie 的类型推断。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/', ({ cookie: { name } }) => { // Set name.value = { id: 617, name: 'Summoning 101' } }, { cookie: t.Cookie({ name: t.Object({ id: t.Numeric(), name: t.String() }) }) }) ``` ## 可空 Cookie 要处理可空的 cookie 值,你可以在想要设置为可空的 cookie 名称上使用 `t.Optional`。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/', ({ cookie: { name } }) => { // Set name.value = { id: 617, name: 'Summoning 101' } }, { cookie: t.Cookie({ name: t.Optional( t.Object({ id: t.Numeric(), name: t.String() }) ) }) }) ``` ## Cookie 签名 通过引入 Cookie Schema 和 `t.Cookie` 类型,我们可以创建一个统一的类型,用于自动处理 sign/verify cookie 签名。 Cookie 签名是附加到 cookie 值的加密哈希,使用密钥和 cookie 内容生成,通过向 cookie 添加签名来增强安全性。 这确保 cookie 值未被恶意行为者修改,有助于验证 cookie 数据的真实性和完整性。 ## 使用 Cookie 签名 通过提供 cookie 密钥和 `sign` 属性来指示哪些 cookie 应进行签名验证。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/', ({ cookie: { profile } }) => { profile.value = { id: 617, name: 'Summoning 101' } }, { cookie: t.Cookie({ profile: t.Object({ id: t.Numeric(), name: t.String() }) }, { secrets: 'Fischl von Luftschloss Narfidort', sign: ['profile'] }) }) ``` Elysia 然后会自动对 cookie 值进行签名和取消签名。 ## 构造函数 你可以使用 Elysia 构造函数设置全局 cookie `secret` 和 `sign` 值,而不是在每个需要的路由中内联,从而将其应用到所有全局路由。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia({ cookie: { secrets: 'Fischl von Luftschloss Narfidort', sign: ['profile'] } }) .get('/', ({ cookie: { profile } }) => { profile.value = { id: 617, name: 'Summoning 101' } }, { cookie: t.Cookie({ profile: t.Object({ id: t.Numeric(), name: t.String() }) }) }) ``` ## Cookie 轮换 Elysia 会自动处理 Cookie 的密钥轮换。 Cookie 轮换是一种迁移技术,使用较新的密钥对 cookie 进行签名,同时也能够验证 cookie 的旧签名。 ```ts import { Elysia } from 'elysia' new Elysia({ cookie: { secrets: ['Vengeance will be mine', 'Fischl von Luftschloss Narfidort'] } }) ``` ## 配置 以下是 Elysia 接受的 cookie 配置。 ### secret 用于签名/取消签名 cookies 的密钥。 如果传递数组,将使用 Key Rotation。 Key rotation 是当加密密钥被退休并替换为生成的新加密密钥时。 *** 以下是扩展自 [cookie](https://npmjs.com/package/cookie) 的配置。 ### domain 指定 [Domain Set-Cookie 属性](https://tools.ietf.org/html/rfc6265#section-5.2.3) 的值。 默认情况下,不设置域,大多数客户端会认为 cookie 仅适用于当前域。 ### encode @default `encodeURIComponent` 指定一个函数,用于编码 cookie 的值。 由于 cookie 的值具有有限的字符集(并且必须是简单的字符串),此函数可用于将值编码为适合 cookie 值的字符串。 默认函数是全局 `encodeURIComponent`,它会将 JavaScript 字符串编码为 UTF-8 字节序列,然后 URL-编码超出 cookie 范围的任何序列。 ### expires 指定 Date 对象作为 [Expires Set-Cookie 属性](https://tools.ietf.org/html/rfc6265#section-5.2.1) 的值。 默认情况下,不设置过期时间,大多数客户端会认为这是一个“非持久 cookie”,并在满足像退出 Web 浏览器应用这样的条件时删除它。 ::: tip [cookie 存储模型规范](https://tools.ietf.org/html/rfc6265#section-5.3) 规定,如果同时设置了 `expires` 和 `maxAge`,则 `maxAge` 优先,但并非所有客户端都会遵守此规定,因此如果同时设置,它们应该指向相同的日期和时间。 ::: ### httpOnly @default `false` 指定 [HttpOnly Set-Cookie 属性](https://tools.ietf.org/html/rfc6265#section-5.2.6) 的布尔值。 当为 truthy 时,设置 HttpOnly 属性,否则不设置。 默认情况下,不设置 HttpOnly 属性。 ::: tip 设置此值为 true 时要小心,因为合规客户端将不允许客户端 JavaScript 在 `document.cookie` 中看到 cookie。 ::: ### maxAge @default `undefined` 指定数字(以秒为单位)作为 [Max-Age Set-Cookie 属性](https://tools.ietf.org/html/rfc6265#section-5.2.2) 的值。 给定的数字将通过向下取整转换为整数。默认情况下,不设置最大年龄。 ::: tip [cookie 存储模型规范](https://tools.ietf.org/html/rfc6265#section-5.3) 规定,如果同时设置了 `expires` 和 `maxAge`,则 `maxAge` 优先,但并非所有客户端都会遵守此规定,因此如果同时设置,它们应该指向相同的日期和时间。 ::: ### path 指定 [Path Set-Cookie 属性](https://tools.ietf.org/html/rfc6265#section-5.2.4) 的值。 默认情况下,路径处理程序被视为默认路径。 ### priority 指定字符串作为 [Priority Set-Cookie 属性](https://tools.ietf.org/html/draft-west-cookie-priority-00#section-4.1) 的值。 `low` 将 Priority 属性设置为 Low。 `medium` 将 Priority 属性设置为 Medium,这是未设置时的默认优先级。 `high` 将 Priority 属性设置为 High。 有关不同优先级级别的信息,可参阅 [规范](https://tools.ietf.org/html/draft-west-cookie-priority-00#section-4.1)。 ::: tip 这是一个尚未完全标准化的属性,未来可能会发生变化。这也意味着许多客户端在理解它之前可能会忽略此属性。 ::: ### sameSite 指定布尔值或字符串作为 [SameSite Set-Cookie 属性](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-09#section-5.4.7) 的值。 `true` 将 SameSite 属性设置为 Strict 以进行严格的同站强制执行。 `false` 不会设置 SameSite 属性。 `'lax'` 将 SameSite 属性设置为 Lax 以进行宽松的同站强制执行。 `'none'` 将 SameSite 属性设置为 None 以明确跨站 cookie。 `'strict'` 将 SameSite 属性设置为 Strict 以进行严格的同站强制执行。 有关不同强制执行级别的信息,可参阅 [规范](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-09#section-5.4.7)。 ::: tip 这是一个尚未完全标准化的属性,未来可能会发生变化。这也意味着许多客户端在理解它之前可能会忽略此属性。 ::: ### secure 指定 [Secure Set-Cookie 属性](https://tools.ietf.org/html/rfc6265#section-5.2.5) 的布尔值。当为 truthy 时,设置 Secure 属性,否则不设置。默认情况下,不设置 Secure 属性。 ::: tip 设置此值为 true 时要小心,因为合规客户端如果浏览器没有 HTTPS 连接,将不会在未来将 cookie 发送回服务器。 ::: --- --- url: 'https://elysia.cndocs.org/essential/handler.md' --- # 处理程序 处理程序是一个响应每个路由请求的函数。 接受请求信息并向客户端返回响应。 或者,在其他框架中,处理程序也被称为 **Controller**。 ```typescript import { Elysia } from 'elysia' new Elysia() // 函数 `() => 'hello world'` 是一个处理程序 .get('/', () => 'hello world') .listen(3000) ``` 处理程序可以是字面值,并且可以内联使用。 ```typescript import { Elysia, file } from 'elysia' new Elysia() .get('/', 'Hello Elysia') .get('/video', file('kyuukurarin.mp4')) .listen(3000) ``` 使用内联值总是返回相同的值,这对于优化静态资源(如文件)的性能很有用。 这允许 Elysia 提前编译响应以优化性能。 ::: tip 提供内联值不是缓存。 静态资源值、标头和状态可以使用生命周期动态修改。 ::: ## 上下文 **Context** 包含每个请求唯一的请求信息,除了 `store` (全局可变状态) 外,不会与其他请求共享。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', (context) => context.path) // ^ 这是上下文 ``` **Context** 只能在路由处理程序中检索。它包括: #### 属性 * [**body**](/essential/validation.html#body) - [HTTP 消息](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages),表单或文件上传。 * [**query**](/essential/validation.html#query) - [查询字符串](https://en.wikipedia.org/wiki/Query_string),作为 JavaScript 对象包含用于搜索查询的附加参数。(查询是从路径名后的“?”问号符号后的值中提取的) * [**params**](/essential/validation.html#params) - Elysia 的路径参数解析为 JavaScript 对象 * [**headers**](/essential/validation.html#headers) - [HTTP 标头](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers),关于请求的附加信息,如 User-Agent、Content-Type、缓存提示。 * [**cookie**](#cookie) - 用于与 Cookie 交互的全局可变信号存储(包括 get/set) * [**store**](#state) - Elysia 实例的全局可变存储 #### 实用函数 * [**redirect**](#redirect) - 用于重定向响应的函数 * [**status**](#status) - 用于返回自定义状态码的函数 * [**set**](#set) - 应用于 Response 的属性: * [**headers**](#set.headers) - 响应标头 #### 附加属性 * [**request**](#request) - [Web 标准 Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) * [**server**](#server-bun-only) - Bun 服务器实例 * **path** - 请求的路径名 ## status 一个带有类型收窄的函数,用于返回自定义状态码。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', ({ status }) => status(418, "Kirifuji Nagisa")) .listen(3000) ``` 推荐使用 **never-throw** 方法返回 **status** 而不是抛出异常,因为它: * 允许 TypeScript 检查返回值是否正确类型化为响应架构 * 根据状态码提供类型收窄的自动补全 * 使用端到端类型安全 ([Eden](/eden/overview)) 进行错误处理类型收窄 ## Set **set** 是一个可变属性,用于形成通过 `Context.set` 访问的响应。 ```ts twoslash import { Elysia } from 'elysia' new Elysia() .get('/', ({ set, status }) => { set.headers = { 'X-Teapot': 'true' } return status(418, 'I am a teapot') }) .listen(3000) ``` ### set.headers 允许我们附加或删除响应标头,表示为对象。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', ({ set }) => { set.headers['x-powered-by'] = 'Elysia' return 'a mimir' }) .listen(3000) ``` ::: tip Elysia 提供小写的自动补全,以确保大小写敏感性一致,例如使用 `set-cookie` 而不是 `Set-Cookie`。 ::: 将请求重定向到另一个资源。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', ({ redirect }) => { return redirect('https://youtu.be/whpVWVWBW4U?&t=8') }) .get('/custom-status', ({ redirect }) => { // 也可以设置自定义状态进行重定向 return redirect('https://youtu.be/whpVWVWBW4U?&t=8', 302) }) .listen(3000) ``` 使用重定向时,返回值不是必需的,并且将被忽略。因为响应将来自另一个资源。 如果未提供,则设置默认状态码。 推荐在只需要返回特定状态码的插件中使用此功能,同时允许用户返回自定义值。例如,HTTP 201/206 或 403/405 等。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .onBeforeHandle(({ set }) => { set.status = 418 return 'Kirifuji Nagisa' }) .get('/', () => 'hi') .listen(3000) ``` 与 `status` 函数不同,`set.status` 无法推断返回值类型,因此无法检查返回值是否正确类型化为响应架构。 ::: tip HTTP 状态指示响应类型。如果路由处理程序成功执行而无错误,Elysia 将返回状态码 200。 ::: 也可以使用状态码的通用名称而不是数字来设置状态码。 ```typescript twoslash // @errors 2322 import { Elysia } from 'elysia' new Elysia() .get('/', ({ set }) => { set.status // ^? return 'Kirifuji Nagisa' }) .listen(3000) ``` ## Cookie Elysia 提供了一个可变信号,用于与 Cookie 交互。 没有 get/set,您可以直接提取 Cookie 名称并检索或更新其值。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/set', ({ cookie: { name } }) => { // 获取 name.value // 设置 name.value = "New Value" }) ``` 有关更多信息,请参阅 [Patterns: Cookie](/essentials/cookie)。 ## 重定向 将请求重定向到另一个资源。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', ({ redirect }) => { return redirect('https://youtu.be/whpVWVWBW4U?&t=8') }) .get('/custom-status', ({ redirect }) => { // 也可以设置自定义状态进行重定向 return redirect('https://youtu.be/whpVWVWBW4U?&t=8', 302) }) .listen(3000) ``` 使用重定向时,返回值不是必需的,并且将被忽略。因为响应将来自另一个资源。 ## Formdata 我们可以通过直接从处理程序返回 `form` 实用工具来返回 `FormData`。 ```typescript import { Elysia, form, file } from 'elysia' new Elysia() .get('/', () => form({ name: 'Tea Party', images: [file('nagi.web'), file('mika.webp')] })) .listen(3000) ``` 如果需要返回文件或 multipart 表单数据,这种模式很有用。 ### 返回文件 或者,您可以直接返回 `file` 来返回单个文件,而无需使用 `form`。 ```typescript import { Elysia, file } from 'elysia' new Elysia() .get('/', file('nagi.web')) .listen(3000) ``` ## 流 通过使用带有 `yield` 关键字的生成器函数,即开箱即用地返回响应流。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/ok', function* () { yield 1 yield 2 yield 3 }) ``` 在这个例子中,我们可以使用 `yield` 关键字来流式传输响应。 ## 服务器发送事件 (SSE) Elysia 通过提供 `sse` 实用函数支持 [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)。 ```typescript twoslash import { Elysia, sse } from 'elysia' new Elysia() .get('/sse', function* () { yield sse('hello world') yield sse({ event: 'message', data: { message: 'This is a message', timestamp: new Date().toISOString() }, }) }) ``` 当值被包装在 `sse` 中时,Elysia 将自动将响应标头设置为 `text/event-stream`,并将数据格式化为 SSE 事件。 ### 服务器发送事件中的标头 标头只能在第一个块被 yield 之前设置。 ```typescript twoslash import { Elysia } from 'elysia' const app = new Elysia() .get('/ok', function* ({ set }) { // 这将设置标头 set.headers['x-name'] = 'Elysia' yield 1 yield 2 // 这将无效果 set.headers['x-id'] = '1' yield 3 }) ``` 一旦第一个块被 yield,Elysia 将向客户端发送标头,因此在第一个块被 yield 后修改标头将无效果。 ### 条件流 如果响应在没有 yield 的情况下返回,Elysia 将自动将流转换为正常响应。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/ok', function* () { if (Math.random() > 0.5) return 'ok' yield 1 yield 2 yield 3 }) ``` 这允许我们有条件地流式传输响应,如果需要,可以返回正常响应。 ### 自动取消 在响应流完成之前,如果用户取消请求,Elysia 将自动停止生成器函数。 ### Eden [Eden](/eden/overview) 将流响应解释为 `AsyncGenerator`,允许我们使用 `for await` 循环消耗流。 ```typescript twoslash import { Elysia } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .get('/ok', function* () { yield 1 yield 2 yield 3 }) const { data, error } = await treaty(app).ok.get() if (error) throw error for await (const chunk of data) console.log(chunk) ``` ## 请求 Elysia 建立在 [Web 标准 Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) 之上,该 Request 在 Node、Bun、Deno、Cloudflare Worker、Vercel Edge Function 等多个运行时之间共享。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/user-agent', ({ request }) => { return request.headers.get('user-agent') }) .listen(3000) ``` 如果需要,这允许您访问低级请求信息。 ## 服务器 仅限 Bun 服务器实例是 Bun 服务器实例,允许我们访问服务器信息,如端口号或请求 IP。 服务器仅在 HTTP 服务器使用 `listen` 运行时可用。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/port', ({ server }) => { return server?.port }) .listen(3000) ``` ### 请求 IP 仅限 Bun 我们可以使用 `server.requestIP` 方法获取请求 IP。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/ip', ({ server, request }) => { return server?.requestIP(request) }) .listen(3000) ``` ## 扩展上下文 高级概念 由于 Elysia 只提供基本信息,我们可以自定义 Context 以满足特定需求,例如: * 将用户 ID 提取为变量 * 注入通用模式存储库 * 添加数据库连接 我们可以使用以下 API 扩展 Elysia 的上下文来自定义 Context: * [state](#state) - 全局可变状态 * [decorate](#decorate) - 分配给 **Context** 的附加属性 * [derive](#derive) / [resolve](#resolve) - 从现有属性创建新值 ### 何时扩展上下文 您应该只在以下情况下扩展上下文: * 属性是全局可变状态,并在多个路由中使用 [state](#state) 共享 * 属性与请求或响应相关,使用 [decorate](#decorate) * 属性从现有属性派生,使用 [derive](#derive) / [resolve](#resolve) 否则,我们推荐将值或函数单独定义,而不是扩展上下文。 ::: tip 推荐将与请求和响应相关的属性,或经常使用的函数分配给 Context 以实现关注点分离。 ::: ## 状态 **State** 是整个 Elysia 应用共享的全局可变对象或状态。 一旦调用 **state**,值将在调用时添加到 **store** 属性中,并在处理程序中使用。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .state('version', 1) .get('/a', ({ store: { version } }) => version) // ^? .get('/b', ({ store }) => store) .get('/c', () => 'still ok') .listen(3000) ``` ### 何时使用 * 当您需要跨多个路由共享原始可变值时 * 如果您想要使用非原始值或 `wrapper` 值或类来变异内部状态,请改用 [decorate](#decorate)。 ### 关键要点 * **store** 是整个 Elysia 应用的单一真相源全局可变对象的表示。 * **state** 是一个函数,用于将初始值分配给 **store**,该值稍后可以被变异。 * 确保在使用处理程序之前分配值。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' new Elysia() // ❌ TypeError: counter 不存在于 store 中 .get('/error', ({ store }) => store.counter) .state('counter', 0) // ✅ 因为我们在之前分配了 counter,现在可以访问它 .get('/', ({ store }) => store.counter) ``` ::: tip 注意,我们不能在使用之前使用状态值。 Elysia 会自动将状态值注册到存储中,而无需显式类型或额外的 TypeScript 泛型。 ::: ### 引用和值 陷阱 要变异状态,推荐使用 **引用** 来变异而不是使用实际值。 在从 JavaScript 访问属性时,如果我们将对象属性的原始值定义为新值,则引用丢失,该值被视为新的单独值。 例如: ```typescript const store = { counter: 0 } store.counter++ console.log(store.counter) // ✅ 1 ``` 我们可以访问和变异属性 **store.counter**。 然而,如果我们将 counter 定义为新值 ```typescript const store = { counter: 0 } let counter = store.counter counter++ console.log(store.counter) // ❌ 0 console.log(counter) // ✅ 1 ``` 一旦原始值被重新定义为新变量,引用 **“链接”** 将丢失,导致意外行为。 这可以应用于 `store`,因为它是一个全局可变对象。 ```typescript import { Elysia } from 'elysia' new Elysia() .state('counter', 0) // ✅ 使用引用,值是共享的 .get('/', ({ store }) => store.counter++) // ❌ 在原始值上创建新变量,链接丢失 .get('/error', ({ store: { counter } }) => counter) ``` ## Decorate **decorate** 在 **调用时** 直接将附加属性分配给 **Context**。 ```typescript twoslash import { Elysia } from 'elysia' class Logger { log(value: string) { console.log(value) } } new Elysia() .decorate('logger', new Logger()) // ✅ 从前一行定义 .get('/', ({ logger }) => { logger.log('hi') return 'hi' }) ``` ### 何时使用 * 将常量或只读值对象分配给 **Context** * 可能包含内部可变状态的非原始值或类 * 向所有处理程序添加附加函数、单例或不可变属性。 ### 关键要点 * 与 **state** 不同,装饰的值 **不应** 被变异,尽管它是可能的 * 确保在使用处理程序之前分配值。 ## Derive ###### ⚠️ Derive 不处理类型完整性,您可能想要改用 [resolve](#resolve)。 从 **Context** 中的现有属性检索值并分配新属性。 Derive 在请求发生时 **在转换生命周期** 分配,允许我们 “derive” (从现有属性创建新属性)。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .derive(({ headers }) => { const auth = headers['authorization'] return { bearer: auth?.startsWith('Bearer ') ? auth.slice(7) : null } }) .get('/', ({ bearer }) => bearer) ``` 因为 **derive** 在新请求启动时被分配,**derive** 可以访问请求属性,如 **headers**、**query**、**body**,而 **store** 和 **decorate** 不能。 ### 何时使用 * 从 **Context** 中的现有属性创建新属性,而无需验证或类型检查 * 当您需要访问无需验证的请求属性如 **headers**、**query**、**body** 时 ### 关键要点 * 与 **state** 和 **decorate** 不同,**derive** 在新请求启动时分配,而不是在调用时。 * **derive 在转换或验证之前调用**,Elysia 无法安全确认请求属性的类型,导致其为 **unknown**。如果您想要从类型化的请求属性分配新值,您可能想要使用 [resolve](#resolve)。 ## Resolve 类似于 [derive](#derive),但确保类型完整性。 Resolve 允许我们将新属性分配给上下文。 Resolve 在 **beforeHandle** 生命周期或 **验证后** 调用,允许我们安全地 **resolve** 请求属性。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .guard({ headers: t.Object({ bearer: t.String({ pattern: '^Bearer .+$' }) }) }) .resolve(({ headers }) => { return { bearer: headers.bearer.slice(7) } }) .get('/', ({ bearer }) => bearer) ``` ### 何时使用 * 从 **Context** 中的现有属性创建新属性,并具有类型完整性(类型检查) * 当您需要访问带有验证的请求属性如 **headers**、**query**、**body** 时 ### 关键要点 * **resolve 在 beforeHandle 或验证后调用**。Elysia 可以安全确认请求属性的类型,导致其为 **typed**。 ### 来自 resolve/derive 的错误 由于 resolve 和 derive 基于 **transform** 和 **beforeHandle** 生命周期,我们可以从 resolve 和 derive 返回错误。如果从 **derive** 返回错误,Elysia 将提前退出并将错误作为响应返回。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .derive(({ headers, status }) => { const auth = headers['authorization'] if(!auth) return status(400) return { bearer: auth?.startsWith('Bearer ') ? auth.slice(7) : null } }) .get('/', ({ bearer }) => bearer) ``` ## 模式 高级概念 **state**、**decorate** 提供了类似的 API 模式,用于将属性分配给 Context,如下所示: * 键值 * 对象 * 重映射 而 **derive** 只能与 **重映射** 一起使用,因为它依赖于现有值。 ### 键值 我们可以使用 **state** 和 **decorate** 以键值模式分配值。 ```typescript import { Elysia } from 'elysia' class Logger { log(value: string) { console.log(value) } } new Elysia() .state('counter', 0) .decorate('logger', new Logger()) ``` 这种模式对于设置单个属性来说可读性很好。 ### 对象 分配多个属性最好包含在一个对象中进行单一分配。 ```typescript import { Elysia } from 'elysia' new Elysia() .decorate({ logger: new Logger(), trace: new Trace(), telemetry: new Telemetry() }) ``` 对象提供了设置多个值的更少重复的 API。 ### 重映射 重映射是一种函数重新分配。 允许我们从现有值创建新值,如重命名或移除属性。 通过提供一个函数,并返回一个全新的对象来重新分配值。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' new Elysia() .state('counter', 0) .state('version', 1) .state(({ version, ...store }) => ({ ...store, elysiaVersion: 1 })) // ✅ 从状态重映射创建 .get('/elysia-version', ({ store }) => store.elysiaVersion) // ❌ 从状态重映射排除 .get('/version', ({ store }) => store.version) ``` 使用状态重映射来从现有值创建新的初始值是一个好主意。 然而,需要注意的是,Elysia 不提供这种方法的响应性,因为重映射仅分配初始值。 ::: tip 使用重映射,Elysia 将返回的对象视为新属性,移除对象中缺少的任何属性。 ::: ## Affix 高级概念 为了提供更流畅的体验,一些插件可能有很多属性值,一一重映射可能会很繁琐。 **Affix** 函数由 **prefix** 和 **suffix** 组成,允许我们重映射实例的所有属性。 ```ts twoslash import { Elysia } from 'elysia' const setup = new Elysia({ name: 'setup' }) .decorate({ argon: 'a', boron: 'b', carbon: 'c' }) const app = new Elysia() .use(setup) .prefix('decorator', 'setup') .get('/', ({ setupCarbon, ...rest }) => setupCarbon) ``` 允许我们轻松批量重映射插件的属性,防止插件的名称冲突。 默认情况下,**affix** 将自动处理运行时和类型级代码,将属性重映射为驼峰命名约定。 在某些情况下,我们也可以重映射插件的 `all` 属性: ```ts twoslash import { Elysia } from 'elysia' const setup = new Elysia({ name: 'setup' }) .decorate({ argon: 'a', boron: 'b', carbon: 'c' }) const app = new Elysia() .use(setup) .prefix('all', 'setup') // [!code ++] .get('/', ({ setupCarbon, ...rest }) => setupCarbon) ``` --- --- url: 'https://elysia.cndocs.org/eden/treaty/websocket.md' --- # WebSocket 天堂条约支持使用 `subscribe` 方法的 WebSocket。 ```typescript twoslash import { Elysia, t } from "elysia"; import { treaty } from "@elysiajs/eden"; const app = new Elysia() .ws("/chat", { body: t.String(), response: t.String(), message(ws, message) { ws.send(message); }, }) .listen(3000); const api = treaty("localhost:3000"); const chat = api.chat.subscribe(); chat.subscribe((message) => { console.log("收到", message); }); chat.on("open", () => { chat.send("来自客户端的问候"); }); ``` **.subscribe** 接受与 `get` 和 `head` 相同的参数。 ## 响应 **Eden.subscribe** 返回 **EdenWS**,它扩展了 [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket) 并且语法相同。 如果需要更多控制,可以访问 **EdenWebSocket.raw** 以与原生 WebSocket API 进行交互。 --- --- url: 'https://elysia.cndocs.org/patterns/macro.md' --- # 宏 Macro 类似于一个函数,它可以控制生命周期事件、模式和上下文,并具有完整的类型安全性。 一旦定义,它将在 hook 中可用,并可以通过添加属性来激活。 ```typescript twoslash import { Elysia } from 'elysia' const plugin = new Elysia({ name: 'plugin' }) .macro({ hi: (word: string) => ({ beforeHandle() { console.log(word) } }) }) const app = new Elysia() .use(plugin) .get('/', () => 'hi', { hi: 'Elysia' // [!code ++] }) ``` 访问路径应该会记录 **"Elysia"** 作为结果。 ## 属性简写 从 Elysia 1.2.10 开始,macro 对象中的每个属性可以是函数或对象。 如果属性是一个对象,它将被转换为接受一个布尔参数的函数,如果参数为 true,则执行该函数。 ```typescript import { Elysia } from 'elysia' export const auth = new Elysia() .macro({ // 此属性简写 isAuth: { resolve: () => ({ user: 'saltyaom' }) }, // 等同于 isAuth(enabled: boolean) { if(!enabled) return return { resolve() { return { user } } } } }) ``` ## API **macro** 具有与 hook 相同的 API。 在上一个示例中,我们创建了一个接受 **string** 的 **hi** macro。 然后我们将 **hi** 分配为 **"Elysia"**,该值随后被发送回 **hi** 函数,然后该函数将一个新事件添加到 **beforeHandle** 堆栈中。 这等同于将函数推送到 **beforeHandle**,如下所示: ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/', () => 'hi', { beforeHandle() { console.log('Elysia') } }) ``` **macro** 在逻辑比接受新函数更复杂时大放异彩,例如为每个路由创建授权层。 ```typescript twoslash // @filename: auth.ts import { Elysia } from 'elysia' export const auth = new Elysia() .macro({ isAuth: { resolve() { return { user: 'saltyaom' } } }, role(role: 'admin' | 'user') { return {} } }) // @filename: index.ts // ---cut--- import { Elysia } from 'elysia' import { auth } from './auth' const app = new Elysia() .use(auth) .get('/', ({ user }) => user, { // ^? isAuth: true, role: 'admin' }) ``` Macro 还可以向上下文中注册一个新属性,允许我们直接从上下文中访问该值。 该字段可以接受从字符串到函数的任何内容,允许我们创建自定义生命周期事件。 **macro** 将按照 hook 中定义的从上到下的顺序执行,确保堆栈以正确的顺序处理。 ## Resolve 通过返回一个包含 [**resolve**](/essential/life-cycle.html#resolve) 函数的对象,将属性添加到上下文中。 ```ts twoslash import { Elysia } from 'elysia' new Elysia() .macro({ user: (enabled: true) => ({ resolve: () => ({ user: 'Pardofelis' }) }) }) .get('/', ({ user }) => user, { // ^? user: true }) ``` 在上例中,我们通过返回一个包含 **resolve** 函数的对象,将一个新属性 **user** 添加到上下文中。 这是一个 macro resolve 可能有用的示例: * 执行身份验证并将用户添加到上下文中 * 运行额外的数据库查询并将数据添加到上下文中 * 将新属性添加到上下文中 ### 使用 resolve 的 Macro 扩展 由于 TypeScript 的限制,扩展其他 macro 的 macro 无法将类型推断到 **resolve** 函数中。 我们提供了一个命名单个 macro 作为此限制的变通方法。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .macro('user', { resolve: () => ({ user: 'lilith' as const }) }) .macro('user2', { user: true, resolve: ({ user }) => { // ^? } }) ``` ## Schema 您可以为 macro 定义自定义模式,以确保使用 macro 的路由传递正确的类型。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .macro({ withFriends: { body: t.Object({ friends: t.Tuple([t.Literal('Fouco'), t.Literal('Sartre')]) }) } }) .post('/', ({ body }) => body.friends, { // ^? body: t.Object({ name: t.Literal('Lilith') }), withFriends: true }) ``` 带有 schema 的 Macro 将自动验证并推断类型以确保类型安全,并且它可以与现有 schema 共存。 您还可以堆叠来自不同 macro 的多个 schema,甚至来自 Standard Validator 的 schema,它们将无缝协作。 ### 同一 Macro 中的 Schema 与生命周期 类似于 [使用 resolve 的 Macro 扩展](#macro-extension-with-resolve), Macro schema 也支持 **同一 Macro 内生命周期** 的类型推断 **但** 仅限于命名单个 macro,由于 TypeScript 的限制。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .macro('withFriends', { body: t.Object({ friends: t.Tuple([t.Literal('Fouco'), t.Literal('Sartre')]) }), beforeHandle({ body: { friends } }) { // ^? } }) ``` 如果您想在同一 macro 内使用生命周期类型推断,您可能想使用命名单个 macro 而不是多个堆叠 macro > 不要与使用 macro schema 将类型推断到路由的生命周期事件混淆。那样做没问题,此限制仅适用于同一 macro 内的生命周期。 ## Extension Macro 可以扩展其他 macro,允许您基于现有的进行构建。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .macro({ sartre: { body: t.Object({ sartre: t.Literal('Sartre') }) }, fouco: { body: t.Object({ fouco: t.Literal('Fouco') }) }, lilith: { fouco: true, sartre: true, body: t.Object({ lilith: t.Literal('Lilith') }) } }) .post('/', ({ body }) => body, { // ^? lilith: true }) // ---cut-after--- // ``` 这允许您基于现有 macro 进行构建,并为其添加更多功能。 ## Deduplication Macro 将自动对生命周期事件进行去重,确保每个生命周期事件仅执行一次。 默认情况下,Elysia 将使用属性值作为种子,但您可以通过提供自定义种子来覆盖它。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .macro({ sartre: (role: string) => ({ seed: role, // [!code ++] body: t.Object({ sartre: t.Literal('Sartre') }) }) }) ``` 但是,如果您意外创建了循环依赖,Elysia 有一个 16 的限制堆栈,以防止运行时和类型推断中的无限循环。 如果路由已经具有 OpenAPI 细节,它将合并细节,但优先使用路由细节而不是 macro 细节。 --- --- url: 'https://elysia.cndocs.org/quick-start.md' --- # 快速入门 Elysia 是一个支持多种运行环境的 TypeScript 后端框架,但已针对 Bun 进行了优化。 然而,你也可以在其他运行环境如 Node.js 中使用 Elysia。 \ Elysia 针对 Bun 进行了优化,Bun 是一种旨在作为 Node.js 的直接替代品的 JavaScript 运行时。 你可以使用下面的命令安装 Bun: ::: code-group ```bash [MacOS/Linux] curl -fsSL https://bun.sh/install | bash ``` ```bash [Windows] powershell -c "irm bun.sh/install.ps1 | iex" ``` ::: \ 我们建议使用 `bun create elysia` 启动一个新的 Elysia 服务器,该命令会自动设置所有内容。 ```bash bun create elysia app ``` 完成后,你应该会在目录中看到名为 `app` 的文件夹。 ```bash cd app ``` 通过以下命令启动开发服务器: ```bash bun dev ``` 访问 [localhost:3000](http://localhost:3000) 应该会显示 "Hello Elysia"。 ::: tip Elysia 提供了 `dev` 命令,能够在文件更改时自动重新加载你的服务器。 ::: 要手动创建一个新的 Elysia 应用,请将 Elysia 作为一个包安装: ```typescript bun add elysia bun add -d @types/bun ``` 这将安装 Elysia 和 Bun 的类型定义。 创建一个新文件 `src/index.ts`,并添加以下代码: ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/', () => 'Hello Elysia') .listen(3000) console.log( `🦊 Elysia 正在运行在 ${app.server?.hostname}:${app.server?.port}` ) ``` 打开你的 `package.json` 文件,并添加以下脚本: ```json { "scripts": { "dev": "bun --watch src/index.ts", "build": "bun build src/index.ts --target bun --outdir ./dist", "start": "NODE_ENV=production bun dist/index.js", "test": "bun test" } } ``` 这些脚本适用于应用程序开发的不同阶段: * **dev** - 在开发模式下启动 Elysia,并在代码更改时自动重新加载。 * **build** - 为生产使用构建应用程序。 * **start** - 启动 Elysia 生产服务器。 如果你正在使用 TypeScript,请确保创建并更新 `tsconfig.json`,将 `compilerOptions.strict` 设置为 `true`: ```json { "compilerOptions": { "strict": true } } ``` Node.js 是一个用于服务器端应用的 JavaScript 运行时,也是 Elysia 支持的最流行的运行时。 您可以使用以下命令安装 Node.js: ::: code-group ```bash [MacOS] brew install node ``` ```bash [Windows] choco install nodejs ``` ```bash [apt (Linux)] sudo apt install nodejs ``` ```bash [pacman (Arch)] pacman -S nodejs npm ``` ::: ## 设置 我们建议在你的 Node.js 项目中使用 TypeScript。 \ 要使用 TypeScript 创建一个新的 Elysia 应用,我们建议通过 `tsx` 安装 Elysia: ::: code-group ```bash [bun] bun add elysia @elysiajs/node && \ bun add -d tsx @types/node typescript ``` ```bash [pnpm] pnpm add elysia @elysiajs/node && \ pnpm add -D tsx @types/node typescript ``` ```bash [npm] npm install elysia @elysiajs/node && \ npm install --save-dev tsx @types/node typescript ``` ```bash [yarn] yarn add elysia @elysiajs/node && \ yarn add -D tsx @types/node typescript ``` ::: 这将安装 Elysia、TypeScript 和 `tsx`。 `tsx` 是一个 CLI,可以将 TypeScript 转换为 JavaScript,具有热重载和现代开发环境所需的其他功能。 创建一个新文件 `src/index.ts` 并添加以下代码: ```typescript import { Elysia } from 'elysia' import { node } from '@elysiajs/node' const app = new Elysia({ adapter: node() }) .get('/', () => 'Hello Elysia') .listen(3000, ({ hostname, port }) => { console.log( `🦊 Elysia 正在运行在 ${hostname}:${port}` ) }) ``` 打开你的 `package.json` 文件并添加以下脚本: ```json { "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc src/index.ts --outDir dist", "start": "NODE_ENV=production node dist/index.js" } } ``` 这些脚本适用于应用程序开发的不同阶段: * **dev** - 在开发模式下启动 Elysia,并在代码更改时自动重新加载。 * **build** - 为生产使用构建应用程序。 * **start** - 启动 Elysia 生产服务器。 确保创建 `tsconfig.json` ```bash npx tsc --init ``` 不要忘记更新 `tsconfig.json`,将 `compilerOptions.strict` 设置为 `true`: ```json { "compilerOptions": { "strict": true } } ``` ::: warning 如果您在没有 TypeScript 的情况下使用 Elysia,您可能会错过一些功能,比如自动补全、先进的类型检查和端到端的类型安全,这些都是 Elysia 的核心功能。 ::: 要使用 JavaScript 创建一个新的 Elysia 应用,首先安装 Elysia: ::: code-group ```bash [pnpm] bun add elysia @elysiajs/node ``` ```bash [pnpm] pnpm add elysia @elysiajs/node ``` ```bash [npm] npm install elysia @elysiajs/node ``` ```bash [yarn] yarn add elysia @elysiajs/node ``` ::: 这将安装 Elysia 和 TypeScript。 创建一个新文件 `src/index.ts` 并添加以下代码: ```javascript import { Elysia } from 'elysia' import { node } from '@elysiajs/node' const app = new Elysia({ adapter: node() }) .get('/', () => 'Hello Elysia') .listen(3000, ({ hostname, port }) => { console.log( `🦊 Elysia 正在运行在 ${hostname}:${port}` ) }) ``` 打开你的 `package.json` 文件并添加以下脚本: ```json { "type": "module", "scripts": { "dev": "node src/index.ts", "start": "NODE_ENV=production node src/index.js" } } ``` 这些脚本适用于应用程序开发的不同阶段: * **dev** - 在开发模式下启动 Elysia,并在代码更改时自动重新加载。 * **start** - 启动 Elysia 生产服务器。 确保创建 `tsconfig.json` ```bash npx tsc --init ``` 不要忘记更新 `tsconfig.json`,将 `compilerOptions.strict` 设置为 `true`: ```json { "compilerOptions": { "strict": true } } ``` Elysia 是一个符合 WinterCG 标准的库,这意味着如果一个框架或运行时支持 Web 标准的请求/响应,它就可以运行 Elysia。 首先,使用下面的命令安装 Elysia: ::: code-group ```bash [bun] bun install elysia ``` ```bash [pnpm] pnpm install elysia ``` ```bash [npm] npm install elysia ``` ```bash [yarn] yarn add elysia ``` ::: 接下来,选择一个支持 Web 标准请求/响应的运行时。 我们有一些推荐: ### 没在列表上? 如果您使用自定义运行时,您可以访问 `app.fetch` 手动处理请求和响应。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/', () => 'Hello Elysia') .listen(3000) export default app.fetch console.log( `🦊 Elysia 正在运行在 ${app.server?.hostname}:${app.server?.port}` ) ``` ## 下一步 我们建议你查看以下之一: 如果你有任何问题,欢迎在我们的 [Discord](https://discord.gg/eaFJ2KDJck) 社区询问。 --- --- url: 'https://elysia.cndocs.org/essential/plugin.md' --- # 插件 插件是一种将功能解耦为更小部分的设计模式。为我们的 Web 服务器创建可重用的组件。 要创建插件,就是创建一个单独的实例。 ```typescript twoslash 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) ``` 我们可以通过将实例传递给 **Elysia.use** 来使用插件。 插件将继承插件实例的所有属性,如 `state`、`decorate`,但 **不会继承插件生命周期**,因为它默认是 [隔离的](#scope)。 Elysia 还会自动处理类型推断。 ## 插件 每个 Elysia 实例都可以作为一个插件。 我们将逻辑解耦到一个单独的 Elysia 实例中,并在多个实例中重用它。 要创建插件,只需在单独的文件中定义一个实例: ```typescript twoslash // plugin.ts import { Elysia } from 'elysia' export const plugin = new Elysia() .get('/plugin', () => 'hi') ``` 然后我们将实例导入到主文件中: ```typescript import { Elysia } from 'elysia' import { plugin } from './plugin' // [!code ++] const app = new Elysia() .use(plugin) // [!code ++] .listen(3000) ``` ## 作用域 Elysia 生命周期方法 **仅封装在其自身实例中**。 这意味着如果你创建一个新实例,它不会与其他实例共享生命周期方法。 ```ts import { Elysia } from 'elysia' const profile = new Elysia() .onBeforeHandle(({ cookie }) => { throwIfNotSignIn(cookie) }) .get('/profile', () => 'Hi there!') const app = new Elysia() .use(profile) // ⚠️ 这将没有登录检查 .patch('/rename', ({ body }) => updateProfile(body)) ``` 在这个例子中,`isSignIn` 检查仅适用于 `profile`,而不适用于 `app`。 > 尝试在 URL 栏中更改路径为 **/rename** 并查看结果 **Elysia 默认隔离生命周期**,除非明确指定。这类似于 JavaScript 中的 **export**,你需要导出函数才能在模块外部使用它。 要将生命周期 **“导出”** 到其他实例,必须指定作用域。 ```ts import { Elysia } from 'elysia' const profile = new Elysia() .onBeforeHandle( { as: 'global' }, // [!code ++] ({ cookie }) => { throwIfNotSignIn(cookie) } ) .get('/profile', () => 'Hi there!') const app = new Elysia() .use(profile) // 这有登录检查 .patch('/rename', ({ body }) => updateProfile(body)) ``` 将生命周期转换为 **"global"** 将导出生命周期到 **每个实例**。 ### 作用域级别 Elysia 有 3 个作用域级别,如下所示: 作用域类型如下: 1. **local** (默认) - 仅适用于当前实例及其后代 2. **scoped** - 适用于父实例、当前实例及其后代 3. **global** - 适用于应用该插件的所有实例(所有父实例、当前实例及其后代) 让我们通过以下示例回顾每种作用域类型的作用: ```typescript import { Elysia } from 'elysia' const child = new Elysia() .get('/child', 'hi') const current = new Elysia() // ? 根据下表提供的值 .onBeforeHandle({ as: 'local' }, () => { // [!code ++] 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') ``` 通过更改 `type` 值,结果应如下所示: | 类型 | child | current | parent | main | | ---------- | ----- | ------- | ------ | ---- | | local | ✅ | ✅ | ❌ | ❌ | | scoped | ✅ | ✅ | ✅ | ❌ | | global | ✅ | ✅ | ✅ | ✅ | ### 后代 默认情况下,插件将 **仅将钩子应用到自身及其后代**。 如果钩子在插件中注册,继承该插件的实例将 **不会** 继承钩子和模式。 ```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') ``` 要全局应用钩子,我们需要将钩子指定为 global。 ```typescript 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') ``` ## 配置 为了使插件更有用,推荐允许通过配置进行自定义。 你可以创建一个接受参数的函数,这些参数可能会改变插件的行为,使其更具可重用性。 ```typescript import { Elysia } from 'elysia' const version = (version = 1) => new Elysia() .get('/version', version) const app = new Elysia() .use(version(1)) .listen(3000) ``` ### 函数回调 推荐定义一个新的插件实例,而不是使用函数回调。 函数回调允许我们访问主实例的现有属性。例如,检查特定路由或存储是否存在,但正确处理封装和作用域会更难。 要定义函数回调,请创建一个接受 Elysia 作为参数的函数。 ```typescript twoslash 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) ``` 一旦传递给 `Elysia.use`,函数回调的行为就像普通插件一样,只是属性直接分配给主实例。 ::: tip 您不必担心函数回调和创建实例之间的性能差异。 Elysia 可以在几毫秒内创建 10k 个实例,新的 Elysia 实例甚至比函数回调具有更好的类型推断性能。 ::: ## 插件去重 默认情况下,Elysia 将注册任何插件并处理类型定义。 某些插件可能被多次使用以提供类型推断,导致初始值或路由的重复。 Elysia 通过使用 **name** 和 **可选种子** 来区分实例,从而避免这种情况,以帮助 Elysia 识别实例重复: ```typescript import { Elysia } from 'elysia' const plugin = (config: { prefix: T }) => new Elysia({ name: 'my-plugin', // [!code ++] seed: config, // [!code ++] }) .get(`${config.prefix}/hi`, () => 'Hi') const app = new Elysia() .use( plugin({ prefix: '/v2' }) ) .listen(3000) ``` Elysia 将使用 **name** 和 **seed** 创建校验和,以识别实例是否之前已注册,如果是,则 Elysia 将跳过插件的注册。 如果未提供 seed,Elysia 将仅使用 **name** 来区分实例。这意味着即使您多次注册插件,它也只注册一次。 ```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) ``` 这允许 Elysia 通过重用已注册的插件而不是反复处理插件来提高性能。 ::: tip Seed 可以是任何东西,从字符串到复杂对象或类。 如果提供的值是类,Elysia 将尝试使用 `.toString` 方法生成校验和。 ::: ### 服务定位器 当您将带有 state/decorators 的插件应用到实例时,该实例将获得类型安全性。 但如果您未将插件应用到另一个实例,它将无法推断类型。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' const child = new Elysia() // ❌ 'a' is missing .get('/', ({ a }) => a) const main = new Elysia() .decorate('a', 'a') .use(child) ``` Elysia 引入 **Service Locator** 模式来对抗这种情况。 Elysia 将查找插件校验和并获取值或注册一个新值。从插件推断类型。 因此,我们必须提供插件引用,以便 Elysia 找到服务以添加类型安全性。 ```typescript twoslash // @errors: 2339 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) // With `setup`, type will be inferred const child = new Elysia() .use(setup) // [!code ++] .get('/', ({ a }) => a) // ^? const main = new Elysia() .use(child) ``` ## 防护 Guard 允许我们一次性将钩子和模式应用到多个路由。 ```typescript twoslash const signUp = (a: T) => a const signIn = (a: T) => a const isUserExists = (a: T) => a // ---cut--- import { Elysia, t } from 'elysia' new Elysia() .guard( { // [!code ++] body: t.Object({ // [!code ++] username: t.String(), // [!code ++] password: t.String() // [!code ++] }) // [!code ++] }, // [!code ++] (app) => // [!code ++] app .post('/sign-up', ({ body }) => signUp(body)) .post('/sign-in', ({ body }) => signIn(body), { // ^? beforeHandle: isUserExists }) ) .get('/', 'hi') .listen(3000) ``` 这段代码将验证应用于 '/sign-in' 和 '/sign-up' 的 `body`,而不是逐一内联模式,但不应用于 '/'。 我们可以将路由验证总结如下: | 路径 | 是否有验证 | | ------- | ------------- | | /sign-up | ✅ | | /sign-in | ✅ | | / | ❌ | Guard 接受与内联钩子相同的参数,唯一区别是你可以将钩子应用到作用域内的多个路由。 这意味着上面的代码被翻译为: ```typescript twoslash const signUp = (a: T) => a const signIn = (a: T) => a const isUserExists = (a: any) => a // ---cut--- 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) ``` ### 分组 Guard 我们可以通过向 group 提供 3 个参数来使用带有前缀的分组。 1. 前缀 - 路由前缀 2. Guard - 模式 3. 作用域 - Elysia 应用回调 使用与 guard 相同的 API 应用于第二个参数,而不是将 group 和 guard 嵌套在一起。 考虑以下示例: ```typescript twoslash 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) ``` 从嵌套的分组 guard 开始,我们可以通过将 guard 作用域提供给 group 的第二个参数来合并 group 和 guard: ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .group( '/v1', (app) => app.guard( // [!code --] { body: t.Literal('Rikuhachima Aru') }, (app) => app.post('/student', ({ body }) => body) ) // [!code --] ) .listen(3000) ``` 结果语法如下: ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .group( '/v1', { body: t.Literal('Rikuhachima Aru') }, (app) => app.post('/student', ({ body }) => body) // ^? ) .listen(3000) ``` ## 作用域转换 高级概念 要将钩子应用到父实例,可以使用以下之一: 1. [内联 as](#inline-as) 仅应用于单个钩子 2. [guard as](#guard-as) 应用于 guard 中的所有钩子 3. [实例 as](#instance-as) 应用于实例中的所有钩子 ### 内联 每个事件监听器都会接受 `as` 参数来指定钩子的作用域。 ```typescript twoslash import { Elysia } from 'elysia' const plugin = new Elysia() .derive({ as: 'scoped' }, () => { // [!code ++] return { hi: 'ok' } }) .get('/child', ({ hi }) => hi) const main = new Elysia() .use(plugin) // ✅ Hi 现在可用 .get('/parent', ({ hi }) => hi) ``` 但是,这种方法仅适用于单个钩子,对于多个钩子可能不合适。 ### Guard as 每个事件监听器都会接受 `as` 参数来指定钩子的作用域。 ```typescript import { Elysia, t } from 'elysia' const plugin = new Elysia() .guard({ as: 'scoped', // [!code ++] response: t.String(), beforeHandle() { console.log('ok') } }) .get('/child', 'ok') const main = new Elysia() .use(plugin) .get('/parent', 'hello') ``` Guard 允许我们一次性将 `schema` 和 `hook` 应用到多个路由,同时指定作用域。 但是,它不支持 `derive` 和 `resolve` 方法。 ### 实例 as `as` 将读取当前实例的所有钩子和模式作用域,并修改。 ```typescript twoslash import { Elysia } from 'elysia' const plugin = new Elysia() .derive(() => { return { hi: 'ok' } }) .get('/child', ({ hi }) => hi) .as('scoped') // [!code ++] const main = new Elysia() .use(plugin) // ✅ Hi 现在可用 .get('/parent', ({ hi }) => hi) ``` 有时我们想将插件重新应用到父实例,但由于 `scoped` 机制的限制,它仅限于 1 个父实例。 要应用到父实例,我们需要将作用域 **提升** 到父实例,`as` 是实现这一点的完美方法。 这意味着如果你有 `local` 作用域,并想将其应用到父实例,你可以使用 `as('scoped')` 来提升它。 ```typescript twoslash // @errors: 2304 2345 import { Elysia, t } from 'elysia' const plugin = new Elysia() .guard({ response: t.String() }) .onBeforeHandle(() => { console.log('called') }) .get('/ok', () => 'ok') .get('/not-ok', () => 1) .as('scoped') // [!code ++] const instance = new Elysia() .use(plugin) .get('/no-ok-parent', () => 2) .as('scoped') // [!code ++] const parent = new Elysia() .use(instance) // 现在错误,因为 `scoped` 被提升到父实例 .get('/ok', () => 3) ``` ## 懒加载 模块默认是急切加载的。 Elysia 将确保所有模块在服务器启动前注册。 但是,一些模块可能计算密集或阻塞,导致服务器启动缓慢。 为了解决这个问题,Elysia 允许您提供一个异步插件,它不会阻塞服务器启动。 ### 延迟模块 延迟模块是一个异步插件,可以在服务器启动后注册。 ```typescript // 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 } ``` 在主文件中: ```typescript import { Elysia } from 'elysia' import { loadStatic } from './plugin' const app = new Elysia() .use(loadStatic) ``` ### 懒加载模块 与异步插件相同,懒加载模块将在服务器启动后注册。 懒加载模块可以是同步或异步函数,只要使用 `import` 导入模块,它就会被懒加载。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .use(import('./plugin')) ``` 当模块计算密集和/或阻塞时,推荐使用模块懒加载。 要确保模块在服务器启动前注册,我们可以使用 `await` 于延迟模块。 ### 测试 在测试环境中,我们可以使用 `await app.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') }) }) ``` --- --- url: 'https://elysia.cndocs.org/plugins/overview.md' --- # 概述 Elysia 旨在实现模块化和轻量化。 遵循与 Arch Linux 相同的理念(顺便说一句,我使用 Arch): > 设计决策通过开发者共识逐案作出 这确保了开发者最终得到他们所希望创建的高性能 Web 服务器。由此,Elysia 包含了预构建的常见模式插件,以方便开发者使用: ## 官方插件: * [Bearer](/plugins/bearer) - 自动获取 [Bearer](https://swagger.io/docs/specification/authentication/bearer-authentication/) 令牌 * [CORS](/plugins/cors) - 设置 [跨域资源共享 (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) * [Cron](/plugins/cron) - 设置 [cron](https://en.wikipedia.org/wiki/Cron) 任务 * [Eden](/eden/overview) - Elysia 的端到端类型安全客户端 * [GraphQL Apollo](/plugins/graphql-apollo) - 在 Elysia 上运行 [Apollo GraphQL](https://www.apollographql.com/) * [GraphQL Yoga](/plugins/graphql-yoga) - 在 Elysia 上运行 [GraphQL Yoga](https://github.com/dotansimha/graphql-yoga) * [HTML](/plugins/html) - 处理 HTML 响应 * [JWT](/plugins/jwt) - 使用 [JWTs](https://jwt.io/) 进行身份验证 * [OpenAPI](/plugins/openapi) - 生成 [OpenAPI](https://swagger.io/specification/) 文档 * [OpenTelemetry](/plugins/opentelemetry) - 添加 OpenTelemetry 支持 * [Server Timing](/plugins/server-timing) - 使用 [Server-Timing API](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing) 审计性能瓶颈 * [Static](/plugins/static) - 提供静态文件/文件夹服务 * [Stream](/plugins/stream) - 集成响应流和 [服务器发送事件 (SSEs)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) * [WebSocket](/patterns/websocket) - 支持 [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) ## 社区插件: * [Create ElysiaJS](https://github.com/kravetsone/create-elysiajs) - 轻松搭建您的 Elysia 项目环境(支持 ORM、代码规范和插件)! * [Lucia Auth](https://github.com/pilcrowOnPaper/lucia) - 简洁的身份验证 * [Elysia Clerk](https://github.com/wobsoriano/elysia-clerk) - 非官方 Clerk 身份验证插件 * [Elysia Polyfills](https://github.com/bogeychan/elysia-polyfills) - 在 Node.js 和 Deno 上运行 Elysia 生态系统 * [Vite server](https://github.com/kravetsone/elysia-vite-server) - 启动并装饰 `vite` 开发服务器的插件,在“开发”模式下运行,在“生产”模式下提供静态文件(如果需要) * [Vite](https://github.com/timnghg/elysia-vite) - 提供注入了 Vite 脚本的入口 HTML 文件 * [Nuxt](https://github.com/trylovetom/elysiajs-nuxt) - 轻松将 Elysia 与 Nuxt 集成! * [Remix](https://github.com/kravetsone/elysia-remix) - 使用支持 `HMR` 的 [Remix](https://remix.run/)(由 [`vite`](https://vitejs.dev/) 提供支持)!解决了一个长期请求的插件[#12](https://github.com/elysiajs/elysia/issues/12) * [Sync](https://github.com/johnny-woodtke/elysiajs-sync) - 由 [Dexie.js](https://dexie.org/) 驱动的轻量级离线优先数据同步框架 * [Connect middleware](https://github.com/kravetsone/elysia-connect-middleware) - 允许你直接在 Elysia 中使用 [`express`](https://www.npmjs.com/package/express)/[`connect`](https://www.npmjs.com/package/connect) 中间件的插件! * [Elysia HTTP Exception](https://github.com/codev911/elysia-http-exception) - Elysia 插件,用于处理 HTTP 4xx/5xx 错误,提供结构化异常类 * [Elysia Helmet](https://github.com/DevTobias/elysia-helmet) - 通过各种 HTTP 头保护 Elysia 应用安全 * [Vite Plugin SSR](https://github.com/timnghg/elysia-vite-plugin-ssr) - 基于 Elysia 服务器的 Vite SSR 插件 * [OAuth 2.0](https://github.com/kravetsone/elysia-oauth2) - 支持超过 **42** 个提供者并具备 **类型安全** 的 [OAuth 2.0](https://en.wikipedia.org/wiki/OAuth) 授权流程插件! * [OAuth2](https://github.com/bogeychan/elysia-oauth2) - 处理 OAuth 2.0 授权代码流程 * [OAuth2 Resource Server](https://github.com/ap-1/elysia-oauth2-resource-server) - 通过 JWKS 端点验证 OAuth2 提供者的 JWT 令牌的插件,支持验证发行者、受众和权限 * [Elysia OpenID Client](https://github.com/macropygia/elysia-openid-client) - 基于 [openid-client](https://github.com/panva/node-openid-client) 的 OpenID 客户端 * [Rate Limit](https://github.com/rayriffy/elysia-rate-limit) - 简单轻量的速率限制器 * [Logysia](https://github.com/tristanisham/logysia) - 经典日志中间件 * [Logestic](https://github.com/cybercoder-naj/logestic) - 为 ElysiaJS 设计的高级且可定制的日志库 * [Logger](https://github.com/bogeychan/elysia-logger) - 基于 [pino](https://github.com/pinojs/pino) 的日志中间件 * [Elylog](https://github.com/eajr/elylog) - 简易的标准输出日志库,支持一定的自定义 * [Logify for Elysia.js](https://github.com/0xrasla/logify) - 为 Elysia.js 应用提供美观、快速且类型安全的日志中间件 * [Nice Logger](https://github.com/tanishqmanuja/nice-logger) - 不是最漂亮,但非常不错和简洁的 Elysia 日志器 * [Sentry](https://github.com/johnny-woodtke/elysiajs-sentry) - 使用 [Sentry](https://docs.sentry.io/) 捕获跟踪和错误的插件 * [Elysia Lambda](https://github.com/TotalTechGeek/elysia-lambda) - 在 AWS Lambda 上部署 * [Decorators](https://github.com/gaurishhs/elysia-decorators) - 使用 TypeScript 装饰器 * [Autoload](https://github.com/kravetsone/elysia-autoload) - 基于目录结构的文件系统路由器,支持为 [Eden](https://elysiajs.com/eden/overview.html) 生成类型,且支持 [`Bun.build`](https://github.com/kravetsone/elysia-autoload?tab=readme-ov-file#bun-build-usage) * [Msgpack](https://github.com/kravetsone/elysia-msgpack) - 支持操作 [MessagePack](https://msgpack.org) * [XML](https://github.com/kravetsone/elysia-xml) - 支持处理 XML * [Autoroutes](https://github.com/wobsoriano/elysia-autoroutes) - 文件系统路由 * [Group Router](https://github.com/itsyoboieltr/elysia-group-router) - 基于文件夹的分组路由器 * [Basic Auth](https://github.com/itsyoboieltr/elysia-basic-auth) - 基础 HTTP 认证 * [ETag](https://github.com/bogeychan/elysia-etag) - 自动生成 HTTP [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) * [CDN Cache](https://github.com/johnny-woodtke/elysiajs-cdn-cache) - Elysia 的 Cache-Control 插件 - 无需手动设置 HTTP 头 * [Basic Auth](https://github.com/eelkevdbos/elysia-basic-auth) - 基础 HTTP 认证(使用 `request` 事件) * [i18n](https://github.com/eelkevdbos/elysia-i18next) - 基于 [i18next](https://www.i18next.com/) 的本地化封装 * [Elysia Request ID](https://github.com/gtramontina/elysia-requestid) - 添加/转发请求 ID(`X-Request-ID` 或自定义) * [Elysia HTMX](https://github.com/gtramontina/elysia-htmx) - 提供针对 [HTMX](https://htmx.org/) 的上下文辅助 * [Elysia HMR HTML](https://github.com/gtrabanco/elysia-hmr-html) - 在目录中任意文件变更时重新加载 HTML 文件 * [Elysia Inject HTML](https://github.com/gtrabanco/elysia-inject-html) - 向 HTML 文件注入 HTML 代码 * [Elysia HTTP Error](https://github.com/yfrans/elysia-http-error) - 从 Elysia 处理程序返回 HTTP 错误 * [Elysia Http Status Code](https://github.com/sylvain12/elysia-http-status-code) - 集成 HTTP 状态码 * [NoCache](https://github.com/gaurishhs/elysia-nocache) - 禁用缓存 * [Elysia Tailwind](https://github.com/gtramontina/elysia-tailwind) - 在插件中编译 [Tailwindcss](https://tailwindcss.com/) * [Elysia Compression](https://github.com/gusb3ll/elysia-compression) - 压缩响应 * [Elysia IP](https://github.com/gaurishhs/elysia-ip) - 获取 IP 地址 * [OAuth2 Server](https://github.com/myazarc/elysia-oauth2-server) - 使用 Elysia 开发 OAuth2 服务器 * [Elysia Flash Messages](https://github.com/gtramontina/elysia-flash-messages) - 启用闪存消息 * [Elysia AuthKit](https://github.com/gtramontina/elysia-authkit) - 非官方 [WorkOS 的 AuthKit](https://www.authkit.com/) 认证支持 * [Elysia Error Handler](https://github.com/gtramontina/elysia-error-handler) - 更简洁的错误处理 * [Elysia env](https://github.com/yolk-oss/elysia-env) - 使用 typebox 的类型安全环境变量 * [Elysia Drizzle Schema](https://github.com/Edsol/elysia-drizzle-schema) - 帮助在 Elysia OpenAPI 模型中使用 Drizzle ORM 模式 * [Unify-Elysia](https://github.com/qlaffont/unify-elysia) - 统一 Elysia 的错误代码 * [Unify-Elysia-GQL](https://github.com/qlaffont/unify-elysia-gql) - 统一 Elysia GraphQL 服务器(Yoga & Apollo)的错误代码 * [Elysia Auth Drizzle](https://github.com/qlaffont/elysia-auth-drizzle) - 使用 JWT(Header/Cookie/QueryParam)处理身份验证的库 * [graceful-server-elysia](https://github.com/qlaffont/graceful-server-elysia) - 受 [graceful-server](https://github.com/gquittet/graceful-server) 启发的库 * [Logixlysia](https://github.com/PunGrumpy/logixlysia) - 带颜色和时间戳的美观简洁的 ElysiaJS 日志中间件 * [Elysia Fault](https://github.com/vitorpldev/elysia-fault) - 简单且可定制的错误处理中间件,支持自定义 HTTP 错误 * [Elysia Compress](https://github.com/vermaysha/elysia-compress) - 受 [@fastify/compress](https://github.com/fastify/fastify-compress) 启发的 ElysiaJS 响应压缩插件 * [@labzzhq/compressor](https://github.com/labzzhq/compressor/) - 适用于 Elysia 和 Bunnyhop 的 HTTP 压缩器,支持 gzip、deflate 和 brotli * [Elysia Accepts](https://github.com/morigs/elysia-accepts) - 用于解析 Accept 头和内容协商的 Elysia 插件 * [Elysia Compression](https://github.com/chneau/elysia-compression) - 用于压缩响应的 Elysia 插件 * [Elysia Logger](https://github.com/chneau/elysia-logger) - 受 [hono/logger](https://hono.dev/docs/middleware/builtin/logger) 启发的 Elysia HTTP 请求和响应日志插件 * [Elysia CQRS](https://github.com/jassix/elysia-cqrs) - 适用于 CQRS 模式的 Elysia 插件 * [Elysia Supabase](https://github.com/mastermakrela/elysia-supabase) - 无缝集成 [Supabase](https://supabase.com/) 身份验证和数据库功能,轻松访问认证用户数据和 Supabase 客户端实例,特别适用于 [边缘函数](https://supabase.com/docs/guides/functions) * [Elysia XSS](https://www.npmjs.com/package/elysia-xss) - 为 Elysia.js 提供的 XSS(跨站脚本攻击)防护插件,通过清理请求体数据实现 * [Elysiajs Helmet](https://www.npmjs.com/package/elysiajs-helmet) - 为 Elysia.js 应用提供的全面安全中间件,设置多种 HTTP 头以增强安全性 * [Decorators for Elysia.js](https://github.com/Ateeb-Khan-97/better-elysia) - 通过这个小型库无缝开发并集成 API、Websocket 和流式 API * [Elysia Protobuf](https://github.com/ilyhalight/elysia-protobuf) - 支持 Elysia 的 protobuf * [Elysia Prometheus](https://github.com/m1handr/elysia-prometheus) - 用于暴露 Prometheus HTTP 指标的 Elysia 插件 * [Elysia Remote DTS](https://github.com/rayriffy/elysia-remote-dts) - 为 Eden Treaty 提供远程 .d.ts 类型的插件 * [Cap Checkpoint plugin for Elysia](https://capjs.js.org/guide/middleware/elysia.html) - 类似 Cloudflare 的 Cap 中间件,一个基于 SHA-256 PoW 设计的轻量级现代开源 CAPTCHA 替代方案 * [Elysia Background](https://github.com/staciax/elysia-background) - Elysia.js 的后台任务处理插件 * [@fedify/elysia](https://github.com/fedify-dev/fedify/tree/main/packages/elysia) - 与 [Fedify](https://fedify.dev/)(ActivityPub 服务器框架)无缝集成的插件 ## 相关项目: * [prismabox](https://github.com/m1212e/prismabox) - 基于您的数据库模型生成 typebox 模式的生成器,适用于 Elysia *** 如果您为 Elysia 编写了一个插件,请随时通过 **点击下面的 在 GitHub 上编辑此页面** 将您的插件添加到列表中 👇 --- --- url: 'https://elysia.cndocs.org/tutorial.md' --- # Elysia 教程 我们将构建一个小型的 CRUD 笔记 API 服务器。 没有数据库或其他“生产就绪”功能。本教程仅关注 Elysia 的功能以及如何使用 Elysia。 预计如果跟随教程,大约需要 15-20 分钟。 *** ### 不喜欢教程? 如果你更喜欢“自己试试”的方法,可以跳过本教程,直接转到 [关键概念](/key-concept) 页面,以更好地理解 Elysia 的工作原理。 ### 来自其他框架? 如果你使用过其他流行框架,如 Express、Fastify 或 Hono,你会发现 Elysia 非常熟悉,只有一点差异。 ### llms.txt 或者,你可以下载 llms.txt 或 llms-full.txt,并将其输入到你喜欢的 LLM,如 ChatGPT、Claude 或 Gemini,以获得更互动的体验。 ## 设置 Elysia 设计用于在 [Bun](https://bun.sh) 上运行,这是 Node.js 的替代运行时,但它也可以在 Node.js 或任何支持 Web 标准 API 的运行时上运行。 但是,在本教程中,我们将使用 Bun。 如果你还没有安装 Bun,请安装它。 ::: code-group ```bash [MacOS/Linux] curl -fsSL https://bun.sh/install | bash ``` ```bash [Windows] powershell -c "irm bun.sh/install.ps1 | iex" ``` ::: ### 创建新项目 ```bash # 创建新项目 bun create elysia hi-elysia # 进入项目 cd hi-elysia # 安装依赖 bun install ``` 这将创建一个带有 Elysia 和基本 TypeScript 配置的骨架项目。 ### 启动开发服务器 ```bash bun dev ``` 打开浏览器并访问 **http://localhost:3000**,你应该看到屏幕上显示 **Hello Elysia** 消息。 Elysia 使用 Bun 的 `--watch` 标志,在你进行更改时自动重新加载服务器。 ## 路由 要添加新路由,我们指定 HTTP 方法、路径名和值。 让我们从打开 `src/index.ts` 文件开始,如下所示: ```typescript [index.ts] import { Elysia } from 'elysia' const app = new Elysia() .get('/', () => 'Hello Elysia') .get('/hello', 'Do you miss me?') // [!code ++] .listen(3000) ``` 打开 **http://localhost:3000/hello**,你应该看到 **Do you miss me?**。 我们可以使用几种 HTTP 方法,但本教程将使用以下方法: * get * post * put * patch * delete 其他方法可用,使用与 `get` 相同的语法 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/', () => 'Hello Elysia') .get('/hello', 'Do you miss me?') // [!code --] .post('/hello', 'Do you miss me?') // [!code ++] .listen(3000) ``` Elysia 接受值和函数作为响应。 但是,我们可以使用函数来访问 `Context`(路由和实例信息)。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/', () => 'Hello Elysia') // [!code --] .get('/', ({ path }) => path) // [!code ++] .post('/hello', 'Do you miss me?') .listen(3000) ``` ## OpenAPI 在浏览器中输入 URL 只能与 GET 方法交互。要与其他方法交互,我们需要像 Postman 或 Insomnia 这样的 REST 客户端。 幸运的是,Elysia 带有 **OpenAPI Schema** 和 [Scalar](https://scalar.com),用于与我们的 API 交互。 ```bash # 安装 OpenAPI 插件 bun add @elysiajs/openapi ``` 然后将插件应用到 Elysia 实例。 ```typescript import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' const app = new Elysia() // 应用 openapi 插件 .use(openapi()) // [!code ++] .get('/', ({ path }) => path) .post('/hello', 'Do you miss me?') .listen(3000) ``` 导航到 **http://localhost:3000/openapi**,你应该看到如下文档: ![Scalar 文档登陆页](/tutorial/scalar-landing.webp) 现在我们可以与我们创建的所有路由交互。 滚动到 **/hello** 并点击蓝色 **Test Request** 按钮以显示表单。 通过点击黑色 **Send** 按钮查看结果。 ![Scalar 文档请求](/tutorial/scalar-request.webp) ## Decorate 但是,对于更复杂的数据,我们可能想要使用类来处理复杂数据,因为它允许我们定义自定义方法和属性。 现在,让我们创建一个单例类来存储我们的笔记。 ```typescript import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' class Note { // [!code ++] constructor(public data: string[] = ['Moonhalo']) {} // [!code ++] } // [!code ++] const app = new Elysia() .use(openapi()) .decorate('note', new Note()) // [!code ++] .get('/note', ({ note }) => note.data) // [!code ++] .listen(3000) ``` `decorate` 允许我们将单例类注入到 Elysia 实例中,从而允许我们在路由处理程序中访问它。 打开 **http://localhost:3000/note**,我们应该在屏幕上看到 **\["Moonhalo"]**。 对于 Scalar 文档,我们可能需要重新加载页面以查看新更改。 ![Scalar 文档 Moonhalo](/tutorial/scalar-moonhalo.webp) ## 路径参数 现在让我们通过其索引检索笔记。 我们可以通过在冒号前缀定义路径参数。 ```typescript twoslash // @errors: 7015 import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' class Note { constructor(public data: string[] = ['Moonhalo']) {} } const app = new Elysia() .use(openapi()) .decorate('note', new Note()) .get('/note', ({ note }) => note.data) .get('/note/:index', ({ note, params: { index } }) => { // [!code ++] return note.data[index] // [!code ++] }) // [!code ++] .listen(3000) ``` 现在忽略错误。 打开 **http://localhost:3000/note/0**,我们应该在屏幕上看到 **Moonhalo**。 路径参数允许我们从 URL 中检索特定部分。在我们的例子中,我们从 **/note/0** 中检索 **"0"** 并放入名为 **index** 的变量中。 ## 验证 上面的错误是一个警告,路径参数可以是任何字符串,而数组索引应该是数字。 例如,**/note/0** 是有效的,但 **/note/zero** 不是。 我们可以通过声明 schema 来强制和验证类型: ```typescript import { Elysia, t } from 'elysia' // [!code ++] import { openapi } from '@elysiajs/openapi' class Note { constructor(public data: string[] = ['Moonhalo']) {} } const app = new Elysia() .use(openapi()) .decorate('note', new Note()) .get('/note', ({ note }) => note.data) .get( '/note/:index', ({ note, params: { index } }) => { return note.data[index] }, { // [!code ++] params: t.Object({ // [!code ++] index: t.Number() // [!code ++] }) // [!code ++] } // [!code ++] ) .listen(3000) ``` 我们从 Elysia 导入 **t** 来为路径参数定义 schema。 现在,如果我们尝试访问 **http://localhost:3000/note/abc**,我们应该看到错误消息。 这段代码解决了我们之前看到的错误,因为 **TypeScript 警告**。 Elysia schema 不仅在运行时强制验证,还推断 TypeScript 类型以实现自动完成和提前检查错误,以及 Scalar 文档。 大多数框架只提供这些功能之一,或分别提供,需要我们单独更新每个,但 Elysia 将它们全部作为 **单一真相来源** 提供。 ### 验证类型 Elysia 为以下属性提供验证: * params - 路径参数 * query - URL 查询字符串 * body - 请求体 * headers - 请求头 * cookie - cookie * response - 响应体 它们都共享与上面示例相同的语法。 ## 状态码 默认情况下,Elysia 将为所有路由返回 200 状态码,即使响应是错误。 例如,如果我们尝试访问 **http://localhost:3000/note/1**,我们应该在屏幕上看到 **undefined**,这不应该是 200 状态码 (OK)。 我们可以通过返回错误来更改状态码 ```typescript import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' class Note { constructor(public data: string[] = ['Moonhalo']) {} } const app = new Elysia() .use(openapi()) .decorate('note', new Note()) .get('/note', ({ note }) => note.data) .get( '/note/:index', ({ note, params: { index }, status }) => { // [!code ++] return note.data[index] ?? status(404) // [!code ++] }, { params: t.Object({ index: t.Number() }) } ) .listen(3000) ``` 现在,如果我们尝试访问 **http://localhost:3000/note/1**,我们应该在屏幕上看到 **Not Found**,状态码为 404。 我们也可以通过向错误函数传递字符串返回自定义消息。 ```typescript import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' class Note { constructor(public data: string[] = ['Moonhalo']) {} } const app = new Elysia() .use(openapi()) .decorate('note', new Note()) .get('/note', ({ note }) => note.data) .get( '/note/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'oh no :(') // [!code ++] }, { params: t.Object({ index: t.Number() }) } ) .listen(3000) ``` ## 插件 主实例开始变得拥挤,我们可以将路由处理程序移动到单独的文件并作为插件导入。 创建一个名为 **note.ts** 的新文件: ::: code-group ```typescript [note.ts] import { Elysia, t } from 'elysia' class Note { constructor(public data: string[] = ['Moonhalo']) {} } export const note = new Elysia() .decorate('note', new Note()) .get('/note', ({ note }) => note.data) .get( '/note/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'oh no :(') }, { params: t.Object({ index: t.Number() }) } ) ``` ::: 然后在 **index.ts** 上,将 **note** 应用到主实例: ::: code-group ```typescript [index.ts] import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' import { note } from './note' // [!code ++] class Note { // [!code --] constructor(public data: string[] = ['Moonhalo']) {} // [!code --] } // [!code --] const app = new Elysia() .use(openapi()) .use(note) // [!code ++] .decorate('note', new Note()) // [!code --] .get('/note', ({ note }) => note.data) // [!code --] .get( // [!code --] '/note/:index', // [!code --] ({ note, params: { index }, status }) => { // [!code --] return note.data[index] ?? status(404, 'oh no :(') // [!code --] }, // [!code --] { // [!code --] params: t.Object({ // [!code --] index: t.Number() // [!code --] }) // [!code --] } // [!code --] ) // [!code --] .listen(3000) ``` ::: 打开 **http://localhost:3000/note/1**,你应该再次看到 **oh no :(**,就像之前一样。 我们刚刚创建了一个 **note** 插件,通过声明一个新的 Elysia 实例。 每个插件都是 Elysia 的单独实例,具有自己的路由、中间件和装饰器,可以应用到其他实例。 ## 应用 CRUD 我们可以应用相同的模式来创建、更新和删除路由。 ::: code-group ```typescript [note.ts] import { Elysia, t } from 'elysia' class Note { constructor(public data: string[] = ['Moonhalo']) {} add(note: string) {// [!code ++] this.data.push(note) // [!code ++] return this.data // [!code ++] } // [!code ++] remove(index: number) { // [!code ++] return this.data.splice(index, 1) // [!code ++] } // [!code ++] update(index: number, note: string) { // [!code ++] return (this.data[index] = note) // [!code ++] } // [!code ++] } export const note = new Elysia() .decorate('note', new Note()) .get('/note', ({ note }) => note.data) .put('/note', ({ note, body: { data } }) => note.add(data), { // [!code ++] body: t.Object({ // [!code ++] data: t.String() // [!code ++] }) // [!code ++] }) // [!code ++] .get( '/note/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'Not Found :(') }, { params: t.Object({ index: t.Number() }) } ) .delete( // [!code ++] '/note/:index', // [!code ++] ({ note, params: { index }, status }) => { // [!code ++] if (index in note.data) return note.remove(index) // [!code ++] return status(422) // [!code ++] }, // [!code ++] { // [!code ++] params: t.Object({ // [!code ++] index: t.Number() // [!code ++] }) // [!code ++] } // [!code ++] ) // [!code ++] .patch( // [!code ++] '/note/:index', // [!code ++] ({ note, params: { index }, body: { data }, status }) => { // [!code ++] if (index in note.data) return note.update(index, data) // [!code ++] return status(422) // [!code ++] }, // [!code ++] { // [!code ++] params: t.Object({ // [!code ++] index: t.Number() // [!code ++] }), // [!code ++] body: t.Object({ // [!code ++] data: t.String() // [!code ++] }) // [!code ++] } // [!code ++] ) // [!code ++] ``` ::: 现在让我们打开 **http://localhost:3000/openapi** 并尝试使用 CRUD 操作玩耍。 ## 分组 如果我们仔细观察,**note** 插件中的所有路由共享 **/note** 前缀。 我们可以通过声明 **prefix** 来简化这一点 ::: code-group ```typescript [note.ts] export const note = new Elysia({ prefix: '/note' }) // [!code ++] .decorate('note', new Note()) .get('/', ({ note }) => note.data) // [!code ++] .put('/', ({ note, body: { data } }) => note.add(data), { body: t.Object({ data: t.String() }) }) .get( '/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'Not Found :(') }, { params: t.Object({ index: t.Number() }) } ) .delete( '/:index', ({ note, params: { index }, status }) => { if (index in note.data) return note.remove(index) return status(422) }, { params: t.Object({ index: t.Number() }) } ) .patch( '/:index', ({ note, params: { index }, body: { data }, status }) => { if (index in note.data) return note.update(index, data) return status(422) }, { params: t.Object({ index: t.Number() }), body: t.Object({ data: t.String() }) } ) ``` ::: ## Guard 现在我们可能注意到插件中有几个路由具有 **params** 验证。 我们可以定义一个 **guard** 来将验证应用到插件中的路由。 ::: code-group ```typescript [note.ts] export const note = new Elysia({ prefix: '/note' }) .decorate('note', new Note()) .get('/', ({ note }) => note.data) .put('/', ({ note, body: { data } }) => note.add(data), { body: t.Object({ data: t.String() }) }) .guard({ // [!code ++] params: t.Object({ // [!code ++] index: t.Number() // [!code ++] }) // [!code ++] }) // [!code ++] .get( '/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'Not Found :(') }, { // [!code --] params: t.Object({ // [!code --] index: t.Number() // [!code --] }) // [!code --] } // [!code --] ) .delete( '/:index', ({ note, params: { index }, status }) => { if (index in note.data) return note.remove(index) return status(422) }, { // [!code --] params: t.Object({ // [!code --] index: t.Number() // [!code --] }) // [!code --] } // [!code --] ) .patch( '/:index', ({ note, params: { index }, body: { data }, status }) => { if (index in note.data) return note.update(index, data) return status(422) }, { params: t.Object({ // [!code --] index: t.Number() // [!code --] }), // [!code --] body: t.Object({ data: t.String() }) } ) ``` ::: 验证将应用到调用 **guard** 后的所有路由,并绑定到插件。 ## 生命周期 现在在实际使用中,我们可能想要在请求处理之前做一些事情,比如记录日志。 与其为每个路由内联 `console.log`,我们可以使用 **lifecycle** 在处理请求前后拦截它。 我们可以使用几种生命周期,但在此例中我们将使用 `onTransform`。 ::: code-group ```typescript [note.ts] export const note = new Elysia({ prefix: '/note' }) .decorate('note', new Note()) .onTransform(function log({ body, params, path, request: { method } }) { // [!code ++] console.log(`${method} ${path}`, { // [!code ++] body, // [!code ++] params // [!code ++] }) // [!code ++] }) // [!code ++] .get('/', ({ note }) => note.data) .put('/', ({ note, body: { data } }) => note.add(data), { body: t.Object({ data: t.String() }) }) .guard({ params: t.Object({ index: t.Number() }) }) .get('/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'Not Found :(') }) .delete('/:index', ({ note, params: { index }, status }) => { if (index in note.data) return note.remove(index) return status(422) }) .patch( '/:index', ({ note, params: { index }, body: { data }, status }) => { if (index in note.data) return note.update(index, data) return status(422) }, { body: t.Object({ data: t.String() }) } ) ``` ::: `onTransform` 在 **路由后但验证前** 调用,因此我们可以做一些事情,比如记录不触发 **404 未找到** 路由的请求日志。 这允许我们在处理请求之前记录它,我们可以看到请求体和路径参数。 ### 范围 默认情况下,**生命周期钩子是封装的**。钩子应用到同一实例中的路由,不应用到其他插件(不在同一插件中定义的路由)。 这意味着 `onTransform` 钩子中的日志函数不会在其他实例上调用,除非我们明确将其定义为 `scoped` 或 `global`。 ## 认证 现在我们可能想要为我们的路由添加限制,以便只有笔记的所有者才能更新或删除它。 让我们创建一个 `user.ts` 文件来处理用户认证: ```typescript [user.ts] import { Elysia, t } from 'elysia' // [!code ++] // [!code ++] export const user = new Elysia({ prefix: '/user' }) // [!code ++] .state({ // [!code ++] user: {} as Record, // [!code ++] session: {} as Record // [!code ++] }) // [!code ++] .put( // [!code ++] '/sign-up', // [!code ++] async ({ body: { username, password }, store, status }) => { // [!code ++] if (store.user[username]) // [!code ++] return status(400, { // [!code ++] success: false, // [!code ++] message: 'User already exists' // [!code ++] }) // [!code ++] // [!code ++] store.user[username] = await Bun.password.hash(password) // [!code ++] // [!code ++] return { // [!code ++] success: true, // [!code ++] message: 'User created' // [!code ++] } // [!code ++] }, // [!code ++] { // [!code ++] body: t.Object({ // [!code ++] username: t.String({ minLength: 1 }), // [!code ++] password: t.String({ minLength: 8 }) // [!code ++] }) // [!code ++] } // [!code ++] ) // [!code ++] .post( // [!code ++] '/sign-in', // [!code ++] async ({ // [!code ++] store: { user, session }, // [!code ++] status, // [!code ++] body: { username, password }, // [!code ++] cookie: { token } // [!code ++] }) => { // [!code ++] if ( // [!code ++] !user[username] || // [!code ++] !(await Bun.password.verify(password, user[username])) // [!code ++] ) // [!code ++] return status(400, { // [!code ++] success: false, // [!code ++] message: 'Invalid username or password' // [!code ++] }) // [!code ++] const key = crypto.getRandomValues(new Uint32Array(1))[0] // [!code ++] session[key] = username // [!code ++] token.value = key // [!code ++] return { // [!code ++] success: true, // [!code ++] message: `Signed in as ${username}` // [!code ++] } // [!code ++] }, // [!code ++] { // [!code ++] body: t.Object({ // [!code ++] username: t.String({ minLength: 1 }), // [!code ++] password: t.String({ minLength: 8 }) // [!code ++] }), // [!code ++] cookie: t.Cookie( // [!code ++] { // [!code ++] token: t.Number() // [!code ++] }, // [!code ++] { // [!code ++] secrets: 'seia' // [!code ++] } // [!code ++] ) // [!code ++] } // [!code ++] ) // [!code ++] ``` 现在有很多事情需要展开: 1. 我们创建了一个带有注册和登录两个路由的新实例。 2. 在实例中,我们定义了一个内存存储 `user` 和 `session` * 2.1 `user` 将保存 `username` 和 `password` 的键值对 * 2.2 `session` 将保存 `session` 和 `username` 的键值对 3. 在 `/sign-up` 中,我们使用 argon2id 插入用户名和哈希密码 4. 在 `/sign-in` 中,我们执行以下操作: * 4.1 检查用户是否存在并验证密码 * 4.2 如果密码匹配,则生成一个新会话到 `session` * 4.3 我们使用会话值设置 cookie `token` * 4.4 我们将 `secret` 附加到 cookie 以添加哈希并阻止攻击者篡改 cookie ::: tip 由于我们使用内存存储,数据会在每次重新加载或编辑代码时被清除。 我们将在教程的后面部分修复这个问题。 ::: 现在,如果我们想要检查用户是否登录,我们可以检查 `token` cookie 的值并与 `session` 存储检查。 ## 引用模型 但是,我们可以认识到 `/sign-in` 和 `/sign-up` 共享相同的 `body` 模型。 与其到处复制粘贴模型,我们可以使用 **引用模型** 通过指定名称来重用模型。 要创建 **引用模型**,我们可以使用 `.model` 并传递模型的名称和值: ```typescript [user.ts] import { Elysia, t } from 'elysia' export const user = new Elysia({ prefix: '/user' }) .state({ user: {} as Record, session: {} as Record }) .model({ // [!code ++] signIn: t.Object({ // [!code ++] username: t.String({ minLength: 1 }), // [!code ++] password: t.String({ minLength: 8 }) // [!code ++] }), // [!code ++] session: t.Cookie( // [!code ++] { // [!code ++] token: t.Number() // [!code ++] }, // [!code ++] { // [!code ++] secrets: 'seia' // [!code ++] } // [!code ++] ), // [!code ++] optionalSession: t.Cookie( { token: t.Optional(t.Number()) }, { secrets: 'seia' } ) // [!code ++] }) // [!code ++] .put( '/sign-up', async ({ body: { username, password }, store, status }) => { if (store.user[username]) return status(400, { success: false, message: 'User already exists' }) store.user[username] = await Bun.password.hash(password) return { success: true, message: 'User created' } }, { body: 'signIn' // [!code ++] } ) .post( '/sign-in', async ({ store: { user, session }, status, body: { username, password }, cookie: { token } }) => { if ( !user[username] || !(await Bun.password.verify(password, user[username])) ) return status(400, { success: false, message: 'Invalid username or password' }) const key = crypto.getRandomValues(new Uint32Array(1))[0] session[key] = username token.value = key return { success: true, message: `Signed in as ${username}` } }, { body: 'signIn', // [!code ++] cookie: 'session' // [!code ++] } ) ``` 添加模型/模型后,我们可以通过在 schema 中引用它们的名称来重用它们,而不是提供字面类型,同时提供相同的功能和类型安全。 `Elysia.model` 可以接受多个重载: 1. 提供对象,然后注册所有键值作为模型 2. 提供函数,然后访问所有先前模型并返回新模型 最后,我们可以添加 `/profile` 和 `/sign-out` 路由,如下所示: ```typescript [user.ts] import { Elysia, t } from 'elysia' export const user = new Elysia({ prefix: '/user' }) .state({ user: {} as Record, session: {} as Record }) .model({ signIn: t.Object({ username: t.String({ minLength: 1 }), password: t.String({ minLength: 8 }) }), session: t.Cookie( { token: t.Number() }, { secrets: 'seia' } ), optionalSession: t.Cookie( { token: t.Optional(t.Number()) }, { secrets: 'seia' } ) }) .put( '/sign-up', async ({ body: { username, password }, store, status }) => { if (store.user[username]) return status(400, { success: false, message: 'User already exists' }) store.user[username] = await Bun.password.hash(password) return { success: true, message: 'User created' } }, { body: 'signIn' } ) .post( '/sign-in', async ({ store: { user, session }, status, body: { username, password }, cookie: { token } }) => { if ( !user[username] || !(await Bun.password.verify(password, user[username])) ) return status(400, { success: false, message: 'Invalid username or password' }) const key = crypto.getRandomValues(new Uint32Array(1))[0] session[key] = username token.value = key return { success: true, message: `Signed in as ${username}` } }, { body: 'signIn', cookie: 'optionalSession' } ) .get( // [!code ++] '/sign-out', // [!code ++] ({ cookie: { token } }) => { // [!code ++] token.remove() // [!code ++] // [!code ++] return { // [!code ++] success: true, // [!code ++] message: 'Signed out' // [!code ++] } // [!code ++] }, // [!code ++] { // [!code ++] cookie: 'optionalSession' // [!code ++] } // [!code ++] ) // [!code ++] .get( // [!code ++] '/profile', // [!code ++] ({ cookie: { token }, store: { session }, status }) => { // [!code ++] const username = session[token.value] // [!code ++] // [!code ++] if (!username) // [!code ++] return status(401, { // [!code ++] success: false, // [!code ++] message: 'Unauthorized' // [!code ++] }) // [!code ++] // [!code ++] return { // [!code ++] success: true, // [!code ++] username // [!code ++] } // [!code ++] }, // [!code ++] { // [!code ++] cookie: 'session' // [!code ++] } // [!code ++] ) // [!code ++] ``` 由于我们将要在 `note` 中应用 `authorization`,我们将需要重复两件事: 1. 检查用户是否存在 2. 获取用户 ID(在我们的例子中是 'username') 对于 **1.**,与其使用 guard,我们可以使用 **macro**。 ## 插件去重 由于我们将在多个模块(user 和 note)中重用这个钩子,让我们提取服务(实用程序)部分并将其应用到两个模块。 ```ts [user.ts] // @errors: 2538 import { Elysia, t } from 'elysia' export const userService = new Elysia({ name: 'user/service' }) // [!code ++] .state({ // [!code ++] user: {} as Record, // [!code ++] session: {} as Record // [!code ++] }) // [!code ++] .model({ // [!code ++] signIn: t.Object({ // [!code ++] username: t.String({ minLength: 1 }), // [!code ++] password: t.String({ minLength: 8 }) // [!code ++] }), // [!code ++] session: t.Cookie( // [!code ++] { // [!code ++] token: t.Number() // [!code ++] }, // [!code ++] { // [!code ++] secrets: 'seia' // [!code ++] } // [!code ++] ), // [!code ++] optionalSession: t.Cookie( { token: t.Optional(t.Number()) }, { secrets: 'seia' } ) // [!code ++] }) // [!code ++] export const user = new Elysia({ prefix: '/user' }) .use(userService) // [!code ++] .state({ // [!code --] user: {} as Record, // [!code --] session: {} as Record // [!code --] }) // [!code --] .model({ // [!code --] signIn: t.Object({ // [!code --] username: t.String({ minLength: 1 }), // [!code --] password: t.String({ minLength: 8 }) // [!code --] }), // [!code --] session: t.Cookie( // [!code --] { // [!code --] token: t.Number() // [!code --] }, // [!code --] { // [!code --] secrets: 'seia' // [!code --] } // [!code --] ), // [!code --] optionalSession: t.Cookie( { token: t.Optional(t.Number()) }, { secrets: 'seia' } ) // [!code --] }) // [!code --] ``` 这里的 `name` 属性非常重要,因为它是插件的唯一标识符,用于防止重复实例(像单例一样)。 如果我们不带插件定义实例,钩子/生命周期和路由将每次使用插件时都被注册。 我们的意图是将这个插件(服务)应用到多个模块以提供实用函数,这使得去重非常重要,因为生命周期不应该被注册两次。 ## Macro Macro 允许我们定义自定义钩子并带有自定义生命周期管理。 要定义 macro,我们可以使用 `.macro` 如以下所示: ```ts [user.ts] import { Elysia, t } from 'elysia' export const userService = new Elysia({ name: 'user/service' }) .state({ user: {} as Record, session: {} as Record }) .model({ signIn: t.Object({ username: t.String({ minLength: 1 }), password: t.String({ minLength: 8 }) }), session: t.Cookie( { token: t.Number() }, { secrets: 'seia' } ), optionalSession: t.Cookie( { token: t.Optional(t.Number()) }, { secrets: 'seia' } ) }) .macro({ isSignIn(enabled: boolean) { // [!code ++] if (!enabled) return // [!code ++] return { beforeHandle({ status, cookie: { token }, store: { session } }) { // [!code ++] if (!token.value) // [!code ++] return status(401, { // [!code ++] success: false, // [!code ++] message: 'Unauthorized' // [!code ++] }) // [!code ++] const username = session[token.value as unknown as number] // [!code ++] if (!username) // [!code ++] return status(401, { // [!code ++] success: false, // [!code ++] message: 'Unauthorized' // [!code ++] }) // [!code ++] } // [!code ++] } // [!code ++] } // [!code ++] }) // [!code ++] ``` 我们刚刚创建了一个名为 `isSignIn` 的新 macro,它接受一个 `boolean` 值,如果为 true,则添加一个 `onBeforeHandle` 事件,在 **验证后但主处理程序前** 执行,允许我们在这里提取认证逻辑。 要使用 macro,只需指定 `isSignIn: true` 如以下所示: ```ts [user.ts] import { Elysia, t } from 'elysia' export const user = new Elysia({ prefix: '/user' }).use(userService).get( '/profile', ({ cookie: { token }, store: { session }, status }) => { const username = session[token.value] if (!username) // [!code --] return status(401, { // [!code --] success: false, // [!code --] message: 'Unauthorized' // [!code --] }) // [!code --] return { success: true, username } }, { isSignIn: true, // [!code ++] cookie: 'session' } ) ``` 由于我们指定了 `isSignIn`,我们可以提取命令式检查部分,并在多个路由上重用相同的逻辑,而无需再次复制粘贴相同的代码。 ::: tip 这可能看起来像是一个小的代码更改,以换取更大的样板代码,但随着服务器变得更加复杂,用户检查也可能成长为一个非常复杂的机制。 ::: ## Resolve 我们的最后一个目标是从 token 获取用户名 (id)。我们可以使用 `resolve` 来定义一个新属性到与 `store` 相同的上下文中,但仅每个请求执行一次。 与 `decorate` 和 `store` 不同,resolve 在 `beforeHandle` 阶段定义,否则值将在 **验证后** 可用。 这确保了像 `cookie: 'session'` 这样的属性在创建新属性之前存在。 ```ts [user.ts] export const getUserId = new Elysia() // [!code ++] .use(userService) // [!code ++] .guard({ // [!code ++] cookie: 'session' // [!code ++] }) // [!code ++] .resolve(({ store: { session }, cookie: { token } }) => ({ // [!code ++] username: session[token.value] // [!code ++] })) // [!code ++] ``` 在这个实例中,我们使用 `resolve` 定义一个新属性 `username`,允许我们将获取 `username` 逻辑减少为一个属性。 我们在这个 `getUserId` 实例中不定义名称,因为我们希望 `guard` 和 `resolve` 重新应用到多个实例。 ::: tip 与 macro 类似,如果获取属性的逻辑复杂,`resolve` 会发挥作用,对于这个像这样的小操作可能不值得。但在现实世界中,我们将需要数据库连接、缓存和队列,它可能适合这个叙述。 ::: ## 范围 现在如果我们尝试使用 `getUserId`,我们可能会注意到属性 `username` 和 `guard` 没有应用。 ```ts [user.ts] export const getUserId = new Elysia() .use(userService) .guard({ isSignIn: true, cookie: 'session' }) .resolve(({ store: { session }, cookie: { token } }) => ({ username: session[token.value] })) export const user = new Elysia({ prefix: '/user' }) .use(getUserId) .get('/profile', ({ username }) => ({ success: true, username })) ``` 这是因为 Elysia **封装生命周期** 默认这样做,如 [生命周期](#lifecycle) 中所述 这是有意设计,因为我们不希望每个模块对其他模块产生副作用。具有副作用在大型代码库中调试起来非常困难,尤其是具有多个 (Elysia) 依赖项。 如果我们希望生命周期应用到父级,我们可以明确标注它可以应用到父级,使用以下任一方式: 1. scoped - 仅应用到上一级的父级,不再进一步 2. global - 应用到所有父级 在我们的例子中,我们想要使用 **scoped**,因为它将仅应用到使用该服务的控制器。 要做到这一点,我们需要将生命周期标注为 `scoped`: ```typescript [user.ts] export const getUserId = new Elysia() .use(userService) .guard({ as: 'scoped', // [!code ++] isSignIn: true, cookie: 'session' }) .resolve( { as: 'scoped' }, // [!code ++] ({ store: { session }, cookie: { token } }) => ({ username: session[token.value] }) ) export const user = new Elysia({ prefix: '/user' }) .use(getUserId) .get('/profile', ({ username }) => ({ // ^? success: true, username })) ``` 或者,如果我们定义了多个 `scoped`,我们可以使用 `as` 来转换多个生命周期。 ```ts [user.ts] export const getUserId = new Elysia() .use(userService) .guard({ as: 'scoped', // [!code --] isSignIn: true, cookie: 'session' }) .resolve( { as: 'scoped' }, // [!code --] ({ store: { session }, cookie: { token } }) => ({ username: session[token.value] }) ) .as('scoped') // [!code ++] export const user = new Elysia({ prefix: '/user' }) .use(getUserId) .get('/profile', ({ username }) => ({ success: true, username })) ``` 两者达到相同的效果,唯一的区别是单个或多个转换实例。 ::: tip 封装发生在运行时和类型级别。这允许我们提前捕获错误。 ::: 最后,我们可以使用 `userService` 和 `getUserId` 来帮助 **note** 控制器中的授权。 但首先,不要忘记在 `index.ts` 文件中导入 `user`: ::: code-group ```typescript [index.ts] import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' import { note } from './note' import { user } from './user' // [!code ++] const app = new Elysia() .use(openapi()) .use(user) // [!code ++] .use(note) .listen(3000) ``` ::: ## 授权 首先,让我们修改 `Note` 类以存储创建笔记的用户。 但与其定义 `Memo` 类型,我们可以定义一个 memo schema 并从中推断类型,从而允许我们同步运行时和类型级别。 ```typescript [note.ts] import { Elysia, t } from 'elysia' const memo = t.Object({ // [!code ++] data: t.String(), // [!code ++] author: t.String() // [!code ++] }) // [!code ++] type Memo = typeof memo.static // [!code ++] class Note { constructor(public data: string[] = ['Moonhalo']) {} // [!code --] constructor( // [!code ++] public data: Memo[] = [ // [!code ++] { // [!code ++] data: 'Moonhalo', // [!code ++] author: 'saltyaom' // [!code ++] } // [!code ++] ] // [!code ++] ) {} // [!code ++] add(note: string) { // [!code --] add(note: Memo) { // [!code ++] this.data.push(note) return this.data } remove(index: number) { return this.data.splice(index, 1) } update(index: number, note: string) { // [!code --] return (this.data[index] = note) // [!code --] } // [!code --] update(index: number, note: Partial) { // [!code ++] return (this.data[index] = { ...this.data[index], ...note }) // [!code ++] } // [!code ++] } export const note = new Elysia({ prefix: '/note' }) .decorate('note', new Note()) .model({ // [!code ++] memo: t.Omit(memo, ['author']) // [!code ++] }) // [!code ++] .onTransform(function log({ body, params, path, request: { method } }) { console.log(`${method} ${path}`, { body, params }) }) .get('/', ({ note }) => note.data) .put('/', ({ note, body: { data } }) => note.add(data), { // [!code --] body: t.Object({ // [!code --] data: t.String() // [!code --] }), // [!code --] }) // [!code --] .put('/', ({ note, body: { data }, username }) => note.add({ data, author: username }), { // [!code ++] body: 'memo' // [!code ++] } ) // [!code ++] .guard({ params: t.Object({ index: t.Number() }) }) .get( '/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'Not Found :(') } ) .delete( '/:index', ({ note, params: { index }, status }) => { if (index in note.data) return note.remove(index) return status(422) } ) .patch( '/:index', ({ note, params: { index }, body: { data }, status }) => { // [!code --] if (index in note.data) return note.update(index, data) // [!code --] ({ note, params: { index }, body: { data }, status, username }) => { // [!code ++] if (index in note.data) // [!code ++] return note.update(index, { data, author: username })) // [!code ++] return status(422) }, { body: t.Object({ // [!code --] data: t.String() // [!code --] }), // [!code --] body: 'memo' } ) ``` 现在让我们导入并使用 `userService`、`getUserId` 来将授权应用到 **note** 控制器。 ```typescript [note.ts] import { Elysia, t } from 'elysia' import { getUserId, userService } from './user' // [!code ++] const memo = t.Object({ data: t.String(), author: t.String() }) type Memo = typeof memo.static class Note { constructor( public data: Memo[] = [ { data: 'Moonhalo', author: 'saltyaom' } ] ) {} add(note: Memo) { this.data.push(note) return this.data } remove(index: number) { return this.data.splice(index, 1) } update(index: number, note: Partial) { return (this.data[index] = { ...this.data[index], ...note }) } } export const note = new Elysia({ prefix: '/note' }) .use(userService) // [!code ++] .decorate('note', new Note()) .model({ memo: t.Omit(memo, ['author']) }) .onTransform(function log({ body, params, path, request: { method } }) { console.log(`${method} ${path}`, { body, params }) }) .get('/', ({ note }) => note.data) .use(getUserId) // [!code ++] .put( '/', ({ note, body: { data }, username }) => note.add({ data, author: username }), { body: 'memo' } ) .get( '/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'Not Found :(') }, { params: t.Object({ index: t.Number() }) } ) .guard({ params: t.Object({ index: t.Number() }) }) .delete('/:index', ({ note, params: { index }, status }) => { if (index in note.data) return note.remove(index) return status(422) }) .patch( '/:index', ({ note, params: { index }, body: { data }, status, username }) => { if (index in note.data) return note.update(index, { data, author: username }) return status(422) }, { isSignIn: true, body: 'memo' } ) ``` 就是这样 🎉 我们刚刚通过重用我们之前创建的服务实现了授权。 ## 错误处理 API 的一个最重要的方面是确保一切正常,如果出错了,我们需要正确处理它。 我们使用 `onError` 生命周期来捕获服务器中抛出的任何错误。 ::: code-group ```typescript [index.ts] import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' import { note } from './note' import { user } from './user' const app = new Elysia() .use(openapi()) .onError(({ error, code }) => { // [!code ++] if (code === 'NOT_FOUND') return // [!code ++] console.error(error) // [!code ++] }) // [!code ++] .use(user) .use(note) .listen(3000) ``` ::: 我们刚刚添加了一个错误监听器,它将捕获服务器中抛出的任何错误(排除 **404 未找到**)并将其记录到控制台。 ::: tip 注意 `onError` 在 `use(note)` 之前使用。这很重要,因为 Elysia 从上到下应用方法。监听器必须在路由之前应用。 并且由于 `onError` 应用在根实例上,它不需要定义范围,因为它将应用到所有子实例。 ::: 返回真值将覆盖默认错误响应,因此我们可以返回一个继承状态码的自定义错误响应。 ::: code-group ```typescript [index.ts] import { Elysia, t } from 'elysia' import { openapi } from '@elysiajs/openapi' import { note } from './note' const app = new Elysia() .use(openapi()) .onError(({ error, code }) => { // [!code ++] if (code === 'NOT_FOUND') return 'Not Found :(' // [!code ++] console.error(error) // [!code ++] }) // [!code ++] .use(note) .listen(3000) ``` ::: ### 可观察性 现在我们有一个工作的 API,最终一步是确保部署服务器后一切正常。 Elysia 默认支持 OpenTelemetry,使用 `@elysiajs/opentelemetry` 插件。 ```bash bun add @elysiajs/opentelemetry ``` 确保运行 OpenTelemetry 收集器,否则我们将使用 Docker 中的 Jaeger。 ```bash docker run --name jaeger \ -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 \ -e COLLECTOR_OTLP_ENABLED=true \ -p 6831:6831/udp \ -p 6832:6832/udp \ -p 5778:5778 \ -p 16686:16686 \ -p 4317:4317 \ -p 4318:4318 \ -p 14250:14250 \ -p 14268:14268 \ -p 14269:14269 \ -p 9411:9411 \ jaegertracing/all-in-one:latest ``` 现在让我们将 OpenTelemetry 插件应用到我们的服务器。 ::: code-group ```typescript [index.ts] import { Elysia, t } from 'elysia' import { opentelemetry } from '@elysiajs/opentelemetry' // [!code ++] import { openapi } from '@elysiajs/openapi' import { note } from './note' import { user } from './user' const app = new Elysia() .use(opentelemetry()) // [!code ++] .use(openapi()) .onError(({ error, code }) => { if (code === 'NOT_FOUND') return 'Not Found :(' console.error(error) }) .use(note) .use(user) .listen(3000) ``` ::: 现在尝试一些更多请求并打开 http://localhost:16686 查看跟踪。 选择服务 **Elysia** 并点击 **Find Traces**,我们应该能够看到我们所做的请求列表。 ![Jaeger 显示请求列表](/tutorial/jaeger-list.webp) 点击任何请求以查看每个生命周期钩子处理请求需要多长时间。 ![Jaeger 显示请求跨度](/tutorial/jaeger-span.webp) 点击根父跨度以查看请求细节,这将显示请求和响应负载,以及任何错误。 ![Jaeger 显示请求细节](/tutorial/jaeger-detail.webp) Elysia 开箱即用支持 OpenTelemetry,它自动与其他支持 OpenTelemetry 的 JavaScript 库集成,如 Prisma、GraphQL Yoga、Effect 等。 你也可以使用其他 OpenTelemetry 插件将跟踪发送到其他服务,如 Zipkin、Prometheus 等。 ## 代码库回顾 如果你跟随教程,你的代码库应该看起来像这样: ::: code-group ```typescript twoslash [index.ts] // @errors: 2538 // @filename: user.ts import { Elysia, t } from 'elysia' export const userService = new Elysia({ name: 'user/service' }) .state({ user: {} as Record, session: {} as Record }) .model({ signIn: t.Object({ username: t.String({ minLength: 1 }), password: t.String({ minLength: 8 }) }), session: t.Cookie( { token: t.Number() }, { secrets: 'seia' } ), optionalSession: t.Cookie( { token: t.Optional(t.Number()) }, { secrets: 'seia' } ) }) .macro({ isSignIn(enabled: boolean) { if (!enabled) return return { beforeHandle({ status, cookie: { token }, store: { session } }) { if (!token.value) return status(401, { success: false, message: 'Unauthorized' }) const username = session[token.value as unknown as number] if (!username) return status(401, { success: false, message: 'Unauthorized' }) } } } }) export const getUserId = new Elysia() .use(userService) .guard({ isSignIn: true, cookie: 'session' }) .resolve(({ store: { session }, cookie: { token } }) => ({ username: session[token.value] })) .as('scoped') export const user = new Elysia({ prefix: '/user' }) .use(userService) .put( '/sign-up', async ({ body: { username, password }, store, status }) => { if (store.user[username]) return status(400, { success: false, message: 'User already exists' }) store.user[username] = await Bun.password.hash(password) return { success: true, message: 'User created' } }, { body: 'signIn' } ) .post( '/sign-in', async ({ store: { user, session }, status, body: { username, password }, cookie: { token } }) => { if ( !user[username] || !(await Bun.password.verify(password, user[username])) ) return status(400, { success: false, message: 'Invalid username or password' }) const key = crypto.getRandomValues(new Uint32Array(1))[0] session[key] = username token.value = key return { success: true, message: `Signed in as ${username}` } }, { body: 'signIn', cookie: 'optionalSession' } ) .get( '/sign-out', ({ cookie: { token } }) => { token.remove() return { success: true, message: 'Signed out' } }, { cookie: 'optionalSession' } ) .use(getUserId) .get('/profile', ({ username }) => ({ success: true, username })) // @filename: note.ts import { Elysia, t } from 'elysia' import { getUserId, userService } from './user' const memo = t.Object({ data: t.String(), author: t.String() }) type Memo = typeof memo.static class Note { constructor( public data: Memo[] = [ { data: 'Moonhalo', author: 'saltyaom' } ] ) {} add(note: Memo) { this.data.push(note) return this.data } remove(index: number) { return this.data.splice(index, 1) } update(index: number, note: Partial) { return (this.data[index] = { ...this.data[index], ...note }) } } export const note = new Elysia({ prefix: '/note' }) .use(userService) .decorate('note', new Note()) .model({ memo: t.Omit(memo, ['author']) }) .onTransform(function log({ body, params, path, request: { method } }) { console.log(`${method} ${path}`, { body, params }) }) .get('/', ({ note }) => note.data) .use(getUserId) .put( '/', ({ note, body: { data }, username }) => note.add({ data, author: username }), { body: 'memo' } ) .get( '/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'Not Found :(') }, { params: t.Object({ index: t.Number() }) } ) .guard({ params: t.Object({ index: t.Number() }) }) .delete('/:index', ({ note, params: { index }, status }) => { if (index in note.data) return note.remove(index) return status(422) }) .patch( '/:index', ({ note, params: { index }, body: { data }, status, username }) => { if (index in note.data) return note.update(index, { data, author: username }) return status(422) }, { isSignIn: true, body: 'memo' } ) // @filename: index.ts // ---cut--- import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' import { opentelemetry } from '@elysiajs/opentelemetry' import { note } from './note' import { user } from './user' const app = new Elysia() .use(opentelemetry()) .use(openapi()) .onError(({ error, code }) => { if (code === 'NOT_FOUND') return 'Not Found :(' console.error(error) }) .use(user) .use(note) .listen(3000) ``` ```typescript twoslash [user.ts] // @errors: 2538 import { Elysia, t } from 'elysia' export const userService = new Elysia({ name: 'user/service' }) .state({ user: {} as Record, session: {} as Record }) .model({ signIn: t.Object({ username: t.String({ minLength: 1 }), password: t.String({ minLength: 8 }) }), session: t.Cookie( { token: t.Number() }, { secrets: 'seia' } ), optionalSession: t.Cookie( { token: t.Optional(t.Number()) }, { secrets: 'seia' } ) }) .macro({ isSignIn(enabled: boolean) { if (!enabled) return return { beforeHandle({ status, cookie: { token }, store: { session } }) { if (!token.value) return status(401, { success: false, message: 'Unauthorized' }) const username = session[token.value as unknown as number] if (!username) return status(401, { success: false, message: 'Unauthorized' }) } } } }) export const getUserId = new Elysia() .use(userService) .guard({ isSignIn: true, cookie: 'session' }) .resolve(({ store: { session }, cookie: { token } }) => ({ username: session[token.value] })) .as('scoped') export const user = new Elysia({ prefix: '/user' }) .use(userService) .put( '/sign-up', async ({ body: { username, password }, store, status }) => { if (store.user[username]) return status(400, { success: false, message: 'User already exists' }) store.user[username] = await Bun.password.hash(password) return { success: true, message: 'User created' } }, { body: 'signIn' } ) .post( '/sign-in', async ({ store: { user, session }, status, body: { username, password }, cookie: { token } }) => { if ( !user[username] || !(await Bun.password.verify(password, user[username])) ) return status(400, { success: false, message: 'Invalid username or password' }) const key = crypto.getRandomValues(new Uint32Array(1))[0] session[key] = username token.value = key return { success: true, message: `Signed in as ${username}` } }, { body: 'signIn', cookie: 'optionalSession' } ) .get( '/sign-out', ({ cookie: { token } }) => { token.remove() return { success: true, message: 'Signed out' } }, { cookie: 'optionalSession' } ) .use(getUserId) .get('/profile', ({ username }) => ({ success: true, username })) ``` ```typescript twoslash [note.ts] // @errors: 2538 // @filename: user.ts import { Elysia, t } from 'elysia' export const userService = new Elysia({ name: 'user/service' }) .state({ user: {} as Record, session: {} as Record }) .model({ signIn: t.Object({ username: t.String({ minLength: 1 }), password: t.String({ minLength: 8 }) }), session: t.Cookie( { token: t.Number() }, { secrets: 'seia' } ), optionalSession: t.Cookie( { token: t.Optional(t.Number()) }, { secrets: 'seia' } ) }) .macro({ isSignIn(enabled: boolean) { if (!enabled) return return { beforeHandle({ status, cookie: { token }, store: { session } }) { if (!token.value) return status(401, { success: false, message: 'Unauthorized' }) const username = session[token.value as unknown as number] if (!username) return status(401, { success: false, message: 'Unauthorized' }) } } } }) export const getUserId = new Elysia() .use(userService) .guard({ isSignIn: true, cookie: 'session' }) .resolve(({ store: { session }, cookie: { token } }) => ({ username: session[token.value] })) .as('scoped') export const user = new Elysia({ prefix: '/user' }) .use(getUserId) .get('/profile', ({ username }) => ({ success: true, username })) // @filename: note.ts // ---cut--- import { Elysia, t } from 'elysia' import { getUserId, userService } from './user' const memo = t.Object({ data: t.String(), author: t.String() }) type Memo = typeof memo.static class Note { constructor( public data: Memo[] = [ { data: 'Moonhalo', author: 'saltyaom' } ] ) {} add(note: Memo) { this.data.push(note) return this.data } remove(index: number) { return this.data.splice(index, 1) } update(index: number, note: Partial) { return (this.data[index] = { ...this.data[index], ...note }) } } export const note = new Elysia({ prefix: '/note' }) .use(userService) .decorate('note', new Note()) .model({ memo: t.Omit(memo, ['author']) }) .onTransform(function log({ body, params, path, request: { method } }) { console.log(`${method} ${path}`, { body, params }) }) .get('/', ({ note }) => note.data) .use(getUserId) .put( '/', ({ note, body: { data }, username }) => note.add({ data, author: username }), { body: 'memo' } ) .get( '/:index', ({ note, params: { index }, status }) => { return note.data[index] ?? status(404, 'Not Found :(') }, { params: t.Object({ index: t.Number() }) } ) .guard({ params: t.Object({ index: t.Number() }) }) .delete('/:index', ({ note, params: { index }, status }) => { if (index in note.data) return note.remove(index) return status(422) }) .patch( '/:index', ({ note, params: { index }, body: { data }, status, username }) => { if (index in note.data) return note.update(index, { data, author: username }) return status(422) }, { isSignIn: true, body: 'memo' } ) ``` ::: ## 为生产构建 最后,我们可以使用 `bun build` 将服务器打包成二进制文件用于生产: ```bash bun build \ --compile \ --minify-whitespace \ --minify-syntax \ --target bun \ --outfile server \ ./src/index.ts ``` 这个命令有点长,让我们分解它: 1. `--compile` - 将 TypeScript 编译成二进制 2. `--minify-whitespace` - 移除不必要的空白 3. `--minify-syntax` - 最小化 JavaScript 语法以减少文件大小 4. `--target bun` - 针对 `bun` 平台,这可以为目标平台优化二进制 5. `--outfile server` - 将二进制输出为 `server` 6. `./src/index.ts` - 服务器(代码库)的入口文件 现在我们可以使用 `./server` 运行二进制,它将以与使用 `bun dev` 相同的端口 3000 启动服务器。 ```bash ./server ``` 打开浏览器并导航到 `http://localhost:3000/openapi`,你应该看到与使用开发命令相同的结果。 通过最小化二进制,不仅使我们的服务器小而便携,我们还显著减少了它的内存使用。 ::: tip Bun 确实有 `--minify` 标志,它将最小化二进制,但是包括 `--minify-identifiers`,并且由于我们使用 OpenTelemetry,它将重命名函数名并使跟踪比应该的更难。 ::: ::: warning 练习:尝试运行开发服务器和生产服务器,并比较内存使用。 开发服务器将使用名为 'bun' 的进程,而生产服务器将使用名为 'server' 的进程。 ::: ## 总结 就是这样 🎉 我们使用 Elysia 创建了一个简单的 API,我们学习了如何创建简单的 API、如何处理错误,以及如何使用 OpenTelemetry 观察我们的服务器。 你可以进一步尝试连接到真实数据库、连接到真实前端或使用 WebSocket 实现实时通信。 本教程涵盖了创建 Elysia 服务器所需的大多数概念,但是还有其他几个有用的概念你可能想了解。 ### 如果你卡住了 如果你有任何进一步的问题,请随时在 GitHub Discussions、Discord 和 Twitter 上向我们的社区提问。 祝你在 Elysia 的旅程中一切顺利 ❤️ --- --- url: 'https://elysia.cndocs.org/plugins/server-timing.md' --- # 服务器计时插件 该插件支持通过服务器计时 API 审计性能瓶颈 安装方法: ```bash bun add @elysiajs/server-timing ``` 然后使用它: ```typescript twoslash import { Elysia } from 'elysia' import { serverTiming } from '@elysiajs/server-timing' new Elysia() .use(serverTiming()) .get('/', () => 'hello') .listen(3000) ``` 然后,服务器计时将附加 'Server-Timing' 头,记录每个生命周期函数的持续时间、函数名称和细节。 要检查,请打开浏览器开发者工具 > 网络 > \[通过 Elysia 服务器发出的请求] > 时序。 ![开发者工具显示的服务器计时截图](/assets/server-timing.webp) 现在,您可以轻松审计服务器的性能瓶颈。 ## 配置 以下是插件接受的配置 ### enabled @default `NODE_ENV !== 'production'` 确定是否启用服务器计时 ### allow @default `undefined` 一个条件,决定是否记录服务器计时 ### trace @default `undefined` 允许服务器计时记录指定的生命周期事件: Trace 接受以下对象: * request: 捕获请求的持续时间 * parse: 捕获解析的持续时间 * transform: 捕获转化的持续时间 * beforeHandle: 捕获处理前的持续时间 * handle: 捕获处理的持续时间 * afterHandle: 捕获处理后的持续时间 * total: 捕获从开始到结束的总持续时间 ## 模式 下面您可以找到使用插件的常见模式。 * [允许条件](#allow-condition) ## 允许条件 您可以通过 `allow` 属性在特定路由上禁用服务器计时 ```ts twoslash import { Elysia } from 'elysia' import { serverTiming } from '@elysiajs/server-timing' new Elysia() .use( serverTiming({ allow: ({ request }) => { return new URL(request.url).pathname !== '/no-trace' } }) ) ``` --- --- url: 'https://elysia.cndocs.org/eden/treaty/overview.md' --- # Eden 条约 Eden 条约是用于与服务器交互的对象表示,具有类型安全、自动补全和错误处理等特性。 要使用 Eden 条约,首先导出您现有的 Elysia 服务器类型: ```typescript // server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/hi', () => '你好 Elysia') .get('/id/:id', ({ params: { id } }) => id) .post('/mirror', ({ body }) => body, { body: t.Object({ id: t.Number(), name: t.String() }) }) .listen(3000) export type App = typeof app // [!代码 ++] ``` 然后导入服务器类型并在客户端使用 Elysia API: ```typescript twoslash // @filename: server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/hi', () => '你好 Elysia') .get('/id/:id', ({ params: { id } }) => id) .post('/mirror', ({ body }) => body, { body: t.Object({ id: t.Number(), name: t.String() }) }) .listen(3000) export type App = typeof app // [!代码 ++] // @filename: client.ts // ---cut--- // client.ts import { treaty } from '@elysiajs/eden' import type { App } from './server' // [!代码 ++] const app = treaty('localhost:3000') // 响应类型: '你好 Elysia' const { data, error } = await app.hi.get() // ^? ``` ## 树状语法 HTTP 路径是文件系统树的资源指示符。 文件系统由多个级别的文件夹组成,例如: * /documents/elysia * /documents/kalpas * /documents/kelvin 每个层级由 **/**(斜杠)和一个名称分隔。 但是在 JavaScript 中,我们使用 **"."**(点)来访问更深层的资源,而不是使用 **"/"**(斜杠)。 Eden 条约将 Elysia 服务器转换为可以在 JavaScript 前端访问的树状文件系统。 | 路径 | 条约 | | ------------ | ------------ | | / | | | /hi | .hi | | /deep/nested | .deep.nested | 结合 HTTP 方法,我们可以与 Elysia 服务器进行交互。 | 路径 | 方法 | 条约 | | ------------ | ------ | ------------------- | | / | GET | .get() | | /hi | GET | .hi.get() | | /deep/nested | GET | .deep.nested.get() | | /deep/nested | POST | .deep.nested.post() | ## 动态路径 然而,动态路径参数无法使用符号表示。如果它们被完全替换,我们不知道参数名称应该是什么。 ```typescript // ❌ 不清楚这个值应该表示什么? treaty.item['skadi'].get() ``` 为了解决这个问题,我们可以使用函数指定一个动态路径,以提供键值。 ```typescript // ✅ 清楚值的动态路径是 'name' treaty.item({ name: 'Skadi' }).get() ``` | 路径 | 条约 | | --------------- | -------------------------------- | | /item | .item | | /item/:name | .item({ name: 'Skadi' }) | | /item/:name/id | .item({ name: 'Skadi' }).id | --- --- url: 'https://elysia.cndocs.org/plugins/stream.md' --- # 流插件 ::: warning 此插件处于维护模式,将不再接收新功能。我们建议使用 [生成器流](/essential/handler#stream) 代替。 ::: 此插件添加对流响应或向客户端发送服务器推送事件的支持。 安装命令: ```bash bun add @elysiajs/stream ``` 然后使用它: ```typescript import { Elysia } from 'elysia' import { Stream } from '@elysiajs/stream' new Elysia() .get('/', () => new Stream(async (stream) => { stream.send('hello') await stream.wait(1000) stream.send('world') stream.close() })) .listen(3000) ``` 默认情况下,`Stream` 将返回 `Response`,其 `content-type` 为 `text/event-stream; charset=utf8`。 ## 构造函数 以下是 `Stream` 接受的构造参数: 1. 流: * 自动:自动从提供的值流响应 * Iterable * AsyncIterable * ReadableStream * Response * 手动:`(stream: this) => unknown` 或 `undefined` 的回调 2. 选项:`StreamOptions` * [event](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event):标识事件类型的字符串 * [retry](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#retry):重连时间(毫秒) ## 方法 以下是 `Stream` 提供的方法: ### send 将数据加入队列以发送回客户端 ### close 关闭流 ### wait 返回在提供的毫秒数后解析的 promise ### value `ReadableStream` 的内部值 ## 模式 以下是使用该插件的常见模式。 * [OpenAI](#openai) * [获取流](#fetch-stream) * [服务器推送事件](#server-sent-event) ## OpenAI 当参数为 `Iterable` 或 `AsyncIterable` 时,自动模式将被触发,自动将响应流返回给客户端。 以下是集成 ChatGPT 到 Elysia 的示例。 ```ts new Elysia() .get( '/ai', ({ query: { prompt } }) => new Stream( openai.chat.completions.create({ model: 'gpt-3.5-turbo', stream: true, messages: [{ role: 'user', content: prompt }] }) ) ) ``` 默认情况下 [openai](https://npmjs.com/package/openai) 的 chatGPT 完成返回 `AsyncIterable`,因此您应该能够将 OpenAI 包裹在 `Stream` 中。 ## 获取流 您可以传递一个从返回流的端点获取的 fetch 来代理一个流。 这对于那些使用 AI 文本生成的端点非常有用,因为您可以直接代理,例如 [Cloudflare AI](https://developers.cloudflare.com/workers-ai/models/llm/#examples---chat-style-with-system-prompt-preferred)。 ```ts const model = '@cf/meta/llama-2-7b-chat-int8' const endpoint = `https://api.cloudflare.com/client/v4/accounts/${process.env.ACCOUNT_ID}/ai/run/${model}` new Elysia() .get('/ai', ({ query: { prompt } }) => fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${API_TOKEN}`, 'content-type': 'application/json' }, body: JSON.stringify({ messages: [ { role: 'system', content: '你是一个友好的助手' }, { role: 'user', content: prompt } ] }) }) ) ``` ## 服务器推送事件 当参数为 `callback` 或 `undefined` 时,手动模式将被触发,允许您控制流。 ### 基于回调 以下是使用构造函数回调创建服务器推送事件端点的示例 ```ts new Elysia() .get('/source', () => new Stream((stream) => { const interval = setInterval(() => { stream.send('hello world') }, 500) setTimeout(() => { clearInterval(interval) stream.close() }, 3000) }) ) ``` ### 基于值 以下是使用基于值创建服务器推送事件端点的示例 ```ts new Elysia() .get('/source', () => { const stream = new Stream() const interval = setInterval(() => { stream.send('hello world') }, 500) setTimeout(() => { clearInterval(interval) stream.close() }, 3000) return stream }) ``` 基于回调和基于值的流在工作原理上相同,但语法不同以满足您的偏好。 --- --- url: 'https://elysia.cndocs.org/patterns/unit-test.md' --- # 单元测试 作为 WinterCG 的合规实现,我们可以使用 Request/Response 类来测试 Elysia 服务器。 Elysia 提供了 **Elysia.handle** 方法,该方法接受 Web 标准 [Request](https://developer.mozilla.org/zh-CN/docs/Web/API/Request) 并返回 [Response](https://developer.mozilla.org/zh-CN/docs/Web/API/Response),模拟 HTTP 请求。 Bun 包含一个内置的 [测试运行器](https://bun.sh/docs/cli/test),通过 `bun:test` 模块提供类似 Jest 的 API,便于创建单元测试。 在项目根目录下创建 **test/index.test.ts**,内容如下: ```typescript // test/index.test.ts import { describe, expect, it } from 'bun:test' import { Elysia } from 'elysia' describe('Elysia', () => { it('returns a response', async () => { const app = new Elysia().get('/', () => 'hi') const response = await app .handle(new Request('http://localhost/')) .then((res) => res.text()) expect(response).toBe('hi') }) }) ``` 然后我们可以通过运行 **bun test** 来进行测试。 ```bash bun test ``` 对 Elysia 服务器的新请求必须是一个完全有效的 URL,**不能**是 URL 的一部分。 请求必须提供如下格式的 URL: | URL | 有效 | | --------------------- | ----- | | http://localhost/user | ✅ | | /user | ❌ | 我们还可以使用其他测试库,如 Jest 或其他测试库来创建 Elysia 单元测试。 ## Eden Treaty 测试 我们可以使用 Eden Treaty 创建 Elysia 服务器的端到端类型安全测试,如下所示: ```typescript twoslash // 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('returns a response', async () => { const { data, error } = await api.hello.get() expect(data).toBe('hi') // ^? }) }) ``` 有关设置和更多信息,请参阅 [Eden Treaty 单元测试](/eden/treaty/unit-test)。 --- --- url: 'https://elysia.cndocs.org/essential/life-cycle.md' --- # 生命周期 生命周期事件允许您在预定义点拦截重要事件,从而根据需要自定义服务器的行为。 Elysia 的生命周期可以如以下图所示。 ![Elysia Life Cycle Graph](/assets/lifecycle-chart.svg) > 点击图像放大 以下是 Elysia 中可用的请求生命周期事件: ## 为什么 假设我们想要返回一些 HTML。 通常,我们会将 **"Content-Type"** 标头设置为 **"text/html"**,以便浏览器可以渲染它。 但为每个路由手动设置一个是很繁琐的。 相反,如果框架能够检测响应是 HTML 并自动为您设置标头呢?这正是生命周期理念的用武之地。 ## 钩子 每个拦截 **生命周期事件** 的函数作为 **"hook"**。 (因为函数 **"hooks"** 进入生命周期事件) 钩子可以分为 2 种类型: 1. [本地钩子](#local-hook):在特定路由上执行 2. [拦截器钩子](#interceptor-hook):在注册钩子后对每个路由执行 ::: tip 钩子将接受与处理器相同的 Context;您可以想象在特定点添加路由处理器。 ::: ## 本地钩子 本地钩子在特定路由上执行。 要使用本地钩子,您可以内联钩子到路由处理器中: ```typescript import { Elysia } from 'elysia' import { isHtml } from '@elysiajs/html' new Elysia() .get('/', () => '

Hello World

', { afterHandle({ responseValue, set }) { if (isHtml(responseValue)) set.headers['Content-Type'] = 'text/html; charset=utf8' } }) .get('/hi', () => '

Hello World

') .listen(3000) ``` 响应应列出如下: | 路径 | Content-Type | | ---- | ------------------------ | | / | text/html; charset=utf8 | | /hi | text/plain; charset=utf8 | ## 拦截器钩子 将钩子注册到当前实例之后的每个处理器中。 要添加拦截器钩子,您可以使用 `.on` 后跟 camelCase 的生命周期事件: ```typescript import { Elysia } from 'elysia' import { isHtml } from '@elysiajs/html' new Elysia() .get('/none', () => '

Hello World

') .onAfterHandle(({ responseValue, set }) => { if (isHtml(responseValue)) set.headers['Content-Type'] = 'text/html; charset=utf8' }) .get('/', () => '

Hello World

') .get('/hi', () => '

Hello World

') .listen(3000) ``` 响应应列出如下: | 路径 | Content-Type | | ----- | ------------------------ | | /none | text/**plain**; charset=utf8 | | / | text/**html**; charset=utf8 | | /hi | text/**html**; charset=utf8 | 其他插件的事件也会应用于路由,因此代码顺序很重要。 ## 代码顺序 事件仅适用于注册之后的路由。 如果您将 `onError` 放在插件之前,插件将不会继承 `onError` 事件。 ```typescript import { Elysia } from 'elysia' new Elysia() .onBeforeHandle(() => { console.log('1') }) .get('/', () => 'hi') .onBeforeHandle(() => { console.log('2') }) .listen(3000) ``` 控制台应记录以下内容: ```bash 1 ``` 注意,它没有记录 **2**,因为事件在路由之后注册,因此不适用于该路由。 这也适用于插件。 ```typescript import { Elysia } from 'elysia' new Elysia() .onBeforeHandle(() => { console.log('1') }) .use(someRouter) .onBeforeHandle(() => { console.log('2') }) .listen(3000) ``` 在这个示例中,只有 **1** 会被记录,因为事件在插件之后注册。 每个事件都遵循相同规则,除了 `onRequest`。 因为 onRequest 发生在请求上,它不知道应用于哪个路由,因此它是一个全局事件 ## Request 每个新请求收到的第一个执行的生命周期事件。 由于 `onRequest` 旨在仅提供最关键的上下文以减少开销,建议在以下场景中使用: * 缓存 * 速率限制器 / IP/Region 锁定 * 分析 * 提供自定义标头,例如 CORS #### 示例 以下是针对特定 IP 地址强制执行速率限制的伪代码。 ```typescript import { Elysia } from 'elysia' new Elysia() .use(rateLimiter) .onRequest(({ rateLimiter, ip, set, status }) => { if (rateLimiter.check(ip)) return status(420, 'Enhance your calm') }) .get('/', () => 'hi') .listen(3000) ``` 如果从 `onRequest` 返回一个值,它将被用作响应,并跳过生命周期的其余部分。 ### Pre Context Context 的 `onRequest` 被键入为 `PreContext`,它是 `Context` 的最小表示,具有以下属性: request: `Request` * set: `Set` * store * decorators Context 不提供 `derived` 值,因为 derive 基于 `onTransform` 事件。 ## Parse Parse 等同于 Express 中的 **body parser**。 一个用于解析 body 的函数,返回值将附加到 `Context.body`,否则 Elysia 将继续迭代由 `onParse` 分配的附加解析器函数,直到 body 被分配或所有解析器都已执行。 默认情况下,Elysia 将使用以下 content-type 解析 body: * `text/plain` * `application/json` * `multipart/form-data` * `application/x-www-form-urlencoded` 建议使用 `onParse` 事件来提供 Elysia 未提供的自定义 body 解析器。 #### 示例 以下是基于自定义标头检索值的示例代码。 ```typescript import { Elysia } from 'elysia' new Elysia().onParse(({ request, contentType }) => { if (contentType === 'application/custom-type') return request.text() }) ``` 返回值将被分配到 `Context.body`。否则,Elysia 将继续迭代来自 **onParse** 堆栈的附加解析器函数,直到 body 被分配或所有解析器都已执行。 ### Context `onParse` Context 从 `Context` 扩展,并具有以下附加属性: * contentType: 请求的 Content-Type 标头 所有上下文基于正常上下文,可以像路由处理器中的正常上下文一样使用。 ### Parser 默认情况下,Elysia 将尝试提前确定 body 解析函数,并选择最合适的函数以加速进程。 Elysia 可以通过读取 `body` 来确定 body 函数。 看看这个示例: ```typescript import { Elysia, t } from 'elysia' new Elysia().post('/', ({ body }) => body, { body: t.Object({ username: t.String(), password: t.String() }) }) ``` Elysia 读取 body 模式并发现类型完全是对象,因此 body 很可能是 JSON。Elysia 然后提前选择 JSON body 解析器函数并尝试解析 body。 以下是 Elysia 用于选择 body 解析器类型的标准 * `application/json`:body 键入为 `t.Object` * `multipart/form-data`:body 键入为 `t.Object`,并且深度为 1 级,具有 `t.File` * `application/x-www-form-urlencoded`:body 键入为 `t.URLEncoded` * `text/plain`:其他原始类型 这允许 Elysia 提前优化 body 解析器,并在编译时减少开销。 ### Explicit Parser 然而,在某些场景中,如果 Elysia 未能选择正确的 body 解析器函数,我们可以通过指定 `type` 明确告诉 Elysia 使用某个函数。 ```typescript import { Elysia } from 'elysia' new Elysia().post('/', ({ body }) => body, { // Short form of application/json parse: 'json' }) ``` 这允许我们在复杂场景中控制 Elysia 选择 body 解析器函数的行为,以满足我们的需求。 `type` 可以是以下之一: ```typescript 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' // Skip body parsing entirely | 'none' | 'text/plain' | 'application/json' | 'multipart/form-data' | 'application/x-www-form-urlencoded' ``` ### Skip Body Parsing 当您需要集成像 `trpc`、`orpc` 这样的第三方库与 HTTP 处理器,并且它抛出 `Body is already used` 时。 这是因为 Web Standard Request 只能解析一次。 Elysia 和第三方库都有自己的 body 解析器,因此您可以通过指定 `parse: 'none'` 在 Elysia 端跳过 body 解析 ```typescript import { Elysia } from 'elysia' new Elysia() .post( '/', ({ request }) => library.handle(request), { parse: 'none' } ) ``` ### Custom Parser 您可以使用 `parser` 注册自定义解析器: ```typescript import { Elysia } from 'elysia' new Elysia() .parser('custom', ({ request, contentType }) => { if (contentType === 'application/elysia') return request.text() }) .post('/', ({ body }) => body, { parse: ['custom', 'json'] }) ``` ## Transform 在 **Validation** 进程之前执行,旨在变异上下文以符合验证或附加新值。 建议将 transform 用于以下情况: * 变异现有上下文以符合验证。 * `derive` 基于 `onTransform`,并支持提供类型。 #### 示例 以下是使用 transform 将 params 变异为数值值的示例。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .get('/id/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number() }), transform({ params }) { const id = +params.id if (!Number.isNaN(id)) params.id = id } }) .listen(3000) ``` ## Derive 直接在验证之前将新值附加到上下文。它与 **transform** 存储在相同的堆栈中。 与在服务器启动前分配值的 **state** 和 **decorate** 不同。**derive** 在每个请求发生时分配属性。这允许我们将一段信息提取到属性中。 ```typescript import { Elysia } from 'elysia' new Elysia() .derive(({ headers }) => { const auth = headers['Authorization'] return { bearer: auth?.startsWith('Bearer ') ? auth.slice(7) : null } }) .get('/', ({ bearer }) => bearer) ``` 因为 **derive** 在新请求开始时分配一次,**derive** 可以访问像 **headers**、**query**、**body** 这样的 Request 属性,而 **store** 和 **decorate** 不能。 与 **state** 和 **decorate** 不同。由 **derive** 分配的属性是唯一的,不与其他请求共享。 ### Queue `derive` 和 `transform` 存储在相同的队列中。 ```typescript import { Elysia } from 'elysia' new Elysia() .onTransform(() => { console.log(1) }) .derive(() => { console.log(2) return {} }) ``` 控制台应记录以下内容: ```bash 1 2 ``` ## Before Handle 在验证之后和主路由处理器之前执行。 旨在提供自定义验证,以在运行主处理器之前满足特定要求。 如果返回一个值,路由处理器将被跳过。 建议在以下情况下使用 Before Handle: * 受限访问检查:授权、用户登录 * 数据结构之上的自定义请求要求 #### 示例 以下是使用 before handle 检查用户登录的示例。 ```typescript import { Elysia } from 'elysia' import { validateSession } from './user' new Elysia() .get('/', () => 'hi', { beforeHandle({ set, cookie: { session }, status }) { if (!validateSession(session.value)) return status(401) } }) .listen(3000) ``` 响应应列出如下: | 是否登录 | 响应 | | -------- | ------------ | | ❌ | Unauthorized | | ✅ | Hi | ### Guard 当我们需要将相同的 before handle 应用于多个路由时,我们可以使用 `guard` 将相同的 before handle 应用于多个路由。 ```typescript import { Elysia } from 'elysia' import { signUp, signIn, validateSession, isUserExists } from './user' new Elysia() .guard( { beforeHandle({ set, cookie: { session }, status }) { if (!validateSession(session.value)) return status(401) } }, (app) => app .get('/user/:id', ({ body }) => signUp(body)) .post('/profile', ({ body }) => signIn(body), { beforeHandle: isUserExists }) ) .get('/', () => 'hi') .listen(3000) ``` ## Resolve 在验证之后将新值附加到上下文。它与 **beforeHandle** 存储在相同的堆栈中。 Resolve 语法与 [derive](#derive) 相同,以下是从 Authorization 插件检索 bearer 标头的示例。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .guard( { headers: t.Object({ authorization: t.TemplateLiteral('Bearer ${string}') }) }, (app) => app .resolve(({ headers: { authorization } }) => { return { bearer: authorization.split(' ')[1] } }) .get('/', ({ bearer }) => bearer) ) .listen(3000) ``` 使用 `resolve` 和 `onBeforeHandle` 存储在相同的队列中。 ```typescript import { Elysia } from 'elysia' new Elysia() .onBeforeHandle(() => { console.log(1) }) .resolve(() => { console.log(2) return {} }) .onBeforeHandle(() => { console.log(3) }) ``` 控制台应记录以下内容: ```bash 1 2 3 ``` 与 **derive** 相同,由 **resolve** 分配的属性是唯一的,不与其他请求共享。 ### Guard resolve 由于 resolve 不可用于本地钩子,建议使用 guard 来封装 **resolve** 事件。 ```typescript import { Elysia } from 'elysia' import { isSignIn, findUserById } from './user' new Elysia() .guard( { beforeHandle: isSignIn }, (app) => app .resolve(({ cookie: { session } }) => ({ userId: findUserById(session.value) })) .get('/profile', ({ userId }) => userId) ) .listen(3000) ``` ## After Handle 在主处理器之后执行,用于将 **before handle** 和 **route handler** 的返回值映射为适当的响应。 建议在以下情况下使用 After Handle: * 将请求转换为新值,例如压缩、事件流 * 基于响应值添加自定义标头,例如 **Content-Type** #### 示例 以下是使用 after handle 为响应标头添加 HTML 内容类型的示例。 ```typescript import { Elysia } from 'elysia' import { isHtml } from '@elysiajs/html' new Elysia() .get('/', () => '

Hello World

', { afterHandle({ response, set }) { if (isHtml(response)) set.headers['content-type'] = 'text/html; charset=utf8' } }) .get('/hi', () => '

Hello World

') .listen(3000) ``` 响应应列出如下: | 路径 | Content-Type | | ---- | ------------------------ | | / | text/html; charset=utf8 | | /hi | text/plain; charset=utf8 | ### Returned Value 如果 After Handle 返回一个值,除非值为 **undefined**,否则将使用返回值作为新响应值 上面的示例可以重写为以下内容: ```typescript import { Elysia } from 'elysia' import { isHtml } from '@elysiajs/html' new Elysia() .get('/', () => '

Hello World

', { afterHandle({ response, set }) { if (isHtml(response)) { set.headers['content-type'] = 'text/html; charset=utf8' return new Response(response) } } }) .get('/hi', () => '

Hello World

') .listen(3000) ``` 与 **beforeHandle** 不同,在从 **afterHandle** 返回值之后,afterHandle 的迭代 **不会** 被跳过。 ### Context `onAfterHandle` 上下文从 `Context` 扩展,并具有 `response` 的附加属性,这是要返回给客户端的响应。 `onAfterHandle` 上下文基于正常上下文,可以像路由处理器中的正常上下文一样使用。 ## Map Response 在 **"afterHandle"** 之后立即执行,旨在提供自定义响应映射。 建议将 transform 用于以下情况: * 压缩 * 将值映射为 Web Standard Response #### 示例 以下是使用 mapResponse 提供响应压缩的示例。 ```typescript import { Elysia } from 'elysia' const encoder = new TextEncoder() new Elysia() .mapResponse(({ responseValue, set }) => { const isJson = typeof response === 'object' const text = isJson ? JSON.stringify(responseValue) : (responseValue?.toString() ?? '') set.headers['Content-Encoding'] = 'gzip' return new Response(Bun.gzipSync(encoder.encode(text)), { headers: { 'Content-Type': `${ isJson ? 'application/json' : 'text/plain' }; charset=utf-8` } }) }) .get('/text', () => 'mapResponse') .get('/json', () => ({ map: 'response' })) .listen(3000) ``` 与 **parse** 和 **beforeHandle** 类似,在返回值之后,**mapResponse** 的下一次迭代将被跳过。 Elysia 将自动处理来自 **mapResponse** 的 **set.headers** 合并过程。我们无需手动将 **set.headers** 附加到 Response。 ## On Error (Error Handling) 专为错误处理设计。它将在任何生命周期中抛出错误时执行。 建议在以下情况下使用 on Error: * 提供自定义错误消息 * 故障安全处理、错误处理器或重试请求 * 日志和分析 #### 示例 Elysia 捕获处理器中抛出的所有错误,将错误代码分类,并将它们管道传输到 `onError` 中间件。 ```typescript import { Elysia } from 'elysia' new Elysia() .onError(({ error }) => { return new Response(error.toString()) }) .get('/', () => { throw new Error('Server is during maintenance') return 'unreachable' }) ``` 使用 `onError`,我们可以捕获并将错误转换为自定义错误消息。 ::: tip 重要的是,`onError` 必须在我们想要应用它的处理器之前调用。 ::: ### Custom 404 message 例如,返回自定义 404 消息: ```typescript import { Elysia, NotFoundError } from 'elysia' new Elysia() .onError(({ code, status, set }) => { if (code === 'NOT_FOUND') return status(404, 'Not Found :(') }) .post('/', () => { throw new NotFoundError() }) .listen(3000) ``` ### Context `onError` Context 从 `Context` 扩展,并具有以下附加属性: * **error**:抛出的值 * **code**:*Error Code* ### Error Code Elysia 错误代码包括: * **NOT\_FOUND** * **PARSE** * **VALIDATION** * **INTERNAL\_SERVER\_ERROR** * **INVALID\_COOKIE\_SIGNATURE** * **INVALID\_FILE\_TYPE** * **UNKNOWN** * **number** (基于 HTTP Status) 默认情况下,抛出的错误代码是 `UNKNOWN`。 ::: tip 如果没有返回错误响应,则将使用 `error.name` 返回错误。 ::: ### Local Error 与其它生命周期相同,我们使用 guard 将错误提供到 [scope](/essential/plugin.html#scope) 中: ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', () => 'Hello', { beforeHandle({ set, request: { headers }, error }) { if (!isSignIn(headers)) throw error(401) }, error() { return 'Handled' } }) .listen(3000) ``` ## After Response 在响应发送给客户端之后执行。 建议在以下情况下使用 **After Response**: * 清理响应 * 日志和分析 #### 示例 以下是使用 response handle 检查用户登录的示例。 ```typescript import { Elysia } from 'elysia' new Elysia() .onAfterResponse(() => { console.log('Response', performance.now()) }) .listen(3000) ``` 控制台应记录以下内容: ```bash Response 0.0000 Response 0.0001 Response 0.0002 ``` ### Response 类似于 [Map Response](#map-resonse),`afterResponse` 也接受 `responseValue` 值。 ```typescript import { Elysia } from 'elysia' new Elysia() .onAfterResponse(({ responseValue }) => { console.log(responseValue) }) .get('/', () => 'Hello') .listen(3000) ``` `onAfterResponse` 中的 `response` 不是 Web-Standard 的 `Response`,而是从处理器返回的值。 要获取从处理器返回的标头和状态,我们可以从上下文中访问 `set`。 ```typescript import { Elysia } from 'elysia' new Elysia() .onAfterResponse(({ set }) => { console.log(set.status, set.headers) }) .get('/', () => 'Hello') .listen(3000) ``` --- --- url: 'https://elysia.cndocs.org/eden/overview.md' --- # 端到端类型安全 想象一下你有一个玩具火车套件。 每一段火车轨道都必须完美契合下一段,就像拼图一样。 端到端类型安全就像确保所有轨道的拼接都正确,以免火车脱轨或卡住。 对于一个框架来说,具备端到端类型安全的意思是你可以以类型安全的方式连接客户端和服务器。 Elysia 提供了端到端类型安全 **无代码生成** 开箱即用,与 RPC 类似的连接器 **Eden** 支持 e2e 类型安全的其他框架: * tRPC * Remix * SvelteKit * Nuxt * TS-Rest Elysia 允许你在服务器上更改类型,并会立即反映到客户端,帮助自动完成和类型强制。 ## Eden Eden 是一个类似于 RPC 的客户端,旨在仅使用 TypeScript 的类型推断来连接 Elysia **端到端类型安全**,而无需代码生成。 使你能够轻松同步客户端和服务器类型,体积不到 2KB。 Eden 由两个模块组成: 1. Eden Treaty **(推荐)**: Eden Treaty 的改进版本 RFC 2. Eden Fetch: 具有类型安全的 Fetch 类客户端。 下面是每个模块的概述、用例和比较。 ## Eden Treaty (推荐) Eden Treaty 是一个类似对象的表示,提供 Elysia 服务器的端到端类型安全和显著改善的开发体验。 通过 Eden Treaty,我们可以与 Elysia 服务器进行交互,支持完整的类型和自动完成、类型收窄的错误处理,以及创建类型安全的单元测试。 Eden Treaty 的示例用法: ```typescript twoslash // @filename: server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .get('/', 'hi') .get('/users', () => 'Skadi') .put('/nendoroid/:id', ({ body }) => body, { body: t.Object({ name: t.String(), from: t.String() }) }) .get('/nendoroid/:id/name', () => 'Skadi') .listen(3000) export type App = typeof app // @filename: index.ts // ---cut--- import { treaty } from '@elysiajs/eden' import type { App } from './server' const app = treaty('localhost:3000') // @noErrors app. // ^| // 调用 [GET] 在 '/' const { data } = await app.get() // 调用 [PUT] 在 '/nendoroid/:id' const { data: nendoroid, error } = await app.nendoroid({ id: 1895 }).put({ name: 'Skadi', from: 'Arknights' }) ``` ## Eden Fetch 一个类似于 Eden Treaty 的 Fetch 替代方案,适合偏好 fetch 语法的开发者。 ```typescript import { edenFetch } from '@elysiajs/eden' import type { App } from './server' const fetch = edenFetch('http://localhost:3000') const { data } = await fetch('/name/:name', { method: 'POST', params: { name: 'Saori' }, body: { branch: 'Arius', type: 'Striker' } }) ``` ::: tip 注意 与 Eden Treaty 不同,Eden Fetch 不提供 Elysia 服务器的 Web Socket 实现 ::: --- --- url: 'https://elysia.cndocs.org/patterns/type.md' --- # 类型 以下是使用 `Elysia.t` 编写验证类型的常见模式。 ## 基本类型 TypeBox API 的设计围绕 TypeScript 类型,并与之相似。 有许多熟悉的名称和行为与 TypeScript 对应项相交,例如 **String**、**Number**、**Boolean** 和 **Object**,以及更高级的功能,如 **Intersect**、**KeyOf** 和 **Tuple**,以实现多功能性。 如果您熟悉 TypeScript,创建 TypeBox 模式的行为与编写 TypeScript 类型相同,只是它在运行时提供实际的类型验证。 要创建您的第一个模式,请从 Elysia 导入 **Elysia.t**,并从最基本的类型开始: ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .post('/', ({ body }) => `Hello ${body}`, { body: t.String() }) .listen(3000) ``` 这段代码告诉 Elysia 验证传入的 HTTP 正文,确保正文是一个字符串。如果是字符串,则允许其通过请求管道和处理程序。 如果形状不匹配,它将抛出错误到[错误生命周期](/essential/life-cycle.html#on-error)。 ![Elysia 生命周期](/assets/lifecycle-chart.svg) ### 基本类型 TypeBox 提供与 TypeScript 类型行为相同的基本原始类型。 下表列出了最常见的基本类型: ```typescript t.String() ``` ```typescript string ``` ```typescript t.Number() ``` ```typescript number ``` ```typescript t.Boolean() ``` ```typescript boolean ``` ```typescript t.Array( t.Number() ) ``` ```typescript number[] ``` ```typescript t.Object({ x: t.Number() }) ``` ```typescript { x: number } ``` ```typescript t.Null() ``` ```typescript null ``` ```typescript t.Literal(42) ``` ```typescript 42 ``` Elysia 扩展了 TypeBox 的所有类型,允许您引用 TypeBox 的大部分 API 以在 Elysia 中使用。 有关 TypeBox 支持的更多类型,请参阅 [TypeBox 类型](https://github.com/sinclairzx81/typebox#json-types)。 ### 属性 TypeBox 可以接受参数,以基于 JSON Schema 7 规范实现更全面的行为。 ```typescript t.String({ format: 'email' }) ``` ```typescript saltyaom@elysiajs.com ``` ```typescript t.Number({ minimum: 10, maximum: 100 }) ``` ```typescript 10 ``` ```typescript t.Array( t.Number(), { /** * 最小项目数 */ minItems: 1, /** * 最大项目数 */ maxItems: 5 } ) ``` ```typescript [1, 2, 3, 4, 5] ``` ```typescript t.Object( { x: t.Number() }, { /** * @default false * 接受未在模式中指定 * 但仍匹配类型的附加属性 */ additionalProperties: true } ) ``` ```typescript x: 100 y: 200 ``` 有关每个属性的更多说明,请参阅 [JSON Schema 7 规范](https://json-schema.org/draft/2020-12/json-schema-validation)。 ## 值得注意的类型 以下是创建模式时通常有用的常见模式。 ### 联合类型 允许 `t.Object` 中的字段具有多种类型。 ```typescript t.Union([ t.String(), t.Number() ]) ``` ```typescript string | number ``` ``` Hello 123 ``` ### 可选类型 允许 `t.Object` 中的字段为 undefined 或可选。 ```typescript t.Object({ x: t.Number(), y: t.Optional(t.Number()) }) ``` ```typescript { x: number, y?: number } ``` ```typescript { x: 123 } ``` ### 部分类型 允许 `t.Object` 中的所有字段为可选。 ```typescript t.Partial( t.Object({ x: t.Number(), y: t.Number() }) ) ``` ```typescript { x?: number, y?: number } ``` ```typescript { y: 123 } ``` ## Elysia 类型 `Elysia.t` 基于 TypeBox,并为服务器使用进行了预配置,提供了服务器端验证中常见的附加类型。 您可以在 `elysia/type-system` 中找到 Elysia 类型系统的完整源代码。 以下是 Elysia 提供的类型: ### UnionEnum `UnionEnum` 允许值是指定值之一。 ```typescript t.UnionEnum(['rapi', 'anis', 1, true, false]) ``` ### 文件 单个文件,通常用于**文件上传**验证。 ```typescript t.File() ``` 文件扩展了基础模式的属性,附加属性如下: #### type 指定文件的格式,例如图像、视频或音频。 如果提供了数组,它将尝试验证是否有任何格式有效。 ```typescript type?: MaybeArray ``` #### minSize 文件的最小大小。 接受以字节为单位的数字或文件单位的后缀: ```typescript minSize?: number | `${number}${'k' | 'm'}` ``` #### maxSize 文件的最大大小。 接受以字节为单位的数字或文件单位的后缀: ```typescript maxSize?: number | `${number}${'k' | 'm'}` ``` #### 文件单位后缀: 以下是文件单位的规范: m: 兆字节 (1048576 字节) k: 千字节 (1024 字节) ### 文件集 扩展自[文件](#file),但增加了对单个字段中文件数组的支持。 ```typescript t.Files() ``` 文件集扩展了基础模式、数组和文件的属性。 ### Cookie 从 Object 类型扩展的 Cookie Jar 的对象式表示。 ```typescript t.Cookie({ name: t.String() }) ``` Cookie 扩展了 [Object](https://json-schema.org/draft/2020-12/json-schema-validation#name-validation-keywords-for-obj) 和 [Cookie](https://github.com/jshttp/cookie#options-1) 的属性,附加属性如下: #### secrets 用于签名 cookie 的密钥。 接受字符串或字符串数组。 ```typescript secrets?: string | string[] ``` 如果提供了数组,将使用[密钥轮换](https://crypto.stackexchange.com/questions/41796/whats-the-purpose-of-key-rotation)。新签名的值将使用第一个密钥作为密钥。 ### Nullable 允许值为 null 但不能是 undefined。 ```typescript t.Nullable(t.String()) ``` ### 可能为空 允许值为 null 和 undefined。 ```typescript t.MaybeEmpty(t.String()) ``` 有关更多信息,您可以在 [`elysia/type-system`](https://github.com/elysiajs/elysia/blob/main/src/type-system.ts) 中找到类型系统的完整源代码。 ### 表单 我们的 `t.Object` 的语法糖,支持验证[表单](/essential/handler.html#formdata) (FormData) 的返回值。 ```typescript t.FormData({ someValue: t.File() }) ``` ### UInt8Array 接受可以解析为 `Uint8Array` 的缓冲区。 ```typescript t.UInt8Array() ``` 当您想要接受可以解析为 `Uint8Array` 的缓冲区时,这非常有用,例如在二进制文件上传中。它设计用于通过 `arrayBuffer` 解析器验证正文以强制执行正文类型。 ### ArrayBuffer 接受可以解析为 `ArrayBuffer` 的缓冲区。 ```typescript t.ArrayBuffer() ``` 当您想要接受可以解析为 `Uint8Array` 的缓冲区时,这非常有用,例如在二进制文件上传中。它设计用于通过 `arrayBuffer` 解析器验证正文以强制执行正文类型。 ### 对象字符串 接受可以解析为对象的字符串。 ```typescript t.ObjectString() ``` 当您想要接受可以解析为对象的字符串但环境不允许显式这样做时,这非常有用,例如在查询字符串、标头或 FormData 正文中。 ### 布尔字符串 接受可以解析为布尔值的字符串。 与 [对象字符串](#objectstring) 类似,当您想要接受可以解析为布尔值的字符串但环境不允许显式这样做时,这非常有用。 ```typescript t.BooleanString() ``` ### 数值 数值接受数值字符串或数字,然后将值转换为数字。 ```typescript t.Numeric() ``` 当传入值是数值字符串时,这非常有用,例如路径参数或查询字符串。 数值接受与 [数值实例](https://json-schema.org/draft/2020-12/json-schema-validation#name-validation-keywords-for-num) 相同的属性。 ## Elysia 行为 Elysia 默认使用 TypeBox。 然而,为了帮助更轻松地处理 HTTP,Elysia 有一些专用类型,并且与 TypeBox 有一些行为差异。 ## 可选 要使字段可选,请使用 `t.Optional`。 这将允许客户端选择性地提供查询参数。此行为也适用于 `body`、`headers`。 这与 TypeBox 不同,在 TypeBox 中,可选是将对象的字段标记为可选。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/optional', ({ query }) => query, { // ^? query: t.Optional( t.Object({ name: t.String() }) ) }) ``` ## 数字到数值 默认情况下,当作为路由模式提供时,Elysia 会将 `t.Number` 转换为 [t.Numeric](#numeric)。 因为解析的 HTTP 标头、查询、URL 参数始终是字符串。这意味着即使值是数字,它也会被视为字符串。 Elysia 通过检查字符串值是否看起来像数字然后在适当时转换它来覆盖此行为。 这仅当它用作路由模式而不是嵌套的 `t.Object` 时应用。 ```ts import { Elysia, t } from 'elysia' new Elysia() .get('/:id', ({ id }) => id, { params: t.Object({ // 转换为 t.Numeric() id: t.Number() }), body: t.Object({ // 不转换为 t.Numeric() id: t.Number() }) }) // 不转换为 t.Numeric() t.Number() ``` ## 布尔到布尔字符串 类似于 [数字到数值](#number-to-numeric) 任何 `t.Boolean` 都将转换为 `t.BooleanString`。 ```ts import { Elysia, t } from 'elysia' new Elysia() .get('/:id', ({ id }) => id, { params: t.Object({ // 转换为 t.Boolean() id: t.Boolean() }), body: t.Object({ // 不转换为 t.Boolean() id: t.Boolean() }) }) // 不转换为 t.BooleanString() t.Boolean() ``` --- --- url: 'https://elysia.cndocs.org/essential/structure.md' --- #### 此页面已移至 [最佳实践](/essential/best-practice) # 结构 Elysia 是一个无模式框架,决定使用哪种编码模式由您和您的团队决定。 然而,尝试将 MVC 模式 [(模型-视图-控制器)](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) 与 Elysia 适配时,有几点需要关注,发现很难解耦和处理类型。 此页面是关于如何遵循 Elysia 结构最佳实践与 MVC 模式结合的指南,但可以适应您喜欢的任何编码模式。 ## 方法链 Elysia 代码应始终使用 **方法链**。 由于 Elysia 的类型系统复杂,Elysia 中的每个方法都返回一个新的类型引用。 **这点很重要**,以确保类型的完整性和推断。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .state('build', 1) // Store 是严格类型的 // [!code ++] .get('/', ({ store: { build } }) => build) .listen(3000) ``` 在上面的代码中 **state** 返回一个新的 **ElysiaInstance** 类型,添加了一个 `build` 类型。 ### ❌ 不要:不使用方法链 如果不使用方法链,Elysia 就无法保存这些新类型,从而导致没有类型推断。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' const app = new Elysia() app.state('build', 1) app.get('/', ({ store: { build } }) => build) app.listen(3000) ``` 我们建议 **始终使用方法链** 以提供准确的类型推断。 ## 控制器 > 1 个 Elysia 实例 = 1 个控制器 Elysia 确保类型完整性做了很多工作,如果您将整个 `Context` 类型传递给控制器,这可能会产生以下问题: 1. Elysia 类型复杂且严重依赖插件和多级链式调用。 2. 难以类型化,Elysia 类型可能随时改变,特别是在使用装饰器和存储时。 3. 类型转换可能导致类型完整性的丧失或无法确保类型与运行时代码之间的一致性。 4. 这使得 [Sucrose](/blog/elysia-10#sucrose) *(Elysia的“类似”编译器)* 更难以对您的代码进行静态分析。 ### ❌ 不要:创建单独的控制器 不要创建单独的控制器,而是使用 Elysia 自身作为控制器: ```typescript import { Elysia, t, type Context } from 'elysia' abstract class Controller { static root(context: Context) { return Service.doStuff(context.stuff) } } // ❌ 不要 new Elysia() .get('/', Controller.hi) ``` 将整个 `Controller.method` 传递给 Elysia 相当于拥有两个控制器在数据之间来回传递。这违背了框架和 MVC 模式本身的设计。 ### ✅ 要:将 Elysia 作为控制器使用 相反,将 Elysia 实例视为控制器本身。 ```typescript import { Elysia } from 'elysia' import { Service } from './service' new Elysia() .get('/', ({ stuff }) => { Service.doStuff(stuff) }) ``` ## 服务 服务是一组实用程序/辅助函数,解耦为在模块/控制器中使用的业务逻辑,在我们的例子中就是 Elysia 实例。 任何可以从控制器中解耦的技术逻辑都可以放在 **Service** 中。 Elysia 中有两种类型的服务: 1. 非请求依赖服务 2. 请求依赖服务 ### ✅ 要:非请求依赖服务 这种服务不需要访问请求或 `Context` 的任何属性,可以像通常的 MVC 服务模式一样作为静态类进行初始化。 ```typescript import { Elysia, t } from 'elysia' abstract class Service { static fibo(number: number): number { if (number < 2) return number return Service.fibo(number - 1) + Service.fibo(number - 2) } } new Elysia() .get('/fibo', ({ body }) => { return Service.fibo(body) }, { body: t.Numeric() }) ``` 如果您的服务不需要存储属性,您可以使用 `abstract class` 和 `static` 来避免分配类实例。 ### 请求依赖服务 这种服务可能需要请求中的一些属性,应该 **作为 Elysia 实例进行初始化**。 ### ❌ 不要:将整个 `Context` 传递给服务 **Context 是高度动态的类型**,可以从 Elysia 实例推断。 不要将整个 `Context` 传递给服务,而是使用对象解构提取所需内容并将其传递给服务。 ```typescript import type { Context } from 'elysia' class AuthService { constructor() {} // ❌ 不要这样做 isSignIn({ status, cookie: { session } }: Context) { if (session.value) return status(401) } } ``` 由于 Elysia 类型复杂且严重依赖插件和多级链式调用,手动进行类型化可能会很具挑战性,因为它高度动态。 ### ✅ 建议做法:将依赖服务请求抽象为Elysia实例 我们建议将服务类抽象化,远离 Elysia。 然而,**如果服务是请求依赖服务**或需要处理 HTTP 请求,我们建议将其抽象为 Elysia 实例,以确保类型的完整性和推断: ```typescript import { Elysia } from 'elysia' // ✅ 要 const AuthService = new Elysia({ name: 'Service.Auth' }) .derive({ as: 'scoped' }, ({ cookie: { session } }) => ({ // 这相当于依赖注入 Auth: { user: session.value } })) .macro(({ onBeforeHandle }) => ({ // 这声明了一个服务方法 isSignIn(value: boolean) { onBeforeHandle(({ Auth, status }) => { if (!Auth?.user || !Auth.user) return status(401) }) } })) const UserController = new Elysia() .use(AuthService) .get('/profile', ({ Auth: { user } }) => user, { isSignIn: true }) ``` ::: tip Elysia 默认处理 [插件去重](/essential/plugin.html#plugin-deduplication),您无需担心性能,因为如果您指定了 **"name"** 属性,它将成为单例。 ::: ### ⚠️ 从 Elysia 实例中推断 Context 如果**绝对必要**,您可以从 Elysia 实例本身推断 `Context` 类型: ```typescript import { Elysia, type InferContext } from 'elysia' const setup = new Elysia() .state('a', 'a') .decorate('b', 'b') class AuthService { constructor() {} // ✅ 要 isSignIn({ status, cookie: { session } }: InferContext) { if (session.value) return status(401) } } ``` 然而,我们建议尽可能避免这样做,而是使用 [Elysia 作为服务](✅-do-use-elysia-instance-as-a-service)。 您可以在 [Essential: Handler](/essential/handler) 中了解更多关于 [InferContext](/essential/handler#infercontext) 的信息。 ## 模型 模型或 [DTO (数据传输对象)](https://en.wikipedia.org/wiki/Data_transfer_object) 通过 [Elysia.t (验证)](/validation/overview.html#data-validation) 来处理。 Elysia 内置了验证系统,可以从您的代码中推断类型并在运行时进行验证。 ### ❌ 不要:将类实例声明为模型 不要将类实例声明为模型: ```typescript // ❌ 不要 class CustomBody { username: string password: string constructor(username: string, password: string) { this.username = username this.password = password } } // ❌ 不要 interface ICustomBody { username: string password: string } ``` ### ✅ 要:使用 Elysia 的验证系统 不要声明类或接口,而是使用 Elysia 的验证系统来定义模型: ```typescript twoslash // ✅ 要 import { Elysia, t } from 'elysia' const customBody = t.Object({ username: t.String(), password: t.String() }) // 如果您想获取模型的类型可以选择性地 // 通常如果我们不使用类型,因为它已由 Elysia 推断 type CustomBody = typeof customBody.static // ^? export { customBody } ``` 我们可以通过与 `.static` 属性结合使用 `typeof` 来获取模型的类型。 然后您可以使用 `CustomBody` 类型来推断请求体的类型。 ```typescript twoslash import { Elysia, t } from 'elysia' const customBody = t.Object({ username: t.String(), password: t.String() }) // ---cut--- // ✅ 要 new Elysia() .post('/login', ({ body }) => { // ^? return body }, { body: customBody }) ``` ### ❌ 不要:将类型与模型分开声明 不要将类型与模型分开声明,而是使用 `typeof` 和 `.static` 属性来获取模型的类型。 ```typescript // ❌ 不要 import { Elysia, t } from 'elysia' const customBody = t.Object({ username: t.String(), password: t.String() }) type CustomBody = { username: string password: string } // ✅ 要 const customBody = t.Object({ username: t.String(), password: t.String() }) type customBody = typeof customBody.static ``` ### 分组 您可以将多个模型分组为一个单独的对象,以使其更加有序。 ```typescript import { Elysia, t } from 'elysia' export const AuthModel = { sign: t.Object({ username: t.String(), password: t.String() }) } ``` ### 模型注入 虽然这不是必需的,如果您严格遵循 MVC 模式,您可能想像服务一样将模型注入到控制器中。我们推荐使用 [Elysia 参考模型](/essential/validation.html#reference-model) 使用 Elysia 的模型引用 ```typescript twoslash import { Elysia, t } from 'elysia' const customBody = t.Object({ username: t.String(), password: t.String() }) const AuthModel = new Elysia() .model({ 'auth.sign': customBody }) const UserController = new Elysia({ prefix: '/auth' }) .use(AuthModel) .post('/sign-in', async ({ body, cookie: { session } }) => { // ^? return true }, { body: 'auth.sign' }) ``` 这个方法提供了几个好处: 1. 允许我们命名模型并提供自动完成。 2. 为以后的使用修改模式,或执行 [重新映射](/patterns/remapping.html#remapping)。 3. 在 OpenAPI 合规客户端中显示为“模型”,例如 OpenAPI。 4. 提升 TypeScript 推断速度,因为模型类型在注册期间会被缓存。 *** 如前所述,Elysia 是一个无模式框架,我们仅提供关于如何将 Elysia 与 MVC 模式结合的推荐指南。 是否遵循此推荐完全取决于您和您的团队的偏好和共识。 --- --- url: 'https://elysia.cndocs.org/essential/route.md' --- # 路由 Web 服务器使用请求的**路径和方法**来查找正确的资源,这被称为\*\*“路由”\*\*。 我们可以通过调用以**HTTP 动词命名的方法**来定义路由,传递路径和匹配时执行的函数。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', 'hello') .get('/hi', 'hi') .listen(3000) ``` 我们可以通过访问 **http://localhost:3000** 来访问 Web 服务器。 默认情况下,Web 浏览器在访问页面时会发送 GET 方法。 ::: tip 使用上面的交互式浏览器,将鼠标悬停在蓝色高亮区域,以查看每个路径之间的不同结果。 ::: ## 路径类型 Elysia 中的路径可以分为 3 种类型: * **静态路径** - 用于定位资源的静态字符串 * **动态路径** - 段可以是任意值 * **通配符** - 直到特定点的路径可以是任意内容 您可以将所有路径类型结合使用,以组合您的 Web 服务器行为。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/id/1', 'static path') .get('/id/:id', 'dynamic path') .get('/id/*', 'wildcard path') .listen(3000) ``` ## 静态路径 静态路径是一个硬编码的字符串,用于在服务器上定位资源。 ```ts import { Elysia } from 'elysia' new Elysia() .get('/hello', 'hello') .get('/hi', 'hi') .listen(3000) ``` ## 动态路径 动态路径匹配某些部分并捕获值,以提取额外信息。 要定义动态路径,我们可以使用冒号 `:` 后跟一个名称。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/id/:id', ({ params: { id } }) => id) // ^? .listen(3000) ``` 这里,创建了一个动态路径 `/id/:id`。这告诉 Elysia 捕获 `:id` 段的值,例如 **/id/1**、**/id/123**、**/id/anything**。 当请求时,服务器应返回以下响应: | Path | Response | | ---------------------- | --------- | | /id/1 | 1 | | /id/123 | 123 | | /id/anything | anything | | /id/anything?name=salt | anything | | /id | Not Found | | /id/anything/rest | Not Found | 动态路径非常适合包含像 ID 这样的内容,以便后续使用。 我们将命名变量路径称为**路径参数**或简称**params**。 ### 多个路径参数 您可以拥有任意数量的路径参数,这些参数将被存储到一个 `params` 对象中。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/id/:id', ({ params: { id } }) => id) .get('/id/:id/:name', ({ params: { id, name } }) => id + ' ' + name) // ^? .listen(3000) ``` 服务器将响应如下: | Path | Response | | ---------------------- | ------------- | | /id/1 | 1 | | /id/123 | 123 | | /id/anything | anything | | /id/anything?name=salt | anything | | /id | Not Found | | /id/anything/rest | anything rest | ## 可选路径参数 有时我们可能希望静态路径和动态路径解析为同一个处理程序。 我们可以通过在参数名称后添加问号 `?` 来使路径参数变为可选。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/id/:id?', ({ params: { id } }) => `id ${id}`) // ^? .listen(3000) ``` ## 通配符 动态路径允许捕获单个段,而通配符允许捕获路径的其余部分。 要定义通配符,我们可以使用星号 `*`。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/id/*', ({ params }) => params['*']) // ^? .listen(3000) ``` ## 路径优先级 Elysia 的路径优先级如下: 1. 静态路径 2. 动态路径 3. 通配符 如果路径被解析为静态的、通配符动态路径被呈现,Elysia 将解析静态路径而不是动态路径 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/id/1', 'static path') .get('/id/:id', 'dynamic path') .get('/id/*', 'wildcard path') .listen(3000) ``` ## HTTP 动词 HTTP 定义了一组请求方法,以指示对给定资源执行的预期操作 有几种 HTTP 动词,但最常见的是: ### GET 使用 GET 的请求应仅检索数据。 ### POST 向指定资源提交有效负载,通常导致状态更改或副作用。 ### PUT 使用请求的有效负载替换目标资源的当前所有表示。 ### PATCH 对资源应用部分修改。 ### DELETE 删除指定的资源。 *** 为了处理不同的动词,Elysia 默认内置了几个 HTTP 动词的 API,类似于 `Elysia.get` ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', 'hello') .post('/hi', 'hi') .listen(3000) ``` Elysia HTTP 方法接受以下参数: * **path**: 路径名 * **function**: 用于响应客户端的函数 * **hook**: 额外元数据 您可以在 [HTTP Request Methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) 上阅读更多关于 HTTP 方法的信息。 ## 自定义方法 我们可以使用 `Elysia.route` 来接受自定义 HTTP 方法。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/get', 'hello') .post('/post', 'hi') .route('M-SEARCH', '/m-search', 'connect') // [!code ++] .listen(3000) ``` **Elysia.route** 接受以下内容: * **method**: HTTP 动词 * **path**: 路径名 * **function**: 用于响应客户端的函数 * **hook**: 额外元数据 ::: tip 根据 [RFC 7231](https://www.rfc-editor.org/rfc/rfc7231#section-4.1),HTTP 动词区分大小写。 建议使用大写约定来定义自定义 HTTP 动词与 Elysia。 ::: ### ALL 方法 Elysia 提供了 `Elysia.all`,用于使用与 **Elysia.get** 和 **Elysia.post** 相同的 API 来处理指定路径的任何 HTTP 方法 ```typescript import { Elysia } from 'elysia' new Elysia() .all('/', 'hi') .listen(3000) ``` 任何匹配路径的 HTTP 方法,将被处理如下: | Path | Method | Result | | ---- | -------- | ------ | | / | GET | hi | | / | POST | hi | | / | DELETE | hi | ## Handle 大多数开发者使用像 Postman、Insomnia 或 Hoppscotch 这样的 REST 客户端来测试他们的 API。 但是,Elysia 可以使用 `Elysia.handle` 进行程序化测试。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/', 'hello') .post('/hi', 'hi') .listen(3000) app.handle(new Request('http://localhost/')).then(console.log) ``` **Elysia.handle** 是一个函数,用于处理发送到服务器的实际请求。 ::: tip 与单元测试的模拟不同,**您可以预期它像发送到服务器的实际请求一样行为**。 但也适用于模拟或创建单元测试。 ::: ## Group 在创建 Web 服务器时,您经常会有多个共享相同前缀的路由: ```typescript import { Elysia } from 'elysia' new Elysia() .post('/user/sign-in', 'Sign in') .post('/user/sign-up', 'Sign up') .post('/user/profile', 'Profile') .listen(3000) ``` 这可以通过 `Elysia.group` 来改进,允许我们将多个路由分组在一起,同时为其应用前缀: ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .group('/user', (app) => app .post('/sign-in', 'Sign in') .post('/sign-up', 'Sign up') .post('/profile', 'Profile') ) .listen(3000) ``` 此代码的行为与我们的第一个示例相同,并应结构化为如下: | Path | Result | | ------------- | ------- | | /user/sign-in | Sign in | | /user/sign-up | Sign up | | /user/profile | Profile | `.group()` 还可以接受一个可选的守卫参数,以减少使用组和守卫一起时的样板代码: ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .group( '/user', { body: t.Literal('Rikuhachima Aru') }, (app) => app .post('/sign-in', 'Sign in') .post('/sign-up', 'Sign up') .post('/profile', 'Profile') ) .listen(3000) ``` 您可以在 [scope](/essential/plugin.html#scope) 中找到更多关于分组守卫的信息。 ### 前缀 我们可以通过向构造函数提供**前缀**来将组分离为单独的插件实例,以减少嵌套。 ```typescript import { Elysia } from 'elysia' const users = new Elysia({ prefix: '/user' }) .post('/sign-in', 'Sign in') .post('/sign-up', 'Sign up') .post('/profile', 'Profile') new Elysia() .use(users) .get('/', 'hello world') .listen(3000) ``` --- --- url: 'https://elysia.cndocs.org/integrations/cheat-sheet.md' --- # 速查表 这里是一些常见 Elysia 模式的快速概述 ## Hello World 一个简单的 hello world ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', () => 'Hello World') .listen(3000) ``` ## 自定义 HTTP 方法 使用自定义 HTTP 方法/动词定义路由 参见 [路由](/essential/route.html#custom-method) ```typescript import { Elysia } from 'elysia' new Elysia() .get('/hi', () => 'Hi') .post('/hi', () => 'From Post') .put('/hi', () => 'From Put') .route('M-SEARCH', '/hi', () => 'Custom Method') .listen(3000) ``` ## 路径参数 使用动态路径参数 参见 [路径](/essential/route.html#path-type) ```typescript import { Elysia } from 'elysia' new Elysia() .get('/id/:id', ({ params: { id } }) => id) .get('/rest/*', () => 'Rest') .listen(3000) ``` ## 返回 JSON Elysia 会自动将响应转换为 JSON 参见 [处理器](/essential/handler.html) ```typescript import { Elysia } from 'elysia' new Elysia() .get('/json', () => { return { hello: 'Elysia' } }) .listen(3000) ``` ## 返回文件 文件可以作为 formdata 响应返回 响应必须是 1 级深度对象 ```typescript import { Elysia, file } from 'elysia' new Elysia() .get('/json', () => { return { hello: 'Elysia', image: file('public/cat.jpg') } }) .listen(3000) ``` ## 头部和状态 设置自定义头部和状态码 参见 [处理器](/essential/handler.html) ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', ({ set, status }) => { set.headers['x-powered-by'] = 'Elysia' return status(418, "I'm a teapot") }) .listen(3000) ``` ## 组 为子路由定义一次前缀 参见 [组](/essential/route.html#group) ```typescript import { Elysia } from 'elysia' new Elysia() .get("/", () => "Hi") .group("/auth", app => { return app .get("/", () => "Hi") .post("/sign-in", ({ body }) => body) .put("/sign-up", ({ body }) => body) }) .listen(3000) ``` ## 模式 强制路由的数据类型 参见 [验证](/essential/validation) ```typescript import { Elysia, t } from 'elysia' new Elysia() .post('/mirror', ({ body: { username } }) => username, { body: t.Object({ username: t.String(), password: t.String() }) }) .listen(3000) ``` ## 文件上传 请参见 [验证#文件](/essential/validation#file) ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .post('/body', ({ body }) => body, { // ^? body: t.Object({ file: t.File({ format: 'image/*' }), multipleFiles: t.Files() }) }) .listen(3000) ``` ## 生命周期钩子 按顺序拦截 Elysia 事件 参见 [生命周期](/essential/life-cycle.html) ```typescript import { Elysia, t } from 'elysia' new Elysia() .onRequest(() => { console.log('On request') }) .on('beforeHandle', () => { console.log('Before handle') }) .post('/mirror', ({ body }) => body, { body: t.Object({ username: t.String(), password: t.String() }), afterHandle: () => { console.log("After handle") } }) .listen(3000) ``` ## 守卫 强制子路由的数据类型 参见 [范围](/essential/plugin.html#scope) ```typescript twoslash // @errors: 2345 import { Elysia, t } from 'elysia' new Elysia() .guard({ response: t.String() }, (app) => app .get('/', () => 'Hi') // 无效: 会抛出错误,并且 TypeScript 会报告错误 .get('/invalid', () => 1) ) .listen(3000) ``` ## 自定义上下文 向路由上下文添加自定义变量 参见 [上下文](/essential/handler.html#context) ```typescript import { Elysia } from 'elysia' new Elysia() .state('version', 1) .decorate('getDate', () => Date.now()) .get('/version', ({ getDate, store: { version } }) => `${version} ${getDate()}`) .listen(3000) ``` ## 重定向 重定向响应 参见 [处理器](/essential/handler.html#redirect) ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', () => 'hi') .get('/redirect', ({ redirect }) => { return redirect('/') }) .listen(3000) ``` ## 插件 创建一个单独的实例 参见 [插件](/essential/plugin) ```typescript import { Elysia } from 'elysia' const plugin = new Elysia() .state('plugin-version', 1) .get('/hi', () => 'hi') new Elysia() .use(plugin) .get('/version', ({ store }) => store['plugin-version']) .listen(3000) ``` ## Web Socket 使用 Web Socket 创建实时连接 参见 [Web Socket](/patterns/websocket) ```typescript import { Elysia } from 'elysia' new Elysia() .ws('/ping', { message(ws, message) { ws.send('hello ' + message) } }) .listen(3000) ``` ## OpenAPI 文档 使用 Scalar (或可选的 Swagger) 创建交互式文档 参见 [openapi](/plugins/openapi.html) ```typescript import { Elysia } from 'elysia' import { openapi } from '@elysiajs/openapi' const app = new Elysia() .use(openapi()) .listen(3000) console.log(`在浏览器中访问 "${app.server!.url}openapi" 查看文档`); ``` ## 单元测试 编写 Elysia 应用的单元测试 参见 [单元测试](/patterns/unit-test) ```typescript // test/index.test.ts import { describe, expect, it } from 'bun:test' import { Elysia } from 'elysia' describe('Elysia', () => { it('返回响应', async () => { const app = new Elysia().get('/', () => 'hi') const response = await app .handle(new Request('http://localhost/')) .then((res) => res.text()) expect(response).toBe('hi') }) }) ``` ## 自定义主体解析器 为解析主体创建自定义逻辑 参见 [解析](/essential/life-cycle.html#parse) ```typescript import { Elysia } from 'elysia' new Elysia() .onParse(({ request, contentType }) => { if (contentType === 'application/custom-type') return request.text() }) ``` ## GraphQL 使用 GraphQL Yoga 或 Apollo 创建自定义 GraphQL 服务器 参见 [GraphQL Yoga](/plugins/graphql-yoga) ```typescript import { Elysia } from 'elysia' import { yoga } from '@elysiajs/graphql-yoga' const app = new Elysia() .use( yoga({ typeDefs: /* GraphQL */` type Query { hi: String } `, resolvers: { Query: { hi: () => 'Hello from Elysia' } } }) ) .listen(3000) ``` --- --- url: 'https://elysia.cndocs.org/patterns/deploy.md' --- # 部署到生产环境 本页面是关于如何将 Elysia 部署到生产环境的指南。 ## 编译为二进制 我们建议在部署到生产环境之前运行构建命令,因为这可能会显著减少内存使用和文件大小。 我们推荐使用以下命令将 Elysia 编译成单个二进制文件: ```bash bun build \ --compile \ --minify-whitespace \ --minify-syntax \ --outfile server \ src/index.ts ``` 这将生成一个可移植的二进制文件 `server`,我们可以运行它来启动我们的服务器。 将服务器编译为二进制文件通常相比于开发环境显著降低 2-3 倍的内存使用。 这个命令有点长,我们来拆解一下: 1. **--compile** 将 TypeScript 编译为二进制 2. **--minify-whitespace** 移除不必要的空白 3. **--minify-syntax** 压缩 JavaScript 语法以减小文件大小 4. **--outfile server** 输出二进制文件为 `server` 5. **src/index.ts** 我们服务器的入口文件(代码库) 要启动服务器,只需运行该二进制文件。 ```bash ./server ``` 一旦二进制文件编译完成,您就不需要在机器上安装 `Bun` 运行服务器。 这非常好,因为部署服务器无需安装额外的运行时即可运行,使二进制文件具有可移植性。 ### 目标平台 您也可以添加 `--target` 标志来针对目标平台优化二进制文件。 ```bash bun build \ --compile \ --minify-whitespace \ --minify-syntax \ --target bun-linux-x64 \ --outfile server \ src/index.ts ``` 以下是可用的目标列表: | Target | 操作系统 | 架构 | Modern | Baseline | Libc | |--------------------------|------------------|--------------|--------|----------|-------| | bun-linux-x64 | Linux | x64 | ✅ | ✅ | glibc | | bun-linux-arm64 | Linux | arm64 | ✅ | N/A | glibc | | bun-windows-x64 | Windows | x64 | ✅ | ✅ | - | | bun-windows-arm64 | Windows | arm64 | ❌ | ❌ | - | | bun-darwin-x64 | macOS | x64 | ✅ | ✅ | - | | bun-darwin-arm64 | macOS | arm64 | ✅ | N/A | - | | bun-linux-x64-musl | Linux | x64 | ✅ | ✅ | musl | | bun-linux-arm64-musl | Linux | arm64 | ✅ | N/A | musl | ### 为什么不使用 --minify Bun 确实有 `--minify` 标志,用于压缩二进制文件。 然而,如果我们正在使用 [OpenTelemetry](/plugins/opentelemetry),它会将函数名缩减为单个字符。 这使得跟踪比预期更困难,因为 OpenTelemetry 依赖于函数名。 但是,如果您不使用 OpenTelemetry,则可以选择使用 `--minify`: ```bash bun build \ --compile \ --minify \ --outfile server \ src/index.ts ``` ### 权限 一些 Linux 发行版可能无法运行二进制文件,如果您使用的是 Linux,建议为二进制文件启用可执行权限: ```bash chmod +x ./server ./server ``` ### 未知的随机中文错误 如果您尝试将二进制文件部署到服务器但无法运行,并出现随机中文字符错误,这意味着您运行的机器 **不支持 AVX2**。 不幸的是,Bun 要求机器必须具备 `AVX2` 硬件支持。 据我们所知目前无替代方案。 ## 编译为 JavaScript 如果您无法编译为二进制文件或您正在 Windows 服务器上进行部署。 您可以将服务器打包为一个 JavaScript 文件。 ```bash bun build \ --minify-whitespace \ --minify-syntax \ --outfile ./dist/index.js \ src/index.ts ``` 这将生成一个可以在服务器上部署的单个可移植 JavaScript 文件。 ```bash NODE_ENV=production bun ./dist/index.js ``` ## Docker 在 Docker 上,我们建议始终编译为二进制以减少基础镜像的开销。 以下是使用二进制的 Distroless 镜像的示例。 ```dockerfile [Dockerfile] FROM oven/bun AS build WORKDIR /app # 缓存包安装 COPY package.json package.json COPY bun.lock bun.lock RUN bun install COPY ./src ./src ENV NODE_ENV=production RUN bun build \ --compile \ --minify-whitespace \ --minify-syntax \ --outfile server \ src/index.ts FROM gcr.io/distroless/base WORKDIR /app COPY --from=build /app/server server ENV NODE_ENV=production CMD ["./server"] EXPOSE 3000 ``` ### OpenTelemetry 如果您使用 [OpenTelemetry](/integrations/opentelemetry) 来部署生产服务器。 由于 OpenTelemetry 依赖于猴子补丁 `node_modules/`。为了确保仪器正常工作,我们需要指定供仪器使用的库是外部模块,以将其排除在打包之外。 例如,如果您使用 `@opentelemetry/instrumentation-pg` 以对 `pg` 库进行仪表化,我们需要将 `pg` 排除在打包之外,并确保它从 `node_modules/pg` 导入。 为使这一切正常工作,我们可以使用 `--external pg` 将 `pg` 指定为外部模块: ```bash bun build --compile --external pg --outfile server src/index.ts ``` 这告诉 bun 不将 `pg` 打包到最终输出文件中,并将在运行时从 `node_modules` 目录导入。因此在生产服务器上,您还必须保留 `node_modules` 目录。 建议在 `package.json` 中将应在生产服务器上可用的包指定为 `dependencies`,并使用 `bun install --production` 仅安装生产依赖项。 ```json { "dependencies": { "pg": "^8.15.6" }, "devDependencies": { "@elysiajs/opentelemetry": "^1.2.0", "@opentelemetry/instrumentation-pg": "^0.52.0", "@types/pg": "^8.11.14", "elysia": "^1.2.25" } } ``` 然后,在生产服务器上运行构建命令后 ```bash bun install --production ``` 如果 `node_modules` 目录仍包含开发依赖项,您可以删除 `node_modules` 目录并重新安装生产依赖项。 ### Monorepo 如果您在 Monorepo 中使用 Elysia,您可能需要包括依赖的 `packages`。 如果您使用 Turborepo,您可以在您的应用程序目录中放置 Dockerfile,例如 **apps/server/Dockerfile**。这也适用于其他 monorepo 管理器,如 Lerna 等。 假设我们的 monorepo 使用 Turborepo,结构如下: * apps * server * **Dockerfile(在此处放置 Dockerfile)** * packages * config 然后我们可以在 monorepo 根目录(而不是应用根目录)构建我们的 Dockerfile: ```bash docker build -t elysia-mono . ``` Dockerfile 如下: ```dockerfile [apps/server/Dockerfile] FROM oven/bun:1 AS build WORKDIR /app # 缓存包 COPY package.json package.json COPY bun.lock bun.lock COPY /apps/server/package.json ./apps/server/package.json COPY /packages/config/package.json ./packages/config/package.json RUN bun install COPY /apps/server ./apps/server COPY /packages/config ./packages/config ENV NODE_ENV=production RUN bun build \ --compile \ --minify-whitespace \ --minify-syntax \ --outfile server \ src/index.ts FROM gcr.io/distroless/base WORKDIR /app COPY --from=build /app/server server ENV NODE_ENV=production CMD ["./server"] EXPOSE 3000 ``` ## Railway [Railway](https://railway.app) 是一个流行的部署平台。 Railway 为每个部署分配一个 **随机端口**,可以通过 `PORT` 环境变量访问。 我们需要修改我们的 Elysia 服务器,以接受 `PORT` 环境变量,以符合 Railway 端口。 我们可以使用 `process.env.PORT`,并在开发期间提供一个后备端口: ```ts new Elysia() .listen(3000) // [!code --] .listen(process.env.PORT ?? 3000) // [!code ++] ``` 这应该允许 Elysia 拦截 Railway 提供的端口。 ::: tip Elysia 自动将主机名分配为 `0.0.0.0`,这与 Railway 兼容 ::: --- --- url: 'https://elysia.cndocs.org/patterns/configuration.md' --- # 配置 Elysia 提供了可配置的行为,允许我们自定义其功能的各个方面。 我们可以通过使用构造函数定义配置。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia({ prefix: '/v1', normalize: true }) ``` ## 适配器 ###### 自 1.1.11 起 用于在不同环境中使用 Elysia 的运行时适配器。 默认适配器会根据环境选择。 ```ts import { Elysia, t } from 'elysia' import { BunAdapter } from 'elysia/adapter/bun' new Elysia({ adapter: BunAdapter }) ``` ## AOT ###### 自 0.4.0 起 提前编译(Ahead of Time compilation)。 Elysia 内置了一个 JIT *"编译器"*,可以[优化性能](/blog/elysia-04.html#ahead-of-time-complie)。 ```ts twoslash import { Elysia } from 'elysia' new Elysia({ aot: true }) ``` 禁用提前编译 #### 选项 - @default `false` * `true` - 在启动服务器之前预编译每个路由 * `false` - 完全禁用 JIT。启动时间更快,而不影响性能 ## 详细信息 为实例的所有路由定义 OpenAPI 方案。 此方案将用于生成实例所有路由的 OpenAPI 文档。 ```ts twoslash import { Elysia } from 'elysia' new Elysia({ detail: { hide: true, tags: ['elysia'] } }) ``` ## encodeSchema 处理自定义 `t.Transform` 模式的自定义 `Encode`,在将响应返回给客户端之前进行处理。 这允许我们在发送响应到客户端之前为数据创建自定义编码函数。 ```ts import { Elysia, t } from 'elysia' new Elysia({ encodeSchema: true }) ``` #### 选项 - @default `true` * `true` - 在将响应发送给客户端之前运行 `Encode` * `false` - 完全跳过 `Encode` ## name 定义实例名称,用于调试和 [插件去重](/essential/plugin.html#plugin-deduplication) ```ts twoslash import { Elysia } from 'elysia' new Elysia({ name: 'service.thing' }) ``` ## nativeStaticResponse ###### 自 1.1.11 起 为每个相应的运行时使用优化的函数处理内联值。 ```ts twoslash import { Elysia } from 'elysia' new Elysia({ nativeStaticResponse: true }) ``` #### 示例 如果在 Bun 上启用,Elysia 将内联值插入到 `Bun.serve.static` 中,从而提高静态值的性能。 ```ts import { Elysia } from 'elysia' // 这是 new Elysia({ nativeStaticResponse: true }).get('/version', 1) // 相当于 Bun.serve({ static: { '/version': new Response(1) } }) ``` ## 规范化 ###### 自 1.1.0 起 Elysia 是否应该将字段强制转换为指定的模式。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia({ normalize: true }) ``` 当在输入和输出中发现不在模式中规定的未知属性时,Elysia 应该如何处理字段? 选项 - @default `true` * `true`: Elysia 将使用 [exact mirror](/blog/elysia-13.html#exact-mirror) 将字段强制转换为指定模式 * `typebox`: Elysia 将使用 [TypeBox's Value.Clean](https://github.com/sinclairzx81/typebox) 将字段强制转换为指定模式 * `false`: 如果请求或响应包含不在各自处理程序的模式中明确允许的字段,Elysia 将引发错误。 ## 预编译 ###### 自 1.0.0 起 Elysia 是否应该在启动服务器之前[预编译所有路由](/blog/elysia-10.html#improved-startup-time)。 ```ts twoslash import { Elysia } from 'elysia' new Elysia({ precompile: true }) ``` 选项 - @default `false` * `true`: 在启动服务器之前对所有路由进行 JIT 编译 * `false`: 动态按需编译路由 推荐将其保持为 `false`。 ## 前缀 定义实例所有路由的前缀 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia({ prefix: '/v1' }) ``` 当定义前缀时,所有路由将以给定值为前缀。 #### 示例 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia({ prefix: '/v1' }).get('/name', 'elysia') // Path is /v1/name ``` ## sanitize 一个函数或一个函数数组,在每个 `t.String` 验证时调用并拦截。 允许我们读取并将字符串转换为新值。 ```ts import { Elysia, t } from 'elysia' new Elysia({ sanitize: (value) => Bun.escapeHTML(value) }) ``` ## 种子 定义一个值,用于生成实例的校验和,用于[插件去重](/essential/plugin.html#plugin-deduplication) ```ts twoslash import { Elysia } from 'elysia' new Elysia({ seed: { value: 'service.thing' } }) ``` 该值可以是任何类型,不限于字符串、数字或对象。 ## 严格路径 Elysia 是否应该严格处理路径。 根据[RFC 3986](https://tools.ietf.org/html/rfc3986#section-3.3),路径应与路由中定义的路径完全相等。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia({ strictPath: true }) ``` #### 选项 - @default `false` * `true` - 严格遵循[RFC 3986](https://tools.ietf.org/html/rfc3986#section-3.3) 进行路径匹配 * `false` - 容忍后缀 '/' 或反之亦然。 #### 示例 ```ts twoslash import { Elysia, t } from 'elysia' // 路径可以是 /name 或 /name/ new Elysia({ strictPath: false }).get('/name', 'elysia') // 路径只能是 /name new Elysia({ strictPath: true }).get('/name', 'elysia') ``` ## 服务 自定义 HTTP 服务器行为。 Bun 服务配置。 ```ts import { Elysia } from 'elysia' new Elysia({ serve: { hostname: 'elysiajs.com', tls: { cert: Bun.file('cert.pem'), key: Bun.file('key.pem') } }, }) ``` 该配置扩展了[Bun Serve API](https://bun.sh/docs/api/http)和[Bun TLS](https://bun.sh/docs/api/http#tls) ### 示例: 最大主体大小 我们可以通过在 `serve` 配置中设置[`serve.maxRequestBodySize`](#serve-maxrequestbodysize)来设置最大主体大小。 ```ts import { Elysia } from 'elysia' new Elysia({ serve: { maxRequestBodySize: 1024 * 1024 * 256 // 256MB } }) ``` 默认情况下,最大请求体大小为 128MB (1024 \* 1024 \* 128)。 定义主体大小限制。 ```ts import { Elysia } from 'elysia' new Elysia({ serve: { // 最大消息大小(以字节为单位) maxPayloadLength: 64 * 1024, } }) ``` ### 示例: HTTPS / TLS 通过传入密钥和证书的值,我们可以启用 TLS(SSL 的继任者);两者均为启用 TLS 所必需。 ```ts import { Elysia, file } from 'elysia' new Elysia({ serve: { tls: { cert: file('cert.pem'), key: file('key.pem') } } }) ``` ### 示例:增加超时 我们可以通过在 `serve` 配置中设置 [`serve.idleTimeout`](#serve-idletimeout) 来增加空闲超时。 ```ts import { Elysia } from 'elysia' new Elysia({ serve: { // Increase idle timeout to 30 seconds idleTimeout: 30 } }) ``` 默认情况下,空闲超时时间为 10 秒(在 Bun 上)。 *** ## serve HTTP 服务器配置。 Elysia 扩展了 Bun 配置,开箱即用地支持 TLS,基于 BoringSSL。 有关可用配置,请参见 [serve.tls](#serve-tls)。 ### serve.hostname @default `0.0.0.0` 服务器应监听的主机名。 ### serve.id Uniquely identify a server instance with an ID This string will be used to hot reload the server without interrupting pending requests or websockets. If not provided, a value will be generated. To disable hot reloading, set this value to `null`. ### serve.idleTimeout @default `10` (10 seconds) By default, Bun set idle timeout to 10 seconds, which means that if a request is not completed within 10 seconds, it will be aborted. ### serve.maxRequestBodySize @default `1024 * 1024 * 128` (128MB) 请求体的最大大小?(以字节为单位) ### serve.port @default `3000` 监听的端口。 ### serve.rejectUnauthorized @default `NODE_TLS_REJECT_UNAUTHORIZED` 环境变量 如果设置为 `false`,将接受任何证书。 ### serve.reusePort @default `true` 是否应设置 `SO_REUSEPORT` 标志。 这允许多个进程绑定到同一端口,对负载均衡很有用。 该配置被覆盖,并默认由 Elysia 打开。 ### serve.unix 如果设置,HTTP 服务器将在 Unix 套接字上监听,而不是在端口上。 (不能与主机名+端口一起使用) ### serve.tls 我们可以通过传入密钥和证书的值启用 TLS(SSL 的继任者);这两者都是启用 TLS 所必需的。 ```ts import { Elysia, file } from 'elysia' new Elysia({ serve: { tls: { cert: file('cert.pem'), key: file('key.pem') } } }) ``` Elysia 扩展了支持 TLS 的 Bun 配置,使用 BoringSSL 作为支持。 ### serve.tls.ca 可选覆盖受信任的 CA 证书。默认是信任 Mozilla 精心挑选的知名 CA。 当使用此选项明确指定 CA 时,Mozilla 的 CA 会完全被替换。 ### serve.tls.cert PEM 格式的证书链。每个私钥应提供一条证书链。 每条证书链应包含为提供的私钥格式化的 PEM 证书,以及 PEM 格式的中间证书(如果有),按顺序排列,不包括根 CA(根 CA 必须提前为对等方所知,参见 ca)。 提供多个证书链时,顺序不必与其在密钥中的私钥顺序相同。 如果未提供中间证书,对等方将无法验证证书,握手将失败。 ### serve.tls.dhParamsFile 自定义 Diffie Helman 参数的 .pem 文件路径。 ### serve.tls.key PEM 格式的私钥。PEM 允许加密私钥的选项。加密密钥将使用 options.passphrase 解密。 可以提供使用不同算法的多个密钥,可以是未加密的密钥字符串或缓冲区的数组,或者以对象形式的数组。 对象形式只能在数组中出现。 **object.passphrase** 是可选的。加密密钥将使用提供的 object.passphrase 解密, **object.passphrase** 如果提供,或 **options.passphrase** 如果未提供。 ### serve.tls.lowMemoryMode @default `false` 将 `OPENSSL_RELEASE_BUFFERS` 设置为 1。 这会降低整体性能,但节省一些内存。 ### serve.tls.passphrase 用于单个私钥和/或 PFX 的共享密码短语。 ### serve.tls.requestCert @default `false` 如果设置为 `true`,服务器将请求客户端证书。 ### serve.tls.secureOptions 可选影响 OpenSSL 协议行为,这通常不是必需的。 应谨慎使用! 值是 OpenSSL 可选选项的 SSL\_OP\_\* 的数字位掩码。 ### serve.tls.serverName 显式设置服务器名称。 ## 标签 为实例的所有路由定义 OpenAPI 方案的标签,类似于[详细信息](#detail)。 ```ts twoslash import { Elysia } from 'elysia' new Elysia({ tags: ['elysia'] }) ``` ### systemRouter 在可能的情况下使用运行时/框架提供的路由器。 在 Bun 上,Elysia 将使用 [Bun.serve.routes](https://bun.sh/docs/api/http#routing) 并回退到 Elysia 自己的路由器。 ## websocket 覆盖 websocket 配置 建议将其保持为默认值,因为 Elysia 将自动生成适合处理 WebSocket 的配置 该配置扩展了 [Bun's WebSocket API](https://bun.sh/docs/api/websockets) #### 示例 ```ts import { Elysia } from 'elysia' new Elysia({ websocket: { // 启用压缩和解压缩 perMessageDeflate: true } }) ``` *** --- --- url: 'https://elysia.cndocs.org/patterns/error-handling.md' --- # 错误处理 本页提供了一个更高级的指南,用于在 Elysia 中有效处理错误。 如果你还没有阅读 **“生命周期 (onError)”**,建议先阅读它。 ## 自定义验证消息 在定义模式时,可以为每个字段提供自定义验证消息。 当验证失败时,该消息将原样返回。 ```ts import { Elysia } from 'elysia' new Elysia().get('/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number({ error: 'id 必须是数字' // [!code ++] }) }) }) ``` 如果 `id` 字段验证失败,响应将返回 `id 必须是数字`。 ### 验证详情 从 `schema.error` 返回一个值将原样返回验证消息,但有时你也希望返回验证细节,比如字段名和期望类型。 你可以通过使用 `validationDetail` 来实现这一点。 ```ts import { Elysia, validationDetail } from 'elysia' // [!code ++] new Elysia().get('/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number({ error: validationDetail('id 必须是数字') // [!code ++] }) }) }) ``` 这将在响应中包含所有验证详情,比如字段名和期望类型。 但是如果你计划在每个字段都使用 `validationDetail`,手动添加会很麻烦。 你可以在 `onError` 钩子中自动处理验证详情。 ```ts new Elysia() .onError(({ error, code }) => { if (code === 'VALIDATION') return error.detail(error.message) // [!code ++] }) .get('/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number({ error: 'id 必须是数字' }) }) }) .listen(3000) ``` 这将为每个带有自定义消息的验证错误添加自定义验证详情。 ## 生产环境中的验证详情 默认情况下,如果 `NODE_ENV` 是 `production`,Elysia 会省略所有验证详情。 这样做是为了防止泄露验证模式的敏感信息,比如字段名和期望类型,这可能被攻击者利用。 Elysia 只会返回验证失败的信息,而不包含任何详情。 ```json { "type": "validation", "on": "body", "found": {}, // 仅对自定义错误显示 "message": "x 必须是数字" } ``` `message` 属性是可选的,默认省略,除非你在模式中提供了自定义错误消息。 ## 自定义错误 Elysia 支持类型层级和实现层级的自定义错误。 默认情况下,Elysia 有一组内置错误类型,如 `VALIDATION`、`NOT_FOUND`,会自动缩小类型。 如果 Elysia 不认识该错误,错误代码将是 `UNKNOWN`,默认状态码为 `500`。 但你也可以通过 `Elysia.error` 添加带类型安全的自定义错误,它能帮助缩小错误类型,提供完整类型安全和自动补全,并支持自定义状态码,如下所示: ```typescript twoslash import { Elysia } from 'elysia' class MyError extends Error { constructor(public message: string) { super(message) } } new Elysia() .error({ MyError }) .onError(({ code, error }) => { switch (code) { // 自动补全 case 'MyError': // 类型缩小 // 悬停查看 error 的类型为 `CustomError` return error } }) .get('/:id', () => { throw new MyError('Hello Error') }) ``` ### 自定义状态码 你也可以通过在自定义错误类中添加 `status` 属性,为你的自定义错误指定状态码。 ```typescript import { Elysia } from 'elysia' class MyError extends Error { status = 418 constructor(public message: string) { super(message) } } ``` 当抛出该错误时,Elysia 会使用此状态码。 否则你也可以在 `onError` 钩子中手动设置状态码。 ```typescript import { Elysia } from 'elysia' class MyError extends Error { constructor(public message: string) { super(message) } } new Elysia() .error({ MyError }) .onError(({ code, error, status }) => { switch (code) { case 'MyError': return status(418, error.message) } }) .get('/:id', () => { throw new MyError('Hello Error') }) ``` ### 自定义错误响应 你也可以在自定义错误类中提供一个自定义的 `toResponse` 方法,当错误被抛出时返回自定义响应。 ```typescript import { Elysia } from 'elysia' class MyError extends Error { status = 418 constructor(public message: string) { super(message) } toResponse() { return Response.json({ error: this.message, code: this.status }, { status: 418 }) } } ``` ## 抛出或返回 大多数错误处理可以通过抛出错误并在 `onError` 中处理完成。 但 `status` 可能会让人困惑,因为它既可以作为返回值也可以抛出错误。 根据你的具体需求,它可以是 **返回** 或 **抛出** 。 * 如果 `status` 被 **抛出**,会被 `onError` 中间件捕获。 * 如果 `status` 被 **返回**,不会被 `onError` 中间件捕获。 请看以下代码: ```typescript import { Elysia, file } from 'elysia' new Elysia() .onError(({ code, error, path }) => { if (code === 418) return 'caught' }) .get('/throw', ({ status }) => { // 这会被 onError 捕获 throw status(418) }) .get('/return', ({ status }) => { // 这不会被 onError 捕获 return status(418) }) ``` --- --- url: 'https://elysia.cndocs.org/plugins/static.md' --- # 静态文件插件 此插件可为 Elysia 服务器提供静态文件/文件夹服务 安装方式: ```bash bun add @elysiajs/static ``` 随后使用: ```typescript twoslash import { Elysia } from 'elysia' import { staticPlugin } from '@elysiajs/static' new Elysia() .use(staticPlugin()) .listen(3000) ``` 默认情况下,静态插件默认文件夹为 `public`,并注册了 `/public` 前缀。 假设您的项目结构如下: ``` | - src | - index.ts | - public | - takodachi.png | - nested | - takodachi.png ``` 可访问路径将变为: * /public/takodachi.png * /public/nested/takodachi.png ## 配置 以下是插件接受的配置选项 ### assets @默认值 `"public"` 要公开为静态资源的文件夹路径 ### prefix @默认值 `"/public"` 注册公共文件的路径前缀 ### ignorePatterns @默认值 `[]` 要忽略不作为静态文件提供的文件列表 ### staticLimit @默认值 `1024` 默认情况下,静态插件会将路径注册到路由器中并带有静态名称,如果超出限制,路径将延迟添加到路由器以减少内存使用。在内存和性能之间进行权衡。 ### alwaysStatic @默认值 `false` 如果设置为 true,静态文件路径将直接注册到路由器,跳过 `staticLimits` 限制。 ### headers @默认值 `{}` 设置文件的响应头 ### indexHTML @默认值 `false` 如果设置为 true,来自静态目录的 `index.html` 文件将用于任何既不匹配路由也不匹配现有静态文件的请求。 ## 模式 以下是使用插件的常见模式。 * [单文件](#单文件) ## 单文件 假设您只想返回单个文件,可以使用 `file` 而不是静态插件 ```typescript import { Elysia, file } from 'elysia' new Elysia() .get('/file', file('public/takodachi.png')) ``` --- --- url: 'https://elysia.cndocs.org/essential/validation.md' --- # 验证 创建 API 服务器的目的是接收输入并对其进行处理。 JavaScript 允许任何数据具有任何类型。Elysia 提供了一个开箱即用的工具来验证数据,以确保数据格式正确。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/id/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number() }) }) .listen(3000) ``` ### TypeBox **Elysia.t** 是一个基于 [TypeBox](https://github.com/sinclairzx81/typebox) 的 schema 构建器,提供运行时、编译时和 OpenAPI schema 生成的类型安全性。 Elysia 扩展并自定义了 TypeBox 的默认行为,以匹配服务器端验证需求。 我们相信验证至少应该由框架原生处理,而不是依赖用户为每个项目设置自定义类型。 ### Standard Schema Elysia 还支持 [Standard Schema](https://github.com/standard-schema/standard-schema),允许您使用喜欢的验证库: * Zod * Valibot * ArkType * Effect Schema * Yup * Joi * [以及更多](https://github.com/standard-schema/standard-schema) 要使用 Standard Schema,只需导入 schema 并将其提供给路由处理器。 ```typescript twoslash import { Elysia } from 'elysia' import { z } from 'zod' import * as v from 'valibot' new Elysia() .get('/id/:id', ({ params: { id }, query: { name } }) => id, { // ^? params: z.object({ id: z.coerce.number() }), query: v.object({ name: v.literal('Lilith') }) }) .listen(3000) ``` 您可以在同一个处理器中使用任何验证器,而不会出现问题。 ## Schema 类型 Elysia 支持以下类型的声明性 schema: *** 这些属性应作为路由处理器的第三个参数提供,以验证传入的请求。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .get('/id/:id', () => 'Hello World!', { query: t.Object({ name: t.String() }), params: t.Object({ id: t.Number() }) }) .listen(3000) ``` 响应应如下所示: | URL | Query | Params | | --- | --------- | ------------ | | /id/a | ❌ | ❌ | | /id/1?name=Elysia | ✅ | ✅ | | /id/1?alias=Elysia | ❌ | ✅ | | /id/a?name=Elysia | ✅ | ❌ | | /id/a?alias=Elysia | ❌ | ❌ | 当提供 schema 时,类型将自动从 schema 推断,并为 API 文档生成 OpenAPI 类型,从而消除手动提供类型的冗余任务。 ## Guard Guard 可用于将 schema 应用于多个处理器。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/none', ({ query }) => 'hi') // ^? .guard({ // [!code ++] query: t.Object({ // [!code ++] name: t.String() // [!code ++] }) // [!code ++] }) // [!code ++] .get('/query', ({ query }) => query) // ^? .listen(3000) ``` 此代码确保后续每个处理器的查询必须具有字符串值的 **name**。响应应列出如下: 响应应列出如下: | Path | Response | | ------------- | -------- | | /none | hi | | /none?name=a | hi | | /query | error | | /query?name=a | a | 如果为同一属性定义了多个全局 schema,则最新的一个将优先。如果同时定义了本地和全局 schema,则本地 schema 将优先。 ### Guard Schema 类型 Guard 支持两种类型来定义验证。 ### **override (默认)** 如果 schema 之间发生冲突,则覆盖 schema。 ![Elysia run with default override guard showing schema gets override](/blog/elysia-13/schema-override.webp) ### **standalone** 分离冲突的 schema,并独立运行两者,从而实现两者的验证。 ![Elysia run with standalone merging multiple guard together](/blog/elysia-13/schema-standalone.webp) 要使用 `schema` 定义 guard 的 schema 类型: ```ts import { Elysia } from 'elysia' new Elysia() .guard({ schema: 'standalone', // [!code ++] response: t.Object({ title: t.String() }) }) ``` ## Body 传入的 [HTTP 消息](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages) 是发送到服务器的数据。它可以是 JSON、form-data 或任何其他格式。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .post('/body', ({ body }) => body, { // ^? body: t.Object({ name: t.String() }) }) .listen(3000) ``` 验证应如下所示: | Body | Validation | | --- | --------- | | { name: 'Elysia' } | ✅ | | { name: 1 } | ❌ | | { alias: 'Elysia' } | ❌ | | `undefined` | ❌ | Elysia 默认禁用 **GET** 和 **HEAD** 消息的 body-parser,遵循 HTTP/1.1 [RFC2616](https://www.rfc-editor.org/rfc/rfc2616#section-4.3) 的规范 > 如果请求方法没有为实体体定义明确的语义,则在处理请求时应忽略消息体。 大多数浏览器默认禁用 **GET** 和 **HEAD** 方法的 body 附件。 #### Specs 验证传入的 [HTTP 消息](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages)(或 body)。 这些消息是 Web 服务器处理的其他附加消息。 body 以与 `fetch` API 中的 `body` 相同的方式提供。内容类型应根据定义的 body 相应设置。 ```typescript fetch('https://elysiajs.com', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Elysia' }) }) ``` ### File 文件是一种特殊的 body 类型,可用于上传文件。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .post('/body', ({ body }) => body, { // ^? body: t.Object({ file: t.File({ format: 'image/*' }), multipleFiles: t.Files() }) }) .listen(3000) ``` 通过提供文件类型,Elysia 将自动假设 content-type 为 `multipart/form-data`。 ### File (Standard Schema) 如果您使用 Standard Schema,请注意 Elysia 将无法像 `t.File` 那样自动验证内容类型。 但是 Elysia 导出了一个 `fileType`,可以使用魔术数字来验证文件类型。 ```typescript twoslash import { Elysia, fileType } from 'elysia' import { z } from 'zod' new Elysia() .post('/body', ({ body }) => body, { body: z.object({ file: z.file().refine((file) => fileType(file, 'image/jpeg')) // [!code ++] }) }) ``` **您应该使用** `fileType` 来验证文件类型,这非常重要,因为**大多数验证器实际上无法正确验证文件**,例如检查内容类型及其值,这可能导致安全漏洞。 ## Query 查询是通过 URL 发送的数据。它可以是 `?key=value` 的形式。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/query', ({ query }) => query, { // ^? query: t.Object({ name: t.String() }) }) .listen(3000) ``` 查询必须以对象形式提供。 验证应如下所示: | Query | Validation | | ---- | --------- | | /?name=Elysia | ✅ | | /?name=1 | ✅ | | /?alias=Elysia | ❌ | | /?name=ElysiaJS\&alias=Elysia | ✅ | | / | ❌ | #### Specs 查询字符串是 URL 的一个部分,以 **?** 开头,可以包含一个或多个查询参数,这些是键值对,用于向服务器传递额外信息,通常用于自定义行为,如过滤或搜索。 ![URL Object](/essential/url-object.svg) 查询在 Fetch API 中在 **?** 之后提供。 ```typescript fetch('https://elysiajs.com/?name=Elysia') ``` 在指定查询参数时,重要的是要理解所有查询参数值必须表示为字符串。这是由于它们如何被编码并附加到 URL。 ### Coercion Elysia 将自动将 `query` 上的适用 schema 强制转换为相应类型。 有关更多信息,请参阅 [Elysia 行为](/patterns/type#elysia-behavior)。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/', ({ query }) => query, { // ^? query: t.Object({ // [!code ++] name: t.Number() // [!code ++] }) // [!code ++] }) .listen(3000) ``` ### Array 默认情况下,即使指定多次,Elysia 也会将查询参数视为单个字符串。 要使用数组,我们需要明确将其声明为数组。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/', ({ query }) => query, { // ^? query: t.Object({ name: t.Array(t.String()) // [!code ++] }) }) .listen(3000) ``` 一旦 Elysia 检测到属性可以分配给数组,Elysia 将将其强制转换为指定类型的数组。 默认情况下,Elysia 使用以下格式格式化查询数组: #### nuqs 此格式由 [nuqs](https://nuqs.47ng.com) 使用。 使用 **,** 作为分隔符,属性将被视为数组。 ``` http://localhost?name=rapi,anis,neon&squad=counter { name: ['rapi', 'anis', 'neon'], squad: 'counter' } ``` #### HTML form format 如果键被指定多次,则键将被视为数组。 这类似于 HTML 表单格式,当具有相同名称的输入被指定多次时。 ``` http://localhost?name=rapi&name=anis&name=neon&squad=counter // name: ['rapi', 'anis', 'neon'] ``` ## Params 参数或路径参数是通过 URL 路径发送的数据。 它们可以是 `/key` 的形式。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/id/:id', ({ params }) => params, { // ^? params: t.Object({ id: t.Number() }) }) ``` 参数必须以对象形式提供。 验证应如下所示: | URL | Validation | | --- | --------- | | /id/1 | ✅ | | /id/a | ❌ | #### Specs 路径参数 (不要与查询字符串或查询参数混淆)。 **此字段通常不需要,因为 Elysia 可以自动从路径参数推断类型**,除非需要特定值模式,例如数字值或模板字面量模式。 ```typescript fetch('https://elysiajs.com/id/1') ``` ### Params type inference 如果未提供 params schema,Elysia 将自动推断类型为字符串。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/id/:id', ({ params }) => params) // ^? ``` ## Headers 标头是通过请求的标头发送的数据。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/headers', ({ headers }) => headers, { // ^? headers: t.Object({ authorization: t.String() }) }) ``` 与其他类型不同,标头默认设置 `additionalProperties` 为 `true`。 这意味着标头可以具有任何键值对,但值必须匹配 schema。 #### Specs HTTP 标头允许客户端和服务器在 HTTP 请求或响应中传递额外信息,通常被视为元数据。 此字段通常用于强制执行某些特定标头字段,例如 `Authorization`。 标头以与 `fetch` API 中的 `body` 相同的方式提供。 ```typescript fetch('https://elysiajs.com/', { headers: { authorization: 'Bearer 12345' } }) ``` ::: tip Elysia 将标头解析为仅小写键。 在使用标头验证时,请确保使用小写字段名。 ::: ## Cookie Cookie 是通过请求的 Cookie 发送的数据。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/cookie', ({ cookie }) => cookie, { // ^? cookie: t.Cookie({ cookieName: t.String() }) }) ``` Cookie 必须以 `t.Cookie` 或 `t.Object` 的形式提供。 与 `headers` 相同,Cookie 默认设置 `additionalProperties` 为 `true`。 #### Specs HTTP Cookie 是服务器发送给客户端的一小段数据。它是与每次访问同一 Web 服务器一起发送的数据,让服务器记住客户端信息。 简单来说,它是一个与每个请求一起发送的字符串化状态。 此字段通常用于强制执行某些特定 Cookie 字段。 Cookie 是一个特殊的标头字段,Fetch API 不接受自定义值,但由浏览器管理。要发送 Cookie,您必须使用 `credentials` 字段: ```typescript fetch('https://elysiajs.com/', { credentials: 'include' }) ``` ### t.Cookie `t.Cookie` 是一个特殊类型,相当于 `t.Object`,但允许设置 Cookie 特定选项。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/cookie', ({ cookie }) => cookie.name.value, { // ^? cookie: t.Cookie({ name: t.String() }, { secure: true, httpOnly: true }) }) ``` ## Response 响应是从处理器返回的数据。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .get('/response', () => { return { name: 'Jane Doe' } }, { response: t.Object({ name: t.String() }) }) ``` ### Response per status 响应可以按状态码设置。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .get('/response', ({ status }) => { if (Math.random() > 0.5) return status(400, { error: 'Something went wrong' }) return { name: 'Jane Doe' } }, { response: { 200: t.Object({ name: t.String() }), 400: t.Object({ error: t.String() }) } }) ``` 这是 Elysia 特定的功能,允许我们将字段设置为可选。 ## Error Provider 验证失败时,提供自定义错误消息有两种方式: 1. 内联 `status` 属性 2. 使用 [onError](/essential/life-cycle.html#on-error) 事件 ### Error Property Elysia 提供额外的 **error** 属性,允许我们在字段无效时返回自定义错误消息。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .post('/', () => 'Hello World!', { body: t.Object({ x: t.Number({ error: 'x must be a number' }) }) }) .listen(3000) ``` 以下是使用错误属性在各种类型上的示例: ```typescript t.String({ format: 'email', error: 'Invalid email :(' }) ``` ``` Invalid Email :( ``` ```typescript t.Array( t.String(), { error: 'All members must be a string' } ) ``` ``` All members must be a string ``` ```typescript t.Object({ x: t.Number() }, { error: 'Invalid object UnU' }) ``` ``` Invalid object UnU ``` ```typescript t.Object({ x: t.Number({ error({ errors, type, validation, value }) { return 'Expected x to be a number' } }) }) ``` ``` Expected x to be a number ``` ## Custom Error TypeBox 提供额外的 "**error**" 属性,允许我们在字段无效时返回自定义错误消息。 ```typescript t.String({ format: 'email', error: 'Invalid email :(' }) ``` ``` Invalid Email :( ``` ```typescript t.Object({ x: t.Number() }, { error: 'Invalid object UnU' }) ``` ``` Invalid object UnU ``` ### Error message as function 除了字符串之外,Elysia 类型的 error 还可以接受一个函数,以编程方式为每个属性返回自定义错误。 错误函数接受与 `ValidationError` 相同的参数 ```typescript import { Elysia, t } from 'elysia' new Elysia() .post('/', () => 'Hello World!', { body: t.Object({ x: t.Number({ error() { return 'Expected x to be a number' } }) }) }) .listen(3000) ``` ::: tip 将鼠标悬停在 `error` 上以查看类型。 ::: ### Error is Called Per Field 请注意,错误函数仅在字段无效时才会被调用。 请考虑以下表格: ```typescript t.Object({ x: t.Number({ error() { return 'Expected x to be a number' } }) }) ``` ```json { x: "hello" } ``` ```typescript t.Object({ x: t.Number({ error() { return 'Expected x to be a number' } }) }) ``` ```json "hello" ``` ```typescript t.Object( { x: t.Number({ error() { return 'Expected x to be a number' } }) }, { error() { return 'Expected value to be an object' } } ) ``` ```json "hello" ``` ### onError 我们可以通过将错误代码缩小到 "**VALIDATION**" 来基于 [onError](/essential/life-cycle.html#on-error) 事件自定义验证行为。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .onError(({ code, error }) => { if (code === 'VALIDATION') return error.message }) .listen(3000) ``` 缩小后的错误类型将被键入为从 **elysia/error** 导入的 `ValidationError`。 **ValidationError** 暴露了一个名为 **validator** 的属性,键入为 [TypeCheck](https://github.com/sinclairzx81/typebox#typecheck),允许我们开箱即用地与 TypeBox 功能交互。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .onError(({ code, error }) => { if (code === 'VALIDATION') return error.all[0].message }) .listen(3000) ``` ### Error List **ValidationError** 提供了一个 `ValidatorError.all` 方法,允许我们列出所有错误原因。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .post('/', ({ body }) => body, { body: t.Object({ name: t.String(), age: t.Number() }), error({ code, error }) { switch (code) { case 'VALIDATION': console.log(error.all) // Find a specific error name (path is OpenAPI Schema compliance) const name = error.all.find( (x) => x.summary && x.path === '/name' ) // If there is a validation error, then log it if(name) console.log(name) } } }) .listen(3000) ``` 有关 TypeBox 验证器的更多信息,请参阅 [TypeCheck](https://github.com/sinclairzx81/typebox#typecheck)。 ## Reference Model 有时您可能会发现自己声明重复的模型或多次重用相同的模型。 使用引用模型,我们可以命名我们的模型并通过引用名称重用它。 让我们从一个简单场景开始。 假设我们有一个使用相同模型处理登录的控制器。 ```typescript twoslash import { Elysia, t } from 'elysia' const app = new Elysia() .post('/sign-in', ({ body }) => body, { body: t.Object({ username: t.String(), password: t.String() }), response: t.Object({ username: t.String(), password: t.String() }) }) ``` 我们可以通过将模型提取为变量并引用它来重构代码。 ```typescript twoslash import { Elysia, t } from 'elysia' // Maybe in a different file eg. models.ts const SignDTO = t.Object({ username: t.String(), password: t.String() }) const app = new Elysia() .post('/sign-in', ({ body }) => body, { body: SignDTO, response: SignDTO }) ``` 这种关注点分离的方法是一种有效的方法,但随着应用程序变得更加复杂,我们可能会发现自己重用多个模型与不同的控制器。 我们可以通过创建“引用模型”来解决这个问题,允许我们命名模型并通过使用 `model` 注册模型来直接在 `schema` 中使用自动完成引用它。 ```typescript twoslash import { Elysia, t } from 'elysia' const app = new Elysia() .model({ sign: t.Object({ username: t.String(), password: t.String() }) }) .post('/sign-in', ({ body }) => body, { // with auto-completion for existing model name body: 'sign', response: 'sign' }) ``` 当我们想要访问模型组时,我们可以将 `model` 分离为插件,当注册时,它将提供一组模型而不是多个导入。 ```typescript // auth.model.ts import { Elysia, t } from 'elysia' export const authModel = new Elysia() .model({ sign: t.Object({ username: t.String(), password: t.String() }) }) ``` 然后在实例文件中: ```typescript twoslash // @filename: auth.model.ts import { Elysia, t } from 'elysia' export const authModel = new Elysia() .model({ sign: t.Object({ username: t.String(), password: t.String() }) }) // @filename: index.ts // ---cut--- // index.ts import { Elysia } from 'elysia' import { authModel } from './auth.model' const app = new Elysia() .use(authModel) .post('/sign-in', ({ body }) => body, { // with auto-completion for existing model name body: 'sign', response: 'sign' }) ``` 这种方法不仅允许我们分离关注点,还使我们能够在多个地方重用模型,同时将模型集成到 OpenAPI 文档中。 ### Multiple Models `model` 接受一个对象,键为模型名称,值为模型定义。默认支持多个模型。 ```typescript // auth.model.ts import { Elysia, t } from 'elysia' export const authModel = new Elysia() .model({ number: t.Number(), sign: t.Object({ username: t.String(), password: t.String() }) }) ``` ### Naming Convention 重复的模型名称将导致 Elysia 抛出错误。为了防止声明重复的模型名称,我们可以使用以下命名约定。 假设我们将所有模型存储在 `models/.ts` 中,并将模型的前缀声明为命名空间。 ```typescript import { Elysia, t } from 'elysia' // admin.model.ts export const adminModels = new Elysia() .model({ 'admin.auth': t.Object({ username: t.String(), password: t.String() }) }) // user.model.ts export const userModels = new Elysia() .model({ 'user.auth': t.Object({ username: t.String(), password: t.String() }) }) ``` 这可以在一定程度上防止命名重复,但最终,最好让您的团队决定命名约定。 Elysia 提供了一个有主见的选项来帮助防止决策疲劳。 ### TypeScript 我们可以通过访问 `static` 属性来获取每个 Elysia/TypeBox 类型的类型定义,如下所示: ```ts twoslash import { t } from 'elysia' const MyType = t.Object({ hello: t.Literal('Elysia') }) type MyType = typeof MyType.static // ^? ``` 这允许 Elysia 自动推断并提供类型,从而减少声明重复 schema 的需求 单个 Elysia/TypeBox schema 可以用于: * 运行时验证 * 数据强制转换 * TypeScript 类型 * OpenAPI schema 这允许我们将 schema 作为 **单一真相来源**。