--- url: /plugins/graphql-apollo.md --- # GraphQL Apollo 插件 用于 [elysia](https://github.com/elysiajs/elysia) 的插件,可以使用 GraphQL Apollo。 使用以下命令安装: ```bash bun add graphql @elysiajs/apollo @apollo/server ``` 然后使用它: ```typescript twoslash 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: /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: /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` 设置为为空或 `/`。 ## Swagger / OpenAPI 更好的身份验证支持使用 `better-auth/plugins` 的 `openapi`。 然而,如果我们使用 [@elysiajs/swagger](/plugins/swagger),您可能希望从更好的身份验证实例中提取文档。 我们可以通过以下代码实现: ```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 { swagger } from '@elysiajs/swagger' import { OpenAPI } from './auth' const app = new Elysia().use( swagger({ 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: /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: /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: /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 } const a = spread(table.user, 'insert') /** * 将 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: /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: /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: /eden/treaty/response.md --- # 响应 调用 fetch 方法后,Eden Treaty 返回一个 Promise,其中包含以下属性: * data - 响应的返回值(2xx) * error - 响应的返回值(>= 3xx) * response `Response` - Web 标准响应类 * 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 }) // type: string | null console.log(data) if (error) switch(error.status) { case 400: // 错误类型将被收窄 throw error.value default: throw error.value } // 一旦错误被处理,类型将被解包 // type: string return data } ``` 默认情况下,Elysia 会自动推断 `error` 和 `response` 的类型为 TypeScript,而 Eden 将提供自动完成和类型收窄以确保准确的行为。 ::: tip 如果服务器的响应 HTTP 状态 >= 300,则值将始终为 null,而 `error` 将有一个返回值。 否则,响应将传递给 data。 ::: ## 流响应 Eden 将视流响应为 `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) // ^? ``` --- --- url: /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: /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('/', () => '嗨 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('/', '嗨 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 ++] // 响应: 嗨 Elysia const { data: index } = await client.get() // 响应: 1895 const { data: id } = await client.id({ id: 1895 }).get() // 响应: { 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 是严格类型 // [!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.file` 或类似 API 的 Bun 特定功能并从处理程序返回它,您可能需要将 Bun 类型定义安装到客户端。 ```bash bun add -d @types/bun ``` ### 路径别名(单一代码库) 如果您在单一代码库中使用路径别名,请确保前端能够与后端相同地解析路径。 ::: tip 在单体库中设置路径别名有点棘手,您可以分叉我们的示例模板:[Kozeki 模板](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 ``` 您**必须**确保您的前端代码能够解析相同的路径别名,否则类型推断将被解析为任何类型。 ```typescript import { treaty } from '@elysiajs/eden' import type { app } from '@/index' const client = treaty('localhost:3000') // 这应该能够在前端和后端解析相同的模块,而不是 `any`。 import { a, b } from '@/controllers' ``` 要解决此问题,您必须确保路径别名在前端和后端解析为相同的文件。 因此,您必须将 **tsconfig.json** 中的路径别名更改为: ```json { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["../apps/backend/src/*"] } } } ``` 如果配置正确,您应该能够在前端和后端解析相同的模块。 ```typescript // 这应该能够在前端和后端解析相同的模块,而不是 `any`。 import { a, b } from '@/controllers' ``` #### Scope 我们建议在您的单体仓库中的每个模块前添加一个 **scope** 前缀,以避免可能发生的任何混淆和冲突。 ```json { "compilerOptions": { "baseUrl": ".", "paths": { "@frontend/*": ["./apps/frontend/src/*"], "@backend/*": ["./apps/backend/src/*"] } } } ``` 然后你可以像这样导入模块: ```typescript // Should work in both frontend and backend and not return `any` import { a, b } from '@backend/controllers' ``` 我们建议创建一个 **single tsconfig.json**,将 `baseUrl` 定义为您仓库的根目录,根据模块位置提供路径,并为每个模块创建一个继承根 **tsconfig.json** 的 **tsconfig.json**,该文件具有路径别名。 您可以在这个 [路径别名示例库](https://github.com/SaltyAom/elysia-monorepo-path-alias) 或 [Kozeki 模板](https://github.com/SaltyAom/kozeki-template) 中找到一个工作示例。 --- --- url: /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: /midori.md --- ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', '你好,世界') .get('/json', { hello: 'world' }) .get('/id/:id', ({ params: { id } }) => id) .listen(3000) ``` ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .post( '/profile', // ↓ 悬停我 ↓ ({ body }) => body, { body: t.Object({ username: t.String() }) } ) .listen(3000) ``` ```ts twoslash // @filename: controllers.ts import { Elysia } from 'elysia' export const users = new Elysia() .get('/users', '梦幻的谐音') export const feed = new Elysia() .get('/feed', ['Hoshino', 'Griseo', 'Astro']) // @filename: server.ts // ---cut--- import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' import { users, feed } from './controllers' new Elysia() .use(swagger()) .use(users) .use(feed) .listen(3000) ``` ```typescript twoslash // @filename: server.ts // ---cut--- // server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .patch( '/user/profile', ({ body, status }) => { if(body.age < 18) return status(400, "哦不") if(body.name === 'Nagisa') return status(418) return body }, { body: t.Object({ name: t.String(), age: t.Number() }) } ) .listen(80) export type App = typeof app ``` ```typescript twoslash // @errors: 2322 1003 // @filename: server.ts import { Elysia, t } from 'elysia' const app = new Elysia() .patch( '/user/profile', ({ body, status }) => { if(body.age < 18) return status(400, "哦不") if(body.name === 'Nagisa') return status(418) return body }, { body: t.Object({ name: t.String(), age: t.Number() }) } ) .listen(80) export type App = typeof app // @filename: client.ts // ---cut--- // client.ts import { treaty } from '@elysiajs/eden' import type { App } from './server' const api = treaty('localhost') const { data, error } = await api.user.profile.patch({ name: 'saltyaom', age: '21' }) if(error) switch(error.status) { case 400: throw error.value // ^? case 418: throw error.value // ^? } data // ^? ``` --- --- url: /blog/elysia-02.md --- \ 「[祝福](https://youtu.be/3eytpBOkOFA)」带来了更多改进,主要集中在 TypeScript 性能、类型推断、更好的自动补全以及一些新功能,以减少样板代码。 以 YOASOBI 的歌曲「祝福」命名,这是《机动战士高达:水星的魔女》的主题曲。 ## 延迟 / 懒加载模块 Elysia 0.2 现在添加了对懒加载模块和异步插件的支持。 这使得插件注册可以延期,并在 Elysia 服务器启动后逐步应用,从而在无服务器/边缘环境中实现尽可能快的启动时间。 要创建延迟模块,只需将插件标记为异步: ```typescript const plugin = async (app: Elysia) => { const stuff = await doSomeHeavyWork() return app.get('/heavy', stuff) } app.use(plugin) ``` ### 懒加载 某些模块可能很大,在启动服务器之前导入可能不是一个好主意。 我们可以告诉 Elysia 跳过该模块,然后稍后注册模块,并在加载完成时使用 `import` 语句在 `use` 中注册模块: ```typescript app.use(import('./some-heavy-module')) ``` 这将在导入完成后注册模块,使模块实现懒加载。 延迟插件和懒加载模块将直接提供所有类型推断。 ## 参考模型 现在 Elysia 可以记住模式并在 Schema 字段中直接引用模式,而无需通过 `Elysia.setModel` 创建导入文件。 可用模式列表带来了自动补全、完整的类型推断和您对内联模式的期望的验证。 要使用参考模型,首先使用 `setModel` 注册模型,然后在 `schema` 中写入模型名称以引用模型: ```typescript const app = new Elysia() .setModel({ sign: t.Object({ username: t.String(), password: t.String() }) }) .post('/sign', ({ body }) => body, { schema: { body: 'sign', response: 'sign' } }) ``` 这将带来已知模型的自动补全。 以及类型引用,防止您意外返回无效类型。 使用 `@elysiajs/swagger` 还会创建一个单独的 `Model` 部分,用于列出可用模型。 参考也会处理您预期的验证。 简而言之,它与内联模式相同,但现在您只需输入模式名称即可处理验证和类型,而不是一长串导入。 ## OpenAPI 详细字段 介绍新字段 `schema.detail`,用于自定义路由的详细信息,遵循 OpenAPI Schema V2 的标准,并具有自动补全。 这使您能够编写更好的文档,并根据您的需求完全可编辑的 Swagger: ## 联合类型 Elysia 的先前版本有时在不同的联合类型上遇到问题,因为 Elysia 尝试捕捉响应以创建 Eden 的完整类型引用。 导致可能类型的无效化。 ## 联合响应 现在支持联合类型,使用 `schema.response[statusCode]` 返回多个响应状态。 ```typescript app .post( '/json/:id', ({ body, params: { id } }) => ({ ...body, id }), { schema: { body: 'sign', response: { 200: t.Object({ username: t.String(), password: t.String(), id: t.String() }), 400: t.Object({ error: t.String() }) } } } ) ``` Elysia 会尝试验证 `response` 中的所有模式,允许返回其中一种类型。 返回类型也在 Swagger 的响应中报告。 ## 更快的类型推断 随着 Elysia 0.1 探索使用类型推断以改善开发者体验,我们发现有时更新类型推断需要很长时间,因为重的类型推断和低效的自定义泛型。 现在 Elysia 0.2 针对更快的类型推断进行了优化,防止重的类型解包的重复,导致更新类型和推断的性能更好。 ## 生态系统 Elysia 0.2 启用异步插件和延迟模块,许多之前不可能的新插件成为现实。 例如: * Elysia 静态插件,具有非阻塞能力 * Eden,支持多个响应的联合类型推断 * 新 Elysia Apollo 插件 ### 重要改进: * `onRequest` 和 `onParse` 现在可以访问 `PreContext` * 默认支持 `application/x-www-form-urlencoded` * 请求体解析器现在解析具有额外属性的 `content-type`,例如 `application/json;charset=utf-8` * 解码 URI 参数路径参数 * 如果没有安装 Elysia,Eden 现在会报告错误 * 跳过现有模型和装饰器的声明 ### 破坏性更改: * `onParse` 现在接受 `(context: PreContext, contentType: string)` 而不是 `(request: Request, contentType: string)` * 迁移时,请将 `.request` 添加到上下文以访问 `Request` ### 之后 感谢您支持 Elysia,并对这个项目感兴趣。 此版本带来了更好的开发体验,希望让您能够使用 Bun 编写出色的软件。 现在我们有了 [Discord 服务器](https://discord.gg/eaFJ2KDJck),您可以在此询问有关 Elysia 的任何问题,或者只是放松一下,也是非常欢迎的。 借助这些优秀的工具,我们期待看到您将构建出什么精彩的软件。 > 不要成为别人描绘的那些形象的一部分 > > 不要在别人选择的展示中前进 > > 你和我,活着去书写我们的故事 > > 永远不会让你孤单,远离你的身边 --- --- url: /blog/elysia-03.md --- \ 以 Camellia 的歌曲[「大地の閾を探して \[Looking for Edge of Ground\]」](https://youtu.be/oyJf72je2U0) ft. 初音未来 命名,是我最喜欢的 Camellia 专辑「U.U.F.O」的最后一曲。这首歌对我个人影响深远,因此我不会轻视这个名字。 这是最具挑战性的更新,带来了迄今为止最大的 Elysia 发布,重新思考和重新设计了 Elysia 架构,以实现高可扩展性,同时尽量减少破坏性更改。 我很高兴地宣布 Elysia 0.3 的发布候选版本,令人兴奋的新功能即将到来。 ## Elysia Fn 介绍 Elysia Fn,可以在前端运行任何后端函数,具备完整的自动补全和类型支持。 为了快速开发,Elysia Fn 允许你“暴露”后端代码,以便从前端调用,具备完整的类型安全、自动补全、原始代码注释和“点击定义”功能,帮助你加速开发进程。 你可以通过 Eden Fn 将 Elysia Fn 与 Eden 结合使用,以实现完全的类型安全。 ### 权限 你可以限制函数的允许或拒绝范围,检查授权头和其他头字段,验证参数,或以编程方式限制密钥访问。 密钥检查支持类型安全和自动补全所有可能的函数,因此你不会错过某些功能或意外输入错误的名称。 ![Narrowed Key](/blog/elysia-03/narrowed-key.webp) 以编程方式缩小属性范围也会缩小参数的类型,换句话说,实现了完全的类型安全。 ![Narrowed Params](/blog/elysia-03/narrowed-param.webp) ### 技术细节 在技术细节上,Elysia Fn 使用 JavaScript 的 Proxy 来捕获对象属性和参数,以创建批量请求发送到服务器进行处理,并在网络上返回值。 Elysia Fn 扩展了 superjson,允许在 JSON 数据中解析 JavaScript 的原生类型,如 Error、Map、Set 和 undefined。 Elysia Fn 支持多种用例,例如在客户端 Nextjs 应用中访问 Prisma。 理论上,可以使用 Redis、Sequelize、RabbitMQ 等等。 由于 Elysia 运行在 Bun 上,Elysia Fn 可以并发运行超过 120 万次操作/秒(在 M1 Max 上测试)。 了解更多关于 Elysia Fn 的信息,请访问 [Eden Fn](/plugins/eden/fn)。 ## 类型重构 类型检查速度提高了 6.5-9 倍,类型行数减少无法计数。 Elysia 0.3 中,超过 80% 的 Elysia 和 Eden 类型已经重写,专注于性能、类型推断和快速自动补全。 对超过 350 条复杂路由进行测试,Elysia 仅需 0.22 秒即可生成用于 Eden 的类型声明。 由于 Elysia 路由现在直接编译为字面对象而不是 Typebox 引用,Elysia 的类型声明比 0.2 版本小得多,Eden 更易于使用。这里的小得多的意思是 50%-99% 更小。 不仅 Elysia 与 TypeScript 的集成显著提升,Elysia 对 TypeScript 和你的代码的理解也更好。 例如,在 0.3 版本中,Elysia 在插件注册时不再那么严格,允许你在没有完全类型补全的情况下注册插件。 内联的 `use` 函数现在推断父类型,而嵌套守卫可以更准确地引用父类的模型类型。 类型声明现在也可以构建和导出。 经过类型重构,Elysia 对 TypeScript 的理解远超以前,类型补全的速度将显著加快,我们鼓励你尝试一下,看看有多快。 有关更多细节,请参见这条 [Twitter 线程](https://twitter.com/saltyAom/status/1629876280517869568?s=20)。 ## 文件上传 感谢 Bun 0.5.7,Elysia 0.3 中默认实现并启用了表单数据 `multipart/formdata`。 为上传文件定义类型补全和验证后,`Elysia.t` 现在通过 `File` 和 `Files` 扩展 TypeBox 以进行文件验证。 验证包括检查文件类型,并自动补全标准文件大小,文件的最小和最大大小,以及每个字段的文件总数。 Elysia 0.3 还引入了 `schema.contentType`,以明确验证传入请求类型,在验证数据之前严格检查头信息。 ## OpenAPI Schema 3.0.x 在 Elysia 0.3 中,Elysia 现在默认使用 OpenAPI schema 3.0.x,以更好地声明 API 定义,并根据内容类型更好地支持多种类型。 `schema.details` 现已更新为 OpenAPI 3.0.x,Elysia 还更新了 Swagger 插件,以匹配 OpenAPI 3.0.x,利用 OpenAPI 3 和 Swagger 中的新功能,尤其是文件上传方面。 ## Eden 重构 为了支持更高的 Elysia 需求,支持 Elysia Fn、Rest 等,Eden 被重新设计以与新架构扩展。 Eden 现在导出 3 类型的函数。 * [Eden Treaty](/plugins/eden/treaty) `eden/treaty`: 原始的 Eden 语法 * [Eden Fn](/plugins/eden/fn) `eden/fn`: 访问 Elysia Fn * [Eden Fetch](/plugins/eden/fetch) `eden/fetch`: 类似 fetch 的语法,适用于复杂的 Elysia 类型 (> 1,000 路由 / Elysia 实例) 通过重构类型定义和支持 Elysia Eden,Eden 现在在推断服务器类型时显著更快和更好。 自动补全速度更快,使用的资源比以往更少,实际上,Eden 的类型声明几乎重构了 100%,以减少大小和推断时间,使其在眨眼之间支持超过 350 条路由的自动补全(约 0.26 秒)。 为了使 Elysia Eden 完全类型安全,利用 Elysia 对 TypeScript 更好的理解,Eden 现在可以根据响应状态缩小类型,使你能够在任何条件下准确捕获类型。 ![Narrowed error.webp](/blog/elysia-03/narrowed-error.webp) ### 显著改进: * 添加字符串格式:'email'、'uuid'、'date'、'date-time' * 更新 @sinclair/typebox 至 0.25.24 * 更新 Raikiri 至 0.2.0-beta.0 (ei) * 感谢 #21 添加文件上传测试 (@amirrezamahyari) * 为 Eden 预编译小写方法 * 为大多数 Elysia 类型减少复杂指令 * 将 `ElysiaRoute` 类型编译为字面量 * 优化类型编译、类型推断和自动补全 * 提高类型编译速度 * 改进插件注册中的 TypeScript 推断 * 优化 TypeScript 推断大小 * 上下文创建优化 * 默认使用 Raikiri 路由 * 移除未使用的函数 * 重构 `registerSchemaPath` 以支持 OpenAPI 3.0.3 * 为 Eden 添加 `error` 推断 * 将 `@sinclair/typebox` 标记为可选的 `peerDenpendencies` 修复: * Raikiri 0.2 在未找到时抛出错误 * 与 `t.File` 的联合响应无法工作 * Swagger 中未定义定义 * 在分组插件中缺少详细信息 * 分组插件无法编译架构 * 因为 EXPOSED 是私有属性,分组不能导出 * 多个 cookies 不将 `content-type` 设置为 `application/json` * 使用 `fn.permission` 时 `EXPOSED` 未导出 * `.ws` 合并返回类型缺失 * 缺少 nanoid * 上下文副作用 * Swagger 中的 `t.Files` 引用单个文件 * Eden 响应类型未知 * 通过 Eden 无法类型化 `setModel` 推断定义 * 在不允许权限的函数中处理抛出的错误 * 导出的变量使用了外部模块的名称 'SCHEMA' * 导出的变量使用了外部模块的名称 'DEFS' * 在 `tsconfig.json` 中使用 `declaration: true` 建立 Elysia 应用时可能出现错误 重大变更: * 将 `inject` 重命名为 `derive` * 弃用 `ElysiaRoute`,更改为内联 * 移除 `derive` * 从 OpenAPI 2.x 更新到 OpenAPI 3.0.3 * 将 context.store\[SYMBOL] 移动到 meta\[SYMBOL] ## 之后 随着 Elysia Fn 的引入,我个人很期待它在前端开发中的应用,打破前端与后端的界限。而 Elysia 的类型重构,使类型检查和自动补全变得更快。 我期待着看到你们使用 Elysia 创建你们要构建的精彩事物。 我们有专门的 [Discord 服务器](https://discord.gg/eaFJ2KDJck) 支持 Elysia。欢迎随时打招呼或畅所欲言。 感谢您对 Elysia 的支持。 > 在永无止境的天幕下 > > 在没有名字的悬崖上 > > 我只是嚎叫 > > 希望那无尽的回响能传达到你 > > 我相信某天,我会站在大地的边缘 > > (直到那天我能回到你身边告诉你) --- --- url: /blog/elysia-04.md --- \ 该名称源自于 [《骗子公主演唱盲王子》预告片](https://youtu.be/UdBespMvxaA) 的开场音乐,由 Akiko Shikata 作曲和演唱的 [「月夜的音乐会」(Moonlit Night Concert)](https://youtu.be/o8b-IQulh1c)。 这个版本没有引入令人兴奋的新功能,而是为 Elysia 的未来打下了更坚实的基础,并进行了内部改进。 ## 提前编译 默认情况下,Elysia 必须处理多种情况下的条件检查,例如,在执行之前检查路由的生命周期是否存在,或在提供的情况下解包验证模式。 这为 Elysia 引入了最小的开销,因为即使路由没有附加生命周期事件,仍需要在运行时进行检查。 由于每个函数都在编译时进行检查,因此不可能有条件的异步,例如,返回文件的简单路由应该是同步的,但由于这是编译时检查,有些路由可能是异步的,从而使相同的简单路由也变成异步的。 异步函数为函数引入额外的周期,导致性能稍慢。但由于 Elysia 是 Web 服务器的基础,我们希望优化每个部分,以确保您不会遇到性能问题。 我们通过引入提前编译来修复这种小开销。 顾名思义,Elysia 会在编译时检查生命周期、验证和异步函数的可能性,并生成一个紧凑的函数,去掉不必要的部分,如未使用的生命周期和验证。 使条件异步函数成为可能,因为我们不再使用一个中央函数来处理,而是为每个路由单独构建一个新函数。然后 Elysia 会检查所有生命周期函数和处理程序,以查看是否存在异步,如果没有,则函数将同步以减少额外开销。 ## TypeBox 0.26 TypeBox 是一个库,为 Elysia 提供了验证和类型提供者,以创建类型级别的单一真相来源,重新导出为 **Elysia.t**。 在此更新中,我们将 TypeBox 从 0.25.4 更新到 0.26。 这带来了许多改进和新功能,例如,`Not` 类型和用于 `coercion` 值的 `Convert`,我们可能会在 Elysia 的下一个版本中支持。 但对 Elysia 的一个好处是,`Error.First()`,它允许我们获取第一个类型错误,而不是使用迭代器,这减少了创建新错误以发送回客户端的开销。 对 **TypeBox** 和 **Elysia.t** 进行了一些更改,通常不会对您产生太大影响,但您可以在 [这里查看 TypeBox 的新功能。](https://github.com/sinclairzx81/typebox/blob/master/changelog/0.26.0.md) ## 按状态验证响应 之前,Elysia 使用联合类型验证多个状态的响应。 这可能在高度动态的应用程序中产生意想不到的结果,特别是当状态响应严格时。 例如,如果您有一个路由如下: ```ts app.post('/strict-status', process, { schema: { response: { 200: t.String(), 400: t.Number() } } }) ``` 如果 200 响应不是字符串,那么应该抛出验证错误,但实际上,不会抛出错误,因为响应验证使用的是联合。这可能会导致向最终用户返回意外的值,并对 Eden Treaty 产生类型错误。 此版本将响应按状态验证,而不是使用联合,这意味着它将严格根据响应状态进行验证,而不是联合类型。 ## 分离 Elysia 函数 Elysia 函数是 Elysia 的一个很好的补充,与 Eden 一起,它打破了客户端与服务器之间的界限,使您能够在客户端使用任何服务器端函数,完全类型安全,甚至支持 primitive 类型,如 Error、Set 和 Map。 但是,随着 primitive 类型的支持,Elysia 函数依赖于 "superjson",这大约占 Elysia 的依赖大小的 38%。 在此版本中,使用 Elysia 函数需要您明确安装 `@elysiajs/fn`。这种方法类似于安装其他功能,就像 `cargo add --feature` 一样。 这样,Elysia 对于不使用 Elysia 函数的服务器来说更加轻便,遵循我们的理念,**确保您拥有实际需要的功能** 然而,如果您忘记安装 Elysia 函数并意外使用了 Elysia 函数,将会出现类型警告,提醒您在使用之前安装 Elysia 函数,并且会有运行时错误提示相同的信息。 在迁移方面,除了需要明确安装 `@elysiajs/fn` 的重大更改外,没有其他迁移需求。 ## 条件路由 此版本引入了用于注册条件路由或插件的 `.if` 方法。 这允许您针对特定条件进行声明,例如在生产环境中排除 Swagger 文档。 ```ts const isProduction = process.env.NODE_ENV === 'production' const app = new Elysia().if(!isProduction, (app) => app.use(swagger()) ) ``` Eden Treaty 将能够识别该路由,就像它是一个普通路由/插件一样。 ## 自定义验证错误 非常感谢 amirrezamahyari 在 [#31](https://github.com/elysiajs/elysia/pull/31) 上的贡献,使得 Elysia 能够访问 TypeBox 的错误属性,从而获得更好的程序错误响应。 ```ts new Elysia() .onError(({ code, error, set }) => { if (code === 'NOT_FOUND') { set.status = 404 return '未找到 :(' } if (code === 'VALIDATION') { set.status = 400 return { fields: error.all() } } }) .post('/sign-in', () => 'hi', { schema: { body: t.Object({ username: t.String(), password: t.String() }) } }) .listen(3000) ``` 现在,您可以为您的 API 创建验证错误,而不仅限于第一个错误。 *** ### 显著改进: * 更新 TypeBox 到 0.26.8 * 内联声明响应类型 * 重构某些类型以加快响应速度 * 使用 Typebox `Error().First()` 代替迭代 * 添加 `innerHandle` 用于返回实际响应(进行基准测试) ### 重大更改: * 将 `.fn` 分离到 `@elysiajs/fn` ## 之后 这个版本可能不是一个具有令人兴奋的新功能的大版本,但它改善了一个坚实的基础,并证明了我对未来 Elysia 计划的概念,使 Elysia 比以前更快、更灵活。 我对未来的展望感到非常兴奋。 感谢您对 Elysia 的持续支持~ > 月夜的音乐会,我们的秘密 > > 让我们重新开始,不放开这只手 > 月夜的音乐会,我们的羁绊 > > 我想告诉你,“你不是骗子” > 我心中的记忆像一朵不断绽放的花 > > 无论你是什么样子,你是我的歌者 > > 今晚要在我身边 --- --- url: /blog/elysia-05.md --- \ 这是以 Arknights 原作音乐「[Radiant](https://youtu.be/QhUjD--UUV4)」为名,由 Monster Sirent Records 作曲。 Radiant 推动了性能的边界,特别是在类型和动态路由的稳定性改进方面。 ## 静态代码分析 随着 Elysia 0.4 引入的提前编译,使得 Elysia 能够优化函数调用并消除我们之前的许多开销。 今天,我们将提前编译扩展为更快的静态代码分析,成为最快的 Bun 网络框架。 静态代码分析允许 Elysia 读取您的函数、处理程序、生命周期和模式,然后尝试调整提取处理程序,提前编译处理程序,消除任何未使用的代码并在可能的地方进行优化。 例如,如果您使用 `schema` 并且主体类型为对象,Elysia 期望该路由是 JSON 优先的,并将主体解析为 JSON,而不是依赖于内容类型头的动态检查: ```ts app.post('/sign-in', ({ body }) => signIn(body), { schema: { body: t.Object({ username: t.String(), password: t.String() }) } }) ``` 这使我们能够将主体解析的性能提高近 2.5 倍。 通过静态代码分析,不再依赖于猜测您是否会使用昂贵的属性。 Elysia 可以读取您的代码并检测您将使用什么,并提前调整以适应您的需求。 这意味着如果您没有使用像 `query` 或 `body` 这样的昂贵属性,Elysia 将完全跳过解析,以提高性能。 ```ts // 身体未使用,跳过身体解析 app.post('/id/:id', ({ params: { id } }) => id, { schema: { body: t.Object({ username: t.String(), password: t.String() }) } }) ``` 通过静态代码分析和提前编译,您可以放心,Elysia 非常擅长读取您的代码并自动调整以最大化性能。 静态代码分析使得我们能够提升 Elysia 性能,超出我们想象,以下是显著的改进: * 整体提高约 15% * 静态路由加速约 33% * 空查询解析加速约 50% * 严格类型主体解析加速约 100% * 空主体解析加速约 150% 凭借这一改进,我们能够在性能上超越 **Stricjs**,比较 Elysia 0.5.0-beta.0 和 Stricjs 2.0.4。 我们打算在将来以研究论文的形式详细解释此主题及我们如何通过静态代码分析提高性能。 ## 新路由 "Memoirist" 自 0.2 起,我们一直在构建自己的路由器 "Raikiri"。 Raikiri 达到了它的目的,它是基于我们自定义的径向树实现,从零开始构建以实现快速。 但随着我们的进步,我们发现 Raikiri 在复杂的径向树重整方面表现不佳,这导致开发人员报告了许多错误,尤其是在动态路由方面,这通常可以通过重新排列路由解决。 我们理解,并修补了 Raikiri 径向树重整算法的许多领域,但我们的算法很复杂,修补路由后变得越来越昂贵,直到它不再适合我们的目的。 这就是我们引入新路由 "Memoirist" 的原因。 Memoirist 是一个稳定的 RaixTree 路由器,能够快速处理基于 Medley Router 算法的动态路径,而在静态方面则利用了提前编译的优势。 随着此次发布,我们将从 Raikiri 迁移到 Memoirist,以提高稳定性并获得比 Raikiri 更快的路径映射。 我们希望您在使用 Raikiri 时遇到的任何问题都能通过 Memoirist 得到解决,并鼓励您进行尝试。 ## TypeBox 0.28 TypeBox 是一个核心库,支持 Elysia 的严格类型系统,称为 **Elysia.t**。 在此次更新中,我们将 TypeBox 从 0.26 更新到 0.28,以使类型系统更加精细,接近严格的类型语言。 我们更新了 TypeBox,以改进 Elysia 类型系统,以匹配新 TypeBox 功能以及与新版本 TypeScript 的兼容性,如 **Constant Generic**。 ```ts new Elysia() .decorate('version', 'Elysia Radiant') .model( 'name', Type.TemplateLiteral([ Type.Literal('Elysia '), Type.Union([ Type.Literal('The Blessing'), Type.Literal('Radiant') ]) ]) ) // 严格检查模板字面量 .get('/', ({ version }) => version) ``` 这使我们能够严格检查模板字面量或字符串/数字的模式,以同时在运行时和开发过程中进行验证。 ### 提前编译与类型系统 通过提前编译,Elysia 可以调整自身以优化并匹配定义的模式类型,以减少开销。 这就是我们引入了一种新类型 **URLEncoded** 的原因。 正如之前提到的,Elysia 现在可以利用模式并在提前进行优化,主体解析是 Elysia 中更昂贵的领域之一,因此我们引入了一种专门用于解析主体的类型,如 URLEncoded。 默认情况下,Elysia 将根据主体的模式类型解析主体,如下所示: * t.URLEncoded -> `application/x-www-form-urlencoded` * t.Object -> `application/json` * t.File -> `multipart/form-data` * 其余 -> `text/plain` 但是,您可以明确告诉 Elysia 使用特定方法解析主体,如下所示: ```ts app.post('/', ({ body }) => body, { type: 'json' }) ``` `type` 可以是以下之一: ```ts type ContentType = | // 'text/plain' 的简写 | 'text' // 'application/json' 的简写 | 'json' // 'multipart/form-data' 的简写 | 'formdata' // 'application/x-www-form-urlencoded' 的简写 | 'urlencoded' | 'text/plain' | 'application/json' | 'multipart/form-data' | 'application/x-www-form-urlencoded' ``` 您可以在概念中的 [explicit body](/life-cycle/parse.html#explicit-body) 页面中找到更多详细信息。 ### 数字类型 我们发现开发人员在使用 Elysia 时经常会遇到冗余的任务,即解析数字字符串。 因此,我们引入了一种新的 **Numeric** 类型。 在 Elysia 0.4 中,解析数字字符串时,我们需要使用 `transform` 手动解析字符串: ```ts app.get('/id/:id', ({ params: { id } }) => id, { schema: { params: t.Object({ id: t.Number() }) }, transform({ params }) { const id = +params.id if(!Number.isNaN(id)) params.id = id } }) ``` 我们发现这个步骤是冗余的,充满了样板代码,我们希望以声明的方式解决这个问题。 感谢静态代码分析,数字类型允许您定义数字字符串并自动将其解析为数字。 一旦验证,数字类型将在运行时和类型级别自动解析为数字,以满足我们的需求。 ```ts app.get('/id/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Numeric() }) }) ``` 您可以在任何支持模式类型的属性上使用数字类型,包括: * params * query * headers * body * response 我们希望您会发现这种新的数字类型在您的服务器中很有用。 您可以在概念中查看 [numeric type](/validation/elysia-type.html#numeric) 页面以获取更多详细信息。 随着 TypeBox 0.28 的发布,我们使 Elysia 的类型系统更加完整,并期待看到它在您那的表现。 ## 内联模式 您可能已经注意到,我们的示例不再使用 `schema.type` 来创建类型,因为我们进行了一项 **重大变更**,将模式移至钩子语句的内联方式。 ```ts // ? 从 app.get('/id/:id', ({ params: { id } }) => id, { schema: { params: t.Object({ id: t.Number() }) }, }) // ? 到 app.get('/id/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number() }) }) ``` 在进行重大变更时我们考虑了很多,特别是对于 Elysia 最重要的部分之一。 基于大量的实验和实际使用,我们尝试通过投票向我们的社区建议这一新变更,并发现约 60% 的 Elysia 开发人员愿意迁移到内联模式。 但我们也倾听了社区其余部分的声音,试图理解反对这一决定的论点: ### 清晰的分离 在旧语法中,您必须明确告诉 Elysia 您创建的部分是一个模式,使用 `Elysia.t`。 创建生命周期和模式之间的明确分离更清晰,具有更好的可读性。 但根据我们的严格测试,我们发现大多数人没有对此新语法感到困惑,生命周期钩子与模式类型的分离仍然具有明确的分离,`t.Type` 和函数之间的语法高亮在代码审查时尤为明显,尽管没有明确的模式那样清晰,但人们可以很快适应新语法,特别是如果他们熟悉 Elysia。 ### 自动完成 人们关注的另一个领域是读写自动完成功能。 将模式和生命周期钩子合并导致自动完成有大约 10 个可供选择的属性,而基于多项经过验证的用户体验研究,这可能会让用户感到沮丧,因为可供选择的选项太多,并可能造成更陡的学习曲线。 然而,我们发现,Elysia 的模式属性名相当可预测,一旦开发人员习惯了 Elysia 类型,就能克服这个问题。 例如,如果您想访问头部,您可以在上下文中访问 `headers`,而在钩子中输入 `headers`,两者共享相同的名称以提高可预测性。 因此,Elysia 可能会有一点学习曲线,但这是我们愿意为更好的开发者体验付出的代价。 ## "headers" 字段 之前,您可以通过访问 `request.headers.get` 来获取 headers 字段,您可能会想知道为什么我们不默认发送 headers。 ```ts app.post('/headers', ({ request: { headers } }) => { return headers.get('content-type') }) ``` 因为解析 headers 有其自身的开销,并且我们发现许多开发人员并不经常访问 headers,因此我们决定不实现 headers。 但通过静态代码分析,这种情况发生了改变,Elysia 可以读取您的代码,判断您是否打算使用 headers,然后动态解析 headers。 静态代码分析使我们能够添加新的功能,而没有任何开销。 ```ts app.post('/headers', ({ headers }) => headers['content-type']) ``` 解析后的 headers 将作为头部名称的小写键的普通对象可用。 ## 状态、装饰、模型重构 Elysia 的主要特性之一是能够根据您的需求自定义 Elysia。 我们重新审视了 `state`、`decorate` 和 `setModel`,并发现 API 不够一致,可以进行改进。 我们发现许多人在设置多个值时会重复使用 `state` 和 `decorate`,好希望能够像使用 `setModel` 一样一次性设置所有内容,我们希望将 `setModel` 的 API 规范统一,以便与 `state` 和 `decorate` 一样使用,以提高可预测性。 因此,我们将 `setModel` 重命名为 `model`,并添加了支持为 `state`、`decorate` 和 `model` 设定单个和多个值的函数重载。 ```ts import { Elysia, t } from 'elysia' const app = new Elysia() // ? 使用标签设置模型 .model('string', t.String()) .model({ number: t.Number() }) .state('visitor', 1) // ? 使用对象设置模型 .state({ multiple: 'value', are: 'now supported!' }) .decorate('visitor', 1) // ? 使用对象设置模型 .decorate({ name: 'world', number: 2 }) ``` 同时,我们将 TypeScript 的最低支持版本提高到 5.0,以增强与 **Constant Generic** 的严格类型支持。 `state`、`decorate` 和 `model` 现在支持字面量类型和模板字符串,以严谨地验证类型,包括运行时和类型级别。 ```ts // ? state、decorate 现在支持字面量 app.get('/', ({ body }) => number, { body: t.Literal(1), response: t.Literal(2) }) ``` ### 组与守卫 我们发现许多开发人员经常将 `group` 和 `guard` 一起使用,我们发现在嵌套时有些多余,可能会充满样板代码。 从 Elysia 0.5 开始,我们为 `.group` 添加了作为可选第二个参数的守卫范围。 ```ts // ✅ 之前,需要将守卫嵌套在组内 app.group('/v1', (app) => app.guard( { body: t.Literal() }, (app) => app.get('/student', () => 'Rikuhachima Aru') ) ) // ✅ 新的,兼容旧语法 app.group( '/v1', { body: t.Literal('Rikuhachima Aru') }, app => app.get('/student', () => 'Rikuhachima Aru') ) // ✅ 兼容函数重载 app.group('/v1', app => app.get('/student', () => 'Rikuhachima Aru')) ``` 我们希望您会发现这些新重新审视的 API 更加有用,并更符合您的用例。 ## 类型稳定性 Elysia 的类型系统是复杂的。 我们可以在类型级别声明变量,通过名称引用类型,应用多个 Elysia 实例,甚至在类型级别支持闭包,这使得为您提供最佳的开发者体验变得非常复杂,特别是在 Eden。 但有时,随着我们更新 Elysia 版本,类型并不会按预期工作,因为我们必须在每次发行之前手动检查,并可能导致人为错误。 随着 Elysia 0.5 的发布,我们为类型级别的测试添加了单元测试,以防止将来的可能错误,这些测试将在每次发布之前运行,如果出现错误,将阻止我们发布软件包,迫使我们修复类型问题。 这意味着,您现在可以依靠我们在每次发布时检查类型完整性,确信在类型引用方面会更少出现错误。 *** ### 显著改进: * 添加对 CommonJS 的支持,以便与 Node 适配器运行 Elysia * 删除手动片段映射,以加速路径提取 * 在 `composeHandler` 中内联验证器,以提高性能 * 使用一次性上下文分配 * 通过静态代码分析添加对延迟上下文注入的支持 * 确保响应的非空性 * 添加联合主体验证检查 * 设置默认对象处理程序进行继承 * 使用 `constructor.name` 映射而不是 `instanceof` 来提高速度 * 添加专用错误构造函数以提高性能 * 为检查 onRequest 迭代,添加条件字面量函数 * 改进 WebSocket 类型 重大变更: * 将 `innerHandle` 重命名为 `fetch` * 要迁移:将 `.innerHandle` 重命名为 `fetch` * 将 `.setModel` 重命名为 `.model` * 要迁移:将 `setModel` 重命名为 `model` * 将 `hook.schema` 移除至 `hook` * 要迁移:移除 schema 及大括号 `schema.type`: ```ts // 从 app.post('/', ({ body }) => body, { schema: { body: t.Object({ username: t.String() }) } }) // 到 app.post('/', ({ body }) => body, { body: t.Object({ username: t.String() }) }) ``` * 移除 `mapPathnameRegex`(内部) ## 后续 推动 JavaScript 在 Bun 中的性能边界是我们非常兴奋的事情! 即使每次发布都有新特性,Elysia 仍然在变得更快,凭借改进的可靠性和稳定性,我们希望 Elysia 会成为下一代 TypeScript 框架的选择之一。 我们很高兴看到许多有才华的开源开发人员通过他们的卓越工作使 Elysia 复苏,例如 [Bogeychan's Elysia Node](https://github.com/bogeychan/elysia-polyfills) 和 Deno 适配器,Rayriffy 的 Elysia 限制,我们也期待您未来的作品! 感谢您对 Elysia 的持续支持,我们希望在下一个版本中见到您。 > 我不会让人失望,要让他们振奋 > > 我们每天都在变得更加响亮,是的,我们在放大 > > 璀璨的光芒 > > 你一定想站在我这边 > > 是的,你知道这是 **全速前进** --- --- url: /blog/elysia-06.md --- \ 以传奇动漫 **“No Game No Life”** 的开场曲命名,**「[This Game](https://youtu.be/kJ04dMmimn8)」** 作曲者是 Konomi Suzuki。 这个游戏将中型项目的边界推向了大型应用程序,重新设计的插件模型、动态模式、声明式自定义错误的开发者体验提升、通过“onResponse”收集更多指标、可自定义的宽松和严格路径映射、TypeBox 0.30 和 WinterCG 框架的互操作性。 ###### (我们仍在等待《No Game No Life》第二季) ## 新插件模型 这个游戏引入了新的插件注册语法,并提出了新的内部插件模型。 之前,你可以通过定义 Elysia 实例的回调函数来注册插件,如下所示: ```ts const plugin = (app: Elysia) => app.get('/', () => 'hello') ``` 使用新插件模型,你可以将 Elysia 实例直接转化为插件: ```ts const plugin = new Elysia() .get('/', () => 'hello') ``` 这允许任何 Elysia 实例甚至现有实例在应用程序中使用,消除了任何可能的额外回调和制表符间隔。 这显著提升了嵌套组的开发者体验: ```ts // < 0.6 const group = (app: Elysia) => app .group('/v1', (app) => app .get('/hello', () => 'Hello World') ) // >= 0.6 const group = new Elysia({ prefix: '/v1' }) .get('/hello', () => 'Hello World') ``` 我们鼓励您使用新的 Elysia 插件实例模型,因为我们可以利用插件校验和未来的新特性。 然而,我们并未**弃用**回调函数方法,因为某些情况下函数模型仍然有用,例如: * 内联函数 * 需要访问主实例信息的插件(例如访问 OpenAPI 架构) 通过这个新的插件模型,我们希望您能够使代码库更易于维护。 ## 插件校验和 默认情况下,Elysia 插件使用函数回调来注册插件。 这意味着如果你为类型声明注册一个插件,它会为了提供类型支持而自我重复,从而在生产中导致插件的重复使用。 因此引入了插件校验和,以防止类型声明注册的插件重复。 要使用插件校验和,您需要使用新的插件模型,并提供一个 `name` 属性来告诉 Elysia 防止插件重复: ```ts const plugin = new Elysia({ name: 'plugin' }) ``` 这让 Elysia 能够根据名称识别插件并进行重复消除。 任何重复的名称将只注册一次,但即使插件已被去重,类型安全将在注册后提供。 如果您的插件需要配置,可以将配置提供到 **seed** 属性中,以生成去重复插件的校验和。 ```ts const plugin = (config) => new Elysia({ name: 'plugin', seed: config }) ``` 名称和种子将用于生成去重注册的校验和,从而实现更好的性能提升。 此更新还修复了插件的生命周期重复消除的情况,当 Elysia 不确定插件是本地还是全局事件时,会意外内联生命周期。 一如既往,这意味着对于大于“Hello World”的应用程序来说性能得到了提升。 ## 挂载和 WinterCG 合规 WinterCG 是一个由 Cloudflare、Deno、Vercel Edge Runtime、Netlify Functions 和其他多种支持的网络互操作运行时标准。 WinterCG 允许 Web 服务器在运行时之间进行互操作,它使用 Fetch、Request 和 Response 等 Web 标准定义。 基于此,Elysia 部分遵循 WinterCG 合规,因为我们对 Bun 进行了优化,但在可能的情况下也开放地支持其他运行时。 这理论上允许任何框架和代码在一起运行,只要它们符合 WinterCG 的标准,这一实现由 [Hono](https://honojs.dev) 提出,它引入了 **.mount** 方法,以 [在一个代码库中运行多个框架](https://twitter.com/honojs/status/1684839623355490304),包括 Remix、Elysia、Itty Router 和 Hono 本身。 因此,我们通过引入 `.mount` 方法实现了相同的逻辑,以运行任何符合 WinterCG 标准的框架或代码。 要使用 `.mount`,只需 [传递一个 `fetch` 函数](https://twitter.com/saltyAom/status/1684786233594290176): ```ts 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 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 项目。 ```ts 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) ``` 如果挂载的实例是 Elysia 实例,它将自动解析为 `use`,提供默认的类型安全和 Eden 支持。 这使得互操作框架和运行时成为现实的可能性。 ## 启动时间改善 启动时间是一个在无服务器环境中重要的度量,Elysia 在这方面表现出色,但我们已经进一步提升了这一点。 默认情况下,Elysia 会自动生成每个路由的 OpenAPI 架构并在内部存储,即使不使用。 在这个版本中,Elysia 推迟了编译,并移至 `@elysiajs/swagger`,从而使 Elysia 的启动时间更快。 通过各种微优化,并在新的插件模型的帮助下,启动时间现在提高了多达 35%。 ## 动态模式 Elysia 引入了静态代码分析和预编译(Ahead of Time Compilation)以推动性能的边界。 静态代码分析允许 Elysia 阅读您的代码,然后生成最优化的代码版本,允许 Elysia 将性能推至极限。 即使 Elysia 符合 WinterCG,像 Cloudflare Worker 这样的环境也不支持函数组合。 这意味着无法进行预编译,从而促使我们创建了一种动态模式,采用 JIT 编译而不是 AoT,使 Elysia 也能够在 Cloudflare Worker 上运行。 要启用动态模式,请将 `aot` 设置为 false。 ```ts new Elysia({ aot: false }) ``` 动态模式在 Cloudflare Worker 中默认启用。 #### 值得注意的是,启用动态模式将禁用一些功能,例如动态注入的代码,如 `t.Numeric`,它会自动将字符串解析为数字。 预编译可以读取、检测并优化您的代码,以便换取启动时间的损失,但动态模式使用 JIT 编译,允许启动时间提高到 6 倍。 但需要注意的是,Elysia 的启动时间默认已经足够快。 Elysia 能够在仅 78 毫秒内注册 10,000 个路由,这意味着平均每个路由为 0.0079 毫秒。 综上所述,我们为您留下了自我决策的选择。 ## 声明式自定义错误 此更新添加了支持添加类型支持以处理自定义错误的能力。 ```ts class CustomError extends Error { constructor(public message: string) { super(message) } } new Elysia() .addError({ MyError: CustomError }) .onError(({ code, error }) => { switch(code) { // 带自动补全 case 'MyError': // 类型缩小 // 错误被类型化为 CustomError return error } }) ``` 这让我们能够使用类型缩小来处理自定义类型,从而处理自定义错误,并为错误代码提供自动补全,以缩小到正确的类型,从而实现完全的声明式类型安全。 这实现了我们主要哲学之一,专注于开发者体验,尤其是类型。 Elysia 的类型系统复杂,但我们尽量让用户无需编写自定义类型或传递自定义泛型,使所有代码看起来就像 JavaScript。 它就是这样工作,所有代码看起来都像 JavaScript。 ## TypeBox 0.30 TypeBox 是驱动 Elysia 严格类型系统的核心库,称为 **Elysia.t**。 在此更新中,我们将 TypeBox 从 0.28 更新到 0.30,以使类型系统更为精细,几乎变成严格类型语言。 这些更新引入了新功能和许多有趣的变化,例如 **Iterator** 类型、减少包的大小、TypeScript 代码生成。 并支持诸如: * `t.Awaited` * `t.Uppercase` * `t.Capitlized` ## 严格路径 我们收到了很多关于处理宽松路径的请求。 默认情况下,Elysia 严格处理路径,这意味着如果您需要支持带有或不带可选 `/` 的路径,它将无法解析,您必须重复两次路径名称。 ```ts new Elysia() .group('/v1', (app) => app // 处理 /v1 .get('', handle) // 处理 /v1/ .get('/', handle) ) ``` 因此,许多人请求 `/v1/` 也应该解析为 `/v1`。 在此更新中,我们默认添加了对宽松路径匹配的支持,以自动启用此功能。 ```ts new Elysia() .group('/v1', (app) => app // 处理 /v1 和 /v1/ .get('/', handle) ) ``` 要禁用宽松路径映射,您可以将 `strictPath` 设置为 true,以使用先前的行为: ```ts new Elysia({ strictPath: false }) ``` 我们希望这将清除关于路径匹配及其预期行为的任何疑问。 ## onResponse 此更新介绍了一个新的生命周期钩子,称为 `onResponse`。 这是由 [elysia#67](https://github.com/elysiajs/elysia/issues/67) 提出的提案。 之前,Elysia 的生命周期如下图所示。 ![Elysia 生命周期图](/blog/elysia-06/lifecycle-05.webp) 对于任何指标、数据收集或日志记录目的,您可以使用 `onAfterHandle` 来运行收集指标的功能,但是当处理程序遇到错误时,此生命周期并不会被执行,无论是路由错误还是提供的自定义错误。 这就是为什么我们引入了 `onResponse` 以处理所有的响应情况。 您可以同时使用 `onRequest` 和 `onResponse` 来测量性能指标或任何所需的日志记录。 引用: > 然而,onAfterHandle 函数仅在成功响应时触发。例如,如果找不到路由,或主体无效,或抛出错误,则不会触发。如何监听成功和不成功的请求?这就是我建议 onResponse 的原因。 > > 根据图示,我建议如下: > ![Elysia 生命周期图, 带 onResponse 钩子](/blog/elysia-06/lifecycle-06.webp) *** ### 显著改进: * 在 Elysia 类型系统中添加错误字段,以添加自定义错误消息 * 支持 Cloudflare Worker 的动态模式(和 ENV) * AfterHandle 现在自动映射值 * 使用 bun build 针对 Bun 环境,整体性能提高 5-10% * 在使用插件注册时消除了内联生命周期的重复 * 支持设置 `prefix` * 递归路径类型 * 轻微提高类型检查速度 * 递归架构冲突导致无限类型 ### 更改: * 将 **registerSchemaPath** 移至 @elysiajs/swagger * \[内部] 向上下文添加 qi (queryIndex) ### 破坏性更改: * \[内部] 移除 Elysia Symbol * \[内部] 将 `getSchemaValidator`, `getResponseSchemaValidator` 重构为命名参数 * \[内部] 将 `registerSchemaPath` 移至 `@elysiajs/swagger` ## 后续 我们刚刚迈过了一年的里程碑,对于 Elysia 和 Bun 在这一年的改进感到非常兴奋! 与 Bun 一起推进 JavaScript 的性能边界,并与 Elysia 一起提升开发者体验,我们非常高兴能与您和我们的社区保持联系。 每次更新都让 Elysia 变得更加稳定,并逐渐提供更好的开发者体验,同时不影响性能和功能。 我们很高兴看到我们的开源开发者社区通过他们的项目使 Elysia 充满活力。 * [Elysia Vite 插件 SSR](https://github.com/timnghg/elysia-vite-plugin-ssr),允许我们使用 Elysia 作为服务器进行 Vite 服务器端渲染。 * [Elysia Connect](https://github.com/timnghg/elysia-connect),使 Connect 的插件与 Elysia 兼容 以及许多选择 Elysia 作为下一个大项目的开发者。 凭借我们的承诺,我们最近还推出了 [Mobius](https://github.com/saltyaom/mobius),这是一个开源 TypeScript 库,可以将 GraphQL 解析为 TypeScript 类型,而无需依赖代码生成,利用 TypeScript 模板字面量类型,使其成为第一个实现端到端类型安全的框架,而不依赖代码生成。 我们非常感谢您对 Elysia 的持续支持,并希望能在下一次发布中与您一起推动边界。 > 当这个全新世界为我欢呼时 > > 我绝不会将其交给命运 > > 当我看到机会时,我会开辟道路 > > 我称之为将军 > > 是时候突破了 > > 所以我会重写故事,最终改变所有规则 > > 我们是独行侠 > > 我们不会放弃,直到赢得这场游戏 > > 尽管我不知道明天会如何 > > 我会下注,尽我所能赢得这场游戏 > > 与其他人不同,我会尽力而为,我永远不会失败 > > 放弃这个机会将是致命的,所以让我们全力以赴 > > 我将把我的命运寄托于此,让 **游戏开始** --- --- url: /blog/elysia-07.md --- \ 以我们永不放弃的精神命名,献给我们心爱的虚拟 YouTuber,~~Suicopath~~ 星街墨春,以及她那绝妙的声音:「[Stellar Stellar](https://youtu.be/AAsRtnbDs-0)」来自她的首张专辑:「Still Still Stellar」 曾经被遗忘,她确实是一颗在黑暗中闪耀的星星。 **Stellar Stellar** 为 Elysia 带来了许多令人兴奋的新更新,帮助 Elysia 坚固基础,轻松处理复杂性,特点包括: * 全面重写类型,类型推断速度提高高达 13 倍。 * 用于声明式遥测和更好性能审计的“跟踪”。 * 反应式 Cookie 模型和 cookie 验证以简化 cookie 处理。 * TypeBox 0.31 和自定义解码器的支持。 * 重写的 Web Socket 以获得更好的支持。 * 定义重映射和声明式后缀以防止名称冲突。 * 基于文本的状态 ## 重写类型 Elysia 的核心特征之一,关注开发者体验。 类型是 Elysia 最重要的方面之一,因为它使我们能够做很多令人惊叹的事情,比如统一类型、同步业务逻辑、打字、文档和前端。 我们希望您在 Elysia 上有出色的体验,专注于您的业务逻辑部分,让 Elysia 处理其余部分,无论是通过统一类型进行的类型推断,还是通过 Eden 连接器与后端同步类型。 为此,我们致力于创建一个统一的类型系统来同步所有类型,但随着功能的增长,我们发现我们的类型推断可能不够快速,因为我们几年前缺乏 TypeScript 的经验。 在处理复杂类型系统的过程中,我们积累了经验,进行了各种优化,参与了多个项目,如 [Mobius](https://github.com/saltyaom/mobius)。我们自我挑战再次加速我们的类型系统,使这成为 Elysia 的第二次类型重写。 我们从头开始删除并重写了每个 Elysia 类型,使 Elysia 类型的速度大幅提升。 这是 0.6 和 0.7 在简单的 `Elysia.get` 代码中的比较: 凭借我们新获得的经验,以及像 const generic 这样的新版 TypeScript 特性,我们简化了许多代码,减少了代码库中一千多行的类型。 这使我们能够进一步优化我们的类型系统,使其速度更快、稳定性更高。 ![在我们的 300 条路由和 3,500 行类型声明的复杂项目中,Elysia 0.6 和 0.7 的比较](/blog/elysia-07/inference-comparison.webp) 使用 Perfetto 和 TypeScript CLI 在一个大规模和复杂应用上生成跟踪,我们测量出了高达 13 倍的推断速度。 如果您想知道我们是否会在 0.6 中破坏类型推断,我们确实在类型级别上有单元测试,以确保大多数情况下没有破坏性更改。 我们希望这一改进能帮助您实现更快的类型推断,比如更快的自动完成,以及您 IDE 的加载时间接近瞬时,以帮助您的开发速度更快、更流畅。 ## 跟踪 性能是 Elysia 另一个重要方面。 我们不想为了基准测试而快速,我们希望您在现实场景中拥有真正快速的服务器,而不仅仅是基准测试。 有许多因素可能会导致您的应用速度变慢,而且很难识别其中一个,这就是我们引入 **“跟踪”** 的原因。 **跟踪** 允许我们利用生命周期事件,识别应用的性能瓶颈。 ![跟踪用法示例](/blog/elysia-07/trace.webp) 这个示例代码允许我们插入所有 **beforeHandle** 事件,并逐个提取执行时间,然后设置 Server-Timing API 来检测性能瓶颈。 而且这不仅限于 `beforeHandle`,甚至 `handler` 本身的事件也可以被跟踪。命名约定是基于您已经熟悉的生命周期事件命名的。 此 API 使我们能够轻松审计 Elysia 服务器的性能瓶颈,并与您选择的报告工具集成。 默认情况下,跟踪使用 AoT 编译和动态代码注入来有条件地报告您实际使用的事件,这意味着不会对性能产生任何影响。 ## 反应式 Cookie 我们将我们的 cookie 插件合并到 Elysia 核心中。 与跟踪相同,反应式 Cookie 使用 AoT 编译和动态代码注入,条件性地注入 cookie 使用代码,如果您不使用它,则不会对性能产生影响。 反应式 Cookie 以更现代的方式使用信号来处理 cookie,并提供符合人体工程学的 API。 ![反应式 Cookie 的使用示例](/blog/elysia-07/cookie.webp) 没有 `getCookie`、`setCookie`,一切皆是一个 cookie 对象。 当您想使用 cookie 时,只需提取名称并获取/设置其值,如下所示: ```typescript app.get('/', ({ cookie: { name } }) => { // 获取 name.value // 设置 name.value = "新值" }) ``` 然后 cookie 会自动将值与 headers 和 cookie 罐进行同步,使 `cookie` 对象成为处理 cookie 的单一真实来源。 Cookie 罐是反应式的,这意味着如果您没有为 cookie 设置新值,则不会发送 `Set-Cookie` 头,以保持相同的 cookie 值并减少性能瓶颈。 ### Cookie 架构 随着 cookie 合并到 Elysia 核心中,我们引入了新的 **Cookie 架构**,用于验证 cookie 值。 当您需要严格验证 cookie 会话或希望对处理 cookie 提供严格的类型或类型推断时,这非常有用。 ```typescript app.get('/', ({ cookie: { name } }) => { // 设置 name.value = { id: 617, name: '召唤 101' } }, { cookie: t.Cookie({ value: t.Object({ id: t.Numeric(), name: t.String() }) }) }) ``` Elysia 自动为您编码和解码 cookie 值,因此如果您想将 JSON 存储在 cookie 中,例如解码的 JWT 值,或者只想确保值是数字字符串,您可以轻松做到这一点。 ### Cookie 签名 最后,凭借 Cookie 架构的引入,以及 `t.Cookie` 类型。我们能够创建一种统一类型,以自动处理 cookie 签名的签名/验证。 Cookie 签名是附加到 cookie 值的加密哈希,使用密钥和 cookie 内容生成,以通过向 cookie 添加签名来增强安全性。 这确保 cookie 值未被恶意行为者修改,有助于验证 cookie 数据的真实性和完整性。 在 Elysia 中处理 cookie 签名,只需提供 `secret` 和 `sign` 属性: ```typescript new Elysia({ cookie: { secret: 'Fischl von Luftschloss Narfidort' } }) .get('/', ({ cookie: { profile } }) => { profile.value = { id: 617, name: '召唤 101' } }, { cookie: t.Cookie({ profile: t.Object({ id: t.Numeric(), name: t.String() }) }, { sign: ['profile'] }) }) ``` 通过提供 cookie 密钥和 `sign` 属性来指示哪个 cookie 应进行签名验证。 Elysia 然后自动签署和取消签署 cookie 值,消除了手动调用 **sign** / **unsign** 函数的需要。 Elysia 自动处理 Cookie 的密钥轮换,因此如果您必须迁移到新的 cookie 密钥,只需附加密钥,Elysia 将使用第一个值来签署新 cookie,而在尝试与其余密钥签署的 cookie 时,如果匹配则取消签署。 ```typescript new Elysia({ cookie: { secrets: ['复仇将属于我', 'Fischl von Luftschloss Narfidort'] } }) ``` 反应式 Cookie API 是声明式和简单明了的,这里有一些关于它提供的符合人体工程学特性的神奇之处,我们非常期待您来尝试它。 ## TypeBox 0.31 随着 0.7 的发布,我们正在更新到 TypeBox 0.31 为 Elysia 带来更多功能。 这带来了新兴的兴奋特性,如在 Elysia 中原生支持 TypeBox 的 `Decode`。 以前,一个像 `Numeric` 这样的自定义类型需要动态代码注入以将数字字符串转换为数字,但借助 TypeBox 的解码,我们允许定义一个自定义函数自动编码和解码类型的值。 这使我们能够将类型简化为: ```typescript Numeric: (property?: NumericOptions) => Type.Transform(Type.Union([Type.String(), Type.Number(property)])) .Decode((value) => { const number = +value if (isNaN(number)) return value return number }) .Encode((value) => value) as any as TNumber, ``` 不再依赖于广泛的检查和代码注入,而是通过 TypeBox 中的 `Decode` 函数实现简化。 我们已经重写了所有需要动态代码注入的类型,以使用 `Transform` 来简化代码维护。 不仅限于此,借助 `t.Transform`,您现在还可以定义一个自定义类型,手动指定自定义函数进行编码和解码,让您能够写出比以往任何时候都更加富有表现力的代码。 我们迫不及待想看看您在 `t.Transform` 引入后会带来什么。 ### 新类型 随着 **Transform** 的引入,我们新增了一种类型,如 `t.ObjectString`,用于自动解码请求中的对象值。 这在您必须使用 **multipart/formdata** 处理文件上传但不支持对象时非常有用。您现在只需使用 `t.ObjectString()` 来告诉 Elysia 该字段是串行化的 JSON,这样 Elysia 就可以自动解码。 ```typescript new Elysia({ cookie: { secret: 'Fischl von Luftschloss Narfidort' } }) .post('/', ({ body: { data: { name } } }) => name, { body: t.Object({ image: t.File(), data: t.ObjectString({ name: t.String() }) }) }) ``` 我们希望这能简化对 JSON 和 **multipart** 的需求。 ## 重写 Web Socket 除了完全重写类型,我们还完全重写了 Web Socket。 以前我们发现 Web Socket 有 3 个主要问题: 1. 模式没有严格验证 2. 类型推断慢 3. 所有插件中需要 `.use(ws())` 通过这次更新,所有上述问题均得到了改善,同时提升了 Web Socket 的性能。 1. 现在,Elysia 的 Web Socket 是严格验证的,类型自动同步。 2. 我们无需在每个插件中使用 `.use(ws())` 来使用 WebSocket。 而且我们为已经快速的 Web Socket 带来了性能改进。 之前,Elysia Web Socket 需要处理每个传入请求的路由,以统一数据和上下文,但通过新模型,Web Socket 现在可以在不依赖于路由器的情况下推断其路由的数据。 将性能接近 Bun 原生 Web Socket 性能。 感谢 [Bogeychan](https://github.com/bogeychan) 提供测试用例,帮助我们自信地重写 Web Socket。 ## 定义重映射 在 [#83](https://github.com/elysiajs/elysia/issues/83) 中由 [Bogeychan](https://github.com/bogeychan) 提出的。 总结来说,Elysia 允许我们装饰请求并存储我们想要的任何值,然而某些插件可能与我们已有的值重复命名,而有时插件可能存在名称冲突,但我们根本无法重命名属性。 ### 重映射 字面意思是,这允许我们重映射现有的 `state`、`decorate`、`model`、`derive` 为我们希望的任何内容,以防止名称冲突,或者仅仅为了重命名属性。 通过提供一个函数作为第一个参数,回调将接受当前值,允许我们将其重映射为任何我们希望的值。 ```typescript new Elysia() .state({ a: "a", b: "b" }) // 排除 b 状态 .state(({ b, ...rest }) => rest) ``` 这在您必须处理具有某些重复名称的插件时非常有用,使您能够重映射插件的名称: ```typescript new Elysia() .use( plugin .decorate(({ logger, ...rest }) => ({ pluginLogger: logger, ...rest })) ) ``` 重映射函数可以与 `state`、`decorate`、`model`、`derive` 一起使用,以帮助您定义正确的属性名称,并防止名称冲突。 ### 后缀 为了提供更顺畅的体验,一些插件可能有很多属性值,这会使逐一重映射变得令人不知所措。 **后缀** 函数由一个 **前缀** 和 **后缀** 组成,允许我们重映射实例的所有属性,以防止插件的名称冲突。 ```typescript const setup = new Elysia({ name: 'setup' }) .decorate({ argon: 'a', boron: 'b', carbon: 'c' }) const app = new Elysia() .use( setup .prefix('decorator', 'setup') ) .get('/', ({ setupCarbon }) => setupCarbon) ``` 这使我们能够轻松地批量重映射插件的属性,从而避免名称冲突。 默认情况下,**后缀** 将自动处理运行时和类型级代码,按照命名约定将属性重映射为驼峰式命名。 在某些情况下,您还可以重映射插件的 `所有` 属性: ```typescript const app = new Elysia() .use( setup .prefix('all', 'setup') ) .get('/', ({ setupCarbon }) => setupCarbon) ``` 我们希望重映射和后缀功能为您处理多个复杂插件提供强大的 API。 ## 真正的封装作用域 随着 Elysia 0.7 的推出,Elysia 现在真正能够通过将作用域实例视为另一个实例来封装实例。 新的作用域模型甚至可以防止事件如 `onRequest` 在主实例上解析,这是不可能的。 ```typescript const plugin = new Elysia({ scoped: true, prefix: '/hello' }) .onRequest(() => { console.log('在作用域中') }) .get('/', () => '你好') const app = new Elysia() .use(plugin) // '在作用域中' 不会日志输出 .get('/', () => 'Hello World') ``` 更重要的是,作用域现在在运行时和类型级别上都是真正限制的,这是之前没有类型重写时无法实现的。 这对维护者来说令人兴奋,因为之前,真正封装一个实例的作用域几乎是不可能的,但通过使用 `mount` 和 WinterCG 的一致性,我们终于能够真正封装插件的实例,同时与主实例的属性如 `state`、`decorate` 之间提供软连接。 ## 基于文本的状态 有超过 64 个标准 HTTP 状态码需要记忆,我承认有时我们也会忘记我们想要使用的状态。 这就是为什么我们以文本形式提供 64 个 HTTP 状态码,并为您提供自动完成功能。 ![基于文本的状态码使用示例](/blog/elysia-07/teapot.webp) 文本将自动解析为状态码,如预期的那样。 当您输入时,IDE 会自动弹出关于文本的自动完成功能,无论是 NeoVim 还是 VSCode,因为这是 TypeScript 的内置功能。 ![基于文本的状态码显示自动完成](/blog/elysia-07/teapot-autocompletion.webp) 这是一个小的符合人体工程学的功能,帮助您开发服务器,而无需在 IDE 和 MDN 之间切换以查找正确的状态码。 ## 显著改进 改进: * `onRequest` 现在可以是异步的 * 将 `Context` 添加到 `onError` * 生命周期钩子现在接受数组函数 * 静态代码分析现在支持 rest 参数 * 将动态路由分解为单个流水线,而不是内联到静态路由中,以减少内存使用 * 将 `t.File` 和 `t.Files` 设置为 `File` 而不是 `Blob` * 跳过类实例合并 * 处理 `UnknownContextPassToFunction` * [#157](https://github.com/elysiajs/elysia/pull/179) WebSocket - 添加单元测试并修复示例和 API 由 @bogeychan 提供 * [#179](https://github.com/elysiajs/elysia/pull/179) 添加 GitHub 行动以运行 bun 测试由 @arthurfiorette 提供 破坏性更改: * 移除 `ws` 插件,迁移到核心 * 将 `addError` 重命名为 `error` 变更: * 使用单个 findDynamicRoute,而不是内联到静态映射 * 移除 `mergician` * 由于 TypeScript 问题,移除数组路由 * 重写 Type.ElysiaMeta 以使用 TypeBox.Transform 错误修复: * 默认严格验证响应 * `t.Numeric` 在 headers / query / params 中不工作 * `t.Optional(t.Object({ [name]: t.Numeric }))` 导致错误 * 在转换 `Numeric` 之前添加 null 检查 * 从实例插件中继承存储 * 处理类重叠 * [#187](https://github.com/elysiajs/elysia/pull/187) InternalServerError 消息修复为 "INTERNAL\_SERVER\_ERROR",而不是 "NOT\_FOUND",由 @bogeychan 提供 * [#167](https://github.com/elysiajs/elysia/pull/167) mapEarlyResponse 在处理后带有 aot ## 之后 自最新发布以来,我们在 GitHub 上获得了超过 2,000 个星星! 回顾过去,我们的进步超乎我们的想象。 推动 TypeScript 和开发者体验的边界,甚至使我们感到做了一些真正深刻的事情。 随着每次发布,我们逐渐朝着实现我们很久以来描绘的未来迈出了一步。 一个我们可以自由创造任何想要的东西,同时拥有惊人的开发者体验的未来。 我们真心感谢您和可爱的 TypeScript 和 Bun 社区的爱与支持。 看到 Elysia 由像以下这样出色的开发者们赋予生命,真是令人兴奋: * [Ethan Niser 和他令人惊叹的 BETH Stack](https://youtu.be/aDYYn9R-JyE?si=hgvGgbywu_-jsmhR) * 被 [Fireship](https://youtu.be/dWqNgzZwVJQ?si=AeCmcMsTZtNwmhm2) 提及 * 有 [Lucia Auth](https://github.com/pilcrowOnPaper/lucia) 的官方集成 以及更多选择 Elysia 作为下一个项目的开发者。 我们的目标很简单,为您营造一个您可以追求梦想的永恒乐园,让每个人都能快乐地生活。 感谢您和您对 Elysia 的爱与支持,我们希望有一天将我们的梦想描绘成现实。 **愿所有美好事物都被祝福** > 伸出那只手,好像想要触碰某人 > > 我和你一样,没什么特别 > > 没错,我会唱夜晚的歌 > > Stellar Stellar > > 在世界的中心,宇宙中 > > 音乐今晚将永不停歇 > > 没错,我总是渴望成为 > > 不是仙履奇缘,永远等待 > > 而是她所渴望的王子 > > 因为我是一颗星,因此 > > Stellar Stellar --- --- url: /blog/elysia-08.md --- \ 以《Steins;Gate Zero》的结尾曲[**"施坦纳之门"**](https://youtu.be/S5fnglcM5VI)命名。 施坦纳之门并不专注于新的激动人心的 API 和功能,而是关注 API 的稳定性和坚实的基础,以确保在 Elysia 1.0 发布后,API 将会稳定。 然而,我们仍然带来了改进和新特性,包括: * [宏 API](#宏-api) * [新的生命周期:解析,映射响应](#新的生命周期) * [错误函数](#错误函数) * [静态内容](#静态内容) * [默认属性](#默认属性) * [默认头](#默认头) * [性能和显著改进](#性能和显著改进) ## 宏 API 宏允许我们定义一个自定义字段,通过暴露完整的生命周期事件栈进行钩住和保护。 这使我们能够将自定义逻辑编排成一个简单的配置,且具有完整的类型安全。 假设我们有一个身份验证插件,根据角色限制访问,我们可以定义一个自定义的 **角色** 字段。 ```typescript import { Elysia } from 'elysia' import { auth } from '@services/auth' const app = new Elysia() .use(auth) .get('/', ({ user }) => user.profile, { role: 'admin' }) ``` 宏拥有对生命周期栈的完全访问权限,使我们能够直接为每个路由添加、修改或删除现有事件。 ```typescript const plugin = new Elysia({ name: 'plugin' }).macro(({ beforeHandle }) => { return { role(type: 'admin' | 'user') { beforeHandle( { insert: 'before' }, async ({ cookie: { session } }) => { const user = await validateSession(session.value) await validateRole('admin', user) } ) } } }) ``` 我们希望通过这个宏 API,插件维护者能够根据自己的需要定制 Elysia,为更好地与 Elysia 互动开启新的方式,而 Elysia 用户将能够享受更符合人机工程学的 API。 [宏 API](/patterns/macro) 文档现在已在 **模式** 部分提供。 下一代可定制性现在只需伸手可及,等待你的键盘和想象力。 ## 新生命周期 Elysia 引入了一个新的生命周期,以解决现有问题和高度请求的 API,包括 **解析** 和 **映射响应**: resolve: 一个安全版本的 **derive**。在与 **beforeHandle** 相同的队列中执行。 mapResponse: 在 **afterResponse** 之后执行,用于提供从原始值到 Web 标准响应的转换函数。 ### 解析 一个 [derive](/essential/context.html#derive) 的“安全”版本。 旨在在验证过程后将新值追加到上下文中,并存储在与 **beforeHandle** 相同的栈中。 解析语法与 [derive](/life-cycle/before-handle#derive) 完全相同,以下是从授权插件中检索 bearer 头的示例。 ```typescript import { Elysia } from 'elysia' new Elysia() .guard( { headers: t.Object({ authorization: t.TemplateLiteral('Bearer ${string}') }) }, (app) => app .resolve(({ headers: { authorization } }) => { return { bearer: authorization.split(' ')[1] } }) .get('/', ({ bearer }) => bearer) ) .listen(3000) ``` ### 映射响应 在 **"afterHandle"** 之后立即执行,旨在提供从原始值到 Web 标准响应的自定义响应映射。 以下是使用映射响应提供响应压缩的示例。 ```typescript import { Elysia, mapResponse } from 'elysia' import { gzipSync } from 'bun' new Elysia() .mapResponse(({ response }) => { return new Response( gzipSync( typeof response === 'object' ? JSON.stringify(response) : response.toString() ) ) }) .listen(3000) ``` 为什么不使用 **afterHandle** 而是引入一个新的 API? 因为 **afterHandle** 旨在读取和修改原始值。存储插件如 HTML 和压缩依赖于创建 Web 标准响应。 这意味着在这种类型的插件之后注册的插件将无法读取或修改值,从而导致插件行为不正确。 这就是为什么我们引入了一个新的生命周期,在 **afterHandle** 之后运行,专门用于提供自定义响应映射,而不是将响应映射和原始值变更混合在同一个队列中。 ## 错误函数 我们可以通过使用 **set.status** 或返回一个新的响应来设置状态代码。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', ({ set }) => { set.status = 418 return "我是一个水壶" }) .listen(3000) ``` 这与我们的目标是一致的,即直接将字面值返回给客户端,而无须担心服务器应如何行为。 然而,这在与 Eden 集成时 proved 有挑战性。由于我们返回一个字面值,我们无法从响应中推断出状态代码,从而使得 Eden 无法区分响应和状态代码。 这导致 Eden 无法充分发挥其潜力,特别是在错误处理时,因为它无法推断类型,而不必为每个状态声明显式响应类型。 伴随着许多用户请求我们希望提供一种更明确的方式来直接返回状态代码的值,而不希望依赖于 **set.status** 和 **new Response** 来进行冗长或从处理函数外部声明的实用函数返回响应。 这就是我们引入 **error** 函数以状态代码和值一起返回给客户端的原因。 ```typescript import { Elysia, error } from 'elysia' // [!code ++] new Elysia() .get('/', () => error(418, "我是一个水壶")) // [!code ++] .listen(3000) ``` 这相当于: ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', ({ set }) => { set.status = 418 return "我是一个水壶" }) .listen(3000) ``` 不同之处在于,使用 **error** 函数时,Elysia 将自动将状态代码区分为专用响应类型,帮助 Eden 根据状态正确推断响应。 这意味着通过使用 **error**,我们不必包含显式响应模式,以便让 Eden 正确推断每个状态代码的类型。 ```typescript import { Elysia, error, t } from 'elysia' new Elysia() .get('/', ({ set }) => { set.status = 418 return "我是一个水壶" }, { // [!code --] response: { // [!code --] 418: t.String() // [!code --] } // [!code --] }) // [!code --] .listen(3000) ``` 我们建议使用 `error` 函数来返回具有状态代码的响应,以获得正确的类型推断,不过,我们并不打算删除 Elysia 中使用 **set.status** 的功能,以保持现有服务器的正常工作。 ## 静态内容 静态内容指的是几乎总是返回相同值的响应,无论传入请求是什么。 这类资源通常是公共 **文件**、**视频** 或者是极少更改的硬编码值,除非服务器更新。 到目前为止,Elysia 中的大部分内容都是静态内容。但我们也发现许多情况,如提供静态文件或使用模板引擎提供 HTML 页面,通常也是静态内容。 这就是 Elysia 引入新 API 的原因,以通过预先确定响应来优化静态内容。 ```typescript new Elysia() .get('/', () => Bun.file('video/kyuukurarin.mp4')) // [!code --] .get('/', Bun.file('video/kyuukurarin.mp4')) // [!code ++] .listen(3000) ``` 请注意,处理程序现在不是一个函数,而是一个内联值。 这将使性能提高约 20-25%,通过提前编译响应来实现。 ## 默认属性 Elysia 0.8 更新了 [TypeBox 到 0.32](https://github.com/sinclairzx81/typebox/blob/index/changelog/0.32.0.md),引入了许多新特性,包括专用正则表达式、解引用,但最重要的是 Elysia 中最被请求的特性,**默认** 字段支持。 现在在类型构建器中定义默认字段,如果未提供值,Elysia 将提供默认值,支持从类型到正文的模式类型。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', ({ query: { name } }) => name, { query: t.Object({ name: t.String({ default: 'Elysia' }) }) }) .listen(3000) ``` 这使我们能够直接从模式提供默认值,尤其是在使用引用模式时特别有用。 ## 默认头 我们可以使用 **set.headers** 设置头,Elysia 总是为每个请求创建一个默认的空对象。 此前,我们可以使用 **onRequest** 来将所需的值附加到 set.headers,但这始终会有一些开销,因为会调用一个函数。 堆叠函数以变更对象可能比在每个请求中直接设置所需值(比如 CORS 或缓存头)要慢。 这就是我们现在支持从一开始设置默认头,而不是为每个新请求创建一个空对象的原因。 ```typescript new Elysia() .headers({ 'X-Powered-By': 'Elysia' }) ``` Elysia CORS 插件也更新了,以使用这个新 API 来提高性能。 ## 性能和显著改进 像往常一样,我们找到了一种方法来进一步优化 Elysia,以确保您获得最佳的开箱即用性能。 ### 移除 bind 我们发现 **.bind** 使路径查找速度降低了约 5%。通过从我们的代码库中移除绑定,我们可以稍微加快这个过程。 ### 静态查询分析 Elysia 静态代码分析现在能够推断查询,如果查询名称在代码中被引用。 这通常会在默认情况下导致 15-20% 的速度提升。 ### 视频流 Elysia 现在默认为文件和 Blob 添加 **content-range** 头,以修复需要按块发送的大文件(如视频)的问题。 为了解决这个问题,Elysia 现在默认添加 **content-range** 头。 Elysia 不会在状态代码被设置为 206、304、412、416 时发送 **content-range**,或者当头信息显式提供 **content-range** 时。 建议使用 [ETag 插件](https://github.com/bogeychan/elysia-etag) 来正确处理状态代码,以避免来自缓存的 **content-range** 冲突。 这是 **content-range** 头的初步支持,我们已创建关于在未来基于 [RPC-7233](https://datatracker.ietf.org/doc/html/rfc7233#section-4.2) 实施更准确行为的讨论。欢迎在 [讨论 371](https://github.com/elysiajs/elysia/discussions/371) 中加入讨论,提出有关 Elysia 的 **content-range** 和 **etag 生成** 的新行为建议。 ### 运行时内存改进 Elysia 现在重用生命周期事件的返回值,而不是声明一个新的专用值。 这略微减少了 Elysia 的内存使用,对于高峰并发请求表现更好。 ### 插件 大多数官方插件现在利用了更新的 **Elysia.headers**、静态内容、**MapResponse** 和修订过的代码,以遵循静态代码分析,进一步提高整体性能。 受到改进的插件包括:静态、HTML 和 CORS。 ### 验证错误 Elysia 现在将验证错误作为 JSON 返回,而不是文本。 显示当前错误及所有错误和预期值,以帮助您更容易地识别错误。 示例: ```json { "type": "query", "at": "密码", "message": "必填属性", "expected": { "email": "eden@elysiajs.com", "password": "" }, "found": { "email": "eden@elysiajs.com" }, "errors": [ { "type": 45, "schema": { "type": "string" }, "path": "/密码", "message": "必填属性" }, { "type": 54, "schema": { "type": "string" }, "path": "/密码", "message": "期望字符串" } ] } ``` **expect** 和 **errors** 字段在生产环境中默认被移除,以防止攻击者识别模型以进行进一步攻击。 ## 显著改进 **改进** * 懒惰查询引用 * 默认向 `Blob` 添加 content-range 头 * 更新 TypeBox 到 0.32 * 重写生命周期响应 `be` 和 `af` **重大变更** * `afterHandle` 不再提前返回 **变化** * 更改验证响应为 JSON * 将 derive 与 `decorator['request']` 区分为 `decorator['derive']` * `derive` 现在在 onRequest 中不再显示推断类型 **错误修复** * 从 `PreContext` 中移除 `headers`、`path` * 从 `PreContext` 中移除 `derive` * Elysia 类型不输出自定义 `error` ## 结语 自首次发布以来,这一路走来都是一次伟大的旅程。 Elysia 从一个通用的 REST API 框架发展为一个符合人机工程学的框架,以支持端到端类型安全、OpenAPI 文档生成,我们希望继续在未来引入更多激动人心的特性。 看到 Elysia 作为社区不断成长令人兴奋: * [Scalar 的 Elysia 主题](https://x.com/saltyAom/status/1737468941696421908?s=20),为新的文档替代 Swagger UI。 * [pkgx](https://pkgx.dev/) 无缝支持 Elysia。 * 社区将 Elysia 提交到 [TechEmpower](https://www.techempower.com/benchmarks/#section=data-r22\&hw=ph\&test=composite) 排名,综合得分第 32,甚至超越了 Go 的 Gin 和 Echo。 我们现在将尽力为每个运行环境、插件和集成提供更多支持,以回报您给予我们的善意,首先是重写文档以更详细和更适合初学者,[与 Nextjs 集成](/integrations/nextj)、[Astro](/integrations/astro) 等等将来会有更多。 自 0.7 发布以来,我们看到的问题比以往更少。 现在 **我们正在准备 Elysia 的首次稳定版本**,Elysia 1.0 计划于 **2024 年第一季度** 发布,以回报您的善良。 Elysia 将进入软 API 锁定模式,以防止 API 更改并确保在稳定版本发布后不会或很少有重大修改。 所以,您可以期待您的 Elysia 应用从 0.7 开始正常工作,而无需或以最少的变动来支持 Elysia 的稳定版本。 我们再次感谢您对 Elysia 的持续支持,希望在稳定版本发布日再次见到您。 *为这个世界上所有美好事物而奋斗*。 在那之前,*El Psy Congroo*。 > 在黑暗中的一滴 小さな命 > > 独特而珍贵,永远 > > 让人感伤的回忆 夢幻の刹那 > > 让这一刻持续,持续永远 > > 我们在天际漂流 果てない想い > > 充满来自上方的爱 > > 他引导我的旅程 せまる刻限 > > 轻轻落泪,跳向新世界 --- --- url: /blog/elysia-10.md --- \ Elysia 1.0 是经过 1.8 年开发后的第一个稳定版本。 自项目启动以来,我们一直在等待一个专注于开发者体验、速度以及如何让程序为人类而非机器而写的框架。 我们在各种场景中对 Elysia 进行了实战测试,模拟中型和大型项目,向客户交付代码,这是我们第一次感到足够自信可以发布的版本。 Elysia 1.0 引入了显著的改进,并包含 1 个必要的重大变化。 * [Sucrose](#sucrose) - 重写了基于模式匹配的静态分析,而非使用正则表达式 * [启动时间改进](#improved-startup-time) 提高至 14 倍 * [移除 ~40 个路由/实例的 TypeScript 限制](#remove-40-routesinstance-limit) * [更快的类型推断](#type-inference-improvement) 提高至 ~3.8 倍 * [条约2](#treaty-2) * [Hook 类型](#hook-type-breaking-change)(重大的变化) * [内联错误](#inline-error) 用于严格的错误检查 *** Elysia 的发布说明有一个传统,每个版本都以一首歌或媒体命名。 这个重要版本的名称来自于 ["倒下者的哀歌"](https://youtu.be/v1sd5CzR504)。 这是来自我最喜欢的故事弧中的 **"崩坏:第三次崩坏"** 的短动画,和我最喜欢的角色 **"雷电芽衣"**,她的主题曲是 ["崩坏世界女神"](https://youtu.be/s_ZLfaZMpe0)。 这是一个非常好的游戏,你应该去看看。 ー SaltyAom 也被称为来自《枪娘Z》、《崩坏:第三次崩坏》、《崩坏:星穹铁道》的雷电芽衣。还有她的“变体”,来自《原神》的雷电将军,可能还是来自《崩坏:星穹铁道》的阿喀琉斯(因为她可能是提到的星穹铁道 2.1 中的反派赫尔莎形态)。 ::: tip 请记住,ElysiaJS 是一个由志愿者维护的开源库,并不与米哈游或 HoYoverse 有关。但我们非常喜欢崩坏系列,可以吗? ::: ## Sucrose Elysia 被优化以在各种基准测试中表现出色,其中一个主要因素得益于 Bun 及我们的自定义 JIT 静态代码分析。 如果你不知道,Elysia 中嵌入了一种“编译器”,可以读取你的代码并生成优化的函数处理方式。 这个过程快速且实时发生,无需构建步骤。 但因为大部分代码是用复杂的正则表达式编写的,所以在维护时会比较具有挑战性,如果发生递归时可能会变慢。 这就是为什么我们重写了静态分析部分,采用了部分 AST 基础与基于模式匹配的混合方法,命名为 **"Sucrose"**。 我们选择仅实现一组改进性能所需的规则,而非使用全面的 AST 基础(虽然准确性更高),因为这需要在运行时保持速度。 Sucrose 非常擅长低内存使用情况下准确推断处理函数的递归属性,导致推断时间提高了 37%,并显著降低了内存使用。 从 Elysia 1.0 开始,Sucrose 被用来替换基于正则表达式的部分 AST 和模式匹配。 ## 改进的启动时间 得益于 Sucrose 及动态注入阶段的分离,我们可以将分析时间延迟到 JIT,而不是 AOT。 换句话说,“编译”阶段可以懒惰求值。 在第一次匹配路由时将评估阶段从 AOT 转移到 JIT,并缓存结果以便根据需要编译,而不是在服务器启动之前对所有路由进行编译。 在运行时性能方面,单次编译通常非常快速,耗时不超过 0.01-0.03 毫秒(毫秒不是秒)。 在中型应用程序和压力测试中,我们测得启动时间提高了 ~6.5-14 倍。 ## 移除 ~40 个路由/实例限制 之前,从 Elysia 0.1 开始,你只能堆叠约 40 个路由/1 个 Elysia 实例。 这是 TypeScript 的一个限制,每个队列有有限的内存,如果超出,TypeScript 会认为 **“类型实例化过深,可能是无限的”**。 ```typescript const main = new Elysia() .get('/1', () => '1') .get('/2', () => '2') .get('/3', () => '3') // 重复 40 次 .get('/42', () => '42') // 类型实例化过深,可能是无限的 ``` 为了解决这个限制,我们需要将实例分离为控制器来克服限制,然后重新合并类型以卸载队列,如下所示。 ```typescript const controller1 = new Elysia() .get('/42', () => '42') .get('/43', () => '43') const main = new Elysia() .get('/1', () => '1') .get('/2', () => '2') // 重复 40 次 .use(controller1) ``` 然而,从 Elysia 1.0 开始,在优化类型性能(特别是尾调用优化和变体)一年后,我们克服了限制。 这意味着理论上,我们可以堆叠无限数量的路由和方法,直到 TypeScript 崩溃。 (剧透:我们已经做到这一点,大约在 558 个路由/实例之前 TypeScript CLI 和语言服务器因 JavaScript 每个堆栈/队列的内存限制而崩溃) ```typescript const main = new Elysia() .get('/1', () => '1') .get('/2', () => '2') .get('/3', () => '42') // 重复 n 次 .get('/550', () => '550') ``` 所以我们将限制从 ~40 个路由改为 JavaScript 内存限制,因此请尽量不要堆叠超过 ~558 个路由/实例,并在必要时分开为插件。 ![TypeScript 在 558 个路由时崩溃](/blog/elysia-10/558-ts-limit.webp) 让我们觉得 Elysia 还没有准备好投入生产的障碍终于被解决了。 ## 类型推断改进 得益于我们所做的优化,我们在大多数 Elysia 服务器中测得 **高达 ~82%** 的改进。 由于移除了堆栈限制,并提高了类型性能,即使在 500 个路由堆叠后的类型检查和自动完成几乎是即时的。 **对于 Eden 条约的性能提高高达 13 倍**,类型推断性能通过预计算类型而非卸载类型重映射到 Eden。 总体而言,Elysia 和 Eden 条约共同工作可以 **提高到 ~3.9 倍的速度**。 以下是 Elysia + Eden 条约在 0.8 和 1.0 中 450 个路由之间的比较。 ![Elysia Eden 0.8 和 1.0 的类型性能比较,图中显示 Elysia 0.8 耗时 ~1500ms,而 Elysia 1.0 耗时 ~400ms](/blog/elysia-10/ely-comparison.webp) 使用 450 个路由的 Elysia 和 Eden 条约的压力测试结果如下: * Elysia 0.8 耗时 ~1500ms * Elysia 1.0 耗时 ~400ms 并且由于移除了堆栈限制和重映射过程,现在可以为单个 Eden 条约实例堆叠超过 1000 个路由。 ## 条约 2 我们请你对 Eden 条约给出反馈,告诉我们你喜欢什么以及可以改进的地方,你为我们提供了一些设计缺陷和几个改进建议。 这就是为什么今天我们推出 Eden 条约 2,对其进行了完全改造,更加人性化的设计。 尽管我们不喜欢重大变化,但条约 2 是条约 1 的继承者。 **条约 2 的新特性**: * 更加人性化的语法 * 单元测试的端到端类型安全 * 拦截器 * 无需 "$" 前缀和属性 我们最喜欢的是单元测试的端到端类型安全。 因此,与其启动一个模拟服务器并发送请求,不如使用 Eden 条约 2 来编写具有自动补全和类型安全的单元测试。 ```typescript // 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') }) }) ``` 两者之间的区别在于 **条约 2 是条约 1 的继承者**。 我们无意在条约 1 中引入任何重大变化,也不会强迫你更新至条约 2。 你可以选择继续在当前项目中使用条约 1,而无需更新至条约 2,我们会将其保持在维护模式。 * 你可以导入 `treaty` 来使用条约 2。 * 导入 `edenTreaty` 用于条约 1。 新条约的文档可以在 [条约概述](/eden/treaty/overview.html) 中找到,而条约 1 的文档可以在 [条约遗留](/eden/treaty/legacy.html) 中找到。 ## Hook 类型(重大变化) 我们讨厌重大变化,这是我们第一次进行大规模的改变。 我们投入了大量精力在 API 设计上,以减少对 Elysia 所做更改的需要,但修复设计漏洞是必要的。 以前,当我们使用 **"on"** 添加一个 Hook,例如 `onTransform` 或 `onBeforeHandle` 时,它将成为全局 Hook。 这对于创建插件之类的功能很好,但对于像控制器这样的局部实例并不理想。 ```typescript const plugin = new Elysia() .onBeforeHandle(() => { console.log('嗨') }) // 日志嗨 .get('/嗨', () => '在插件中') const app = new Elysia() .use(plugin) // 也会记录嗨 .get('/不嗨请', () => '哦不') ``` 然而,我们发现这种行为引发了几个问题。 * 我们发现许多开发者在新实例中有很多嵌套的守卫。守卫几乎被用作启动新实例的方式,以避免旁作用。 * 默认全局可能导致不可预测(旁作用)行为,特别是在团队中缺乏经验的开发者。 * 我们询问了许多熟悉和不熟悉 Elysia 的开发者,发现大多数人在最初都期望 Hook 是局部的。 * 基于之前的要点,我们发现,默认将 Hook 设为全局很容易导致意外的 bug(旁作用),如果不仔细审核,会很难调试和观察。 *** 为了解决这个问题,我们引入了 Hook 类型来指定 Hook 的继承方式,添加了一种 **“hook-type”**。 Hook 类型可以分类如下: * local(默认)- 仅适用于当前实例及其后代 * scoped - 仅适用于 1 个祖先、当前实例和后代 * global(旧行为)- 适用于所有应用插件的实例(所有祖先、当前和后代) 要指定 Hook 的类型,只需在 Hook 中添加 `{ as: hookType }`。 ```typescript const plugin = new Elysia() .onBeforeHandle(() => { // [!代码 --] .onBeforeHandle({ as: 'global' }, () => { // [!代码 ++] console.log('嗨') }) .get('/子', () => '记录嗨') const main = new Elysia() .use(plugin) .get('/父', () => '记录嗨') ``` 此 API 的设计旨在解决 Elysia 的 **守卫嵌套问题**,开发者通常害怕在根实例上引入 Hook,因为担心旁作用。 例如,对于整个实例进行身份验证检查,我们需要在守卫中包装路由。 ```typescript const plugin = new Elysia() .guard((app) => app .onBeforeHandle(checkAuthSomehow) .get('/profile', () => '记录嗨') ) ``` 但是,通过引入 Hook 类型,我们可以去除嵌套守卫的样板代码。 ```typescript const plugin = new Elysia() .guard((app) => // [!代码 --] app // [!代码 --] .onBeforeHandle(checkAuthSomehow) .get('/profile', () => '记录嗨') ) // [!代码 --] ``` Hook 类型将指定 Hook 应该如何被继承,让我们创建一个插件来说明 Hook 类型的工作原理。 ```typescript // ? 值基于下表提供的值 const type = 'local' const child = new Elysia() .get('/child', () => '你好') const current = new Elysia() .onBeforeHandle({ as: type }, () => { console.log('嗨') }) .use(child) .get('/当前', () => '你好') const parent = new Elysia() .use(current) .get('/父', () => '你好') const main = new Elysia() .use(parent) .get('/主', () => '你好') ``` 通过改变 `type` 值,结果应如下所示: | type | child | current | parent | main | | ---------- | ----- | ------- | ------ | ---- | | 'local' | ✅ | ✅ | ❌ | ❌ | | 'scope' | ✅ | ✅ | ✅ | ❌ | | 'global' | ✅ | ✅ | ✅ | ✅ | 从 Elysia 0.8 迁移,如果你希望使 Hook 为全局的,你需要指定该 Hook 是全局的。 ```typescript // 从 Elysia 0.8 new Elysia() .onBeforeHandle(() => "A") .derive(() => {}) // 转入 Elysia 1.0 new Elysia() .onBeforeHandle({ as: 'global' }, () => "A") .derive({ as: 'global' }, () => {}) ``` 尽管我们讨厌重大变化和迁移,但我们认为这是一个重要的修复,迟早会发生,以解决问题。 大多数服务器可能不需要自己执行迁移,但 **极大依赖于插件作者**,如果迁移是必要的,通常不会超过 5-15 分钟。 有关完整的迁移说明,请参见 [Elysia#513](https://github.com/elysiajs/elysia/issues/513)。 有关 Hook 类型的文档,请参见 [生命周期#hook-type](https://beta.elysiajs.com/essential/scope.html#hook-type)。 ## 内联错误 自 Elysia 0.8 开始,我们可以使用 `error` 函数返回带有状态码的响应,用于 Eden 推断。 然而,这存在一些缺陷。 如果你为路由指定响应模式,Elysia 将无法为状态码提供准确的自动补全。 例如,缩小可用状态码的范围。 ![在 Elysia 中使用导入错误](/blog/elysia-10/error-fn.webp) 内联错误可以从处理程序中解构如下: ```typescript import { Elysia } from 'elysia' new Elysia() .get('/hello', ({ error }) => { if(Math.random() > 0.5) return error(418, 'Nagisa') return 'Azusa' }, { response: t.Object({ 200: t.Literal('Azusa'), 418: t.Literal('Nagisa') }) }) ``` 内联错误可以根据模式生成细粒度的类型,提供类型缩小、自动补全和对值的准确性进行类型检查,否定红色波浪线下的值,而不是整个函数。 ![在 Elysia 中使用内联错误函数,自动补全显示缩小后的状态码](/blog/elysia-10/inline-error-fn.webp) 我们建议使用内联错误,而不是导入错误,以获得更准确的类型安全性。 ## v1 对我们意味着什么,接下来会怎样 达到稳定版本意味着我们相信 Elysia 足够稳定,准备在生产中使用。 维护向后兼容性现在是我们的目标之一,我们努力不向 Elysia 引入重大变化,除了安全问题。 我们的目标是使后端开发变得简单、有趣和直观,同时确保使用 Elysia 构建的产品具有牢固的基础。 在此之后,我们将专注于优化我们的生态系统和插件。 介绍处理冗余和单调任务的人性化方法,开始进行一些内部插件重写,身份验证,JIT 与非 JIT 模式之间的同步行为,以及 **通用运行时支持。** Bun 在运行时、包管理和他们提供的所有工具中表现出色,我们相信 Bun 将成为 JavaScript 的未来。 我们相信,通过将 Elysia 开放给更多的运行时,并提供有趣的 Bun 特定功能(或至少易于配置,例如 [Bun Loaders API](https://bun.sh/docs/bundler/loaders)),最终将使人们尝试使用 Bun,而不是选择仅支持 Elysia。 Elysia 核心本身部分与 WinterCG 兼容,但并不是所有的官方插件都与 WinterCG 兼容,其中一些具有 Bun 特定的功能,我们希望修复这一点。 我们还没有确切的日期或版本用于通用运行时的支持,因为我们将逐渐应用并测试,直到确保它在没有意外行为的情况下工作。 你可以期待支持以下运行时: * Node * Deno * Cloudflare Worker 我们还希望支持以下内容: * Vercel 边缘函数 * Netlify 函数 * AWS Lambda / LLRT 此外,我们还在以下支持服务器端渲染或边缘函数的框架上测试并支持 Elysia: * Nextjs * Expo * Astro * SvelteKit 同时,Bogeychan(Elysia 的一位活跃贡献者)维护的 [Elysia Polyfills](https://github.com/bogeychan/elysia-polyfills)。 此外,我们重写了 [Eden 文档](/eden/overview),以更深入地解释 Eden 的细节,我们认为你应该查看一下。 我们还改进了几个页面,并删除了冗余的文档部分,你可以在 [Elysia 1.0 文档 PR](https://github.com/elysiajs/documentation/pull/282/files) 中查看受影响的页面。 最后,如果你在迁移过程中遇到问题或有与 Elysia 相关的疑问,可以在 Elysia 的 Discord 服务器中随时提问。 ## 突出改进 ### 改进: * 细粒度反应式 Cookie * 使用单一真相源管理 Cookie * WebSocket 的宏支持 * 添加 `mapResolve` * 添加 `{ as: 'global' | 'scoped' | 'local' }` 到生命周期事件 * 添加瞬态类型 * 内联 `error` 到处理程序中 * 内联 `error` 基于状态码具有自动补全和类型检查 * 处理程序现在根据状态码检查 `error` 的返回类型 * 工具 `Elysia._types` 用于类型推断 * [#495](https://github.com/elysiajs/elysia/issues/495) 为解析失败提供用户友好的错误 * 处理程序现在推断条约的错误状态返回类型 * `t.Date` 现在允许字符串化的日期 * 改进类型测试用例 * 为所有生命周期添加测试用例 * resolve、mapResolve、derive、mapDerive 使用瞬态类型准确范围 * 推断查询动态变量 ### 重大变化: * [#513](https://github.com/elysiajs/elysia/issues/513) 生命周期现在优先局部 ### 更改: * 分组私有 API 属性 * 将 `Elysia.routes` 移动到 `Elysia.router.history` * 检测可能的 JSON 在返回之前 * 未知响应现在原样返回而不是 JSON.stringify() * 更改 Elysia 验证错误为 JSON 而不是字符串 ### Bug 修复: * [#466](https://github.com/elysiajs/elysia/issues/466) Async Derive 在 `aot: true` 时泄漏请求上下文到其他请求 * [#505](https://github.com/elysiajs/elysia/issues/505) 空 ObjectString 在查询模式中缺少验证 * [#503](https://github.com/elysiajs/elysia/issues/503) Beta:使用装饰和派生时的 undefined 类 * 调用 .stop 时 onStop 回调被调用两次 * mapDerive 现在解析到 `Singleton['derive']` 而不是 `Singleton['store']` * `ValidationError` 不会将 `content-type` 设置为 `application/json` * 验证 `error(status, value)` 针对每个状态进行验证 * derive/resolve 始终作用于全局 * 如果未处理,则重复调用 onError * [#516](https://github.com/elysiajs/elysia/issues/516) 服务器计时在 beforeHandle 守卫之前中断 * cookie.remove() 没有设置正确的 cookie 路径 ## 后记 ::: tip 以下内容包含个人感受,可能是发泄、抱怨、可能是尴尬和不专业,这不应该出现在软件发布说明中。你可以选择不继续阅读,因为我们已经阐明了发布的所有必要内容。 ::: 两年前,我有一个悲惨的记忆。 这无疑是我最痛苦的记忆之一,日以继夜地工作,处理不公平的任务,这些任务利用了我们与某些软件公司的松散合同。 这花费了我超过 6 个月的时间,我不得不从早醒到睡觉(15 小时)重复工作,**整整两个月都没有做任何事,甚至没有休息 5 分钟,完全没有放松时间,几乎没有单日休息,甚至在医院床上也快得工作。** 我就像一个没有魂魄的人,生活中毫无目标,我唯一的愿望就是让一切成为一场梦。 那时,破坏性变化很多,从松散的要求和合同中引入了无数新功能。 跟踪这些几乎不可能,而我们甚至没有获得应得的报酬,理由是“没有满意”,我们对此无能为力。 我花了一个月的时间才从对编码的恐惧中恢复过来,因不专业而无法正常完成工作,心里受到创伤,并向经理咨询我遭受的职业倦怠。 这就是为什么我们如此讨厌重大变化,并希望设计 Elysia 以便轻松处理变化,即使这并不好,但这就是我们所拥有的。 我不希望任何人经历这样的事情。 我们设计了一个框架来应对我们在那个合同中遇到的所有缺陷。 我看到的技术缺陷中,并没有任何基于 JavaScript 的解决方案可以满足我,因此我进行了实验。 我本可以选择继续前行,避免将来出现这种松散合同,并赚钱,而不是花费大部分休息时间创造一个框架,但我没有。 我最喜欢的部分是 [动画短片中的一句话](https://youtu.be/v1sd5CzR504?t=128),其中芽衣反对琪亚娜牺牲自己拯救世界的想法,而芽衣回应: \> 然而你独自扛下所有,付出了生命的代价。 \> 也许这是为了更大的利益... \> 但我如何层层剥离这一切? \> 我只知道,内心深处... \> 世界对我而言毫无意义... \> 如果没有你 这是描绘牺牲自己拯救世界的人与为了拯救所爱之人而牺牲自己的人之间的二元对立。 如果我们看到一个问题就无动于衷,如何确保接下来的人不会 stumble \[被绊倒] 在与我们相同的问题上,需要有人做点什么。 那个人愿意牺牲自己拯救他人,但谁又将拯救被牺牲了的人呢? 这个名字 **"倒下者的哀歌"** 描述了这一点,以及我们为什么创造 Elysia。 \*尽管这与我个人最喜欢的东西有关,我可能与之联系得太深了。 *** 尽管源于悲惨的事件与记忆,但看到 Elysia 成长为如此受爱戴的事物,是我的特权。看到你所创造的东西受到他人的喜爱并被他人接受。 Elysia 是开源开发者的作品,没有任何公司支持。 我们必须为生计而努力,并在空闲时间构建 Elysia。 在某个时刻,我选择不立即寻找工作,而是为了 Elysia 工作了数个月。 我们希望能不断改进 Elysia,而你可以通过 [GitHub sponsors](https://github.com/sponsors/SaltyAom) 来帮助我们,减少我们为自己支持所需的工作,并拥有更多空闲时间去工作让 Elysia 更好。 我们只是想要创建解决我们问题的东西的开发者。 *** 我们不断创建并试验 Elysia,向客户交付真实代码,并在实际项目中使用 Elysia 为我们本地社区 [CreatorsGarten](https://creatorsgarten.org)(地方技术社区,而非组织)提供动力。 确保 Elysia 准备好的确需要大量的时间、准备和勇气。当然,不可避免会有 bug,但我们愿意倾听并修复它。 这是全新的一开始。 而这一切都因为 **你** 的存在而成为可能。 ー SaltyAom > 所有神圣的星星在日子结束时都会消逝, > > 你的温柔灵魂被赋予了诅咒。 > > “猩红的月亮照耀着沾满鲜血的城镇” > > 哀泣的女神陷入了哀歌。 > > 所有那些甜美的梦藏在回忆深处,直到最后。 > > [**如果拯救你是罪,我将心甘情愿地成为一个罪人。**](https://youtu.be/v1sd5CzR504?t=260) --- --- url: /blog/elysia-11.md --- \ 此版本命名来源于 Mili 的一首歌,[**《大人的乐园》**](https://youtu.be/KawV_oK6lIc),并用作 [《Arknights》动画第三季商业宣传的开场曲](https://youtu.be/sZ1OD0cL6Qw)。 作为一名《Arknights》第一天的玩家和 Mili 的长期粉丝,我从未想过 Mili 会为《Arknights》创作歌曲,你一定要去听听,他们是真正的牛! Elysia 1.1 关注以下几个方面对开发者体验的改进: * [OpenTelemetry](#opentelemetry) * [Trace v2 (重大变更)](#trace-v2) * [规范化](#normalization) * [数据强制](#data-type-coercion) * [Guard as](#guard-as) * [批量 `as` 转换](#bulk-cast) * [响应状态协调](#response-reconcilation) * [可选路径参数](#optional-path-parameter) * [生成器响应流](#generator-response-stream) ## OpenTelemetry 可观察性是生产环境中一个重要的方面。 它让我们能够理解我们的服务器在生产中如何工作,识别问题和瓶颈。 最流行的可观察性工具之一是 **OpenTelemetry**。然而,我们承认,正确设置和为你的服务器添加监控是一项困难且耗时的任务。 将 OpenTelemetry 集成到大多数现有框架和库中都是困难的。 大多数解决方案依赖于笨拙的方法、猴子补丁、原型污染或者手动监控,因为大多数框架并没有从一开始就设计为支持可观察性。 这就是我们在 Elysia 中引入 **第一方支持** 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 标准兼容的任何库的跨度**,并将自动应用父子跨度。 在上述代码中,我们应用 `Prisma` 来跟踪每个查询花费的时间。 通过应用 OpenTelemetry,Elysia 将会: * 收集遥测数据 * 将相关生命周期分组 * 测量每个函数的执行时长 * 对 HTTP 请求和响应进行监控 * 收集错误和异常 你可以将遥测数据导出到 Jaeger、Zipkin、New Relic、Axiom 或任何其他兼容 OpenTelemetry 的后端。 以下是将遥测数据导出到 [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 ++] }) ) ] }) ) ``` ![Axiom 显示收集到的 OpenTelemetry 跟踪](/blog/elysia-11/axiom.webp) Elysia OpenTelemetry 仅适用于将 OpenTelemetry 应用于 Elysia 服务器。 你可以正常使用 OpenTelemetry SDK,跨度将在 Elysia 的请求跨度下运行,它将自动出现在 Elysia 的跟踪中。 然而,我们也提供了 `getTracer` 和 `record` 工具,以便从你应用的任何部分收集跨度。 ```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` 相当于 OpenTelemetry 的 `startActiveSpan`,但它将处理自动关闭并自动捕获异常。 你可以将 `record` 看作是你代码的标签,它将在跟踪中显示。 ### 为可观察性准备你的代码库 Elysia OpenTelemetry 将分组生命周期并读取每个钩子的 **函数名称** 作为跨度的名称。 现在是 **命名你的函数** 的好时机。 如果你的钩子处理程序是一个箭头函数,你可以重构它为命名函数,以便更好地理解跟踪;否则,你的跟踪跨度将被命名为 `anonymous`。 ```typescript const bad = new Elysia() // ⚠️ 跨度名称将是 anonymous .derive(async ({ cookie: { session } }) => { return { user: await getProfile(session) } }) const good = new Elysia() // ✅ 跨度名称将是 getProfile .derive(async function getProfile({ cookie: { session } }) { return { user: await getProfile(session) } }) ``` ## Trace v2 Elysia OpenTelemetry 是基于 Trace v2 构建的,替代了 Trace v1。 Trace v2 允许我们以 100% 的同步行为跟踪我们服务器的任何部分,而不再依赖并行事件监听器桥接(告别死锁)。 它完全重写,不仅更快,而且通过依赖 Elysia 的预编译和代码注入提供可靠且准确的微秒级跟踪。 Trace v2 使用回调监听器而不是 Promise,以确保在继续下一个生命周期事件之前,回调已完成。 以下是 Trace v2 使用示例: ```typescript import { Elysia } from 'elysia' new Elysia() .trace(({ onBeforeHandle, set }) => { // 监听处理前事件 onBeforeHandle(({ onEvent }) => { // 按顺序监听所有子事件 onEvent(({ onStop, name }) => { // 在子事件完成后执行某些操作 onStop(({ elapsed }) => { console.log(name, '耗时', elapsed, '毫秒') // 回调在下一个事件之前同步执行 set.headers['x-trace'] = 'true' }) }) }) }) ``` 你也可以在跟踪内使用 `async`,Elysia 会在回调完成之前阻塞事件,直到下一个事件。 Trace v2 是 Trace v1 的重大变更,请查看 [trace api](/life-cycle/trace) 文档以获取更多信息。 ## 规范化 Elysia 1.1 现在在处理数据之前先进行规范化。 为了确保数据一致且安全,Elysia 将努力将数据强制转换为模式中定义的确切数据结构,移除额外字段,并将数据规范化为一致的格式。 例如,如果你有这样的模式: ```typescript import { Elysia, t } from 'elysia' import { treaty } from '@elysiajs/eden' const app = new Elysia() .post('/', ({ body }) => body, { body: t.Object({ name: t.String(), point: t.Number() }), response: t.Object({ name: t.String() }) }) const { data } = await treaty(app).index.post({ name: 'SaltyAom', point: 9001, // ⚠️ 额外字段 title: '维护者' }) // 'point' 被移除了,如响应中所定义 console.log(data) // { name: 'SaltyAom' } ``` 这段代码做了两件事情: * 在服务器使用之前,从主体中移除 `title` * 在发送给客户端之前,从响应中移除 `point` 这对于防止数据不一致,确保数据始终处于正确格式,并不泄露任何敏感信息非常有用。 ## 数据类型强制 以前,Elysia 使用精确的数据类型而不进行强制转换,除非明确指定。 例如,要将查询参数解析为数字,你需要明确地将其转换为 `t.Numeric` 而不是 `t.Number`。 ```typescript import { Elysia, t } from 'elysia' const app = new Elysia() .get('/', ({ query }) => query, { query: t.Object({ page: t.Numeric() }) }) ``` 然而,在 Elysia 1.1 中,我们引入了数据类型强制,这将自动将数据强制转换为正确的数据类型(如果可能)。 这使得我们只需设置 `t.Number` 而不是 `t.Numeric` 来将查询参数解析为数字。 ```typescript import { Elysia, t } from 'elysia' const app = new Elysia() .get('/', ({ query }) => query, { query: t.Object({ // ✅ 页面将被自动强制转换为数字 page: t.Number() }) }) ``` 这也适用于 `t.Boolean`、`t.Object` 和 `t.Array`。 这通过在编译阶段的提前时间交换模式与可能的强制转换对应物来实现,效果与使用 `t.Numeric` 或其他强制转换对应物相同。 ## Guard as 以前,`guard` 只会应用于当前实例。 ```typescript import { Elysia } from 'elysia' const plugin = new Elysia() .guard({ beforeHandle() { console.log('调用') } }) .get('/plugin', () => 'ok') const main = new Elysia() .use(plugin) .get('/', () => 'ok') ``` 使用这段代码,`onBeforeHandle` 仅在访问 `/plugin` 时被调用,而不会在 `/` 时调用。 在 Elysia 1.1 中,我们为 `guard` 添加了 `as` 属性,允许我们将 guard 应用为 `scoped` 或 `global`,就像添加事件监听器一样。 ```typescript import { Elysia } from 'elysia' const plugin1 = new Elysia() .guard({ as: 'scoped', // [!code ++] beforeHandle() { console.log('调用') } }) .get('/plugin', () => 'ok') // 同样的效果 const plugin2 = new Elysia() .onBeforeHandle({ as: 'scoped' }, () => { console.log('调用') }) .get('/plugin', () => 'ok') ``` 这将确保 `onBeforeHandle` 在父级上也会被调用,并遵循作用域机制。 为 guard 添加 `as` 是有用的,因为它允许我们一次性应用多个钩子,同时考虑作用域机制。 但是,它也允许我们应用 `schema`,以确保所有路由的类型安全。 ```typescript import { Elysia, t } from 'elysia' const plugin = new Elysia() .guard({ as: 'scoped', response: t.String() }) .get('/ok', () => 'ok') .get('/not-ok', () => 1) const instance = new Elysia() .use(plugin) .get('/no-ok-parent', () => 2) const parent = new Elysia() .use(instance) // 这是可以的,因为响应被定义为作用域 .get('/ok', () => 3) ``` ## 批量转换 继续上述代码,有时我们希望将插件重新应用到父实例,但由于受到 `scoped` 机制的限制,它仅限于一个父级。 要将其应用到父实例,我们需要 **"提高作用域"** 到父实例。 我们可以通过将其强制转换为 `**as('plugin')**` 来实现。 ```typescript import { Elysia, t } from 'elysia' const plugin = new Elysia() .guard({ as: 'scoped', response: t.String() }) .get('/ok', () => 'ok') .get('/not-ok', () => 1) const instance = new Elysia() .use(plugin) .as('plugin') // [!code ++] .get('/no-ok-parent', () => 2) const parent = new Elysia() .use(instance) // 这将导致错误,因为作用域提高到父级 .get('/ok', () => 3) ``` `as` 转换将提升所有实例的作用域。 其工作原理是,它读取所有钩子和 schema 的作用域,并将其提升到父实例。 这意味着如果你有 `local` 作用域,并希望将其应用于父实例,你可以使用 `as('plugin')` 提升它。 ```typescript import { Elysia, t } from 'elysia' const plugin = new Elysia() .guard({ response: t.String() }) .onBeforeHandle(() => { console.log('调用') }) .get('/ok', () => 'ok') .get('/not-ok', () => 1) .as('plugin') // [!code ++] const instance = new Elysia() .use(plugin) .get('/no-ok-parent', () => 2) .as('plugin') // [!code ++] const parent = new Elysia() .use(instance) // 这将导致错误,因为作用域提高到父级 .get('/ok', () => 3) ``` 这将将 **guard 的响应** 和 **onBeforeHandle** 视为 `scoped`,因此提升到父实例。 **as** 可接受两个可能的参数: * `plugin` 将事件转换为 **scoped** * `global` 将事件转换为 **global** ```typescript import { Elysia, t } from 'elysia' const plugin = new Elysia() .guard({ response: t.String() }) .onBeforeHandle(() => { console.log('调用') }) .get('/ok', () => 'ok') .get('/not-ok', () => 1) .as('global') // [!code ++] const instance = new Elysia() .use(plugin) .get('/no-ok-parent', () => 2) const parent = new Elysia() .use(instance) // 这将导致错误,因为作用域提升到了父级 .get('/ok', () => 3) ``` 这使我们能够一次性提升多个钩子的作用域,避免在每个钩子中添加 `as` 或将其应用于 guard,或提升现有插件的作用域。 ```typescript import { Elysia, t } from 'elysia' // 在 1.0 中 const from = new Elysia() // 在 1.0 中无法将 guard 应用于父级 .guard({ response: t.String() }) .onBeforeHandle({ as: 'scoped' }, () => { console.log('调用') }) .onAfterHandle({ as: 'scoped' }, () => { console.log('调用') }) .onParse({ as: 'scoped' }, () => { console.log('调用') }) // 在 1.1 中 const to = new Elysia() .guard({ response: t.String() }) .onBeforeHandle(() => { console.log('调用') }) .onAfterHandle(() => { console.log('调用') }) .onParse(() => { console.log('调用') }) .as('plugin') ``` ## 响应协调 在 Elysia 1.0 中,Elysia 将优先考虑作用域中的 schema 中的任一项,而不会将它们合并在一起。 然而,在 Elysia 1.1 中,Elysia 将尝试协调来自每个状态码的所有作用域的响应 schema,并将它们合并在一起。 ```typescript import { Elysia, t } from 'elysia' const plugin = new Elysia() .guard({ as: 'global', response: { 200: t.Literal('ok'), 418: t.Literal('Teapot') } }) .get('/ok', ({ error }) => error(418, 'Teapot')) const instance = new Elysia() .use(plugin) .guard({ response: { 418: t.String() } }) // 这是可以的,因为本地响应覆盖了全局响应 .get('/ok', ({ error }) => error(418, 'ok')) const parent = new Elysia() .use(instance) // 因为全局响应的使用,在这里会报错 .get('/not-ok', ({ error }) => error(418, 'ok')) ``` 我们可以看到: * 在实例中:全局作用域的响应 schema 与本地作用域合并,允许我们在此实例中覆盖全局响应 schema。 * 在父级中:全局作用域的响应 schema 被使用,本地作用域来自 **实例** 的响应没有应用,因为作用域机制的原因。 这在类型级别和运行时都得到了处理,为我们提供了更好的类型完整性。 ## 可选路径参数 Elysia 现在通过在路径参数的末尾添加 `?` 来支持可选路径参数。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/ok/:id?', ({ params: { id } }) => id) .get('/ok/:id/:name?', ({ params: { id, name } }) => name) ``` 在上面的示例中,如果我们访问: `/ok/1` 将返回 `1` `/ok` 将返回 `undefined` 默认情况下,如果未提供可选路径参数,则返回 `undefined`。 你可以通过使用 JavaScript 默认值或 schema 默认值提供一个默认值。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .get('/ok/:id?', ({ params: { id } }) => id, { params: t.Object({ id: t.Number({ default: 1 }) }) }) ``` 在此示例中,如果我们访问: `/ok/2` 将返回 `1` `/ok` 将返回 `1` ## 生成器响应流 以前,你可以通过使用 `@elysiajs/stream` 包流式响应。 然而,这有一个限制: * 不提供 Eden 的类型安全推断 * 流式响应的方式不是特别直观 现在,Elysia 通过使用生成器函数默认支持响应流式传输。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/ok', function* () { yield 1 yield 2 yield 3 }) ``` 在这个例子中,我们可以通过使用 `yield` 关键字流式响应。 使用生成器函数,我们现在可以从生成器函数推断返回类型,并直接将其提供给 Eden。 Eden 现在将从生成器函数推断响应类型为 `AsyncGenerator`。 ```typescript 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 会在请求被取消时自动停止生成器函数。 我们建议将流式响应从 `@elysiajs/stream` 迁移到生成器函数,因为它更直观并提供更好的类型推断。 由于流插件将处于维护模式,并将在将来被弃用。 ## 重大变更 * 为所有验证器解析值为字符串,除非明确指定。 * 参见 [50a5d92](https://github.com/elysiajs/elysia/commit/50a5d92ea3212c5f95f94552e4cb7d31b2c253ad), [44bf279](https://github.com/elysiajs/elysia/commit/44bf279c3752c6909533d19c83b24413d19d27fa)。 * 移除查询中的对象自动解析,除非通过查询明确指定。 * 除了 RFC 3986 中定义的查询字符串,TLDR;查询字符串可以是字符串或字符串数组。 * 将 `onResponse` 重命名为 `onAfterResponse` * \[内部] 移除 $passthrough,取而代之的是 toResponse * \[内部] UnwrapRoute 类型现在始终以状态码解析。 ### 突出变更: * 为 `set.headers` 添加自动完成 * 移除钩子的原型污染 * 移除查询名称的静态分析 * 移除查询替换 '+' 以消除静态查询分析 * 添加 `server` 属性 * mapResponse 现在在错误事件中被调用 * 协调装饰器在类型级别 * `onError` 支持数组函数 * 解析带有和不带有模式的查询对象 * 弃用 `ObjectString` 解析数组 * Sucrose: 改进 isContextPassToFunction,提取主参数的稳定性 * 添加 `replaceSchemaType` * 将 `route` 添加到 `context` * 优化递归 MacroToProperty 类型 * 解析查询数组和对象 * 优化 `composeGeneralHandler` 的代码路径 * 添加调试报告以应对编译器恐慌 * 使用 `Cookie` 而不是 `Cookie`,如果未定义模式 * 将大型代码库的路由注册内存使用减少约 36% * 减少编译代码路径 * 移除跟踪推断 * 减少路由编译代码路径 * 移除路由处理程序编译缓存 (st${index}, stc${index}) * 在 cookie 中添加未定义的联合,以防 cookie 不存在 * 优化响应状态解析类型推断。 ### 错误修复: * 规范化头部意外使用查询验证器检查 * `onError` 缺失跟踪符号 * 头部验证器编译未被缓存 * 去重宏传播 * 嵌套组中的 WebSocket 现在可以工作 * 错误响应未检查,除非提供成功状态代码 ## 结束语 大家好,又是 SaltyAom,感谢你在过去两年对 Elysia 的支持。 这段旅程非常美好,看到如此多对 Elysia 的支持让我非常开心,我都不知道该如何表达。 我仍然很高兴能继续在 Elysia 上工作,并期待与您和 Elysia 共同经历更长的旅程。 然而,独自一人维护 Elysia 是不容易的,这就是为什么我需要你的帮助,支持 Elysia,通过报告错误、创建 PR(毕竟我们是开源的)或分享你喜欢的关于 Elysia 的任何内容,甚至只是打个招呼。 在过去的两年中,我知道 Elysia 还不完美,有时候我可能没有足够的时间回应问题,但我在努力尽力使其变得更好,并对它未来的愿景充满信心。 这就是为什么在未来,我们将有更多的维护者来帮助维护 Elysia 的插件,目前 Bogeychan 和 Fecony 正在帮助维护社区服务器,做得非常出色。 *** 如你所知,最初 ElysiaJS 的名字是 "KingWorld",然后改为 "Elysia"。 和 Elysia 的命名约定一样,这两个名字都是受到动画/游戏/vtuber 次文化的启发。 KingWorld 的名字来自于 [KINGWORLD](https://youtu.be/yVaQpUUAzik?si=Dmto2PgA0uDxNi3D) 这首歌,由白上吹雪和 Sasakure.uk 制作,他们都是我最喜欢的 vtuber 和音乐制作人。 这就是 **为什么 logo 是以北极狐的风格设计**,灵感来源于吹雪。 而 Elysia 显然是以 [Elysia](https://honkai-impact-3rd-archives.fandom.com/wiki/Elysia) 命名,来自游戏《崩坏3》,这也是我为我的猫起名的来源。 同时,我还有一份小礼物,那就是我在空闲时间也是一名 coser,我也做了崩坏3 Elysia 的 cos 服。 ![Elysia 维护者](/blog/elysia-11/ely.webp) 所以,Elysia 维护 Elysia,我想是吧? 我计划未来拍摄 Elysia cos 的照片并与你分享,因为我非常喜欢她,想要做到完美。 最后,我期待在下一个版本中见到你,感谢你对 Elysia 的支持。 > 我们曾经如此容易满足和快乐 > > 即使我打破了你最喜欢的泰迪熊 > > 一声“对不起”可以弥补一切 > > 什么时候发生了改变?我们什么时候忘记? > > 为什么现在如此难以原谅? > > 我们是否在不断前进,永不停止 > > 因为我们害怕回顾我们所做的事情? > 其实,我知道只要我们活着 > > 我们的理想便把河流染成猩红 > > 回答我,我沉没的船 > > 我们的明天在哪里? > > 我们的未来去哪儿了? > > 我们的希望一定要建立在某人的悲伤上吗? **ขอให้โลกใจดีกับเธอบ้างนะ** --- --- url: /blog/elysia-12.md --- \ 以 HoyoMix 的专辑《 At the Fingertip of the Sea 》中的歌曲 [Φ²](https://youtu.be/b9IkzWO63Fg) 命名,正如在 [**"你与我"**](https://youtu.be/nz_Ra4G57A4) 中使用的那样。 Elysia 1.2 专注于承诺扩展通用运行时支持和开发者体验: * [适配器](#adapter) * [具有解析的宏](#macro-with-resolve) * [解析器](#parser) * [WebSocket](#websocket) * [Typebox 0.34](#typebox) * [减少内存使用](#reduced-memory-usage) ## 适配器 最受欢迎的请求之一是支持更多运行时。 Elysia 1.2 引入了 **适配器** 以允许 Elysia 在不同的运行时上运行。 ```ts import { node } from '@elysiajs/node' new Elysia({ adapter: node() }) .get('/', 'Hello Node') .listen(3000) ``` Elysia 设计为在 Bun 上运行,并将继续将 Bun 作为主要运行时,优先在 Bun 上实现功能。 然而,我们为您提供了更多选择,可以在适合您需求的不同环境中尝试 Elysia,例如,无服务器的 AWS Lambda、Supabase Function 等。 Elysia 的适配器的目标是提供跨不同运行时的一致 API,同时保持相同代码或对每个运行时的最小更改所带来的最佳性能。 ### 性能 性能是 Elysia 的强项之一。我们不妥协于性能。 Elysia 直接使用原生 Node API,以实现最佳性能,而不是依赖于桥接将 Web 标准的请求转换为 Node 请求/响应。并在需要时提供 Web 标准兼容性。 通过利用 Sucrose 静态代码分析,Elysia 比大多数 Web 标准框架(如 Hono、h3,甚至原生 Node 框架如 Fastify、Express)都要快。 ![Node 基准测试](/blog/elysia-12/node-benchmark.webp) 与往常一样,您可以在 [Bun HTTP 框架基准测试](https://github.com/saltyaom/bun-http-framework-benchmark) 中找到基准测试。 Elysia 现在支持以下运行时适配器: * Bun * Web 标准 (WinterCG) 例如 Deno、浏览器 * Node (beta) 虽然 Node 适配器仍在 beta 阶段,但它具备您从返回生成器流到 WebSocket 所期待的最多功能。我们建议您尝试一下。 我们将继续扩展对更多运行时的支持,未来从以下开始: * Cloudflare Worker * AWS Lambda * uWebSocket.js ### 通用运行时 API 为了与不同的运行时兼容,Elysia 现在围绕精心挑选的工具函数提供一致的 API。 例如,在 Bun 中,您可以使用 `Bun.file` 返回文件响应,而该功能在 Node 中不可用。 ```ts import { Elysia } from 'elysia' // [!code --] import { Elysia, file } from 'elysia' // [!code ++] new Elysia() .get('/', () => Bun.file('./public/index.html')) // [!code --] .get('/', () => file('./public/index.html')) // [!code ++] ``` 这些工具函数是 Bun 的工具函数的复制,旨在与 Elysia 支持的运行时兼容,而未来将扩展。 目前,Elysia 支持: * `file` - 返回文件响应 * `form` - 返回表单数据响应 * `server` - Bun 的 `Server` 类型声明的移植 ## 具有解析的宏 从 Elysia 1.2 开始,您现在可以在宏中使用 `resolve`。 ```ts twoslash import { Elysia } from 'elysia' new Elysia() .macro({ user: (enabled: true) => ({ resolve: ({ cookie: { session } }) => ({ user: session.value! }) }) }) .get('/', ({ user }) => user, { // ^? user: true }) ``` 使用新的宏对象语法,您现在可以返回生命周期,而不是检索它,以减少样板代码。 以下是旧语法和新语法的比较: ```ts // ✅ 对象宏 new Elysia() .macro({ role: (role: 'admin' | 'user') => ({ beforeHandle: ({ cookie: { session } }) => ({ user: session.value! }) }) }) // ⚠️ 函数宏 new Elysia() .macro(({ onBeforeHandle }) => { role(role: 'admin' | 'user') { onBeforeHandle(({ cookie: { session } }) => ({ user: session.value! }) } }) ``` 两种语法都受支持,但推荐使用新对象语法。我们没有计划去掉之前的语法,但我们将专注于新对象语法,并引入新功能。 ::: info 由于 TypeScript 的限制,宏的 `resolve` 仅适用于新的对象语法,而不适用于之前的语法。 ::: ## 名称解析器 Elysia 1.2 引入了带自定义名称的解析器,允许您指定应使用哪个解析器来解码请求体。 ```ts import { Elysia } from 'elysia' new Elysia() .parser('custom', ({ contentType }) => { if(contentType === "application/kivotos") return 'nagisa' }) .post('/', ({ body }) => body, { parse: 'custom' }) ``` `parser` 的 API 类似于 `onParse`,但带有自定义名称,允许您在路由中引用它。 您还可以引用 Elysia 内置的解析器,或提供多个解析器按顺序使用。 ```ts import { Elysia } from 'elysia' new Elysia() .parser('custom', ({ contentType }) => { if(contentType === "application/kivotos") return 'nagisa' }) .post('/', ({ body }) => body, { parse: ['custom', 'json'] }) ``` 解析器将按顺序调用,如果解析器未返回值,将移至下一个解析器,直到其中一个解析器返回值。 ## WebSocket 我们重写了 WebSocket 以提高性能,并使其 API 和行为与最新的 Bun 的 WebSocket API 匹配,同时保持与每个运行时的兼容性。 ```ts new Elysia() .ws('/ws', { ping: (message) => message, pong: (message) => message }) ``` WebSocket 现在具有更一致的 API,与 HTTP 路由相匹配,并具有与 HTTP 路由相似的生命周期。 ## TypeBox 0.34 Elysia 1.2 现在支持 TypeBox 0.34。 通过此更新,Elysia 现在使用 TypeBox 的 `t.Module` 来处理引用模型以支持循环递归类型。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia() .model({ a: t.Object({ a: t.Optional(t.Ref('a')) }) }) .post('/recursive', ({ body }) => body, { // ^? body: 'a' }) ``` ## 减少内存使用 我们已经重构了 Sucrose 生成的代码,以实现可交换的代码生成过程。 通过重构以减少代码重复、路由优化和不必要代码的移除。 这使得 Elysia 能够重用多个部分的代码,并减少大部分的内存使用。 对于我们的项目,简单升级到 Elysia 1.2,就看到内存使用量减少了多达 2 倍。 ![1.1 和 1.2 之间的内存比较](/blog/elysia-12/memory.webp) 这种内存优化随着路由数量和路由复杂度的增加而扩展。因此,您可能会看到内存使用量呈指数级减少。 ## 重要更新 以下是 Elysia 1.2 中一些显著改进的内容。 ### Eden 验证错误 Eden 现在如果提供了验证模型,也会自动推断 `422` 状态码。 ```ts import { treaty } from '@elysiajs/eden' import type { App } from './app' const api = treaty('localhost:3000') const { data, error } = await api.user.put({ name: 'saltyaom' }) if(error) switch(error.status) { case 422: console.log(error.summary) break default: console.error(error) } ``` ### 路由 我们更新了路由注册的去重机制,使其更加优化。 之前 Elysia 会检查所有可能的路由,以防止路由重复。现在 Elysia 使用校验和哈希映射检查路由是否已经注册,并合并路由和代码生成的过程循环以提高性能。 ### 更改 * 事件监听器现在会根据作用域自动推断路径参数 * 为批量 `as` 添加了 ‘scoped’ 以将类型转换为 ‘scoped’,类似于 ‘plugin’ * 更新 `cookie` 至 1.0.1 * 更新 TypeBox 至 0.33 * `content-length` 现在接受数字 * 在 `trace` 中使用 16 位十六进制数作为 `id` * 在生产构建中禁用 `minify` 以便更好地调试/错误报告 ## 破坏性更改 在升级到 Elysia 1.2 时,您的代码库可能需要进行一些小的必需更改。 不过,以下是您需要注意的所有更改。 ### 解析 `type` 现在与 `parse` 合并,以允许控制自定义和内置解析器的顺序。 ```ts import { Elysia, form, file } from 'elysia' new Elysia() .post('/', ({ body }) => body, { type: 'json' // [!code --] parse: 'json' // [!code ++] }) ``` ### 表单数据 从 1.2 开始,如果响应是表单数据,您现在必须显式返回 `form`,而不是自动检测是否在 1 级深处的对象中存在文件。 ```ts import { Elysia, form, file } from 'elysia' new Elysia() .post('/', ({ file }) => ({ // [!code --] .post('/', ({ file }) => form({ // [!code ++] a: file('./public/kyuukurarin.mp4') })) ``` ### WebSocket WebSocket 方法现在返回相应的值,而不是返回 `WebSocket`。 因此,删除了方法链的能力。 这样做是为了使 WebSocket 的 API 与 Bun 的 WebSocket API 匹配,以便更好地兼容和迁移。 ```ts import { Elysia } from 'elysia' new Elysia() .ws('/', { message(ws) { ws // [!code --] .send('hello') // [!code --] .send('world') // [!code --] ws.send('hello') // [!code ++] ws.send('world') // [!code ++] } }) ``` ### 作用域 Elysia 现在移除了 `constructor scoped`,因为这可能会与 `scope's scoped/global` 混淆。 ```ts import { Elysia } from 'elysia' new Elysia({ scoped: false }) // [!code --] const scoped = new Elysia() // [!code ++] const main = new Elysia() // [!code ++] .mount(scoped) // [!code ++] ``` ### 内部破坏性改动 * 移除路由内部属性 static.http.staticHandlers * 路由历史编译现在与历史组合连接 ## 结语 Elysia 1.2 是我们努力工作很久的雄心勃勃的更新。 这是一个将 Elysia 的影响力扩展到更多开发者和更多运行时的赌博,但我还有其他想说的事情。 ### 让我们重新开始。 嗨,你好吗?我希望你一切都好。 我仍然喜欢这样,写关于 Elysia 的博客文章。已经有一段时间了。 您可能注意到,自上次更新以来已经有一段时间了,而且这次更新并不算长。我为此感到抱歉。 我希望您能理解,我们也有自己的生活要照顾。我们不是机器人,我们是人。有时是生活,有时是工作,有时是家庭,有时是财务。 ### 我希望一直和你在一起。 做我喜欢的事,不断更新 Elysia,不断写博客文章,不断创作艺术,但你知道我也有一些事情要照顾。 我必须为餐桌上带来食物,我需要照顾很多财务上的事情。我也得照顾自己。 我希望你一切都好,希望你快乐,希望你健康,希望你安全。 即使我不在这里。名为 Elysia 的我将与您同在。 感谢您与我同行。 > 在这里我感受到真实号码解带来的触碰。 > > 两个灵魂的涟漪现在达到了我们的双缝。 > > 在世界上投射出光明与黑暗的条纹,似昼似夜。 > > 你让我在阳光下自由。 > > 我将你的摇篮梦飞向月球再返回。 > > 一条虫会变成一只蝴蝶, > > 在一个人回答“我是谁”之前。 > > 当冰变成水后, > > 我倒吊着的海洋将是你的天空。 > 而我们终于再次相遇,Seele。 --- --- url: /blog/elysia-13.md --- \ 以 Mili 的歌曲 [Ga1ahad 和科学巫术](https://youtu.be/d-nxW9qBtxQ) 命名。 此版本没有炫目的新功能。 它是对事物进行改进,以至于我们认为这就是 **“魔法”**。 Elysia 1.3 的功能几乎零开销,经过 refinements、修复技术债务和重构内部代码,具有: * [精确镜像](#exact-mirror) * [Bun 系统路由器](#bun-system-router) * [独立验证器](#standalone-validator) * [减少一半的类型实例化](#reduced-type-instantiation) * [性能改进](#performance-improvement) * [验证 DX 改进](#validation-dx-improvement) * [将错误重命名为状态](#rename-error-to-status) ## 精确镜像 我们在 Elysia 1.1 中引入了 [normalize](/patterns/configuration.html#normalize),确保数据符合我们所需的形状,并且运行良好。 它有助于减少潜在的数据泄露,避免意外的属性,用户非常喜欢它。然而,这也带来了性能成本。 在后台,它使用 `TypeBox 的 Value.Clean` 动态地将数据强制转换为指定的模式。 效果很好,但速度不够快。 由于 TypeBox 不提供与 `TypeCompiler.Check` 类似的 **编译** 版本,后者利用了提前知道形状的优势。 这就是我们引入 [精确镜像](https://github.com/elysiajs/exact-mirror) 作为替代方案的原因。 **精确镜像** 是 TypeBox 的 **Value.Clean** 的即插即用替代,显著提高了性能,利用了提前编译的优势。 ### 性能 对于没有数组的小对象,我们测量的速度 **最快可达 ~500倍**。 ![在小数据上运行的精确镜像,其速度比 TypeBox Value.Clean 快 582.52 倍](/blog/elysia-13/exact-mirror-small.webp) > 在小数据上运行的精确镜像 对于中等和大型对象,我们测量的速度 **最快可达 ~30倍**。 ![在中大型数据上运行的精确镜像,结果依次为 29.46 倍和 31.6 倍](/blog/elysia-13/exact-mirror-large.webp) > 在中大型数据上运行的精确镜像 ### 对 Elysia 的意义 从 Elysia 1.3 开始,精确镜像是默认的规范化策略,取代了 TypeBox。 通过升级到 Elysia 1.3,您可以期待显著的性能提升 **没有任何代码更改**。 以下是 Elysia 1.2 的吞吐量。 ![未开启规范化的 Elysia,吞吐量为 49k req/sec](/blog/elysia-13/normalize-1.2.webp) > 未开启规范化的 Elysia 而以下是同一段代码在 Elysia 1.3 中的结果 ![开启规范化的 Elysia,吞吐量为 77k req/sec](/blog/elysia-13/normalize-1.3.webp) > 开启规范化的 Elysia 我们在使用 **单个** 模式的情况下测得吞吐量最高可达 ~1.5倍。 这意味着如果您使用多个模式,您将在性能上看到更明显的提升。 与没有模式的相同代码相比,性能差异小于 2%。 ![未验证的 Elysia 的运行结果为 79k req/sec](/blog/elysia-13/no-validation.webp) > 未验证的 Elysia 的运行结果 这非常重要。 之前,您必须在安全性和性能之间做出选择,但随着我们缩小了使用验证和不使用验证之间的性能差距,现在您不必担心这个问题。 现在,我们将验证开销从大量下降到几乎接近零,而无需您进行任何更改。 它就像魔法一样运行。 但是,如果您希望使用 TypeBox 或完全禁用规范化,您可以像配置其他设置一样,通过构造函数进行设定: ```ts import { Elysia } from 'elysia' new Elysia({ normalize: 'typebox' // 使用 TypeBox }) ``` 您可以访问 [GitHub上的精确镜像](https://github.com/elysiajs/exact-mirror) 自行尝试基准测试。 ## 系统路由器 我们在 Elysia 中从未遇到过路由器性能问题。 它性能优异,我们尽可能进行了超优化。 我们将其推至 JavaScript 在实际情况下能够提供的近乎极限。 ### Bun 路由器 然而,Bun 1.2.3 提供了一个内置的路由解决方案(可能是在本地代码中)。 尽管对于静态路由,我们没有看到太多性能提升,但我们发现 **动态路由性能提高了 2-5%** ,而没有进行任何代码更改。 从 Elysia 1.3 开始,我们提供了一种双路由策略,将 Bun 的本地路由器和 Elysia 的路由器结合使用。 Elysia 将尽可能使用 Bun 路由器,若不成功则回退到 Elysia 的路由器。 ### 适配器 为了实现这一点,我们必须重写我们的内部编译代码,以支持来自 **适配器** 的自定义路由器。 这意味着现在可以将自定义路由器与 Elysia 自有路由器一起使用。 这在某些环境中为性能提升开辟了机会,例如:使用内置的 `uWebSocket.js 路由器`,该路由器具有原生实现的路由功能。 ## 独立验证器 在 Elysia 中,我们可以定义一个模式并通过 `guard` 将其应用于多个路由。 然后,我们可以通过在路由处理程序中提供一个模式来覆盖公共模式,有时看起来像这样: ![Elysia 运行具有默认覆盖保护的模式,显示模式被覆盖](/blog/elysia-13/schema-override.webp) > Elysia 运行具有默认覆盖保护 但有时我们 **不想覆盖** 一个模式。 相反,我们希望它两者兼具,允许我们组合模式而不是覆盖它们。 从 Elysia 1.3 开始,我们可以做到这一点。 我们现在可以告诉 Elysia 不要覆盖它,而是将其视为其自身,通过提供一个模式作为 **独立**。 ```ts import { Elysia } from 'elysia' new Elysia() .guard({ schema: 'standalone', // [!代码 ++] response: t.Object({ title: t.String() }) }) ``` 结果,我们得到了类似于将本地和全局模式合并的结果。 ![Elysia 运行独立模式,合并多个保护](/blog/elysia-13/schema-standalone.webp) > Elysia 运行独立模式,合并多个保护 ## 减少类型实例化 Elysia 的类型推断已经非常快。 我们对类型推断的优化非常有信心,它的速度比大多数使用类 Express 语法的框架还要快。 然而,我们的用户在规模很大、具有多个路由和复杂的类型推断的情况下,面临着挑战。 我们设法在大多数情况下 **将类型实例化减少了一半**,测量了推断速度提高了高达 60%。 ![类型实例化从 109k 减少到 52k](/blog/elysia-13/type-instantiation.webp) > 类型实例化从 109k 减少到 52k 我们还改变了 `decorate` 的默认行为,而不是递归遍历每个对象和属性进行交集。 这应该解决使用重型对象/类的用户的问题,例如 `PrismaClient`。 因此,结果应该会带来更快速的 IDE 自动补全、建议、类型检查和 Eden Treaty。 ## 性能改进 我们重构和优化了许多内部代码,从而实现了显著的改进。 ### 路由注册 我们重构了存储路由信息的方式,并重用对象引用,而不是克隆/创建新的引用。 我们观察到以下改进: * 内存使用减少到 ~5.6倍 * 路由注册时间提高到 ~2.7倍 ![Elysia 1.2(左)与 1.3(右)之间的路由注册比较](/blog/elysia-13/routes.webp) > Elysia 1.2(左)与 1.3(右)之间的路由注册比较 这些优化应该能在中大型应用中显现出真正的成果,因为它随服务器的路由数量而扩展。 ### Sucrose 我们实现了 Sucrose 缓存,以减少不必要的重新计算,并在为非内联事件编译每个路由时重用已编译的路由。 ![Elysia 1.2(左)和 1.3(右)之间的 Sucrose 性能比较](/blog/elysia-13/sucrose.webp) > Elysia 1.2(左)和 1.3(右)之间的 Sucrose 性能比较 Sucrose 将每个事件转换为校验和号码并将其存储为缓存。它使用很少的内存,并将在服务器启动后清理。 这一改进应该有助于重用全局/作用域事件的每路由的启动时间。 ### 实例 在创建多个实例并将其作为插件应用时,我们看到显著的改进。 * 内存使用减少了 ~10倍 * 插件创建速度提高了 ~3倍 ![Elysia 1.2(左)与 1.3(右)之间的实例比较](/blog/elysia-13/instance.webp) > Elysia 1.2(左)与 1.3(右)之间的实例比较 这些优化将在升级到 Elysia 1.3 时自动应用。然而,这些性能优化对于小型应用可能不会特别显著。 因为 Serving 一个简单的 Bun 服务器的固定成本约为 10-15MB。这些优化更像是减少现有开销,并有助于改善启动时间。 ### 通用更快性能 通过各种微优化、修复技术债务和消除未使用的编译指令。 我们看到 Elysia 请求处理速度有所改善。在某些情况下提高了高达 40%。 ![Elysia.handle 在 Elysia 1.2 和 1.3 之间的比较](/blog/elysia-13/handle.webp) > Elysia.handle 在 Elysia 1.2 和 1.3 之间的比较 ## 验证 DX 改进 我们希望 Elysia 的验证能够 **即刻生效**。 只需告诉它您想要什么,它就能满足。这是 Elysia 最有价值的方面之一。 在这次更新中,我们改善了一些我们一直欠缺的领域。 ### 编码模式 我们已将 [encodeSchema](/patterns/configuration.html#encodeschema) 从 `实验性` 移出,并默认启用。 这使我们能够使用 [t.Transform](https://github.com/sinclairzx81/typebox?tab=readme-ov-file#types-transform) 应用自定义响应映射,以返回给最终用户。 ![使用 t.Transform 进行值拦截](/blog/elysia-13/encode-schema.webp) > 使用 t.Transform 进行值拦截 这段示例代码将拦截响应,将“hi”替换为“intercepted”。 ### 清理 为了防止 SQL 注入和 XSS,并确保字符串输入/输出安全,我们引入了 [sanitize](/patterns/configuration.html#sanitize) 选项。 它接受一个函数或一组函数,拦截每个 `t.String`,并将其转换为新值。 ![使用 sanitize 和 Bun.escapeHTML](/blog/elysia-13/sanitize.webp) > 使用 sanitize 和 Bun.escapeHTML 在这个例子中,我们使用 **Bun.escapeHTML** 并将每个“dorothy”替换为“doro”。 由于 `sanitize` 将全局应用于每个模式,它必须在根实例上应用。 这大大减少了手动安全验证和转换每个字符串字段的样板代码。 ### 表单 在 Elysia 的早期版本中,无法使用 [form](/essential/handler.html#formdata) 和 `t.Object` 在编译时进行类型检查 FormData 响应。 我们现在引入了一个新的 [t.Form](/patterns/type#form) 类型来解决这个问题。 ![使用 t.Form 验证 FormData](/blog/elysia-13/form.webp) > 使用 t.Form 验证 FormData 要迁移到表单类型检查,只需在响应模式中将 `t.Object` 替换为 `t.Form`。 ### 文件类型 Elysia 现在使用 [file-type](https://github.com/sindresorhus/file-type) 验证文件类型。 ![使用 t.File 定义文件类型](/blog/elysia-13/file-type.webp) > 使用 t.File 定义文件类型 一旦指定了 `type`,Elysia 将通过检查魔术数字自动检测文件类型。 然而,它也被列为 **peerDependencies**,并且不会随 Elysia 默认安装,以减少不需要此功能的用户的包大小。 如果您依赖文件类型验证以提高安全性,建议您更新到 Elysia 1.3。 ### Elysia.Ref 我们可以通过使用 `Elysia.model` 创建引用模型,并通过名称引用它。 然而,有时我们需要在模式内部引用它。 我们现在可以通过使用 `Elysia.Ref` 来实现这一点,并自动完成引用模型。 ![使用 Elysia.Ref 引用模型](/blog/elysia-13/elysia-ref.webp) > 使用 Elysia.Ref 引用模型 您也可以使用 `t.Ref` 来引用模型,但它不会提供自动完成。 ### 不验证 我们收到了许多反馈,一些用户希望快速原型化他们的 API,或者有时在强制执行验证时遇到问题。 在 Elysia 1.3 中,我们引入了 `t.NoValidate` 以跳过验证。 ![使用 t.NoValidate 告诉 Elysia 跳过验证](/blog/elysia-13/no-validate.webp) > 使用 t.NoValidate 告诉 Elysia 跳过验证 这将告知 Elysia 跳过运行时验证,但仍然提供 TypeScript 类型检查和 OpenAPI 架构以用于 API 文档。 ## 状态 我们收到了关于 `error` 命名的大量反馈。 从 Elysia 1.3 开始,我们决定弃用 `error`,并建议使用 `status` 代替。 ![IDE 显示 error 被弃用并重命名为 status](/blog/elysia-13/status.webp) > IDE 显示 error 被弃用并重命名为 status `error` 函数将按前一个版本的方式运作,无需立即更改。 但是,我们建议重构为 `status`,因为我们将在接下来的 6 个月内支持 `error` 函数,直到大约 Elysia 1.4 或 1.5。 要迁移,只需将 `error` 重命名为 `status`。 ## ".index" 从 Treaty 中移除 之前,您必须添加 `(treaty).index` 来处理以 **/** 结尾的路径。 从 Elysia 1.3 开始,我们决定放弃使用 `.index`,可以简单地绕过它,直接调用方法。 ![Eden Treaty 显示没有使用 .index](/blog/elysia-13/treaty-index.webp) > Eden Treaty 显示没有使用 .index 这是一个 **破坏性更改**,但迁移只需最低努力。 要迁移,只需从您的代码库中删除 `.index`。使用 IDE 搜索进行批量更改,将 `.index` 匹配并删除,这应该是一个简单的更改。 ## 突出变化 以下是一些来自变更日志的显著变化。 ### 改进 * `encodeSchema` 现在稳定,并默认启用 * 优化类型 * 在使用 Encode 时减少冗余类型检查 * 优化 isAsync * 默认解包 Definition\['typebox'] 以防止不必要的 UnwrapTypeModule 调用 * Elysia.form 现在可以进行类型检查 * 重构类型系统 * 将 `_types` 重构为 `~Types` * 使用 aot 编译检查自定义 Elysia 类型,例如 Numeric * 重构 `app.router.static`,并将静态路由器代码生成移至编译阶段 * 优化 `add`、`_use` 及一些实用函数的内存使用 * 改善多个路由的启动时间 * 动态创建 cookie 验证器,以便在编译过程中按需使用 * 减少对象克隆 * 优化用于查找内容类型头分隔符的起始索引 * Promise 现在可以是静态响应 * `ParseError` 现在保留堆栈跟踪 * 重构 `parseQuery` 和 `parseQueryFromURL` * 向 `mount` 添加 `config` 选项 * 在挂载异步模块后自动重新编译 * 支持宏,当钩子具有函数时 * 支持在 ws 上解析宏 * [#1146](https://github.com/elysiajs/elysia/pull/1146) 添加支持从处理程序返回 Web API 的文件 * [#1165](https://github.com/elysiajs/elysia/pull/1165) 在响应架构验证中跳过非数字状态码 * [#1177](https://github.com/elysiajs/elysia/issues/1177) 当抛出错误时 cookie 不会签名 ### 修复错误 * 从 `onError` 返回的 `Response` 使用八位字节流 * 使用 `mergeObjectArray` 时意外的内存分配 * 处理日期查询的空格 ### 更改 * 当 `maybeStream` 为 true 时,仅向 mapResponse 提供 `c.request` * 使用普通对象作为 `routeTree`,而不是 `Map` * 移除 `compressHistoryHook` 和 `decompressHistoryHook` * webstandard 处理程序现在在未在 Bun 上时返回 `text/plain` * 除非明确指定,否则为 `decorate` 使用非常量值 * `Elysia.mount` 现在默认设置 `detail.hide = true` ### 破坏性更改 * 移除 `as('plugin')`,改用 `as('scoped')` * 移除 Eden Treaty 的根 `index` * 从 `ElysiaAdapter` 中移除 `websocket` * 移除 `inference.request` ## 后记 嗨?好久不见。 生活有时会让人感到困惑,是不是? 有一天,你在追逐梦想,努力工作。 转眼间,你回头发现自己已经远超目标。 有人仰望你,你成了他们的灵感,成为某人的榜样。 听起来很棒,对吧? 但我认为我并不是一个好的榜样。 ### 我想过诚实的生活 有时,事情只是被夸大了。 我可能看起来像个能创造任何东西的天才,但我不是。我只是尽我所能。 我和朋友们一起玩电子游戏,听奇怪的歌曲,看电影,甚至在动漫展上与他们见面。 就像一个普通人。 这段时间,我只是紧紧地抱住了 *你的* 手臂。 **我和你一样,没有特别之处。** 我尽我所能,但我偶尔也会表现得像个傻瓜。 即使我觉得自己没有任何可以成为榜样的特质,我仍想告诉你,我心怀感激。 我的无聊和略显孤独的生活,请不要美化它太多。 *~ 我很高兴你也坏坏的。* --- --- url: /blog/elysia-supabase.md --- \ Supabase 是一个开源的 Firebase 替代品,已成为开发者们快速开发的热门工具包。 它提供了 PostgreSQL、即用型用户认证、无服务器边缘功能、云存储等功能,供您随时使用。 因为 Supabase 已经预构建并组合了情境,您可以减少重复开发相同功能的代码行数,将其缩短到不到 10 行代码。 例如,对于认证,这通常需要您为每个项目重写一百行代码,仅需: ```ts supabase.auth.signUp(body) supabase.auth.signInWithPassword(body) ``` 然后 Supabase 将处理剩余的部分,通过发送确认链接来验证电子邮件,或者使用一个魔术链接或一次性密码 (OTP) 进行认证,确保您的数据库拥有行级认证,您说了算。 在每个项目中需要耗费数小时重新做的事情,现在只需一分钟即可完成。 ## Elysia 如果您还没有听说,Elysia 是一个以 Bun 为核心的 web 框架,旨在提升速度和开发者体验。 Elysia 的性能比 Express 快近 20 倍,同时其语法几乎与 Express 和 Fastify 相同。 ###### (性能可能因机器而异,我们建议您在决定性能之前在您的机器上运行 [基准测试](https://github.com/SaltyAom/bun-http-framework-benchmark)) Elysia 提供了极为灵活的开发者体验。 不仅可以定义单一事实来源类型,并且在您意外修改数据时还可以检测并报警。 这一切都通过简洁的声明式代码实现。 ## 设置 您可以使用 Supabase Cloud 快速入门。 Supabase Cloud 将处理数据库的设置、扩展和您在云中所需的所有内容,只需单击一下即可完成。 创建项目时,您应该会看到类似以下界面,填写所有所需的请求,如果您在亚洲,Supabase 在新加坡和东京都有服务器。 ##### (有时这对生活在亚洲的开发者来说是一个决定性因素,因为延迟问题) 创建项目后,您应该会看到一个欢迎屏幕,可以在其中复制项目 URL 和服务角色。 这两者用于指示您在项目中使用的是哪个 Supabase 项目。 如果您错过了欢迎页面,请导航到 **设置 > API**,复制 **项目 URL** 和 **项目 API 密钥**。 现在在您的命令行中,通过运行以下命令开始创建 Elysia 项目: ```bash bun create elysia elysia-supabase ``` 最后一个参数是我们要创建的 Bun 文件夹名称,可以随意更改该名称。 现在,**cd** 进入我们的文件夹,因我们将使用 Elysia 0.3 (RC) 中的新功能,所以需要先安装 Elysia 的 RC 通道,并在这里获取一个 Cookie 插件和将来要使用的 Supabase 客户端。 ```bash bun add elysia@rc @elysiajs/cookie@rc @supabase/supabase-js ``` 让我们创建一个 **.env** 文件以将 Supabase 服务加载为秘密。 ```bash # .env supabase_url=https://********************.supabase.co supabase_service_role=**** **** **** **** ``` 您不必安装任何插件来加载环境文件,因为 Bun 默认会加载 **.env** 文件。 现在让我们在我们喜欢的 IDE 中打开我们的项目,并在 `src/libs/supabase.ts` 中创建一个文件。 ```ts // src/libs/supabase.ts import { createClient } from '@supabase/supabase-js' const { supabase_url, supabase_service_role } = process.env export const supabase = createClient(supabase_url!, supabase_service_role!) ``` 就这样!设置 Supabase 和 Elysia 项目所需的一切。 现在让我们深入实现! ## 认证 现在让我们创建一个与主文件分开的认证路由。 在 `src/modules/authen.ts` 中,首先为我们的路由创建大纲。 ```ts // src/modules/authen.ts import { Elysia } from 'elysia' const authen = (app: Elysia) => app.group('/auth', (app) => app .post('/sign-up', () => { return 'This route is expected to sign up a user' }) .post('/sign-in', () => { return 'This route is expected to sign in a user' }) ) ``` 现在,让我们应用 Supabase 来认证我们的用户。 ```ts // src/modules/authen.ts import { Elysia } from 'elysia' import { supabase } from '../../libs' // [!code ++] const authen = (app: Elysia) => app.group('/auth', (app) => app .post('/sign-up', async ({ body }) => { const { data, error } = await supabase.auth.signUp(body) // [!code ++] // [!code ++] if (error) return error // [!code ++] return data.user // [!code ++] return 'This route is expected to sign up a user' // [!code --] }) .post('/sign-in', async ({ body }) => { const { data, error } = await supabase.auth.signInWithPassword( // [!code ++] body // [!code ++] ) // [!code ++] // [!code ++] if (error) return error // [!code ++] // [!code ++] return data.user // [!code ++] return 'This route is expected to sign in a user' // [!code --] }) ) ``` 完成了!这就是为我们的用户创建 **sign-in** 和 **sign-up** 路由所需的一切。 但我们这里有一个小问题,您会看到,我们的路由可以接受 **任何** 请求体并将其放入 Supabase 参数,甚至是无效的。 所以,为了确保我们放入正确的数据,我们可以为我们的请求体定义一个 schema。 ```ts // src/modules/authen.ts import { Elysia, t } from 'elysia' import { supabase } from '../../libs' const authen = (app: Elysia) => app.group('/auth', (app) => app .post( '/sign-up', async ({ body }) => { const { data, error } = await supabase.auth.signUp(body) if (error) return error return data.user }, { // [!code ++] schema: { // [!code ++] body: t.Object({ // [!code ++] email: t.String({ // [!code ++] format: 'email' // [!code ++] }), // [!code ++] password: t.String({ // [!code ++] minLength: 8 // [!code ++] }) // [!code ++] }) // [!code ++] } // [!code ++] } // [!code ++] ) .post( '/sign-in', async ({ body }) => { const { data, error } = await supabase.auth.signInWithPassword(body) if (error) return error return data.user }, { // [!code ++] schema: { // [!code ++] body: t.Object({ // [!code ++] email: t.String({ // [!code ++] format: 'email' // [!code ++] }), // [!code ++] password: t.String({ // [!code ++] minLength: 8 // [!code ++] }) // [!code ++] }) // [!code ++] } // [!code ++] } // [!code ++] ) ) ``` 现在我们在 **sign-in** 和 **sign-up** 中都声明了一个 schema,Elysia 将确保传入的请求体与我们声明的格式相同,从而防止无效参数传递给 `supabase.auth`。 Elysia 还理解该 schema,因此不需要单独声明 TypeScript 的类型,Elysia 会自动将 `body` 的类型设为您定义的 schema。 因此,如果您意外在将来创建了破坏性更改,Elysia 会警告您有关数据类型的信息。 我们的代码非常出色,完成了我们期待的工作,但我们可以进一步优化。 您会看到,**sign-in** 和 **sign-up** 都接受相同形状的数据,未来,您可能还会发现自己在多个路由中重复一个长 schema。 我们可以通过告诉 Elysia 记住我们的 schema 来解决这个问题,然后我们可以通过告诉 Elysia 我们要使用的 schema 的名称来使用它。 ```ts // src/modules/authen.ts import { Elysia, t } from 'elysia' import { supabase } from '../../libs' const authen = (app: Elysia) => app.group('/auth', (app) => app .setModel({ // [!code ++] sign: t.Object({ // [!code ++] email: t.String({ // [!code ++] format: 'email' // [!code ++] }), // [!code ++] password: t.String({ // [!code ++] minLength: 8 // [!code ++] }) // [!code ++] }) // [!code ++] }) // [!code ++] .post( '/sign-up', async ({ body }) => { const { data, error } = await supabase.auth.signUp(body) if (error) return error return data.user }, { schema: { body: 'sign', // [!code ++] body: t.Object({ // [!code --] email: t.String({ // [!code --] format: 'email' // [!code --] }), // [!code --] password: t.String({ // [!code --] minLength: 8 // [!code --] }) // [!code --] }) // [!code --] } } ) .post( '/sign-in', async ({ body }) => { const { data, error } = await supabase.auth.signInWithPassword(body) if (error) return error return data.user }, { schema: { body: 'sign', // [!code ++] body: t.Object({ // [!code --] email: t.String({ // [!code --] format: 'email' // [!code --] }), // [!code --] password: t.String({ // [!code --] minLength: 8 // [!code --] }) // [!code --] }) // [!code --] } } ) ) ``` 太好了!我们在路由中只是使用了名称引用! ::: tip 如果您发现自己有一个长 schema,可以将它们声明在一个单独的文件中,并在 Elysia 的任何路由中重新使用,从而将重点放回业务逻辑上。 ::: ## 存储用户会话 太好了,现在为完成认证系统,我们需要做的最后一件事是存储用户会话,在用户登录后,Supabase 中的令牌称为 `access_token` 和 `refresh_token`。 `access_token` 是一个短期有效的 JWT 访问令牌,用于在短时间内验证用户。 `refresh_token` 是一个一次性使用且永不过期的令牌,用于续订 `access_token`。所以只要我们有这个令牌,我们就可以创建一个新的访问令牌来延长我们的用户会话。 我们可以将这两个值存储在一个 cookie 中。 现在,有些人可能不喜欢将访问令牌存储在 cookie 中,可能会使用 Bearer,但为了简单起见,我们将在这里使用 cookie。 ::: tip 我们可以将 cookie 设置为 **HttpOnly** 以防止 XSS,设置为 **Secure** 和 **Same-Site**,还可以加密 cookie 以防止中间人攻击。 ::: ```ts // src/modules/authen.ts import { Elysia, t } from 'elysia' import { cookie } from '@elysiajs/cookie' // [!code ++] import { supabase } from '../../libs' const authen = (app: Elysia) => app.group('/auth', (app) => app .use( // [!code ++] cookie({ // [!code ++] httpOnly: true, // [!code ++] // 如果需要 cookie 仅通过 https 发送 // [!code ++] // secure: true, // [!code ++] // // [!code ++] // 如果需要 cookie 仅对同源可用 // [!code ++] // sameSite: "strict", // [!code ++] // // [!code ++] // 如果希望加密 cookie // [!code ++] // signed: true, // [!code ++] // secret: process.env.COOKIE_SECRET, // [!code ++] }) // [!code ++] ) // [!code ++] .setModel({ sign: t.Object({ email: t.String({ format: 'email' }), password: t.String({ minLength: 8 }) }) }) // 其余代码 ) ``` 就这样,创建了 Elysia 和 Supabase 的 **sign-in** 和 **sign-up** 路由! ## 刷新令牌 如前所述,`access_token` 是短期有效的,我们可能需要时不时地续订令牌。 幸运的是,我们可以用 Supabase 的一行代码做到这一点。 ```ts // src/modules/authen.ts import { Elysia, t } from 'elysia' import { supabase } from '../../libs' const authen = (app: Elysia) => app.group('/auth', (app) => app .setModel({ sign: t.Object({ email: t.String({ format: 'email' }), password: t.String({ minLength: 8 }) }) }) .post( '/sign-up', async ({ body }) => { const { data, error } = await supabase.auth.signUp(body) if (error) return error return data.user }, { schema: { body: 'sign' } } ) .post( '/sign-in', async ({ body }) => { const { data, error } = await supabase.auth.signInWithPassword(body) if (error) return error return data.user }, { schema: { body: 'sign' } } ) .get( // [!code ++] '/refresh', // [!code ++] async ({ setCookie, cookie: { refresh_token } }) => { // [!code ++] const { data, error } = await supabase.auth.refreshSession({ // [!code ++] refresh_token // [!code ++] }) // [!code ++] // [!code ++] if (error) return error // [!code ++] // [!code ++] setCookie('refresh_token', data.session!.refresh_token) // [!code ++] // [!code ++] return data.user // [!code ++] } // [!code ++] ) // [!code ++] ) ``` 最后,将路由添加到主服务器中。 ```ts import { Elysia, t } from 'elysia' import { auth } from './modules' // [!code ++] const app = new Elysia() .use(auth) // [!code ++] .listen(3000) console.log( `🦊 Elysia 正在运行在 ${app.server?.hostname}:${app.server?.port}` ) ``` 就这样! ## 授权路由 我们刚刚实现了用户认证,这很有趣,但现在您可能会发现自己需要对每个路由进行授权,并且在各处重复相同的代码来检查 cookie。 幸运的是,我们可以在 Elysia 中重用这个函数。 让我们通过说,假设我们可能希望用户创建一个简单的博客帖子,而其数据库架构如下: 在 Supabse 控制台中,我们将创建一个名为 'post' 的 Postgres 表,如下所示: **user\_id** 链接到 Supabase 生成的 **auth** 表,链接为 **user.id**,通过这种关系,我们可以创建行级安全性,只允许帖子的所有者修改数据。 现在,让我们在另一个文件夹中创建一个新的 Elysia 路由,以将代码与认证路由分开,文件路径为 `src/modules/post/index.ts`。 ```ts // src/modules/post/index.ts import { Elysia, t } from 'elysia' import { supabase } from '../../libs' export const post = (app: Elysia) => app.group('/post', (app) => app.put( '/create', async ({ body }) => { const { data, error } = await supabase .from('post') .insert({ // 以某种方式添加 user_id // user_id: userId, ...body }) .select('id') if (error) throw error return data[0] }, { schema: { body: t.Object({ detail: t.String() }) } } ) ) ``` 现在,此路由可以接受请求体并将其放入数据库中,我们需要做的唯一事情是处理授权并提取 `user_id`。 幸运的是,由于 Supabase 和我们的 cookies,这一切都很简单。 ```ts import { Elysia, t } from 'elysia' import { cookie } from '@elysiajs/cookie' // [!code ++] import { supabase } from '../../libs' export const post = (app: Elysia) => app.group('/post', (app) => app.put( '/create', async ({ body }) => { let userId: string // [!code ++] // [!code ++] const { data, error } = await supabase.auth.getUser( // [!code ++] access_token // [!code ++] ) // [!code ++] // [!code ++] if(error) { // [!code ++] const { data, error } = await supabase.auth.refreshSession({ // [!code ++] refresh_token // [!code ++] }) // [!code ++] // [!code ++] if (error) throw error // [!code ++] // [!code ++] userId = data.user!.id // [!code ++] } // [!code ++] const { data, error } = await supabase .from('post') .insert({ // 以某种方式添加 user_id // user_id: userId, ...body }) .select('id') if (error) throw error return data[0] }, { schema: { body: t.Object({ detail: t.String() }) } } ) ) ``` 太好了!现在我们可以使用 **supabase.auth.getUser** 从 cookie 中提取 `user_id`。 ## 派生 我们的代码目前运行良好,但让我们描绘一个小场景。 假设您有许多需要授权的路由,像这样,您需要提取 `userId`,这意味着您将拥有大量重复的代码,对吧? 幸运的是,Elysia 特别设计用于解决这个问题。 *** 在 Elysia 中,我们有一个名为 **scope** 的概念。 想象一下,这就像一个 **闭包**,其中变量只能在一个范围内使用,或者如果您来自 Rust,它就像所有权。 在范围内声明的任何生命周期,例如 **group**、**guard**,都只会在该范围内可用。 这意味着您可以为需要授权的特定路由声明一个特定的生命周期,而其他路由则不需要。 例如,某些需要授权的路由范围,而其他则不需要。 因此,我们没有重复使用所有代码,而是定义了一次,并将其应用于您需要的所有路由。 *** 现在,让我们将获取 **user\_id** 的过程放入一个插件中,并将其应用于该范围内的所有路由。 让我们将此插件放在 `src/libs/authen.ts` 中。 ```ts import { Elysia } from 'elysia' import { cookie } from '@elysiajs/cookie' import { supabase } from './supabase' export const authen = (app: Elysia) => app .use(cookie()) .derive( async ({ setCookie, cookie: { access_token, refresh_token } }) => { const { data, error } = await supabase.auth.getUser( access_token ) if (data.user) return { userId: data.user.id } const { data: refreshed, error: refreshError } = await supabase.auth.refreshSession({ refresh_token }) if (refreshError) throw error return { userId: refreshed.user!.id } } ) ``` 此代码尝试提取 userId,并将 `userId` 添加到路由的 `Context` 中,否则将抛出错误并跳过处理程序,防止无效错误被放入我们的业务逻辑,即 **supabase.from.insert**。 ::: tip 我们也可以使用 **onBeforeHandle** 创建自定义验证,以便在进入主处理程序之前进行验证,而 **.derive** 则会执行相同的操作,任何从 **derive** 返回的内容都会添加到 **Context** 中,而 **onBeforeHandle** 则不会。 从技术上讲,**derive** 使用 **transform** 作为底层机制。 ::: 只需一行代码,我们就可以将所有路径都应用到该作用域内,并以类型安全的方式访问 **userId**。 ```ts import { Elysia, t } from 'elysia' import { authen, supabase } from '../../libs' // [!code ++] export const post = (app: Elysia) => app.group('/post', (app) => app .use(authen) // [!code ++] .put( '/create', async ({ body, userId }) => { // [!code ++] let userId: string // [!code --] // [!code --] const { data, error } = await supabase.auth.getUser( // [!code --] access_token // [!code --] ) // [!code --] // [!code --] if(error) { // [!code --] const { data, error } = await supabase.auth.refreshSession({ // [!code --] refresh_token // [!code --] }) // [!code --] // [!code --] if (error) throw error // [!code --] // [!code --] userId = data.user!.id // [!code --] } // [!code --] const { data, error } = await supabase .from('post') .insert({ user_id: userId, // [!code ++] ...body }) .select('id') if (error) throw error return data[0] }, { schema: { body: t.Object({ detail: t.String() }) } } ) ) ``` 太好了!我们在代码中根本看不到处理授权的部分,简直像魔法一样。 将我们的注意力重新放回核心业务逻辑中。 ## 非授权作用域 现在让我们再创建一个路由,从数据库中获取帖子。 ```ts import { Elysia, t } from 'elysia' import { authen, supabase } from '../../libs' export const post = (app: Elysia) => app.group('/post', (app) => app .get('/:id', async ({ params: { id } }) => { // [!code ++] const { data, error } = await supabase // [!code ++] .from('post') // [!code ++] .select() // [!code ++] .eq('id', id) // [!code ++] // [!code ++] if (error) return error // [!code ++] // [!code ++] return { // [!code ++] success: !!data[0], // [!code ++] data: data[0] ?? null // [!code ++] } // [!code ++] }) // [!code ++] .use(authen) .put( '/create', async ({ body, userId }) => { const { data, error } = await supabase .from('post') .insert({ // 以某种方式添加 user_id // user_id: userId, ...body }) .select('id') if (error) throw error return data[0] }, { schema: { body: t.Object({ detail: t.String() }) } } ) ) ``` 我们使用 `success` 来指示帖子是否存在。 如果不存在,我们将返回 `success: false` 和 `data: null`。 如前所述,`.use(authen)` 应用于被定义在自己后面的作用域 **但**,这意味着在之前的语句不会受到影响,而此后则为仅限授权的路由。 最后,不要忘记将路由添加到主服务器中。 ```ts import { Elysia, t } from 'elysia' import { auth, post } from './modules' // [!code ++] const app = new Elysia() .use(auth) .use(post) // [!code ++] .listen(3000) console.log( `🦊 Elysia 正在运行在 ${app.server?.hostname}:${app.server?.port}` ) ``` ## 奖励:文档 作为奖励,在我们创建的一切之后,除了逐条告诉前端开发人员外,我们可以只需一行代码为他们创建文档。 使用 Swagger 插件,我们可以安装: ```bash bun add @elysiajs/swagger@rc ``` 然后只需添加插件: ```ts import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' // [!code ++] import { auth, post } from './modules' const app = new Elysia() .use(swagger()) // [!code ++] .use(auth) .use(post) .listen(3000) console.log( `🦊 Elysia 正在运行在 ${app.server?.hostname}:${app.server?.port}` ) ``` 瞧 🎉 我们为我们的 API 创建了良好定义的文档。 如果更多细节,您不必担心会忘记 OpenAPI Schema 3.0 的规格,我们还有自动补全和类型安全。 我们可以通过 `schema.detail` 定义路线详细信息,这也遵循 OpenAPI Schema 3.0,以便您可以妥善创建文档。 ## 下一步 在接下来的步骤中,我们鼓励您尝试并探索 [我们在本文中编写的代码](https://github.com/saltyaom/elysia-supabase-example),并尝试添加图像上传帖子,以进一步探索 Supabase 和 Elysia 生态系统。 如我们所见,使用 Supabase 创建一个生产就绪的 web 服务器是超级简单的,许多东西只需一行代码,非常有利于快速开发。 特别是当与 Elysia 配对时,您将获得出色的开发者体验,作为单一事实来源的声明式 schema,以及在使用 TypeScript 时创建 API 时的精心设计选择,并且作为奖励,我们可以在仅一行代码中创建文档。 Elysia 正在致力于创建一个以 Bun 为优先的 web 框架,采用新技术和新方法。 如果您对 Elysia 感兴趣,可以随时查看我们的 [Discord 服务器](https://discord.gg/eaFJ2KDJck) 或访问 [Elysia 在 GitHub 上](https://github.com/elysiajs/elysia)。 另外,您可能还想了解 [Elysia Eden](/eden/overview),这是一个完全类型安全、无需代码生成的请求客户端,类似于 Elysia 服务器的 tRPC。 --- --- url: /blog.md --- --- --- url: /plugins/graphql-yoga.md --- # GraphQL Yoga 插件 此插件将 GraphQL Yoga 集成到 Elysia 中 安装方法: ```bash bun add @elysiajs/graphql-yoga ``` 然后使用它: ```typescript twoslash 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: /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: /integrations/nuxt.md --- # Integration with Nuxt We can use [nuxt-elysia](https://github.com/tkesgar/nuxt-elysia), a community plugin for Nuxt, to setup Elysia on Nuxt API route with Eden Treaty. 1. Install the plugin with the following command: ```bash bun add elysia @elysiajs/eden bun add -d nuxt-elysia ``` 2. Add `nuxt-elysia` to your Nuxt config: ```ts export default defineNuxtConfig({ modules: [ // [!code ++] 'nuxt-elysia' // [!code ++] ] // [!code ++] }) ``` 3. Create `api.ts` in the project root: ```typescript [api.ts] export default () => new Elysia() // [!code ++] .get('/hello', () => ({ message: 'Hello world!' })) // [!code ++] ``` 4. Use Eden Treaty in your Nuxt app: ```vue ``` This will automatically setup Elysia to run on Nuxt API route automatically. ## Prefix By default, Elysia will be mounted on **/\_api** but we can customize it with `nuxt-elysia` config. ```ts export default defineNuxtConfig({ nuxtElysia: { path: '/api' // [!code ++] } }) ``` This will mount Elysia on **/api** instead of **/\_api**. For more configuration, please refer to [nuxt-elysia](https://github.com/tkesgar/nuxt-elysia) --- --- url: /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: /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: /integrations/openapi.md --- # OpenAPI Elysia 提供了一流的支持,并默认遵循 OpenAPI 模式。 Elysia 可以通过提供 Swagger 插件自动生成 API 文档页面。 要生成 Swagger 页面,请安装插件: ```bash bun add @elysiajs/swagger ``` 并将插件注册到服务器: ```typescript import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' const app = new Elysia() .use(swagger()) ``` 默认情况下,Elysia 使用 OpenAPI V3 模式和 [Scalar UI](http://scalar.com)。 有关 Swagger 插件配置,请参见 [Swagger 插件页面](/plugins/swagger)。 ## 路由定义 我们通过提供模式类型添加路由信息。 然而,有时候仅定义类型并不能清楚表达路由的功能。您可以使用 `schema.detail` 字段明确地定义路由的目的。 ```typescript import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' new Elysia() .use(swagger()) .post('/sign-in', ({ body }) => body, { body: t.Object( { username: t.String(), password: t.String({ minLength: 8, description: '用户密码(至少 8 个字符)' // [!code ++] }) }, { // [!code ++] description: '期望的用户名和密码' // [!code ++] } // [!code ++] ), detail: { // [!code ++] summary: '用户登录', // [!code ++] tags: ['身份验证'] // [!code ++] } // [!code ++] }) ``` 详细字段遵循 OpenAPI V3 定义,并默认具有自动补全和类型安全。 详细信息随后传递给 Swagger,以便将描述放入 Swagger 路由中。 ### detail `detail` 扩展了 [OpenAPI 操作对象](https://swagger.io/specification#operation-object) 详细字段是一个对象,可以用来描述有关该路由的 API 文档信息。 该字段可能包含以下内容: ### tags 该操作的标签数组。标签可用于根据资源或任何其他标识符逻辑分组操作。 ### summary 该操作执行的简短摘要。 ### description 该操作行为的详细解释。 ### externalDocs 该操作的额外外部文档。 ### operationId 用于唯一标识操作的字符串。该 ID 必须在 API 中所有描述的操作中唯一。operationId 值对大小写敏感。 ### deprecated 声明该操作已被弃用。消费者应避免使用已声明的操作。默认值为 `false`。 ### security 声明该操作可以使用哪些安全机制。值的列表包括可以使用的替代安全要求对象。只需满足安全要求对象中的一个即可授权请求。要使安全性可选,可以在数组中包含一个空的安全要求(`{}`)。 ## 隐藏 您可以通过将 `detail.hide` 设置为 `true` 来隐藏 Swagger 页面上的路由。 ```typescript import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' new Elysia() .use(swagger()) .post('/sign-in', ({ body }) => body, { body: t.Object( { username: t.String(), password: t.String() }, { description: '期望的用户名和密码' } ), detail: { // [!code ++] hide: true // [!code ++] } // [!code ++] }) ``` ## 标签组 Elysia 可能会接受标签以将整个实例或一组路由添加到特定标签。 ```typescript import { Elysia, t } from 'elysia' new Elysia({ tags: ['用户'] }) .get('/user', '用户') .get('/admin', '管理员') ``` ## 保护 另外,Elysia 可能会接受保护以将整个实例或一组路由添加到特定保护。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .guard({ detail: { description: '要求用户已登录' } }) .get('/user', '用户') .get('/admin', '管理员') ``` --- --- url: /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 而工作。 ## setAttribute `setAttribute` 是一个用于将属性设置为当前 span 的实用工具。 ```typescript import { setAttribute } from '@elysiajs/opentelemetry' function utility() { setAttribute('custom.attribute', 'value') } ``` 这是 `getCurrentSpan().setAttributes` 的语法糖。 ## 配置 请查看 [opentelemetry 插件](/plugins/opentelemetry) 以获取配置选项和定义。 --- --- url: /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: /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: /plugins/swagger.md --- # Swagger 插件 该插件为 Elysia 服务器生成 Swagger 端点。 安装方法: ```bash bun add @elysiajs/swagger ``` 然后使用它: ```typescript twoslash 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** * **Array\** ## 模式 以下是使用该插件的常见模式。 ## 更改 Swagger 端点 您可以通过在插件配置中设置 [path](#path) 来更改 swagger 端点。 ```typescript twoslash import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' new Elysia() .use( swagger({ path: '/v2/swagger' }) ) .listen(3000) ``` ## 自定义 Swagger 信息 ```typescript twoslash 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: '认证端点' } ] } }) ) ``` 然后使用端点配置部分的 details 属性将该端点分配到组中 ```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: /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: /plugins/trpc.md --- # tRPC 插件 此插件添加了对使用 [tRPC](https://trpc.io/) 的支持。 安装方法: ```bash bun add @elysiajs/trpc @trpc/server @elysiajs/websocket ``` 然后使用它: ```typescript import { Elysia, t as T } from 'elysia' import { initTRPC } from '@trpc/server' import { compile as c, trpc } from '@elysiajs/trpc' const t = initTRPC.create() const p = t.procedure const router = t.router({ greet: p // 💡 使用 Zod //.input(z.string()) // 💡 使用 Elysia 的 T .input(c(T.String())) .query(({ input }) => input) }) export type Router = typeof router const app = new Elysia().use(trpc(router)).listen(3000) ``` ## trpc 接受 tRPC 路由器并注册到 Elysia 的处理程序。 ```ts trpc( router: Router, option?: { endpoint?: string } ): this ``` `Router` 是 TRPC 路由器实例。 ### endpoint 暴露的 TRPC 端点的路径。 --- --- url: /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: /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: /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: /integrations/nextjs.md --- # 与 Nextjs 集成 使用 Nextjs 应用路由,我们可以在 Nextjs 路由上运行 Elysia。 1. 在应用路由中创建 **api/\[\[...slugs]]/route.ts** 2. 在 **route.ts** 中创建或导入一个现有的 Elysia 服务器 3. 导出您想要暴露的方法的处理程序 ```typescript // 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 // [!code ++] export const POST = app.handle // [!code ++] ``` 由于符合 WinterCG,Elysia 将正常工作,但如果您在 Node 上运行 Nextjs,某些插件(如 **Elysia Static**)可能不会正常工作。 您可以将 Elysia 服务器视为一个普通的 Nextjs API 路由。 通过这种方式,您可以在一个代码库中将前端和后端共同放置,并在客户端和服务器动作中拥有 [Eden 的端到端类型安全](https://elysiajs.com/eden/overview.html) 有关更多信息,请参阅 [Nextjs 路由处理程序](https://nextjs.org/docs/app/building-your-application/routing/route-handlers#static-route-handlers)。 ## 前缀 因为我们的 Elysia 服务器不在应用路由的根目录下,所以您需要为 Elysia 服务器注释前缀。 例如,如果您将 Elysia 服务器放在 **app/user/\[\[...slugs]]/route.ts** 中,则需要将前缀注释为 **/user**。 ```typescript // app/user/[[...slugs]]/route.ts import { Elysia, t } from 'elysia' const app = new Elysia({ prefix: '/user' }) // [!code ++] .get('/', () => 'hi') .post('/', ({ body }) => body, { body: t.Object({ name: t.String() }) }) export const GET = app.handle export const POST = app.handle ``` 这将确保 Elysia 路由能够在您放置它的任何位置正常工作。 --- --- url: /integrations/sveltekit.md --- # 与 SvelteKit 的集成 使用 SvelteKit,您可以在服务器路由上运行 Elysia。 1. 创建 **src/routes/\[...slugs]/+server.ts**。 2. 在 **+server.ts** 中创建或导入一个现有的 Elysia 服务器 3. 导出您想要公开的方法的处理程序 ```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) ``` 您可以将 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 GET: RequestHandler = ({ request }) => app.handle(request) export const POST: RequestHandler = ({ request }) => app.handle(request) ``` 这样可以确保 Elysia 路由在您放置它的任何位置都能正常工作。 --- --- url: /migrate/from-express.md --- # 从 Express 到 Elysia 本指南适用于希望了解 Express 与 Elysia 之间差异的 Express 用户,包括语法,以及如何通过示例将应用程序从 Express 迁移到 Elysia。 **Express** 是一个流行的 Node.js 网络框架,广泛用于构建 Web 应用程序和 API。因其简单性和灵活性而闻名。 **Elysia** 是一个人性化的 Web 框架,适用于 Bun、Node.js 和支持 Web 标准 API 的运行时。设计时强调人体工学和开发者友好,专注于 **健全的类型安全** 和性能。 ## 性能 由于本机 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 内置支持请求验证,具有健全的类型安全,而 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] 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() }) }) ``` ::: > Elysia 使用 TypeBox 进行验证,并自动强制转换类型 ## 文件上传 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('没有上传文件') const type = await fileTypeFromFile(file.path) if (!type || !type.mime.startsWith('image/')) return res .status(422) .send('文件不是有效的图像') 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('未授权') next() }, (req, res) => { res.send('受保护的路由') } ) ``` ::: > Express 使用单个基于队列的顺序来处理中间件,按顺序执行 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() // 全局中间件 .onRequest('/user', ({ 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('未授权') // @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('未授权') // @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: '发生了错误!', error }) } }) // 路由特定错误处理程序 app.get('/error', (req, res) => { throw new CustomError('哦,啊') }) ``` ::: > 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: "如果您看到这个,我们的开发人员忘记处理此错误", error: this } } } const app = new Elysia() // 可选:注册自定义错误类 .error({ CUSTOM: CustomError, }) // 全局错误处理程序 .onError(({ error, code }) => { if(code === 'CUSTOM') // ^? return { message: '发生了错误!', error } }) .get('/error', () => { throw new CustomError('哦,啊') }, { // 可选:路由特定错误处理程序 error({ error }) { return { message: '仅适用于此路由!', 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('未授权') next() }) app.use(subRouter) // 从子路由产生副作用 app.get('/side-effect', (req, res) => { res.send('嗨') }) ``` ::: > 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) // 不会有来自子路由的副作用 .get('/side-effect', () => '嗨') ``` ::: > 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 ++] // 现在有来自子路由的副作用 .get('/side-effect', () => '嗨') ``` Elysia 提供三种类型的作用域机制: 1. **局部** - 只应用于当前实例,没有副作用(默认) 2. **受限** - 将副作用作用域限定于父实例,但不再超过此范围 3. **全局** - 影响所有实例 虽然 Express 可以通过添加前缀来限制中间件副作用,但这并不是真正的封装。副作用仍然存在,但被分隔到以所述前缀开头的任何路由,使得开发人员需要记住哪个前缀具有副作用。 这使得您可以执行以下操作: 1. 重新排列代码顺序,但仅在只有一个具有副作用的实例中有效。 2. 添加前缀,但副作用仍然存在。如果其他实例具有相同的前缀,则它也具有副作用。 这可能导致调试时出现噩梦场景,因为 Express 不提供真正的封装。 ## Cookie Express 使用外部库 `cookie-parser` 解析 cookies,而 Elysia 内置支持 cookie,并使用基于信号的方法处理 cookies。 ::: 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` 解析 Cookies ::: 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 使用基于信号的方法处理 Cookies ## 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: '我的 API', version: '1.0.0' }, paths: { '/users': { post: { summary: '创建用户', requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { name: { type: 'string', description: '仅为名' }, age: { type: 'integer' } }, required: ['name', 'age'] } } } }, responses: { '201': { description: '用户已创建' } } } } } } app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)) ``` ::: > Express 需要单独的配置来支持 OpenAPI、验证和类型安全 ::: code-group ```ts twoslash [Elysia] import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' // [!code ++] const app = new Elysia() .use(swagger()) // [!code ++] .model({ user: t.Object({ name: t.String(), age: t.Number() }) }) .post('/users', ({ body }) => body, { // ^? body: 'user[]', response: { 201: 'user[]' }, detail: { summary: '创建用户' } }) ``` ::: > 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('应该返回 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('应该返回 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('应该返回 Hello World', async () => { const { data, error, status } = await api.hello.get() expect(status).toBe(200) expect(data).toBe('Hello World') // ^? }) }) ``` ## 端到端类型安全 Elysia 内置支持 **端到端类型安全**,无需代码生成,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 标准 API 的框架,Elysia 是您正确的选择。 另外,如果您来自其他框架,可以查看: --- --- url: /migrate/from-fastify.md --- # 从 Fastify 到 Elysia 本指南面向希望看到 Fastify 之间差异的用户,包括语法,以及如何通过示例将应用程序从 Fastify 迁移到 Elysia。 **Fastify** 是一个快速且低开销的 Node.js 网络框架,旨在简单易用。它基于 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/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] 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() }) }) ``` ::: > Elysia 使用 TypeBox 进行验证,并自动强制转化类型 此外,Fastify 还可以使用 **TypeBox** 或 **Zod** 进行验证,使用 `@fastify/type-provider-typebox` 自动推导类型。 而 Elysia **更倾向于使用 TypeBox** 进行验证,Elysia 还支持通过 [TypeMap](https://github.com/sinclairzx81/typemap) 的 **Zod** 和 **Valibot**。 ## 文件上传 Fastify 使用 `fastify-multipart` 处理文件上传,底层使用 `Busboy`,而 Elysia 使用 Web 标准 API 处理表单数据,使用声明性 API 进行 mimetype 验证。 然而,Fastify 并没有提供一种简单的方法进行文件验证,例如,文件大小和 mimetype,需要一些变通方法来验证文件。 ::: 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('未上传文件') const type = await fileTypeFromBuffer(file) if (!type || !type.mime.startsWith('image/')) return res.status(422).send('文件不是有效的图像') 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` 处理文件和 mimetype 验证 由于 **multer** 不验证 mimetype,您需要使用 **file-type** 或类似库手动验证 mimetype。 而 Elysia 自动验证文件上传,使用 **file-type** 自动验证 mimetype。 ## 生命周期事件 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('未授权') done() } }, (request, reply) => { reply.send('受保护的路由') } ) ``` ::: > Fastify 使用 `addHook` 注册中间件,并要求您调用 `done` 继续生命周期事件 ::: code-group ```ts [Elysia] import { Elysia } from 'elysia' const app = new Elysia() // 全局中间件 .onRequest('/user', ({ 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('未授权') // @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('未授权') // @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: '出现问题!', error }) }) app.get( '/error', { // 路由特定错误处理程序 errorHandler(error, request, reply) { reply.send({ message: '仅此路由适用!', error }) } }, (request, reply) => { throw new CustomError('哦 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: "如果您看到此消息,我们的开发人员忘记处理此错误", error: this } } } const app = new Elysia() // 可选:注册自定义错误类 .error({ CUSTOM: CustomError, }) // 全局错误处理程序 .onError(({ error, code }) => { if(code === 'CUSTOM') // ^? return { message: '出现问题!', error } }) .get('/error', () => { throw new CustomError('哦 uh') }, { // 可选:路由特定错误处理程序 error({ error }) { return { message: '仅此路由适用!', error } } }) ``` ::: > Elysia 提供自定义错误代码,简写 HTTP 状态和 `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: '未授权' }) }) 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('中间件已执行') done() } const app = fastify() app.addHook('onRequest', log) app.get('/main', (request, reply) => { reply.send('来自主路由的问候!') }) const subRouter: FastifyPluginCallback = (app, opts, done) => { app.addHook('onRequest', log) // 这将记录两次 app.get('/sub', (request, reply) => { return reply.send('来自子路由的问候!') }) 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` 来解析 cookies,而 Elysia 则内置支持 cookies,使用基于信号的方法处理 cookies。 ::: 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 使用基于信号的方法处理 cookies,签名验证自动处理 ## 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: '我的 API', version: '1.0.0' } }) app.addSchema({ $id: 'user', type: 'object', properties: { name: { type: 'string', description: '仅限名字' }, age: { type: 'integer' } }, required: ['name', 'age'] }) app.post( '/users', { schema: { summary: '创建用户', body: { $ref: 'user#' }, response: { '201': { $ref: 'user#' } } } }, (req, res) => { res.status(201).send(req.body) } ) await fastify.ready() fastify.swagger() ``` ::: > Fastify 使用 `@fastify/swagger` 进行 OpenAPI 文档的生成 ::: code-group ```ts twoslash [Elysia] import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' // [!code ++] const app = new Elysia() .use(swagger()) // [!code ++] .model({ user: t.Object({ name: t.String(), age: t.Number() }) }) .post('/users', ({ body }) => body, { // ^? body: 'user[]', response: { 201: 'user[]' }, detail: { summary: '创建用户' } }) ``` ::: > 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('应该返回 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('应该返回 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('应该返回 Hello World', async () => { const { data, error, status } = await api.hello.get() expect(status).toBe(200) expect(data).toBe('Hello World') // ^? }) }) ``` ## 端到端类型安全 Elysia 提供内置支持 **端到端类型安全**,无需代码生成,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: /migrate/from-hono.md --- # 从 Hono 到 Elysia 本指南适用于希望了解 Elysia 与 Hono 之间的差异,包括语法,以及如何通过示例将应用程序从 Hono 迁移到 Elysia 的 Hono 用户。 **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 支持 **zod**,但 Elysia 专注于与 **TypeBox** 的深度集成,以便在幕后提供无缝集成 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] 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() }) }) ``` ::: > Elysia 使用 TypeBox 进行验证,并自动强制转换类型 两者都自动从模式推断类型到上下文。 ## 文件上传 Hono 和 Elysia 都使用 Web 标准 API 处理文件上传,但 Elysia 具有内置的声明式支持,使用 **file-type** 验证 MIME 类型。 ::: 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` 库来验证 MIME 类型 ::: 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 类型验证 由于 Web 标准 API 不验证 MIME 类型,因此信任客户端提供的 `content-type` 可能存在安全风险,因此 Hono 需要外部库,而 Elysia 则使用 `file-type` 自动验证 MIME 类型。 ## 中间件 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('/user', ({ method, path }) => { console.log(`${method} ${path}`) }) // 路由特定中间件 .get('/protected', () => 'protected', { beforeHandle({ status, headers }) { if (!headers.authorization) 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: '出了一些问题!', error }) } }) // 路由特定错误处理程序 app.get('/error', (req, res) => { throw new CustomError('哦,出错了') }) ``` ::: > Hono 使用 `onError` 函数处理错误,所有路由共享一个错误处理器 ::: code-group ```ts twoslash [Elysia] import { Elysia } from 'elysia' class CustomError extends Error { // Optional: custom HTTP status code status = 500 constructor(message: string) { super(message) this.name = 'CustomError' } // Optional: what should be sent to the client toResponse() { return { message: "If you're seeing this, our dev forgot to handle this error", error: this } } } const app = new Elysia() // Optional: register custom error class .error({ CUSTOM: CustomError, }) // Global error handler .onError(({ error, code }) => { if(code === 'CUSTOM') // ^? return { message: 'Something went wrong!', error } }) .get('/error', () => { throw new CustomError('oh uh') }, { // Optional: route specific error handler 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 采用基于信号的方法处理 Cookies。 ::: 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 使用工具函数处理 cookies ::: 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 使用基于信号的方法处理 cookies ## 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: '仅限姓' }), age: z.number() }) ) const detail = await resolver(model).builder() console.log(detail) app.post( '/', zodValidator('json', model), describeRoute({ validateResponse: true, summary: '创建用户', requestBody: { content: { 'application/json': { schema: detail.schema } } }, responses: { 201: { description: '用户创建', 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: '问候 API' }, components: { ...detail.components } } }) ) export default app ``` ::: > Hono 需要额外努力来描述规范 ::: code-group ```ts twoslash [Elysia] import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' // [!code ++] const app = new Elysia() .use(swagger()) // [!code ++] .model({ user: t.Object({ name: t.String(), age: t.Number() }) }) .post('/users', ({ body }) => body, { // ^? body: 'user[]', response: { 201: 'user[]' }, detail: { summary: '创建用户' } }) ``` ::: > Elysia Seamlessly integrate the specification into the schema Hono 具有单独的函数来描述路由规范、验证,并且需要一些额外的工作进行正确设置。 Elysia 使用您提供的模式生成 OpenAPI 规范,并验证请求/响应,并自动推断类型,所有这些都来自一个 **单一的信息源**。 Elysia 还将注册的模式附加到 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 花费 536 毫秒推断 Elysia 和 Eden(点击放大) ![Hono HC 类型推理性能](/migrate/hono-type-infer.webp) > Hono 花费 1.27 秒推断 Hono 和 HC,带有错误(中止)(点击放大) 1.27 秒并不反映推断的整个持续时间,而是从开始到因错误 **“类型实例化过于深且可能是无限的。”** 而中止的持续时间,这在模式过大时会发生。 ![Hono HC 显示过于深的错误](/migrate/hono-hc-infer.webp) > Hono HC 显示过于深的错误 这是由于模式过大,Hono 不支持超过 100 个路由,且具有复杂主体和响应验证,而 Elysia 则没有这个问题。 ![Elysia Eden 代码显示类型推理没有错误](/migrate/elysia-eden-infer.webp) > Elysia Eden 代码显示类型推理没有错误 Elysia 的类型推理性能更快,且不必担心 **“类型实例化过于深且可能是无限的。”** *至少* 在具有复杂主体和响应验证的 2000 条路由之内。 如果端到端类型安全对您很重要,那么 Elysia 是正确的选择。 *** 两者都是建立在 Web 标准 API 之上的下一代 web 框架,存在细微的差别。 Elysia 旨在符合人体工程学且对开发者友好,关注 **强类型安全**,并且在性能上优于 Hono。 虽然 Hono 提供了对多个运行时的广泛兼容性,特别是与 Cloudflare Workers 兼容,以及更大的用户基础。 如果您是来自其他框架的用户,可以查看: --- --- url: /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: /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: /key-concept.md --- # 关键概念 尽管 Elysia 是一个简单的库,但它有一些关键概念,您需要理解以有效地使用它。 此页面涵盖了您应该了解的 Elysia 的最重要概念。 ::: tip 我们 **强烈推荐** 您在深入学习 Elysia 之前阅读此页面。 ::: ## 一切都是组件 每个 Elysia 实例都是一个组件。 组件是可以连接到其他实例的插件。 它可以是路由、存储、服务或其他任何东西。 ```ts twoslash import { Elysia } from 'elysia' const store = new Elysia() .state({ visitor: 0 }) const router = new Elysia() .use(store) .get('/increase', ({ store }) => store.visitor++) const app = new Elysia() .use(router) .get('/', ({ store }) => store) .listen(3000) ``` 这迫使您将应用程序分解为小块,使您能够轻松添加或删除功能。 在 [插件](/essential/plugin.html) 中了解更多关于此的内容。 ## 方法链 Elysia 代码应始终使用 **方法链**。 由于 Elysia 的类型系统复杂,Elysia 中的每个方法返回一个新的类型引用。 **这很重要**,以确保类型的完整性和推断。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .state('build', 1) // 存储是严格类型 // [!code ++] .get('/', ({ store: { build } }) => build) // ^? .listen(3000) ``` 在上面的代码中,**state** 返回一个新的 **ElysiaInstance** 类型,添加了一个类型化的 `build` 属性。 ### 不要在没有方法链的情况下使用 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) ``` 我们建议您 **始终使用方法链** 来提供准确的类型推断。 ## 作用域 默认情况下,每个实例中的事件/生命周期是相互隔离的。 ```ts twoslash // @errors: 2339 import { Elysia } from 'elysia' const ip = new Elysia() .derive(({ server, request }) => ({ ip: server?.requestIP(request) })) .get('/ip', ({ ip }) => ip) const server = new Elysia() .use(ip) .get('/ip', ({ ip }) => ip) .listen(3000) ``` 在此示例中,`ip` 属性仅在其自身实例中共享,而不在 `server` 实例中共享。 要共享生命周期,在我们的例子中,与 `server` 实例共享 `ip` 属性,我们需要 **明确指定** 它可以被共享。 ```ts twoslash import { Elysia } from 'elysia' const ip = new Elysia() .derive( { as: 'global' }, // [!code ++] ({ server, request }) => ({ ip: server?.requestIP(request) }) ) .get('/ip', ({ ip }) => ip) const server = new Elysia() .use(ip) .get('/ip', ({ ip }) => ip) .listen(3000) ``` 在这个例子中,`ip` 属性在 `ip` 和 `server` 实例之间共享,因为我们将其定义为 `global`。 这迫使您考虑每个属性的作用域,防止您意外地在实例之间共享属性。 在 [作用域](/essential/plugin.html#scope) 中了解更多关于此的内容。 ## 依赖性 默认情况下,每个实例在应用于另一个实例时会被重新执行。 这可能导致相同方法被多次应用,而某些方法,如 **生命周期** 或 **路由**,应该只调用一次。 为了防止生命周期方法重复调用,我们可以为实例添加 **一个唯一标识符**。 ```ts twoslash import { Elysia } from 'elysia' 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) ``` 这将通过使用唯一名称进行去重,防止 `ip` 属性被多次调用。 这使我们能够在没有性能损失的情况下多次重用相同的实例。迫使您考虑每个实例的依赖性。 在 [插件去重](/essential/plugin.html#plugin-deduplication) 中了解更多关于此的内容。 ### 服务定位器 当您将带状态/装饰器的插件应用于实例时,实例将获得类型安全。 但如果您不将插件应用于另一个实例,它将无法推断类型。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' const child = new Elysia() // ❌ 'a' 缺失 .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') // 没有 'setup',类型将缺失 const error = new Elysia() .get('/', ({ a }) => a) const main = new Elysia() // 有了 `setup`,类型将被推断 .use(setup) // [!code ++] .get('/', ({ a }) => a) // ^? ``` 正如在 [依赖性](#dependencies) 中提到的,我们可以使用 `name` 属性来去重实例,因此不会有任何性能损失或生命周期重复。 ## 代码顺序 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**,因为事件是在路由之后注册的,所以它不适用于该路由。 在 [代码顺序](/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() }) }) ``` ### TypeScript 我们可以通过以下方式访问 `static` 属性获取每个 Elysia/TypeBox 类型的类型定义: ```ts twoslash import { t } from 'elysia' const MyType = t.Object({ hello: t.Literal('Elysia') }) type MyType = typeof MyType.static // ^? ``` 这使 Elysia 能够自动推断并提供类型,减少了声明重复架构的需要 单个 Elysia/TypeBox 架构可以用于: * 运行时验证 * 数据强制转换 * TypeScript 类型 * OpenAPI 架构 这使我们能够将架构作为 **单一事实来源**。 在 [最佳实践:MVC 控制器](/essential/best-practice.html#controller) 中了解更多关于此的内容。 --- --- url: /essential/best-practice.md --- # 最佳实践 Elysia 是一个与模式无关的框架,选择何种编码模式由您和您的团队决定。 然而,在尝试将 MVC 模式 [(Model-View-Controller)](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) 适配到 Elysia 时,我们发现很难解耦和处理类型。 本页面是结合 MVC 模式与 Elysia 结构最佳实践的指南,但可以适配到您喜欢的任何编码模式。 ## 方法链 Elysia 代码应始终使用 **方法链**。 由于 Elysia 的类型系统复杂,Elysia 的每个方法都返回一个新的类型引用。 **这很重要**,以确保类型完整性和推断。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .state('build', 1) // 存储是严格类型化的 // [!code ++] .get('/', ({ store: { build } }) => build) .listen(3000) ``` 在上述代码中,**state** 返回一个新的 **ElysiaInstance** 类型,添加了一个 `build` 类型。 ### ❌ 不要:不使用方法链来使用 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) ``` 我们建议 **始终使用方法链** 来提供准确的类型推断。 ## 控制器 > 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) }) ``` ### 测试 您可以使用 `handle` 测试您的控制器直接调用函数(及其生命周期) ```typescript import { Elysia } from 'elysia' import { Service } from './service' import { describe, it, should } from 'bun:test' const app = new Elysia() .get('/', ({ stuff }) => { Service.doStuff(stuff) return 'ok' }) describe('控制器', () => { it('应该工作', async () => { const response = await app .handle(new Request('http://localhost/')) .then((x) => x.text()) expect(response).toBe('ok') }) }) ``` 您可以在 [单元测试](/patterns/unit-test.html) 中找到更多关于测试的信息。 ## 服务 服务是一组实用/辅助功能,作为业务逻辑解耦以用于模块/控制器,在我们的案例中,是一个 Elysia 实例。 任何可以从控制器中解耦的技术逻辑都可以存在于一个 **服务** 中。 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 实例作为服务,以确保类型完整性和推断: ```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-as-a-controller) 代替。 您可以在 [基础:处理程序](/essential/handler) 中找到更多关于 [InferContext](/essential/handler#infercontext) 的信息。 ## 模型 模型或 [DTO(数据传输对象)](https://en.wikipedia.org/wiki/Data_transfer_object) 由 [Elysia.t (验证)](/essential/validation.html#elysia-type) 处理。 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 } ``` 我们可以通过使用 `typeof` 和 `.static` 属性从模型中获取类型。 然后您可以使用 `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() }) } const models = AuthModel.models ``` ### 模型注入 虽然这是可选的,但如果您严格遵循 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. 修改架构以供后续使用,或执行 [重映射](/essential/handler.html#remap)。 3. 作为 OpenAPI 合规客户端中的 “模型” 出现,如 Swagger。 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: /patterns/cookie.md --- # Cookie 要使用 Cookie,您可以提取 Cookie 属性并直接访问其名称和值。 没有 get/set,您可以直接提取 Cookie 名称并检索或更新其值。 ```ts import { Elysia } from 'elysia' new Elysia() .get('/', ({ cookie: { name } }) => { // 获取 name.value // 设置 name.value = "新值" }) ``` 默认情况下,响应式 Cookie 可以自动编码/解码对象类型,使我们能够将 Cookie 视为对象,而无需担心编码/解码。**它就是这样工作的**。 ## 响应性 Elysia 的 Cookie 是响应式的。这意味着当您更改 Cookie 值时,Cookie 会根据类似信号的方式自动更新。 Elysia Cookies 提供了处理 Cookies 的单一真实来源,能够自动设置头部并同步 Cookie 值。 由于 Cookies 默认是基于 Proxy 的对象,提取的值永远不会是 **undefined**;相反,它将始终是一个 `Cookie` 的值,可以通过调用 **.value** 属性获取。 我们可以将 Cookie 罐视为常规对象,对其进行迭代只会迭代已经存在的 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 } }) => { // 获取 name.domain // 设置 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 模式 您可以通过使用 `t.Cookie` 的 Cookie 模式严格验证 Cookie 类型,并提供 Cookie 的类型推断。 ```ts twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/', ({ cookie: { name } }) => { // 设置 name.value = { id: 617, name: '召唤 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 } }) => { // 设置 name.value = { id: 617, name: '召唤 101' } }, { cookie: t.Cookie({ name: t.Optional( t.Object({ id: t.Numeric(), name: t.String() }) ) }) }) ``` ## Cookie 签名 通过引入 Cookie Schema,和 `t.Cookie` 类型,我们可以创建一个统一的类型来处理签名/验证 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: '召唤 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: '召唤 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: ['复仇将属于我', 'Fischl von Luftschloss Narfidort'] } }) ``` ## 配置 以下是 Elysia 接受的 Cookie 配置。 ### secret 用于签名/取消签名 Cookie 的密钥。 如果传递了一个数组,则将使用密钥轮换。 密钥轮换是指将加密密钥退役并通过生成新的加密密钥进行替换。 *** 以下是从 [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 字节序列,然后对落在 Cookie 范围外的进行 URL 编码。 ### expires 指定作为 [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) 的值。 当为真时,会设置 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` 将把优先级属性设置为低。 `medium` 将把优先级属性设置为中,未设置时的默认优先级。 `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 属性设置为严格的同站强制。 `false` 不会设置 SameSite 属性。 `'lax'` 将 SameSite 属性设置为宽松同站强制。 `'none'` 将 SameSite 属性设置为无以示明确的跨站 Cookie。 `'strict'` 将 SameSite 属性设置为严格的同站强制。 有关不同强制级别的更多信息,请参见 [规范](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) 的值。当为真时,设置 Secure 属性,否则不设置。默认情况下,不设置 Secure 属性。 ::: tip 设置为 true 时要小心,因为符合规范的客户端将在未来如果浏览器没有 HTTPS 连接时,不会将该 Cookie 发送回服务器。 ::: --- --- url: /essential/handler.md --- # 处理程序 处理程序是响应每个路由请求的函数。 接受请求信息并返回响应给客户端。 在其他框架中,处理程序也被称为 **控制器**。 ```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 提供内联值并不是缓存。 静态资源值、头部和状态可以使用生命周期动态改变。 ::: ## 上下文 **上下文**包含每个请求唯一的请求信息,除了 `store` (全局可变状态),不被共享。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', (context) => context.path) // ^ 这是上下文 ``` **上下文**只能在路由处理程序中检索,包括: * **path** - 请求的路径名 * **body** - [HTTP 消息](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages),表单或文件上传。 * **query** - [查询字符串](https://en.wikipedia.org/wiki/Query_string),作为 JavaScript 对象包含搜索查询的附加参数。(查询是从路径名后以 '?' 问号开头的值中提取的) * **params** - Elysia 的路径参数解析为 JavaScript 对象 * **headers** - [HTTP 头部](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers),有关请求的附加信息,如 User-Agent、Content-Type、Cache Hint。 * **request** - [Web 标准请求](https://developer.mozilla.org/en-US/docs/Web/API/Request) * **redirect** - 用于重定向响应的函数 * **store** - Elysia 实例的全局可变存储 * **cookie** - 用于与 Cookie 交互的全局可变信号存储(包括取值/设置) * **set** - 应用于响应的属性: * **status** - [HTTP 状态](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status),如果未设置,则默认为 200。 * **headers** - 响应头部 * **redirect** - 作为路径重定向的响应 * **error** - 返回自定义状态码的函数 * **server** - Bun 服务器实例 ## 设置 **set** 是一个可变属性,通过 `Context.set` 访问。 * **set.status** - 设置自定义状态码 * **set.headers** - 附加自定义头部 * **set.redirect** - 附加重定向 ```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) ``` ### 状态 通过以下方法返回自定义状态码: * **status** 函数(推荐) * **set.status**(遗留) ```typescript import { Elysia } from 'elysia' new Elysia() .get('/error', ({ error }) => error(418, 'I am a teapot')) .get('/set.status', ({ set }) => { set.status = 418 return 'I am a teapot' }) .listen(3000) ``` ### 状态函数 专门的 `status` 函数用于返回带有响应的状态码。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', ({ status }) => status(418, "Kirifuji Nagisa")) .listen(3000) ``` 建议在主处理程序中使用 `status`,因为它更有推断能力: * 允许 TypeScript 检查返回值是否正确类型为响应模式 * 基于状态码的类型缩小的自动补全 * 使用端到端类型安全的错误处理的类型缩小 ([Eden](/eden/overview)) ### set.status 如果没有提供,设置默认状态码。 建议在只需返回特定状态码的插件中使用此方法,同时允许用户返回自定义值。例如,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) ``` ### set.headers 允许我们附加或删除呈现为对象的响应头。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', ({ set }) => { set.headers['x-powered-by'] = 'Elysia' return 'a mimir' }) .listen(3000) ``` ::: warning 头部的名称应该是小写,以强制保持 HTTP 头部和自动补全的一致性,例如使用 `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) ``` 在使用重定向时,返回的值不是必需的,将被忽略,因为响应将来自另一个资源。 ## 服务器 服务器实例可以通过 `Context.server` 访问,与服务器进行交互。 服务器可能是可空的,因为它可能在不同的环境中运行(测试)。 如果服务器正在运行(分配),则 `server` 将可用(不为 null)。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/port', ({ server }) => { return server?.port }) .listen(3000) ``` ### 请求 IP 我们可以使用 `server.requestIP` 方法获取请求 IP ```typescript import { Elysia } from 'elysia' new Elysia() .get('/ip', ({ server, request }) => { return server?.requestIP(request) }) .listen(3000) ``` ## 响应 Elysia 是建立在 Web 标准请求/响应之上的。 为遵循 Web 标准,从路由处理程序返回的值将被 Elysia 映射到 [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response)。 让你专注于业务逻辑而不是样板代码。 ```typescript import { Elysia } from 'elysia' new Elysia() // 等价于 "new Response('hi')" .get('/', () => 'hi') .listen(3000) ``` 如果你更喜欢显式的 Response 类,Elysia 也会自动处理。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', () => new Response('hi')) .listen(3000) ``` ::: tip 使用原始值或 `Response` 性能几乎相同(+ - 0.1%),因此选择你更喜欢的,无论性能如何。 ::: ## 表单数据 我们可以通过直接从处理程序返回 `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) ``` 这种模式非常有用,即使需要返回文件或多部分表单数据。 ### 返回单个文件 或者,您可以通过直接返回 `file` 而不使用 `form` 来返回单个文件。 ```typescript import { Elysia, file } from 'elysia' new Elysia() .get('/', file('nagi.web')) .listen(3000) ``` ## 处理 由于 Elysia 建立在 Web 标准请求之上,我们可以使用 `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 与单元测试的模拟不同,**你可以预计它会像实际请求一样表现** 发送到服务器中。 但对于模拟或创建单元测试也很有用。 ::: ## 流 通过使用带有 `yield` 关键字的生成器函数返回响应流。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/ok', function* () { yield 1 yield 2 yield 3 }) ``` 在这个例子中,我们可以通过使用 `yield` 关键字流式传输响应。 ### 设置头部 Elysia 将在第一个块被输出之前延迟返回响应头。 这使我们可以在响应开始流式传输之前设置头部。 ```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 }) ``` 一旦第一个块被输出,Elysia 将发送头部和第一个块在同一响应中。 在第一个块被输出后设置的头部将无效。 ### 条件流 如果响应被返回而没有 `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 仅提供基本信息,我们可以定制上下文以满足我们的特定需求,例如: * 将用户 ID 提取为变量 * 注入一个公共模式库 * 添加数据库连接 我们可以通过使用以下 API 来扩展 Elysia 的上下文以自定义上下文: * [state](#state) - 一个全局可变状态 * [decorate](#decorate) - 分配给 **上下文** 的附加属性 * [derive](#derive) / [resolve](#resolve) - 从现有属性创建新值 ### 何时扩展上下文 你应该仅在以下情况扩展上下文: * 属性是全局可变状态,并通过 [state](#state) 在多个路由之间共享 * 属性与请求或响应相关联使用 [decorate](#decorate) * 属性来源于现有属性的派生使用 [derive](#derive) / [resolve](#resolve) 否则,我们建议将值或函数单独定义,而不是扩展上下文。 ::: tip 建议将与请求和响应相关的属性,或频繁使用的函数分配到上下文中,以实现关注点分离。 ::: ## 状态 **状态** 是一个全局可变对象或状态,在 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 doesn't exist in store .get('/error', ({ store }) => store.counter) .state('counter', 0) // ✅ 因为我们在之前分配了 counter,现在可以访问它 .get('/', ({ store }) => store.counter) ``` ::: tip 请注意,在分配之前我们不能使用状态值。 Elysia 会自动将状态值注册到商店中,无需显式类型或额外的 TypeScript 泛型。 ::: ## 装饰 **decorate** 在 **调用时** 直接为 **上下文** 分配附加属性。 ```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' }) ``` ### 何时使用 * 将常量或只读值对象分配给 **上下文** * 可能包含内部可变状态的非原始值或类 * 附加函数、单例或不变属性到所有处理程序。 ### 关键要点 * 与 **state** 不同,装饰的值 **不应该** 被修改,尽管这是可能的 * 请确保在处理程序中使用之前先分配值。 ## 派生 从 **上下文** 中现有属性中检索值并分配新属性。 派生在请求发生 **于转换生命周期** 时分配,我们可以“派生” 从现有属性创建新属性。 ```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** 则不能。 ### 何时使用 * 从现有属性创建新属性,而无需验证或类型检查 * 当你需要在没有验证的情况下访问请求属性,如 **headers**、**query**、**body** ### 关键要点 * 与 **state** 和 **decorate** 不同,**derive** 是在新请求开始时分配的,而不是在调用时分配。 * **derive** 在转换,或者在验证之前被调用,Elysia 无法安全确认请求属性的类型,导致其结果为 **unknown**。如果你想从类型化的请求属性中分配新值,可能想要使用 [resolve](#resolve) 替代。 ## 解析 与 [derive](#derive) 相同,resolve 允许我们向上下文分配新属性。 解析在 **beforeHandle** 生命周期或 **验证之后** 被调用,使我们能够安全地 **派生** 请求属性。 ```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) ``` ### 何时使用 * 从现有属性创建新属性,并保持类型完整性(经过类型检查) * 当你需要在验证时访问请求属性,如 **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 模式,如下所示: * 键值 * 对象 * 重映射 而 **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** 函数由 **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) ``` ## 引用和数值 要修改状态,建议使用 **引用** 进行修改,而不是使用实际值。 在从 JavaScript 访问属性时,如果我们将对象属性中的原始值定义为新值,则链接丢失,值被视为新单独的值。 例如: ```typescript const store = { counter: 0 } store.counter++ console.log(store.counter) // ✅ 1 ``` 我们可以使用 **store.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) ``` ## TypeScript Elysia 根据商店、装饰器、模式等各种因素自动类型上下文。 建议让 Elysia 自动类型上下文,而不是手动定义一个。 但是,Elysia 也提供了一些实用类型以帮助你定义处理程序类型。 * [InferContext](#infercontext) * [InferHandle](#inferhandler) ### InferContext Infer context 是一个实用类型,帮助你根据 Elysia 实例定义上下文类型。 ```typescript twoslash import { Elysia, type InferContext } from 'elysia' const setup = new Elysia() .state('a', 'a') .decorate('b', 'b') type Context = InferContext const handler = ({ store }: Context) => store.a ``` ### InferHandler Infer handler 是一个实用类型,帮助你根据 Elysia 实例、路径和模式定义处理程序类型。 ```typescript twoslash import { Elysia, type InferHandler } from 'elysia' const setup = new Elysia() .state('a', 'a') .decorate('b', 'b') type Handler = InferHandler< // 基于的 Elysia 实例 typeof setup, // 路径 '/path', // 模式 { body: string response: { 200: string } } > const handler: Handler = ({ body }) => body const app = new Elysia() .get('/', handler) ``` 与 `InferContext` 不同,`InferHandler` 需要路径和模式来定义处理程序类型,并可以安全地确保返回值的类型安全。 --- --- url: /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: /patterns/macro.md --- # 宏 宏允许我们为钩子定义一个自定义字段。 \ 宏 v1 使用带有事件监听器功能的函数回调。 **Elysia.macro** 允许我们将自定义的复杂逻辑组合成一个在钩子中可用的简单配置,并且在类型安全上进行 **guard**。 ```typescript twoslash import { Elysia } from 'elysia' const plugin = new Elysia({ name: 'plugin' }) .macro(({ onBeforeHandle }) => ({ hi(word: string) { onBeforeHandle(() => { console.log(word) }) } })) const app = new Elysia() .use(plugin) .get('/', () => 'hi', { hi: 'Elysia' }) ``` 访问该路径应该会记录 **"Elysia"** 作为结果。 ### API **macro** 应返回一个对象,每个键在钩子中反映,钩子内提供的值将作为第一个参数返回。 在之前的示例中,我们创建了一个接受 **string** 的 **hi**。 然后我们将 **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(() => { return { isAuth(isAuth: boolean) {}, role(role: 'user' | 'admin') {}, } }) // @filename: index.ts // ---cut--- import { Elysia } from 'elysia' import { auth } from './auth' const app = new Elysia() .use(auth) .get('/', () => 'hi', { isAuth: true, role: 'admin' }) ``` 该字段可以接受从字符串到函数的任何内容,允许我们创建一个自定义生命周期事件。 **macro** 将按照定义中从上到下的顺序执行,确保栈以正确的顺序处理。 ### 参数 **Elysia.macro** 参数与生命周期事件交互如下: * onParse * onTransform * onBeforeHandle * onAfterHandle * onError * onResponse * events - 生命周期存储 * global: 全局栈的生命周期 * local: 内联钩子的生命周期(路由) 以 **on** 开头的参数是一个将函数附加到生命周期栈的函数。 而 **events** 是一个实际的栈,存储生命周期事件的顺序。您可以直接修改栈或使用 Elysia 提供的帮助函数。 ### 选项 扩展 API 的生命周期函数接受额外的 **options** 以确保控制生命周期事件。 * **options** (可选)- 确定哪个栈 * **function** - 在事件上执行的函数 ```typescript import { Elysia } from 'elysia' const plugin = new Elysia({ name: 'plugin' }) .macro(({ onBeforeHandle }) => { return { hi(word: string) { onBeforeHandle( { insert: 'before' }, // [!code ++] () => { console.log(word) } ) } } }) ``` **Options** 可接受以下参数: * **insert** * 函数应该添加到哪里 * 值: **'before' | 'after'** * @default: **'after'** * **stack** * 确定应该添加哪种类型的栈 * 值: **'global' | 'local'** * @default: **'local'** 宏 v2 使用对象语法以返回生命周期,如内联钩子。 **Elysia.macro** 允许我们将自定义的复杂逻辑组合成一个在钩子中可用的简单配置,并且在类型安全上进行 **guard**。 ```typescript twoslash import { Elysia } from 'elysia' const plugin = new Elysia({ name: 'plugin' }) .macro({ hi(word: string) { return { beforeHandle() { console.log(word) } } } }) const app = new Elysia() .use(plugin) .get('/', () => 'hi', { hi: 'Elysia' }) ``` 访问该路径应该会记录 **"Elysia"** 作为结果。 ### API **macro** 具有与钩子相同的 API。 在之前的示例中,我们创建了一个接受 **string** 的 **hi** 宏。 然后我们将 **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' }) ``` 宏 v2 还可以向上下文注册一个新属性,允许我们直接从上下文访问该值。 该字段可以接受从字符串到函数的任何内容,允许我们创建一个自定义生命周期事件。 **macro** 将根据钩子的定义从上到下顺序执行,确保堆栈以正确的顺序处理。 ## 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**。 下面是一个宏解析可能有用的示例: * 执行身份验证并将用户添加到上下文中 * 运行额外的数据库查询并将数据添加到上下文中 * 向上下文添加一个新属性 ## Property shorthand Starting from Elysia 1.2.10, each property in the macro object can be a function or an object. If the property is an object, it will be translated to a function that accept a boolean parameter, and will be executed if the parameter is true. ```typescript import { Elysia } from 'elysia' export const auth = new Elysia() .macro({ // This property shorthand isAuth: { resolve() { return { user: 'saltyaom' } } }, // is equivalent to isAuth(enabled: boolean) { if(!enabled) return return { resolve() { return { user } } } } }) ``` --- --- url: /blog/integrate-trpc-with-elysia.md --- \ 最近,tRPC 因其端到端类型安全的方法而成为网络开发的热门选择,它通过模糊前后端之间的界限,自动推断后端的类型,从而加快了开发速度。 帮助开发者更快速、更安全地编写代码,快速发现数据结构迁移时出现的问题,并消除在前端重新创建类型的冗余步骤。 但在扩展 tRPC 时,我们可以做得更多。 ## Elysia Elysia 是一个为 Bun 优化的网络框架,受到包括 tRPC 在内的许多框架的启发。Elysia 默认支持端到端的类型安全,但不同于 tRPC,Elysia 使用许多人已经熟知的类似 Express 的语法,从而消除了 tRPC 的学习曲线。 由于 Bun 是 Elysia 的运行时,Elysia 服务器的速度和吞吐量都非常快,甚至在镜像 JSON 主体时超过了 [Express 的 21 倍和 Fastify 的 12 倍(请参见基准测试)](https://github.com/SaltyAom/bun-http-framework-benchmark/tree/655fe7f87f0f4f73f2121433f4741a9d6cf00de4)。 将现有的 tRPC 服务器结合到 Elysia 中一直是 Elysia 从开始时的首要目标之一。 你可能想要从 tRPC 切换到 Bun 的原因包括: * 速度显著更快,甚至超过许多在 Nodejs 中运行的流行网络框架,而无需更改一行代码。 * 将 tRPC 与 RESTful 或 GraphQL 扩展,两者可以在同一服务器中共存。 * Elysia 具有像 tRPC 一样的端到端类型安全,但对于大多数开发者几乎没有学习曲线。 * 使用 Elysia 是实验和投资 Bun 运行时的良好起点。 ## 创建 Elysia 服务器 要开始,让我们创建一个新的 Elysia 服务器,确保首先安装了 [Bun](https://bun.sh),然后运行以下命令来搭建 Elysia 项目。 ``` bun create elysia elysia-trpc && cd elysia-trpc && bun add elysia ``` ::: tip 有时 Bun 无法正确解析最新的字段,因此我们使用 `bun add elysia` 来指定 Elysia 的最新版本。 ::: 这将创建一个名为 **"elysia-trpc"** 的文件夹,并预先配置好 Elysia。 让我们通过运行 dev 命令启动开发服务器: ``` bun run dev ``` 该命令应在端口 :3000 上启动开发服务器。 ## Elysia tRPC 插件 基于 tRPC Web 标准适配器,Elysia 有一个插件,用于将现有的 tRPC 服务器集成到 Elysia 中。 ```bash bun add @trpc/server zod @elysiajs/trpc @elysiajs/cors ``` 假设这是一个现有的 tRPC 服务器: ```typescript import { initTRPC } from '@trpc/server' import { observable } from '@trpc/server/observable' import { z } from 'zod' const t = initTRPC.create() export const router = t.router({ mirror: t.procedure.input(z.string()).query(({ input }) => input), }) export type Router = typeof router ``` 通常我们只需要导出路由器的类型,但要将 tRPC 集成到 Elysia 中,我们还需要导出路由器的实例。 然后在 Elysia 服务器中,我们导入路由器,并使用 `.use(trpc)` 注册 tRPC 路由器。 ```typescript import { Elysia } from 'elysia' import { cors } from '@elysiajs/cors' // [!code ++] import { trpc } from '@elysiajs/trpc' // [!code ++] import { router } from './trpc' // [!code ++] const app = new Elysia() .use(cors()) // [!code ++] .get('/', () => 'Hello Elysia') .use( // [!code ++] trpc(router) // [!code ++] ) // [!code ++] .listen(3000) console.log(`🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}`) ``` 就这样!🎉 这就是将 tRPC 集成到 Elysia 中的所有步骤,使 tRPC 在 Bun 上运行。 ## tRPC 配置和上下文 要创建上下文,`trpc` 可以接受第二个参数,这与 `createHTTPServer` 配置 tRPC 的方式相同。 例如,将 `createContext` 添加到 tRPC 服务器中: ```typescript // trpc.ts import { initTRPC } from '@trpc/server' import { observable } from '@trpc/server/observable' import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' // [!code ++] import { z } from 'zod' export const createContext = async (opts: FetchCreateContextFnOptions) => { // [!code ++] return { // [!code ++] name: 'elysia' // [!code ++] } // [!code ++] } // [!code ++] const t = initTRPC.context>>().create() // [!code ++] export const router = t.router({ mirror: t.procedure.input(z.string()).query(({ input }) => input), }) export type Router = typeof router ``` 在 Elysia 服务器中: ```typescript import { Elysia } from 'elysia' import { cors } from '@elysiajs/cors' import '@elysiajs/trpc' import { router, createContext } from './trpc' // [!code ++] const app = new Elysia() .use(cors()) .get('/', () => 'Hello Elysia') .use( trpc(router, { // [!code ++] createContext // [!code ++] }) // [!code ++] ) .listen(3000) console.log(`🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}`) ``` 我们还可以使用 `endpoint` 指定 tRPC 的自定义端点: ```typescript import { Elysia } from 'elysia' import { cors } from '@elysiajs/cors' import { trpc } from '@elysiajs/trpc' import { router, createContext } from './trpc' const app = new Elysia() .use(cors()) .get('/', () => 'Hello Elysia') .use( trpc(router, { createContext, endpoint: '/v2/trpc' // [!code ++] }) ) .listen(3000) console.log(`🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}`) ``` ## 订阅 默认情况下,tRPC 使用 WebSocketServer 来支持 `subscription`,但不幸的是,因为 Bun 0.5.4 尚不支持 WebSocketServer,所以我们无法直接使用 WebSocket Server。 然而,Bun 确实支持 Web Socket,使用 `Bun.serve`,并且由于 Elysia tRPC 插件已将 tRPC 的 Web Socket 的所有用法接线到 `Bun.serve`,您可以直接使用 Elysia Web Socket 插件的 tRPC `subscription`: 首先安装 Web Socket 插件: ```bash bun add @elysiajs/websocket ``` 然后在 tRPC 服务器内部: ```typescript import { initTRPC } from '@trpc/server' import { observable } from '@trpc/server/observable' // [!code ++] import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' import { EventEmitter } from 'stream' // [!code ++] import { zod } from 'zod' export const createContext = async (opts: FetchCreateContextFnOptions) => { return { name: 'elysia' } } const t = initTRPC.context>>().create() const ee = new EventEmitter() // [!code ++] export const router = t.router({ mirror: t.procedure.input(z.string()).query(({ input }) => { ee.emit('listen', input) // [!code ++] return input }), listen: t.procedure.subscription(() => // [!code ++] observable((emit) => { // [!code ++] ee.on('listen', (input) => { // [!code ++] emit.next(input) // [!code ++] }) // [!code ++] }) // [!code ++] ) // [!code ++] }) export type Router = typeof router ``` 然后我们注册: ```typescript import { Elysia, ws } from 'elysia' import { cors } from '@elysiajs/cors' import '@elysiajs/trpc' import { router, createContext } from './trpc' const app = new Elysia() .use(cors()) .use(ws()) // [!code ++] .get('/', () => 'Hello Elysia') .trpc(router, { createContext }) .listen(3000) console.log(`🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}`) ``` 这就是将现有完整功能的 tRPC 服务器集成到 Elysia 服务器的所有步骤,从而使 tRPC 在 Bun 上运行 🥳。 当您需要同时使用 tRPC 和 REST API 时,Elysia 非常出色,因为它们可以在一个服务器中共存。 ## 奖励:使用 Eden 的类型安全 Elysia 由于 Elysia 受到 tRPC 的启发,Elysia 也默认支持像 tRPC 一样的端到端类型安全,使用 **"Eden"**。 这意味着您可以使用类似 Express 的语法创建完全类型支持的 RESTful API,就像 tRPC 一样。 要开始,让我们导出应用程序类型。 ```typescript import { Elysia, ws } from 'elysia' import { cors } from '@elysiajs/cors' import { trpc } from '@elysiajs/trpc' import { router, createContext } from './trpc' const app = new Elysia() .use(cors()) .use(ws()) .get('/', () => 'Hello Elysia') .use( trpc(router, { createContext }) ) .listen(3000) export type App = typeof app // [!code ++] console.log(`🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}`) ``` 在客户端: ```bash bun add @elysia/eden && bun add -d elysia ``` 在代码中: ```typescript import { edenTreaty } from '@elysiajs/eden' import type { App } from '../server' // 这现在具有来自服务器的所有类型推断 const app = edenTreaty('http://localhost:3000') // data 将具有值 'Hello Elysia',并且类型为 'string' const data = await app.index.get() ``` 当您希望获得像 tRPC 一样的端到端类型安全,但需要支持更标准的模式(如 REST),同时仍然要支持 tRPC 或需要从一个迁移到另一个时,Elysia 是一个不错的选择。 ## 奖励:Elysia 的额外提示 你可以做的另一个附加事情是,Elysia 不仅支持 tRPC 和端到端类型安全,还有各种为 Bun 配置的基本插件支持。 例如,您可以仅用一行代码使用 [Swagger 插件](/plugins/swagger) 生成文档。 ```typescript import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' // [!code ++] const app = new Elysia() .use(swagger()) // [!code ++] .setModel({ sign: t.Object({ username: t.String(), password: t.String() }) }) .get('/', () => 'Hello Elysia') .post('/typed-body', ({ body }) => body, { schema: { body: 'sign', response: 'sign' } }) .listen(3000) export type App = typeof app console.log(`🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}`) ``` 或者当您想在 Bun 上使用 [GraphQL Apollo](/plugins/graphql-apollo) 时。 ```typescript import { Elysia } from 'elysia' import { apollo, gql } from '@elysiajs/apollo' // [!code ++] const app = new Elysia() .use( // [!code ++] apollo({ // [!code ++] typeDefs: gql` // [!code ++] type Book { // [!code ++] title: String // [!code ++] author: String // [!code ++] } // [!code ++] // [!code ++] type Query { // [!code ++] books: [Book] // [!code ++] } // [!code ++] `, // [!code ++] resolvers: { // [!code ++] Query: { // [!code ++] books: () => { // [!code ++] return [ // [!code ++] { // [!code ++] title: 'Elysia', // [!code ++] author: 'saltyAom' // [!code ++] } // [!code ++] ] // [!code ++] } // [!code ++] } // [!code ++] } // [!code ++] }) // [!code ++] ) // [!code ++] .get('/', () => 'Hello Elysia') .listen(3000) export type App = typeof app console.log(`🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}`) ``` 或使用社区 [OAuth 2.0 插件](https://github.com/bogeychan/elysia-oauth2)。 总之,Elysia 是学习/使用 Bun 及其生态系统的绝佳起点。 如果您想了解更多 Elysia,[Elysia 文档](https://elysiajs.com) 是探索概念和模式的一个很好的起点。如果您遇到困难或需要帮助,请随时在 [Elysia Discord](https://discord.gg/eaFJ2KDJck) 中与我们联系。 所有代码的仓库可以在 找到,欢迎您随意尝试和反馈。 --- --- url: /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: /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**, **derive**, **route**, **lifecycle** 等。 Elysia 还将自动处理类型推断,因此你可以想象就像你在主实例上调用所有其他实例。 ::: tip 请注意插件不包含 **.listen**,因为 **.listen** 将为使用分配一个端口,而我们只希望主实例分配端口。 ::: ## 插件 每一个 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' const app = new Elysia() .use(plugin) .listen(3000) ``` ### 配置 为了使插件更加有用,建议通过配置允许自定义。 你可以创建一个接受参数的函数,这些参数可以改变插件的行为,使其更具可重用性。 ```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** 和 **optional seeds** 区分实例来避免这一点,从而帮助 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 将跳过插件的注册。 如果未提供种子,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 种子可以是任何东西,从字符串到复杂对象或类。 如果提供的值是类,Elysia 将尝试使用 `.toString` 方法生成校验和。 ::: ### 服务定位器 当您将带有状态/装饰器的插件应用于一个实例时,该实例将获得类型安全性。 但如果您不将插件应用于另一个实例,它将无法推断类型。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' const child = new Elysia() // ❌ 'a' 缺失 .get('/', ({ a }) => a) const main = new Elysia() .decorate('a', 'a') .use(child) ``` Elysia 引入了 **服务定位器** 模式来抵消这一点。 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) const main = new Elysia() // With `setup`, type will be inferred .use(setup) // [!code ++] .get('/', ({ a }) => a) // ^? ``` ## 防护 防护允许我们将钩子和模式应用于多个路由一次性。 ```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 | ✅ | | / | ❌ | 防护接受与内联钩子相同的参数,唯一的区别在于你可以将钩子应用于作用域中的多个路由。 这意味着上面的代码被翻译为: ```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) ``` ### 分组防护 我们可以通过提供 3 个参数给分组来使用前缀。 1. 前缀 - 路由前缀 2. 防护 - 模式 3. 范围 - Elysia 应用回调 与防护应用相同的 API 应用到第二个参数,而不是将分组和防护嵌套在一起。 考虑以下示例: ```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) ``` 从嵌套的分组防护中,我们可以通过在 `group` 的第二个参数中提供防护范围将组和防护合并在一起: ```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) ``` ## 范围 默认情况下,钩子和模式将仅适用于 **当前实例**。 Elysia 具有封装范围,以防止意外的副作用。 范围类型用于指定钩子的作用域,是否应该被封装或全局。 ```typescript twoslash // @errors: 2339 import { Elysia } from 'elysia' const plugin = new Elysia() .derive(() => { return { hi: 'ok' } }) .get('/child', ({ hi }) => hi) const main = new Elysia() .use(plugin) // ⚠️ Hi 缺失 .get('/parent', ({ hi }) => hi) ``` 从上面的代码,我们可以看到 `hi` 在父实例中缺失,因为默认情况下,如果未指定,作用域是局部的,并且不会应用于父级。 要将钩子应用于父实例,我们可以使用 `as` 来指定钩子的作用域。 ```typescript twoslash // @errors: 2339 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) ``` ### 范围等级 Elysia 有 3 种范围类型,如下所示: 范围类型如下: * **local** (默认) - 仅适用于当前实例及其后代 * **scoped** - 适用于父级、当前实例及其后代 * **global** - 适用于所有应用插件的实例(所有父级、当前及后代) 我们通过使用以下示例回顾每种范围类型的作用: ```typescript import { Elysia } from 'elysia' // ? 基于下表提供的值 const type = 'local' const child = new Elysia() .get('/child', 'hi') const current = new Elysia() .onBeforeHandle({ as: type }, () => { // [!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' | ✅ | ✅ | ✅ | ✅ | ### 范围提升 要将钩子应用于父级,可以使用以下一种: 1. `inline as` 仅适用于单个钩子 2. `guard as` 适用于防护中的所有钩子 3. `instance as` 适用于实例中的所有钩子 ### 1. 内联提升 每个事件监听器将接受 `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) ``` 但是,这种方法仅适用于单个钩子,并且可能不适合多个钩子。 ### 2. 防护提升 每个事件监听器将接受 `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') ``` 防护允许我们将 `schema` 和 `hook` 应用于多个路由一次性,并且可以指定作用域。 然而,它不支持 `derive` 和 `resolve` 方法。 ### 3. 实例提升 `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) ``` ### 后代 默认情况下,插件将仅 **应用钩子到自身及其后代**。 如果钩子在插件中注册,继承该插件的实例将 **不会** 继承钩子和模式。 ```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') ``` 要全局应用钩子,我们需要将钩子指定为全局。 ```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') ``` ## 延迟加载 模块默认是立即加载的。 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) ``` Elysia 静态插件也是一个延迟模块,因为它以异步方式加载文件并注册文件路径。 ### 延迟加载模块 与异步插件相同,延迟加载模块将在服务器启动后注册。 延迟加载模块可以是同步或异步函数,只要该模块与 `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('模块', () => { it('内联异步', 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: /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) - 使用 [JWT](https://jwt.io/) 进行身份验证 * [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) - 集成响应流和 [服务器发送事件 (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) * [Swagger](/plugins/swagger) - 生成 [Swagger](https://swagger.io/) 文档 * [tRPC](/plugins/trpc) - 支持 [tRPC](https://trpc.io/) * [WebSocket](/patterns/websocket) - 支持 [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) ## 社区插件: * [BunSai](https://github.com/nikiskaarup/bunsai2) - 一个全栈无关的 Web 框架,基于 Bun 和 Elysia 构建 * [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) - 插件,用于在 `development` 模式下启动和装饰 [`vite`](https://vitejs.dev/) 开发服务器,并在 `production` 模式下提供静态服务(如果需要) * [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 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) - 一个用于 [OAuth 2.0](https://en.wikipedia.org/wiki/OAuth) 授权流程的插件,支持超过 **42** 个提供商,并且具备 **类型安全**! * [OAuth2](https://github.com/bogeychan/elysia-oauth2) - 处理 OAuth 2.0 授权码流程 * [OAuth2 Resource Server](https://github.com/ap-1/elysia-oauth2-resource-server) - 一个用于验证来自 OAuth2 提供者的 JWT 令牌与 JWKS 端点的插件,支持发行者、受众和范围验证。 * [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) * [Basic Auth](https://github.com/eelkevdbos/elysia-basic-auth) - 基本 HTTP 身份验证(使用 `request` 事件) * [i18n](https://github.com/eelkevdbos/elysia-i18next) - 基于 [i18next](https://www.i18next.com/) 的 [i18n](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/i18n) 包装 * [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 swagger 模型中使用 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) - Elysia 插件,用于解析接受头和内容协商 * [Elysia Compression](https://github.com/chneau/elysia-compression) - Elysia 插件,用于压缩响应 * [Elysia Logger](https://github.com/chneau/elysia-logger) - Elysia 插件,用于记录 HTTP 请求和响应,灵感来自 [hono/logger](https://hono.dev/docs/middleware/builtin/logger) * [Elysia CQRS](https://github.com/jassix/elysia-cqrs) - Elysia 插件,用于 CQRS 模式 * [Elysia Supabase](https://github.com/mastermakrela/elysia-supabase) - 无缝集成 [Supabase](https://supabase.com/) 身份验证和数据库功能到 Elysia,使访问经过身份验证的用户数据和 Supabase 客户端实例变得简单,特别适用于 [Edge Functions](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) - 一个全面的安全中间件,帮助通过设置各种 HTTP 头来保护 Elysia.js 应用。 * [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) - Elysia 插件,用于暴露 Prometheus 的 HTTP 指标。 * [Elysia Remote DTS](https://github.com/rayriffy/elysia-remote-dts) - 一个为 Eden Treaty 提供远程 .d.ts 类型的插件。 ## 相关项目: * [prismabox](https://github.com/m1212e/prismabox) - 基于您的数据库模型生成 typebox 模式的生成器,适用于 elysia *** 如果您为 Elysia 编写了一个插件,请随时通过 **点击下面的 在 GitHub 上编辑此页面** 将您的插件添加到列表中 👇 --- --- url: /tutorial.md --- # Elysia 教程 我们将构建一个简单的 CRUD 笔记 API 服务器。 这里没有数据库,也没有其他“生产就绪”功能。本教程将重点介绍 Elysia 的功能以及如何仅使用 Elysia。 如果你跟着做,我们预计大约需要 15-20 分钟。 *** ### 来自其他框架? 如果您使用过其他流行框架,如 Express、Fastify 或 Hono,您会发现 Elysia 非常熟悉,只是有一些小差异。 ### 不喜欢教程? 如果您更倾向于自己动手的方式,可以跳过这个教程,直接访问 [关键概念](/key-concept) 页面,深入了解 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) ``` ## Swagger 在浏览器中输入 URL 只能与 GET 方法进行交互。要与其他方法进行交互,我们需要像 Postman 或 Insomnia 这样的 REST 客户端。 幸运的是,Elysia 配备了一个 **OpenAPI Schema** 和 [Scalar](https://scalar.com),以与我们的 API 进行交互。 ```bash # 安装 Swagger 插件 bun add @elysiajs/swagger ``` 然后将插件应用于 Elysia 实例。 ```typescript import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' const app = new Elysia() // 应用 Swagger 插件 .use(swagger()) // [!code ++] .get('/', ({ path }) => path) .post('/hello', 'Do you miss me?') .listen(3000) ``` 导航到 **http://localhost:3000/swagger**,你应该看到如下文档: ![Scalar Documentation landing](/tutorial/scalar-landing.webp) 现在我们可以与所有已创建的路由进行交互。 滚动到 **/hello**,点击蓝色的 **测试请求** 按钮以显示表单。 我们可以通过点击黑色的 **发送** 按钮来查看结果。 ![Scalar Documentation landing](/tutorial/scalar-request.webp) ## 装饰 然而,对于更复杂的数据,我们可能希望使用类来存储复杂数据,因为它允许我们定义自定义方法和属性。 现在,让我们创建一个单例类来存储我们的笔记。 ```typescript import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' class Note { // [!code ++] constructor(public data: string[] = ['Moonhalo']) {} // [!code ++] } // [!code ++] const app = new Elysia() .use(swagger()) .decorate('note', new Note()) // [!code ++] .get('/note', ({ note }) => note.data) // [!code ++] .listen(3000) ``` `decorate` 允许我们将单例类注入到 Elysia 实例中,从而允许我们在路由处理程序中访问它。 打开 **http://localhost:3000/note**,我们应该在屏幕上看到 **\["Moonhalo"]**。 对于 Scalar 文档,我们可能需要重新加载页面以查看新更改。 ![Scalar Documentation landing](/tutorial/scalar-moonhalo.webp) ## 路径参数 现在,让我们根据索引检索笔记。 我们可以通过在前面加冒号来定义路径参数。 ```typescript twoslash // @errors: 7015 import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' class Note { constructor(public data: string[] = ['Moonhalo']) {} } const app = new Elysia() .use(swagger()) .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** 不是。 我们可以通过声明架构来强制执行和验证类型: ```typescript import { Elysia, t } from 'elysia' // [!code ++] import { swagger } from '@elysiajs/swagger' class Note { constructor(public data: string[] = ['Moonhalo']) {} } const app = new Elysia() .use(swagger()) .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** 并为路径参数定义一个架构。 现在,如果我们尝试访问 **http://localhost:3000/note/abc**,我们应该看到错误消息。 这段代码解决了我们之前看到的错误,因为它是由于 **TypeScript 警告** 引起的。 Elysia 的架构不仅在运行时强制执行验证,还会推导出 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 { swagger } from '@elysiajs/swagger' class Note { constructor(public data: string[] = ['Moonhalo']) {} } const app = new Elysia() .use(swagger()) .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**,我们应该看到 **未找到** 的状态码为 404。 我们还可以通过将字符串传递给错误函数来返回自定义消息。 ```typescript import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' class Note { constructor(public data: string[] = ['Moonhalo']) {} } const app = new Elysia() .use(swagger()) .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 { swagger } from '@elysiajs/swagger' import { note } from './note' // [!code ++] class Note { // [!code --] constructor(public data: string[] = ['Moonhalo']) {} // [!code --] } // [!code --] const app = new Elysia() .use(swagger()) .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**,你应该看到 **哦,不 :(**,与之前相同。 我们刚刚创建了一种 **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/swagger** 并尝试进行 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() }) } ) ``` ::: ## 守卫 现在我们可能注意到插件中的几条路由都有 **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 未找到** 路由的情况下记录请求。 这使我们能够在请求处理之前记录请求,我们可以查看请求体和路径参数。 ### 范围 默认情况下,**lifecycle hook 被封装**。钩子应用于同一实例中的路由,而不应用于其他插件(未在同一插件中定义的路由)。 这意味着 `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` 设置为 session 的值 * 4.4 我们将 `secret` 附加到 cookie,以防止攻击者篡改 cookie ::: tip 由于我们使用的是内存存储,数据在每次重新加载或每次编辑代码时都会被清除。 我们将在本教程的后面部分进行修复。 ::: 现在,如果我们想要检查用户是否已登录,我们可以检查 `token` cookie 的值,并与 `session` 存储进行检查。 ## 参考模型 然而,我们可以识别出 `/sign-in` 和 `/sign-up` 都共享同一个 `body` 模型。 我们可以通过使用 **reference model** 来重用模型,具体方法是指定一个名称。 要创建 **reference model**,我们可以使用 `.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 ++] } ) ``` 在添加模型后,我们可以通过在模式中引用它们的名称来重用它们,而不是提供字面类型,同时提供相同的功能和类型安全性。 `Elysia.model` 可以接受多个重载: 1. 提供一个对象,将所有键值注册为模型 2. 提供一个函数,然后访问所有先前的模型并返回新模型 最后,我们可以添加 `/profile` 和 `/sign-out` 路由,如下所示: ```typescript twoslash [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.** ,我们可以使用 **macro**。 ## 插件去重 由于我们要在多个模块(用户和笔记)中重用此钩子,因此我们可以将服务(实用程序)部分提取出来并应用于两个模块。 ```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`,如下所示: ```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` 的新宏,接受 `boolean` 值,如果为 true,则添加一个 `onBeforeHandle` 事件,该事件在 **验证之后但在主处理程序之前** 执行,允许我们在此处提取身份验证逻辑。 要使用宏,只需指定 `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 这看起来可能是一个小的代码更改,以换取更大的样板,但随着服务器变得复杂,用户检查也可能变得非常复杂。 ::: ## 解析 我们最后的目标是从令牌中获取用户名(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 同样,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 { swagger } from '@elysiajs/swagger' import { note } from './note' import { user } from './user' // [!code ++] const app = new Elysia() .use(swagger()) .use(user) // [!code ++] .use(note) .listen(3000) ``` ::: ## 授权 首先,让我们修改 `Note` 以存储创建笔记的用户。 但我们可以定义一个笔记架构,推导出其类型,允许我们同步运行时和类型级别。 ```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 { swagger } from '@elysiajs/swagger' import { note } from './note' import { user } from './user' const app = new Elysia() .use(swagger()) .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 { swagger } from '@elysiajs/swagger' import { note } from './note' const app = new Elysia() .use(swagger()) .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 { swagger } from '@elysiajs/swagger' import { note } from './note' import { user } from './user' const app = new Elysia() .use(opentelemetry()) // [!code ++] .use(swagger()) .onError(({ error, code }) => { if (code === 'NOT_FOUND') return 'Not Found :(' console.error(error) }) .use(note) .use(user) .listen(3000) ``` ::: 现在尝试进行更多请求并打开 http://localhost:16686 查看追踪信息。 选择服务 **Elysia**,点击 **查找追踪**,我们应该能够看到我们所做请求的列表。 ![Jaeger showing list of requests](/tutorial/jaeger-list.webp) 点击任何请求以查看每个生命周期钩子处理请求所花费的时间。 ![Jaeger showing request span](/tutorial/jaeger-span.webp) 点击根父跨度以查看请求的详细信息,这将显示请求和响应有效载荷,以及任何错误。 ![Jaeger showing request detail](/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 { swagger } from '@elysiajs/swagger' import { opentelemetry } from '@elysiajs/opentelemetry' import { note } from './note' import { user } from './user' const app = new Elysia() .use(opentelemetry()) .use(swagger()) .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` 运行二进制文件,它将在 3000 端口启动服务器,效果与使用 `bun dev` 相同。 ```bash ./server ``` 打开浏览器并导航到 `http://localhost:3000/swagger`,你应该看到与使用开发命令相同的结果。 通过压缩二进制文件,我们不仅使服务器变得小巧且可移植,而且还显著减少了内存使用。 ::: tip Bun 确实有 `--minify` 标志,可以压缩二进制文件,但它包含 `--minify-identifiers`,而由于我们使用 OpenTelemetry,这会重命名函数名称,使追踪变得比应有的更困难。 ::: ::: warning 练习:尝试同时运行开发服务器和生产服务器,并比较内存使用情况。 开发服务器将使用进程名称 'bun',而生产服务器将使用名称 'server'。 ::: ## 总结 好的,完成了 🎉 我们使用 Elysia 创建了一个简单的 API,学习了如何创建一个简单的 API、如何处理错误,以及如何使用 OpenTelemetry 观察我们的服务器。 你可以进一步尝试连接到一个真实的数据库,连接到一个真实的前端或实现基于 WebSocket 的实时通信。 本教程涵盖了创建 Elysia 服务器所需了解的大部分概念,但还有一些有用的概念你可能想知道。 ### 如果你遇到问题 如果你有任何进一步的问题,请随时在 GitHub讨论、Discord和Twitter上询问我们的社区。 我们祝你在 Elysia 的旅程中好运 ❤️ --- --- url: /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: /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: /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: /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('返回一个响应', 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('返回一个响应', async () => { const { data, error } = await api.hello.get() expect(data).toBe('hi') // ^? }) }) ``` 有关设置和更多信息,请参阅 [Eden Treaty 单元测试](/eden/treaty/unit-test)。 --- --- url: /essential/life-cycle.md --- # 生命周期 生命周期允许我们在预定义的点拦截一个重要事件,从而根据需要自定义服务器的行为。 Elysia的生命周期事件可以如下所示。 ![Elysia 生命周期图](/assets/lifecycle-chart.svg) > 点击图片放大 以下是Elysia中可用的请求生命周期: ## 为什么 想象一下,我们想要返回一些HTML。 我们需要将 **"Content-Type"** 头设置为 **"text/html"** 以便浏览器渲染HTML。 如果有很多处理程序,比如 ~200 个端点,明确指定响应为HTML可能会很重复。 这可能导致大量重复代码,仅仅为了指定 **"text/html"** **"Content-Type"**。 但如果在发送响应后,我们能够检测到响应是一个HTML字符串,然后自动附加标题呢? 这就是生命周期概念发挥作用的时候。 ## 钩子 我们将每个拦截生命周期事件的函数称为 **"钩子"**,因为该函数钩入了生命周期事件。 钩子可以分为两种类型: 1. 本地钩子:在特定路由上执行 2. 拦截钩子:在每个路由上执行 ::: tip 钩子将接受与处理程序相同的上下文,您可以想象在特定点添加一个路由处理程序。 ::: ## 本地钩子 本地钩子在特定路由上执行。 要使用本地钩子,您可以内联钩子到路由处理程序中: ```typescript import { Elysia } from 'elysia' import { isHtml } from '@elysiajs/html' new Elysia() .get('/', () => '

你好,世界

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

你好,世界

') .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', () => '

你好,世界

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

你好,世界

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

你好,世界

') .listen(3000) ``` 响应应列出如下: | 路径 | Content-Type | | ----- | ------------------------ | | / | text/html; charset=utf8 | | /hi | text/html; charset=utf8 | | /none | text/plain; charset=utf8 | 来自其他插件的事件也适用于路由,因此代码的顺序很重要。 ::: tip 以上代码仅适用于当前实例,不适用于父实例。 请参阅 [作用域](/essential/plugin#scope) 以了解原因 ::: ## 代码顺序 Elysia的生命周期代码顺序非常重要。 因为事件仅在注册后 **应用于** 路由。 如果您在插件之前放置 onError,则插件将不继承 onError 事件。 ```typescript import { Elysia } from 'elysia' new Elysia() .onBeforeHandle(() => { console.log('1') }) .get('/', () => '你好') .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` 旨在仅提供最重要的上下文以减少开销,因此建议在以下场景中使用: * 缓存 * 限速器 / IP/区域锁定 * 分析 * 提供自定义头部,例如 CORS #### 示例 以下是强制限制某个 IP 地址的速率的伪代码。 ```typescript import { Elysia } from 'elysia' new Elysia() .use(rateLimiter) .onRequest(({ rateLimiter, ip, set, status }) => { if (rateLimiter.check(ip)) return status(420, '保持冷静') }) .get('/', () => '你好') .listen(3000) ``` 如果从 `onRequest` 返回一个值,它将作为响应使用,并且其余生命周期将被跳过。 ### 预上下文 上下文的 onRequest 被类型化为 `PreContext`,是一种表示 `Context` 的最小表示形式,包含如下属性: 请求: `Request` * set: `Set` * store * decorators 上下文不提供 `derived` 值,因为派生是基于 `onTransform` 事件的。 ## 解析 解析是Express中**主体解析器**的等价物。 一个解析主体的函数,返回值将附加到 `Context.body`,如果没有,Elysia将继续迭代由 `onParse` 分配的其他解析函数,直到主体被分配或所有解析程序都执行完毕。 默认情况下,Elysia将解析以下内容类型的主体: * `text/plain` * `application/json` * `multipart/form-data` * `application/x-www-form-urlencoded` 建议使用 `onParse` 事件提供Elysia不提供的自定义主体解析器。 #### 示例 以下是基于自定义头部检索值的示例代码。 ```typescript import { Elysia } from 'elysia' new Elysia().onParse(({ request, contentType }) => { if (contentType === 'application/custom-type') return request.text() }) ``` 返回的值将被分配给 Context.body。如果没有,Elysia将继续迭代 `onParse` 栈中的其他解析函数,直到主体被分配或所有解析器都执行完毕。 ### 上下文 `onParse` 上下文是从 `Context` 扩展的,具有以下附加属性: * contentType: 请求的 Content-Type 头 所有上下文是基于正常上下文的,可以像常规上下文一样在路由处理程序中使用。 ### 解析器 默认情况下,Elysia将尝试提前确定主体解析函数并选择最合适的函数以加快处理速度。 Elysia能够通过读取 `body` 来确定该主体函数。 看看这个例子: ```typescript import { Elysia, t } from 'elysia' new Elysia().post('/', ({ body }) => body, { body: t.Object({ username: t.String(), password: t.String() }) }) ``` Elysia读取主体架构并发现类型完全是一个对象,因此主体很可能是JSON。Elysia会提前选择JSON主体解析函数并尝试解析主体。 这是Elysia用于选择主体解析器类型的标准 * `application/json`: 主体类型为 `t.Object` * `multipart/form-data`: 主体类型为 `t.Object`,并且是1级深,包含 `t.File` * `application/x-www-form-urlencoded`: 主体类型为 `t.URLEncoded` * `text/plain`: 其他基本类型 这使Elysia能够提前优化主体解析器,减少编译时的开销。 ### 显式解析器 然而,在某些情况下,如果Elysia未能选择正确的主体解析函数,我们可以通过指定 `type` 显式告知Elysia使用某个函数。 ```typescript import { Elysia } from 'elysia' new Elysia().post('/', ({ body }) => body, { // application/json的简写 parse: 'json' }) ``` 允许我们在复杂情况下控制Elysia选择主体解析函数以适应我们的需求。 `type` 可以是以下之一: ```typescript type ContentType = | // 'text/plain' 的简写 | 'text' // 'application/json' 的简写 | 'json' // 'multipart/form-data' 的简写 | 'formdata' // 'application/x-www-form-urlencoded' 的简写 | 'urlencoded' | 'text/plain' | 'application/json' | 'multipart/form-data' | 'application/x-www-form-urlencoded' ``` ### 自定义解析器 您可以使用 `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'] }) ``` ## 转换 在**验证**过程之前执行,旨在修改上下文以符合验证或附加新值。 建议在以下情况下使用转换: * 修改现有上下文以符合验证。 * `derive` 基于 `onTransform`,支持提供类型。 #### 示例 以下是使用转换将参数修改为数字值的示例。 ```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) ``` ## 派生 在**验证**之前直接追加新值到上下文。它存储在与**转换**相同的栈中。 与**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**,而 **store** 和 **decorate** 则不能。 与 **state** 和 **decorate** 不同,由 **derive** 分配的属性是唯一的,不与另一个请求共享。 ### 队列 `derive` 和 `transform` 存储在同一个队列中。 ```typescript import { Elysia } from 'elysia' new Elysia() .onTransform(() => { console.log(1) }) .derive(() => { console.log(2) return {} }) ``` 控制台应该记录如下: ```bash 1 2 ``` ## 处理前 在验证后和主要路由处理程序之前执行。 旨在提供自定义验证,以提供运行主要处理程序之前的特定需求。 如果返回一个值,则将跳过路由处理程序。 建议在以下情况下使用处理前: * 限制访问检查:授权,用户登录 * 针对数据结构的自定义请求要求 #### 示例 以下是使用处理前检查用户登录状态的示例。 ```typescript import { Elysia } from 'elysia' import { validateSession } from './user' new Elysia() .get('/', () => '你好', { beforeHandle({ set, cookie: { session }, status }) { if (!validateSession(session.value)) return status(401) } }) .listen(3000) ``` 响应应列出如下: | 是否已登录 | 响应 | | ---------- | ------------ | | ❌ | 未授权 | | ✅ | 你好 | ### 守卫 当我们需要将同一个处理前应用于多个路由时,可以使用 `guard` 将相同的处理前应用于多个路由。 ```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('/', () => '你好') .listen(3000) ``` ## 解析 在验证**之后**追加新值到上下文。它存储在与**处理前**相同的栈中。 解析的语法与 [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** 分配的属性是唯一的,不与另一个请求共享。 ### 守卫解析 因为解析在本地钩子中不可用,建议使用守卫来封装 **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) ``` ## 处理后 在主要处理程序之后执行,用于将**处理前**和**路由处理程序**的返回值映射到适当的响应中。 建议在以下情况下使用处理后: * 将请求转换为新值,例如压缩、事件流 * 根据响应值添加自定义头部,例如 **Content-Type** #### 示例 以下是使用处理后将HTML内容类型添加到响应头的示例。 ```typescript import { Elysia } from 'elysia' import { isHtml } from '@elysiajs/html' new Elysia() .get('/', () => '

你好,世界

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

你好,世界

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

你好,世界

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

你好,世界

') .listen(3000) ``` 与 **beforeHandle** 不同的是,在 **afterHandle** 返回值后,后处理的迭代 **将 **不** 被跳过。** ### 上下文 `onAfterHandle` 上下文从 `Context` 扩展,并具有额外的 `response` 属性,这是返回给客户端的响应。 `onAfterHandle` 上下文是基于正常上下文的,可以像常规上下文一样在路由处理程序中使用。 ## 映射响应 在\*\*"afterHandle"\*\* 之后执行,旨在提供自定义响应映射。 建议在以下情况下使用映射响应: * 压缩 * 将值映射到Web标准响应 #### 示例 以下是使用mapResponse提供响应压缩的示例。 ```typescript import { Elysia } from 'elysia' const encoder = new TextEncoder() new Elysia() .mapResponse(({ response, set }) => { const isJson = typeof response === 'object' const text = isJson ? JSON.stringify(response) : (response?.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', () => '映射响应') .get('/json', () => ({ map: '响应' })) .listen(3000) ``` 与 **parse** 和 **beforeHandle** 一样,在返回一个值后,下一个**mapResponse** 的迭代将被跳过。 Elysia将自动处理**mapResponse**中**set.headers**的合并过程。我们无需担心手动将**set.headers**附加到响应中。 ## 错误处理(On Error) 旨在进行错误处理。当在任何生命周期中抛出错误时,它将执行。 建议在以下情况下使用错误处理: * 提供自定义错误消息 * 作为容错或错误处理程序或重试请求 * 日志记录和分析 #### 示例 Elysia捕获所有在处理程序中抛出的错误,分类错误代码并将其传递到`onError`中间件。 ```typescript import { Elysia } from 'elysia' new Elysia() .onError(({ code, error }) => { return new Response(error.toString()) }) .get('/', () => { throw new Error('服务器正在维护') return '无法到达' }) ``` 通过 `onError` 我们可以捕获并将错误转换为自定义错误消息。 ::: tip 重要的是,`onError` 必须在我们希望应用它的处理程序之前被调用。 ::: ### 自定义404消息 例如,返回自定义404消息: ```typescript import { Elysia, NotFoundError } from 'elysia' new Elysia() .onError(({ code, status, set }) => { if (code === 'NOT_FOUND') return status(404, '未找到 :(') }) .post('/', () => { throw new NotFoundError() }) .listen(3000) ``` ### 上下文 `onError` 上下文是从 `Context` 扩展的,具有以下附加属性: * **error**: 被抛出的值 * **code**: *错误代码* ### 错误代码 Elysia错误代码包括: "UNKNOWN" | "VALIDATION" | "NOT\_FOUND" | "PARSE" | "INTERNAL\_SERVER\_ERROR" | "INVALID\_COOKIE\_SIGNATURE" | "INVALID\_FILE\_TYPE" * **NOT\_FOUND** * **PARSE** * **VALIDATION** * **INTERNAL\_SERVER\_ERROR** * **INVALID\_COOKIE\_SIGNATURE** * **INVALID\_FILE\_TYPE** * **UNKNOWN** * **number**(基于HTTP状态) 默认情况下,抛出的错误代码为 `UNKNOWN`。 ::: tip 如果没有返回错误响应,则错误将使用`error.name`返回。 ::: ### 抛出或返回 `Elysia.error` 是返回具有特定HTTP状态代码的错误的简写。 根据您的具体需求,它可以是 **返回** 或 **抛出**。 * 如果 `status` **为抛出**,它将被 `onError` 中间件捕获。 * 如果 `status` **为返回**,它将 **不会** 被 `onError` 中间件捕获。 请看以下代码: ```typescript import { Elysia, file } from 'elysia' new Elysia() .onError(({ code, error, path }) => { if (code === 418) return '捕获' }) .get('/throw', ({ status }) => { // 这将被 onError 捕获 throw status(418) }) .get('/return', ({ status }) => { // 这将 **不会** 被 onError 捕获 return status(418) }) ``` ### 自定义错误 Elysia支持在类型级别和实现级别的自定义错误。 要提供自定义错误代码,我们可以使用 `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': // 具有类型缩小 // 悬停以查看错误的类型为`CustomError` return error } }) .get('/', () => { throw new MyError('你好,错误') }) ``` ### 本地错误 与其他生命周期相同,我们使用守卫将错误提供到[作用域](/essential/plugin.html#scope)中: ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', () => '你好', { beforeHandle({ set, request: { headers }, error }) { if (!isSignIn(headers)) throw error(401) }, error({ error }) { return '已处理' } }) .listen(3000) ``` ## 响应后 在响应发送到客户端后执行。 建议在以下情况下使用 **响应后**: * 清理响应 * 日志记录和分析 #### 示例 以下是使用响应处理程序检查用户登录状态的示例。 ```typescript import { Elysia } from 'elysia' new Elysia() .onAfterResponse(() => { console.log('响应', performance.now()) }) .listen(3000) ``` 控制台应记录如下: ```bash 响应 0.0000 响应 0.0001 响应 0.0002 ``` ### 响应 类似于 [映射响应](#map-resonse),`afterResponse` 也接受一个 `response` 值。 ```typescript import { Elysia } from 'elysia' new Elysia() .onAfterResponse(({ response }) => { console.log(response) }) .get('/', () => '你好') .listen(3000) ``` `onAfterResponse` 的 `response` 不是Web标准的 `Response`,而是处理程序返回的值。 要获取从处理程序返回的头部和状态,我们可以通过上下文访问 `set`。 ```typescript import { Elysia } from 'elysia' new Elysia() .onAfterResponse(({ set }) => { console.log(set.status, set.headers) }) .get('/', () => '你好') .listen(3000) ``` --- --- url: /blog/with-prisma.md --- \ Prisma 是一个著名的 TypeScript ORM,以其优秀的开发者体验而闻名。 它提供了类型安全和直观的 API,使我们能够使用流畅自然的语法与数据库进行交互。 编写数据库查询就像使用 TypeScript 的自动补全编写数据结构一样简单,随后 Prisma 会生成高效的 SQL 查询并在后台处理数据库连接。 Prisma 的一个突出特点是它与流行数据库的无缝集成,例如: * PostgreSQL * MySQL * SQLite * SQL Server * MongoDB * CockroachDB 因此,我们可以灵活地选择最适合我们项目需求的数据库,而不必妥协于 Prisma 带来的强大性能。 这意味着你可以专注于真正重要的事情:构建应用程序逻辑。 Prisma 是 Elysia 的灵感之一,其声明性 API 和流畅的开发体验让人愉悦。 现在,我们可以通过 [Bun 0.6.7 的发布](https://bun.sh/blog/bun-v0.6.7) 让期待已久的想法成真,Bun 现在原生支持 Prisma。 ## Elysia 当你问应该使用什么框架和 Bun 搭配时,Elysia 是显而易见的选择。 虽然你可以使用 Express 与 Bun,但 Elysia 是专为 Bun 构建的。 Elysia 的性能几乎比 Express 快了 19 倍,结合了声明性 API,能够创建统一的类型系统和端到端的类型安全。 Elysia 以其流畅的开发者体验而闻名,尤其是自早期以来 Elysia 就被设计用于与 Prisma 一起使用。 凭借 Elysia 的严格类型验证,我们可以轻松地使用声明性 API 集成 Elysia 和 Prisma。 换句话说,Elysia 确保运行时类型与 TypeScript 的类型始终同步,使其表现得像一种类型严格的语言,你可以完全信任类型系统,提前发现任何类型错误,减少与类型相关的调试错误。 ## 设置 我们开始的第一步是运行 `bun create` 来设置一个 Elysia 服务器。 ```bash bun create elysia elysia-prisma ``` 其中 `elysia-prisma` 是我们的项目名称(文件夹目的地),可以自由更改为你喜欢的名称。 现在进入我们的文件夹,安装 Prisma CLI 作为开发依赖。 ```ts bun add -d prisma ``` 然后我们可以使用 `prisma init` 设置 Prisma 项目。 ```ts bunx prisma init ``` `bunx` 是 Bun 的命令,相当于 `npx`,允许我们执行包的执行文件。 设置完成后,我们可以看到 Prisma 会更新 `.env` 文件,并生成一个名为 **prisma** 的文件夹,文件夹内有 **schema.prisma** 文件。 **schema.prisma** 是使用 Prisma 的 schema 语言定义的数据库模型。 让我们将 **schema.prisma** 文件更新如下作为演示: ```ts generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id Int @id @default(autoincrement()) username String @unique password String } ``` 这段代码告诉 Prisma 我们想创建一个名为 **User** 的表,包含以下列: | 列名 | 类型 | 约束 | | --- | --- | --- | | id | 数字 | 主键并自动增值 | | username | 字符串 | 唯一 | | password | 字符串 | - | 然后 Prisma 会读取模式,并根据 `.env` 文件中的 DATABASE\_URL,因此在同步我们的数据库之前,我们需要先定义 `DATABASE_URL`。 由于我们没有正在运行的数据库,可以使用 Docker 设置一个: ```bash docker run -p 5432:5432 -e POSTGRES_PASSWORD=12345678 -d postgres ``` 现在进入项目根目录下的 `.env` 文件并编辑: ``` DATABASE_URL="postgresql://postgres:12345678@localhost:5432/db?schema=public" ``` 然后我们可以运行 `prisma migrate` 来同步数据库与 Prisma 模式: ```bash bunx prisma migrate dev --name init ``` 之后 Prisma 将根据我们的模式生成强类型的 Prisma Client 代码。 这意味着我们可以在代码编辑器中获得自动补全和类型检查,在编译时捕获潜在错误,而不是在运行时。 ## 进入代码 在我们的 **src/index.ts** 中,更新 Elysia 服务器以创建一个简单的用户注册接口。 ```ts import { Elysia } from 'elysia' import { PrismaClient } from '@prisma/client' // [!code ++] const db = new PrismaClient() // [!code ++] const app = new Elysia() .post( // [!code ++] '/sign-up', // [!code ++] async ({ body }) => db.user.create({ // [!code ++] data: body // [!code ++] }) // [!code ++] ) // [!code ++] .listen(3000) console.log( `🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}` ) ``` 我们刚刚创建了一个简单的接口,用于使用 Elysia 和 Prisma 向数据库插入新用户。 ::: tip **重要**的是,在返回 Prisma 函数时,你应该始终将回调函数标记为 async。 因为 Prisma 函数不返回原生 Promise,Elysia 不能动态处理自定义 Promise 类型,但通过静态代码分析,通过将回调函数标记为 async,Elysia 会尝试等待函数的返回类型,从而允许我们映射 Prisma 结果。 ::: 现在问题是,body 可能是任何内容,而不仅限于我们预期定义的类型。 我们可以通过使用 Elysia 的类型系统来改进这一点。 ```ts import { Elysia, t } from 'elysia' // [!code ++] import { PrismaClient } from '@prisma/client' const db = new PrismaClient() const app = new Elysia() .post( '/sign-up', async ({ body }) => db.user.create({ data: body }), { // [!code ++] body: t.Object({ // [!code ++] username: t.String(), // [!code ++] password: t.String({ // [!code ++] minLength: 8 // [!code ++] }) // [!code ++] }) // [!code ++] } // [!code ++] ) .listen(3000) console.log( `🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}` ) ``` 这告诉 Elysia 验证传入请求的 body 是否匹配指定的形状,并将回调中 `body` 的 TypeScript 类型更新为匹配相同类型: ```ts // 'body' 现在的类型如下: { username: string password: string } ``` 这意味着如果这个形状与数据库表不匹配,它会立即给你警告。 这在你需要编辑表格或执行迁移时非常有效,Elysia 可以逐行记录错误,因为类型冲突在达到生产环境之前。 ## 错误处理 由于我们的 `username` 字段是唯一的,有时 Prisma 可能会抛出错误,可能会在尝试注册时意外重复 `username`,如: ```ts Invalid `prisma.user.create()` invocation: Unique constraint failed on the fields: (`username`) ``` 默认的 Elysia 错误处理程序可以自动处理这种情况,但我们可以通过指定使用 Elysia 的局部 `onError` 钩子来改进: ```ts import { Elysia, t } from 'elysia' import { PrismaClient } from '@prisma/client' const db = new PrismaClient() const app = new Elysia() .post( '/', async ({ body }) => db.user.create({ data: body }), { error({ code }) { // [!code ++] switch (code) { // [!code ++] // Prisma P2002: "Unique constraint failed on the {constraint}" // [!code ++] case 'P2002': // [!code ++] return { // [!code ++] error: '用户名必须是唯一的' // [!code ++] } // [!code ++] } // [!code ++] }, // [!code ++] body: t.Object({ username: t.String(), password: t.String({ minLength: 8 }) }) } ) .listen(3000) console.log( `🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}` ) ``` 使用 `error` 钩子,回调内部抛出的任何错误都会传递到 `error` 钩子,允许我们定义自定义错误处理。 根据 [Prisma 文档](https://www.prisma.io/docs/reference/api-reference/error-reference#p2002),错误代码 'P2002' 意味着执行查询时违反了唯一约束。 由于此表只有一个 `username` 字段是唯一的,我们可以推断该错误是由于用户名不唯一引起,因此我们返回自定义错误消息: ```ts { error: '用户名必须是唯一的' } ``` 当唯一约束失败时,这将返回我们自定义错误消息的 JSON 等效项。 使我们能够流畅地从 Prisma 错误中定义任何自定义错误。 ## 奖励:参考模式 当我们的服务器变得复杂,类型变得冗余并成为模板代码时,使用 **参考模式** 可以改进内联的 Elysia 类型。 简单地说,我们可以为我们的模式命名,并通过名称引用类型。 ```ts import { Elysia, t } from 'elysia' import { PrismaClient } from '@prisma/client' const db = new PrismaClient() const app = new Elysia() .model({ // [!code ++] 'user.sign': t.Object({ // [!code ++] username: t.String(), // [!code ++] password: t.String({ // [!code ++] minLength: 8 // [!code ++] }) // [!code ++] }) // [!code ++] }) // [!code ++] .post( '/', async ({ body }) => db.user.create({ data: body }), { error({ code }) { switch (code) { // Prisma P2002: "Unique constraint failed on the {constraint}" case 'P2002': return { error: '用户名必须是唯一的' } } }, body: 'user.sign', // [!code ++] body: t.Object({ // [!code --] username: t.String(), // [!code --] password: t.String({ // [!code --] minLength: 8 // [!code --] }) // [!code --] }) // [!code --] } ) .listen(3000) console.log( `🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}` ) ``` 这与使用内联相同,但你只需定义一次,然后通过名称引用模式以消除冗余的验证代码。 TypeScript 和验证代码会按预期工作。 ## 奖励:文档 作为奖励,Elysia 的类型系统也是 OpenAPI Schema 3.0 的兼容版,这意味着它能够与支持 OpenAPI Schema 的工具(如 Swagger)生成文档。 我们可以使用 Elysia Swagger 插件以一行代码生成 API 文档。 ```bash bun add @elysiajs/swagger ``` 然后只需添加插件: ```ts import { Elysia, t } from 'elysia' import { PrismaClient } from '@prisma/client' import { swagger } from '@elysiajs/swagger' // [!code ++] const db = new PrismaClient() const app = new Elysia() .use(swagger()) // [!code ++] .post( '/', async ({ body }) => db.user.create({ data: body, select: { // [!code ++] id: true, // [!code ++] username: true // [!code ++] } // [!code ++] }), { error({ code }) { switch (code) { // Prisma P2002: "Unique constraint failed on the {constraint}" case 'P2002': return { error: '用户名必须是唯一的' } } }, body: t.Object({ username: t.String(), password: t.String({ minLength: 8 }) }), response: t.Object({ // [!code ++] id: t.Number(), // [!code ++] username: t.String() // [!code ++] }) // [!code ++] } ) .listen(3000) console.log( `🦊 Elysia 正在运行于 ${app.server?.hostname}:${app.server?.port}` ) ``` 这就是创建一个良好定义的 API 文档所需的一切。 由于严格定义类型的文档,我们发现由于不应返回私密信息而意外返回了 `password` 字段。 得益于 Elysia 的类型系统,我们定义响应不应包含 `password`,这会自动警告我们 Prisma 查询返回了密码,允许我们提前修复这个问题。 此外,我们无须担心可能会忘记 OpenAPI Schema 3.0 的规范,因为我们也有自动补全和类型安全。 我们可以用 `detail` 定义我们的路由细节,它也遵循 OpenAPI Schema 3.0,因此我们可以轻松创建文档。 ## 接下来是什么 在 Bun 和 Elysia 的支持下,我们进入了一个全新的开发者体验时代。 通过 Prisma,我们可以加速与数据库的交互,Elysia 则加速了我们在开发者体验和性能方面创建后台 Web 服务器的过程。 > 与之工作是一种绝对的乐趣。 Elysia 正在努力创建一个更好的开发者体验的新标准,以 Bun 构建高性能的 TypeScript 服务器,能够与 Go 和 Rust 的性能相匹配。 如果你在寻找学习 Bun 的起点,可以考虑看看 Elysia ,特别是在 [端到端类型安全](/eden/overview) 方面,类似于 tRPC,但基于 REST 标准,而无需任何代码生成。 如果你对 Elysia 感兴趣,欢迎查看我们的 [Discord 服务器](https://discord.gg/eaFJ2KDJck) 或查看 [Elysia 的 GitHub](https://github.com/elysiajs/elysia) --- --- url: /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: /at-glance.md --- # 简介 Elysia 是一个用于构建后端服务器的符合人体工学的 Web 框架,旨在与 Bun 配合使用。 Elysia 以简单性和类型安全为设计理念,拥有熟悉的 API,并广泛支持 TypeScript,针对 Bun 进行了优化。 以下是在 Elysia 中的简单 hello world 示例。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', '你好 Elysia') .get('/user/:id', ({ params: { id }}) => id) .post('/form', ({ body }) => body) .listen(3000) ``` 打开 [localhost:3000](http://localhost:3000/),结果应该显示 '你好 Elysia'。 ::: tip 将鼠标悬停在代码片段上以查看类型定义。 在 mock 浏览器中,点击蓝色路径以更改路径并预览响应。 Elysia 可以在浏览器中运行,您看到的结果实际上是使用 Elysia 运行的。 ::: ## 性能 基于 Bun 及诸多优化(如静态代码分析),Elysia 能够动态生成优化后的代码。 Elysia 的性能优于当今大多数 Web 框架\[1],甚至可以与 Golang 和 Rust 框架的性能相匹配\[2]。 | 框架 | 运行时 | 平均 | 普通文本 | 动态参数 | JSON 数据 | | ------------ | ------ | ---------- | ---------- | --------------- | ----------- | | 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,但建议使用 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 默认采用许多标准,如 OpenAPI 和 WinterCG 合规,允许你与大多数行业标准工具集成,或至少与你熟悉的工具轻松集成。 例如,因为 Elysia 默认采用 OpenAPI,生成 Swagger 文档就像添加一行代码一样简单: ```typescript twoslash import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' new Elysia() .use(swagger()) .get('/user/:id', ({ params: { id } }) => id, { params: t.Object({ id: t.Number() }) }) .listen(3000) ``` 使用 Swagger 插件,你可以轻松生成一个 Swagger 页面,而无需额外代码或特定配置,并轻松与团队分享。 ## 端到端类型安全 使用 Elysia,类型安全不仅限于服务器端。 使用 Elysia,你可以像 tRPC 一样自动与前端团队同步你的类型,使用 Elysia 的客户端库 "Eden"。 ```typescript twoslash import { Elysia, t } from 'elysia' import { swagger } from '@elysiajs/swagger' const app = new Elysia() .use(swagger()) .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') // 从 /user/617 获取数据 const { data } = await app.user({ id: 617 }).get() // ^? console.log(data) ``` 使用 Eden,你可以使用现有的 Elysia 类型来查询 Elysia 服务器 **无需代码生成**,并自动同步前后端的类型。 Elysia 不仅仅是帮助你创建一个可靠的后端,还关乎这个世界上美好的事物。 ## 平台无关性 Elysia 最初是为 Bun 设计的,但 **不限于 Bun**。因为 Elysia 符合 [WinterCG](https://wintercg.org/),你可以将 Elysia 服务器部署在 Cloudflare Workers、Vercel Edge Functions 和其他支持 Web 标准请求的运行时上。 ## 我们的社区 如果你有关于 Elysia 的问题或遇到困难,请随时在我们的 GitHub Discussions、Discord 和 Twitter 上提问。 *** 1\. 测量请求/秒。基于在 Debian 11 上进行的查询、路径参数解析和设置响应头的基准测试,Intel i7-13700K 测试于 2023 年 8 月 6 日,基于 Bun 0.7.2。有关基准测试条件,请参见 [此处](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: /patterns/type.md --- # 类型 这是在 Elysia 中编写验证类型的常见模式。 ## 基本类型 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` 中的字段为未定义或可选。 ```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` 允许值是指定的值之一。 ```typescript t.UnionEnum(['rapi', 'anis', 1, true, false]) ``` 默认情况下,这些值不会自动 ### 文件 单个文件,通常用于 **文件上传** 验证。 ```typescript t.File() ``` 文件扩展了基本模式的属性,并具有如下附加属性: #### 类型 指定文件的格式,如图像、视频或音频。 如果提供了一个数组,它将尝试验证任何格式是否有效。 ```typescript type?: MaybeArray ``` #### 最小大小 文件的最小大小。 接受以字节为单位的数字或文件单位的后缀: ```typescript minSize?: number | `${number}${'k' | 'm'}` ``` #### 最大大小 文件的最大大小。 接受以字节为单位的数字或文件单位的后缀: ```typescript maxSize?: number | `${number}${'k' | 'm'}` ``` #### 文件单位后缀: 以下是文件单位的规格: m: 兆字节(1048576 字节) k: 千字节(1024 字节) ### 文件数组 从 [文件](#file) 扩展,但增加了对单个字段中的文件数组的支持。 ```typescript t.Files() ``` 文件数组扩展了基本模式、数组和文件的属性。 ### Cookie 从对象类型扩展的 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)。新签名的值将使用第一个秘密作为密钥。 ### 可为空 允许值为 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() }) ``` ### 数字(遗留) ::: warning 这不需要,因为 Elysia 类型自 1.0 起已自动将 Number 转换为 Numeric ::: 数字接受数字字符串或数字,然后将值转换为数字。 ```typescript t.Numeric() ``` 当传入值是数字字符串时,这非常有用,例如路径参数或查询字符串。 数字接受与 [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 不同,其中可选用于标记对象字段为可选。 ```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-legacy) 当作为路由模式提供时。 因为解析的 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({ // NOT 转换为 t.Numeric() id: t.Number() }) }) // NOT 转换为 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({ // NOT 转换为 t.Boolean() id: t.Boolean() }) }) // NOT 转换为 t.BooleanString() t.Boolean() ``` --- --- url: /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 实例作为服务以确保类型的完整性和推断: ```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 作为服务](✅-要-将-elysia-实例作为服务使用) 。 您可以在 [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 合规客户端中显示为“模型”,例如 Swagger。 4. 提升 TypeScript 推断速度,因为模型类型在注册期间会被缓存。 *** 如前所述,Elysia 是一个无模式框架,我们仅提供关于如何将 Elysia 与 MVC 模式结合的推荐指南。 是否遵循此推荐完全取决于您和您的团队的偏好和共识。 --- --- url: /essential/route.md --- # 路由 Web 服务器使用请求的 **路径和 HTTP 方法** 来查找正确的资源,这被称为 **“路由”**。 我们可以通过调用一个 **与 HTTP 动词同名的方法**,传递一个路径和一个匹配时执行的函数来定义路由。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', '你好') .get('/hi', '嗨') .listen(3000) ``` 我们可以通过访问 **http://localhost:3000** 来访问 web 服务器。 默认情况下,web 浏览器在访问页面时会发送 GET 方法。 ::: tip 使用上面的交互式浏览器,将鼠标悬停在蓝色高亮区域以查看每个路径之间的不同结果 ::: ## 路径类型 Elysia 中的路径可以分为 3 种类型: * **静态路径** - 用于定位资源的静态字符串 * **动态路径** - 段可以是任何值 * **通配符** - 直到特定点的路径可以是任何内容 你可以将所有路径类型结合在一起,为你的 web 服务器组成行为。 优先级如下: 1. 静态路径 2. 动态路径 3. 通配符 如果路径被解析为静态而动态路径被提供,Elysia 将优先解析静态路径而不是动态路径。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/id/1', '静态路径') .get('/id/:id', '动态路径') .get('/id/*', '通配符路径') .listen(3000) ``` 在这里,服务器将返回以下响应: | 路径 | 响应 | | ------- | ------------- | | /id/1 | 静态路径 | | /id/2 | 动态路径 | | /id/2/a | 通配符路径 | ## 静态路径 路径或路径名是一个用于定位服务器资源的标识符。 ```bash http://localhost:/path/page ``` Elysia 使用路径和方法来查找正确的资源。 路径从源地址之后开始。以 **/** 开头,在查询参数 **(?)** 之前结束。 我们可以将 URL 和路径分类如下: | URL | 路径 | | ------------------------------- | ------------ | | http://example.com/ | / | | http://example.com/hello | /hello | | http://example.com/hello/world | /hello/world | | http://example.com/hello?name=salt | /hello | | http://example.com/hello#title | /hello | ::: tip 如果路径未指定,浏览器和 web 服务器将将路径视为 '/' 作为默认值。 ::: Elysia 将对每个请求进行 [路由](/essential/route) 查找,并使用 [处理程序](/essential/handler) 函数进行响应。 ## 动态路径 URLs 可以是静态和动态的。 静态路径是硬编码的字符串,可以用于定位服务器上的资源,而动态路径匹配某些部分并捕获值以提取额外的信息。 例如,我们可以从路径名中提取用户 ID。例如: ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/id/:id', ({ params: { id } }) => id) // ^? .listen(3000) ``` 这里动态路径是通过 `/id/:id` 创建的,告诉 Elysia 匹配直到 `/id` 的任何路径。之后的部分将存储为 **params** 对象。 请求时,服务器应该返回以下响应: | 路径 | 响应 | | ---------------------- | ------- | | /id/1 | 1 | | /id/123 | 123 | | /id/anything | anything | | /id/anything?name=salt | anything | | /id | 未找到 | | /id/anything/rest | 未找到 | 动态路径非常适合包括如 ID 之类的内容,这些可以后续使用。 我们将命名变量路径称为 **路径参数** 或简称为 **params**。 ## 段 URL 段是组成完整路径的每个路径。 段由 `/` 分隔。 ![URL 段的表示](/essential/url-segment.webp) Elysia 中的路径参数通过在段前加上 ':' 后跟名称来表示。 ![路径参数的表示](/essential/path-parameter.webp) 路径参数允许 Elysia 捕获 URL 的特定段。 命名的路径参数将存储在 `Context.params` 中。 | 路由 | 路径 | 参数 | | --------- | ------ | ------- | | /id/:id | /id/1 | id=1 | | /id/:id | /id/hi | id=hi | | /id/:name | /id/hi | name=hi | ## 多个路径参数 你可以有任意多个路径参数,这些参数将存储在一个 `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) ``` 服务器将返回以下响应: | 路径 | 响应 | | ---------------------- | ------------- | | /id/1 | 1 | | /id/123 | 123 | | /id/anything | anything | | /id/anything?name=salt | anything | | /id | 未找到 | | /id/anything/rest | anything rest | ## 可选路径参数 有时我们可能希望静态路径和动态路径解析为相同的处理程序。 我们可以通过在参数名称后添加问号 `?` 来使路径参数可选。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/id/:id?', ({ params: { id } }) => `id ${id}`) // ^? .listen(3000) ``` 服务器将返回以下响应: | 路径 | 响应 | | ---------------------- | ------------- | | /id | id undefined | | /id/1 | id 1 | ## 通配符 动态路径允许捕获 URL 的某些段。 但是,当你需要路径的值更动态,并希望捕获其余的 URL 段时,可以使用通配符。 通配符可以使用 "\*" 捕获段之后的值,无论数量。 ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/id/*', ({ params }) => params['*']) // ^? .listen(3000) ``` 在这种情况下,服务器将返回以下响应: | 路径 | 响应 | | ---------------------- | ------------- | | /id/1 | 1 | | /id/123 | 123 | | /id/anything | anything | | /id/anything?name=salt | anything | | /id | 未找到 | | /id/anything/rest | anything/rest | 通配符非常适合捕获达到特定点的路径。 ::: tip 你可以将通配符与路径参数一起使用。 ::: ## HTTP 动词 HTTP 定义了一组请求方法,以指示对给定资源要执行的操作。 有几个 HTTP 动词,但最常见的包括: ### GET 使用 GET 的请求应该仅用于检索数据。 ### POST 将有效负载提交到指定资源,通常导致状态更改或副作用。 ### PUT 使用请求的有效负载替换目标资源的所有当前表示。 ### PATCH 对资源进行部分修改。 ### DELETE 删除指定的资源。 *** 为了处理每个不同的动词,Elysia 具有内置 API 可用于多个 HTTP 动词,与 `Elysia.get` 类似。 ```typescript import { Elysia } from 'elysia' new Elysia() .get('/', '你好') .post('/hi', '嗨') .listen(3000) ``` Elysia HTTP 方法接受以下参数: * **path**: 路径名 * **function**: 对客户端响应的函数 * **hook**: 额外的元数据 你可以在 [HTTP 请求方法](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**: 额外的元数据 导航到每个方法时,你应该看到以下结果: | 路径 | 方法 | 结果 | | --------- | -------- | ------- | | /get | GET | hello | | /post | POST | hi | | /m-search | M-SEARCH | connect | ::: tip 基于 [RFC 7231](https://www.rfc-editor.org/rfc/rfc7231#section-4.1),HTTP 动词是区分大小写的。 建议使用大写约定为 Elysia 定义自定义 HTTP 动词。 ::: ## Elysia.all Elysia 提供了一个 `Elysia.all`,用于处理指定路径的任何 HTTP 方法,通过与 **Elysia.get** 和 **Elysia.post** 使用相同的 API。 ```typescript import { Elysia } from 'elysia' new Elysia() .all('/', '嗨') .listen(3000) ``` 任何与该路径匹配的 HTTP 方法,将被处理如下: | 路径 | 方法 | 结果 | | ---- | -------- | ------ | | / | GET | 嗨 | | / | POST | 嗨 | | / | DELETE | 嗨 | ## 处理 大多数开发人员使用 REST 客户端,如 Postman、Insomnia 或 Hoppscotch 测试他们的 API。 然而,Elysia 可以使用 `Elysia.handle` 通过编程方式进行测试。 ```typescript import { Elysia } from 'elysia' const app = new Elysia() .get('/', '你好') .post('/hi', '嗨') .listen(3000) app.handle(new Request('http://localhost/')).then(console.log) ``` **Elysia.handle** 是处理发送到服务器的实际请求的函数。 ::: tip 与单元测试的 mock 不同,**你可以期望它表现得像发送到服务器的实际请求**。 但也有助于模拟或创建单元测试。 ::: ## 404 如果没有路径与定义的路由匹配,Elysia 将在返回 **"NOT\_FOUND"** 和 HTTP 状态码 404 之前将请求传递给 [error](/essential/life-cycle.html#on-error) 生命周期。 我们可以通过从 `error` 生命周期返回一个值来处理自定义的 404 错误,如下所示: ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .get('/', '嗨') .onError(({ code }) => { if (code === 'NOT_FOUND') { return '路由未找到 :(' } }) .listen(3000) ``` 当导航到你的 web 服务器时,你应该看到以下结果: | 路径 | 方法 | 结果 | | ---- | ------ | ------------------- | | / | GET | 嗨 | | / | POST | 路由未找到 :( | | /hi | GET | 路由未找到 :( | 你可以在 [生命周期事件](/essential/life-cycle#events) 和 [错误处理](/essential/life-cycle.html#on-error) 中了解更多关于生命周期和错误处理的信息。 ::: tip HTTP 状态用于指示响应的类型。 默认情况下,如果一切正确,服务器将返回 '200 OK' 状态码(如果路由匹配且没有错误,Elysia 将默认返回 200)。 如果服务器未能找到任何可处理的路由,在这种情况下,服务器将返回 '404 NOT FOUND' 状态码。 ::: ## 组 在创建 web 服务器时,你通常会有多个路由共享相同的前缀: ```typescript import { Elysia } from 'elysia' new Elysia() .post('/user/sign-in', '登录') .post('/user/sign-up', '注册') .post('/user/profile', '个人资料') .listen(3000) ``` 这可以通过 `Elysia.group` 改进,允许我们通过将它们分组一起应用前缀到多个路由: ```typescript twoslash import { Elysia } from 'elysia' new Elysia() .group('/user', (app) => app .post('/sign-in', '登录') .post('/sign-up', '注册') .post('/profile', '个人资料') ) .listen(3000) ``` 这段代码的行为与我们的第一个示例相同,结构如下: | 路径 | 结果 | | ------------- | ------- | | /user/sign-in | 登录 | | /user/sign-up | 注册 | | /user/profile | 个人资料 | `.group()` 还可以接受一个可选的保护参数,以减少同时使用组和保护的样板代码: ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .group( '/user', { body: t.Literal('Rikuhachima Aru') }, (app) => app .post('/sign-in', '登录') .post('/sign-up', '注册') .post('/profile', '个人资料') ) .listen(3000) ``` 你可以在 [作用域](/essential/plugin.html#scope) 中找到有关分组保护的更多信息。 ### 前缀 我们可以通过为构造函数提供 **前缀** 将一组分离到一个单独的插件实例,以减少嵌套。 ```typescript import { Elysia } from 'elysia' const users = new Elysia({ prefix: '/user' }) .post('/sign-in', '登录') .post('/sign-up', '注册') .post('/profile', '个人资料') new Elysia() .use(users) .get('/', '你好,世界') .listen(3000) ``` --- --- url: /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) 创建交互式文档 参见 [swagger](/plugins/swagger.html) ```typescript import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' const app = new Elysia() .use(swagger()) .listen(3000) console.log(`在浏览器中访问 "${app.server!.url}swagger" 查看文档`); ``` ## 单元测试 编写 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: /patterns/deploy.md --- # 部署到生产环境 本页面是关于如何将 Elysia 部署到生产环境的指南。 ## 编译为二进制 我们建议在部署到生产环境之前运行构建命令,因为这可能会显著减少内存使用和文件大小。 我们推荐使用以下命令将 Elysia 编译成单个二进制文件: ```bash bun build \ --compile \ --minify-whitespace \ --minify-syntax \ --target bun \ --outfile server \ ./src/index.ts ``` 这将生成一个可移植的二进制文件 `server`,我们可以运行它来启动我们的服务器。 将服务器编译为二进制文件通常会将内存使用量显著减少 2-3 倍,相较于开发环境。 这个命令有点长,所以让我们分解一下: 1. `--compile` - 将 TypeScript 编译为二进制 2. `--minify-whitespace` - 删除不必要的空白 3. `--minify-syntax` - 压缩 JavaScript 语法以减少文件大小 4. `--target bun` - 针对 `bun` 平台,可以为目标平台优化二进制文件 5. `--outfile server` - 输出二进制文件为 `server` 6. `./src/index.ts` - 我们服务器的入口文件(代码库) 要启动我们的服务器,只需运行二进制文件。 ```bash ./server ``` 一旦二进制文件编译完成,您就不需要在机器上安装 `Bun` 以运行服务器。 这很好,因为部署服务器不需要安装额外的运行时,使得二进制文件便于移植。 ### 为什么不使用 --minify Bun 确实有 `--minify` 标志,用于压缩二进制文件。 然而,如果我们正在使用 [OpenTelemetry](/plugins/opentelemetry),它会将函数名缩减为单个字符。 这使得跟踪变得比应该的更加困难,因为 OpenTelemetry 依赖于函数名。 但是,如果您不使用 OpenTelemetry,您可以选择使用 `--minify`: ```bash bun build \ --compile \ --minify \ --target bun \ --outfile server \ ./src/index.ts ``` ### 权限 一些 Linux 发行版可能无法运行二进制文件,如果您使用的是 Linux,建议为二进制文件启用可执行权限: ```bash chmod +x ./server ./server ``` ### 未知的随机中文错误 如果您尝试将二进制文件部署到服务器但无法运行,并出现随机中文字符错误。 这意味着您运行的机器 **不支持 AVX2**。 不幸的是,Bun 要求机器具有 `AVX2` 硬件支持。 据我们所知没有替代方案。 ## 编译为 JavaScript 如果您无法编译为二进制文件或您正在 Windows 服务器上进行部署。 您可以将服务器打包为一个 JavaScript 文件。 ```bash bun build \ --compile \ // [!code --] --minify-whitespace \ --minify-syntax \ --target bun \ --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 \ --target bun \ --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 \ --target bun \ --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: /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 ``` ## santize 一个函数或一个函数数组,在每个 `t.String` 验证时调用并拦截。 允许我们读取并将字符串转换为新值。 ```ts import { Elysia, t } from 'elysia' new Elysia({ santize: (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') } } }) ``` Elysia 扩展了支持 TLS 的 Bun 配置,使用 BoringSSL 作为支持。 查看[serve.tls](#serve-tls)以获取可用配置。 ### serve.hostname @default `0.0.0.0` 服务器应监听的主机名。 ### serve.id 使用 ID 唯一标识服务器实例。 此字符串将用于热重载服务器,而不会中断待处理的请求或网页套接字。如果未提供,将生成一个值。要禁用热重载,请将此值设置为 `null`。 ### 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: /plugins/static.md --- # 静态插件 此插件可以为 Elysia Server 提供静态文件/文件夹的服务 安装方法: ```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 @default `"public"` 要暴露为静态的文件夹路径 ### prefix @default `"/public"` 注册公共文件的路径前缀 ### ignorePatterns @default `[]` 要忽略的不提供静态文件服务的文件列表 ### staticLimit @default `1024` 默认为,静态插件将以静态名称将路径注册到路由器,如果超过限制,路径将懒惰地添加到路由器以减少内存使用。 在内存和性能之间权衡。 ### alwaysStatic @default `false` 如果设置为 true,静态文件路径将跳过 `staticLimits` 注册到路由器。 ### headers @default `{}` 设置文件的响应头 ### indexHTML @default `false` 如果设置为 true,当请求既不匹配路由也不匹配任何现有静态文件时,将提供静态目录中的 `index.html` 文件。 ## 模式 以下是使用该插件的常见模式。 * [单个文件](#单个文件) ## 单个文件 假设你只想返回一个单独的文件,可以使用 `file` 而不是使用静态插件 ```typescript twoslash import { Elysia, file } from 'elysia' new Elysia() .get('/file', file('public/takodachi.png')) ``` --- --- url: /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) 的模式构建器,提供了运行时、编译时和 OpenAPI 模式的类型安全,用于生成 OpenAPI/Swagger 文档。 TypeBox 是一个非常快速、轻量且类型安全的 TypeScript 运行时验证库。Elysia 扩展并定制了 TypeBox 的默认行为,以适应服务器端验证。 我们认为验证应该由框架原生处理,而不是依赖用户为每个项目设置自定义类型。 ### TypeScript 我们可以通过访问 `static` 属性来获取每个 Elysia/TypeBox 类型的类型定义,如下所示: ```ts twoslash import { t } from 'elysia' const MyType = t.Object({ hello: t.Literal('Elysia') }) type MyType = typeof MyType.static // ^? ``` 这使得 Elysia 能够自动推断和提供类型,减少了声明重复模式的需要。 一个单一的 Elysia/TypeBox 模式可以用于: * 运行时验证 * 数据强制转换 * TypeScript 类型 * OpenAPI 模式 这使我们能够将模式作为 **单一真相来源**。 ## 模式类型 Elysia 支持具有以下类型的声明式模式: *** 这些属性应作为路由处理器的第三个参数提供,以验证传入请求。 ```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 | 查询 | 参数 | | --- | --------- | ------------ | | /id/a | ❌ | ❌ | | /id/1?name=Elysia | ✅ | ✅ | | /id/1?alias=Elysia | ❌ | ✅ | | /id/a?name=Elysia | ✅ | ❌ | | /id/a?alias=Elysia | ❌ | ❌ | 当提供模式时,类型将自动从模式推断,并为 Swagger 文档生成 OpenAPI 类型,从而消除了手动提供类型的冗余任务。 ## Guard Guard 可用于将模式应用于多个处理程序。 ```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**,并且该值是字符串。响应应列出如下: 响应应列出如下: | 路径 | 响应 | | ------------- | -------- | | /none | hi | | /none?name=a | hi | | /query | error | | /query?name=a | a | 如果为同一属性定义了多个全局模式,则最后一个将优先。如果同时定义了本地和全局模式,则本地模式将优先。 ### Guard Schema 类型 Guard 支持 2 种类型来定义验证。 ### **覆盖(默认)** 如果模式彼此冲突,则覆盖模式。 ![Elysia 运行默认覆盖保护,显示模式被覆盖](/blog/elysia-13/schema-override.webp) ### **独立** 分别处理碰撞的模式,并独立运行,从而使两者都得到验证。 ![Elysia 独立运行多个守护合并在一起](/blog/elysia-13/schema-standalone.webp) 使用 `schema` 定义守护的模式类型: ```ts import { Elysia } from 'elysia' new Elysia() .guard({ schema: 'standalone', // [!code ++] response: t.Object({ title: t.String() }) }) ``` ## 主体 传入的 [HTTP 消息](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages) 是发送到服务器的数据。它可以是 JSON、表单数据或任何其他格式。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .post('/body', ({ body }) => body, { // ^? body: t.Object({ name: t.String() }) }) .listen(3000) ``` 验证应如下所示: | 主体 | 验证 | | --- | --------- | | { name: 'Elysia' } | ✅ | | { name: 1 } | ❌ | | { alias: 'Elysia' } | ❌ | | `undefined` | ❌ | Elysia 默认禁用了 **GET** 和 **HEAD** 消息的 body 解析,遵循 HTTP/1.1 规范 [RFC2616](https://www.rfc-editor.org/rfc/rfc2616#section-4.3) > 如果请求方法不包括对实体主体的定义语义,则在处理请求时应忽略消息主体。 大多数浏览器默认禁用将主体附加到 **GET** 和 **HEAD** 方法。 #### 规格 验证传入的 [HTTP 消息](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages)(或主体)。 这些消息是供 Web 服务器处理的附加消息。 主体与 `fetch` API 中的 `body` 图相同提供。内容类型应根据定义的主体进行相应设置。 ```typescript fetch('https://elysiajs.com', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Elysia' }) }) ``` ### 文件 文件是一种特殊的主体类型,可用于上传文件。 ```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 将自动假设内容类型为 `multipart/form-data`。 ## 查询 查询是通过 URL 发送的数据。可以采用 `?key=value` 的形式。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/query', ({ query }) => query, { // ^? query: t.Object({ name: t.String() }) }) .listen(3000) ``` 查询必须以对象的形式提供。 验证应如下所示: | 查询 | 验证 | | ---- | --------- | | /?name=Elysia | ✅ | | /?name=1 | ✅ | | /?alias=Elysia | ❌ | | /?name=ElysiaJS\&alias=Elysia | ✅ | | / | ❌ | #### 规格 查询字符串是 URL 的一部分,以 **?** 开头,可以包含一个或多个查询参数,这些参数是用于向服务器传达附加信息的一对键值对,通常用于自定义行为,例如过滤或搜索。 ![URL 对象](/essential/url-object.svg) 查询在 Fetch API 的 **?** 之后提供。 ```typescript fetch('https://elysiajs.com/?name=Elysia') ``` 在指定查询参数时,必须了解所有查询参数值必须表示为字符串。这是因为它们的编码和添加到 URL 的方式。 ### 强制转换 Elysia 将自动强制将适用的模式转换为查询中的相应类型。 有关更多信息,请参见 [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) ``` ### 数组 默认情况下,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 表单格式 如果一个键被分配多次,该键将被视为数组。 这与 HTML 表单格式类似,当一个名称相同的输入被指定多次时。 ``` http://localhost?name=rapi&name=anis&name=neon&squad=counter // name: ['rapi', 'anis', 'neon'] ``` ## 参数 参数或路径参数是通过 URL 路径发送的数据。 可以采用 `/key` 的形式。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/id/:id', ({ params }) => params, { // ^? params: t.Object({ id: t.Number() }) }) ``` 参数必须以对象的形式提供。 验证应如下所示: | URL | 验证 | | --- | --------- | | /id/1 | ✅ | | /id/a | ❌ | #### 规格 路径参数 (与查询字符串或查询参数不同)。 **通常不需要此字段,因为 Elysia 可以自动推断路径参数的类型**,除非需要特定值模式,例如数字值或模板字面量模式。 ```typescript fetch('https://elysiajs.com/id/1') ``` ### 参数类型推断 如果未提供参数模式,Elysia 将自动将类型推断为字符串。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/id/:id', ({ params }) => params) // ^? ``` ## 头部 头部是通过请求的头部发送的数据。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .get('/headers', ({ headers }) => headers, { // ^? headers: t.Object({ authorization: t.String() }) }) ``` 与其他类型不同,头部的 `additionalProperties` 默认设置为 `true`。 这意味着头部可以包含任何键值对,但值必须符合模式。 #### 规格 HTTP headers let the client and the server pass additional information with an HTTP request or response, usually treated as metadata. 此字段通常用于强制执行某些特定的头部字段,例如 `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` 相同,头部的 `additionalProperties` 默认设置为 `true`。 #### 规格 HTTP Cookie 是服务器发送给客户端的小数据块,这是数据,在每次访问同一网页服务器时都会发送,以便让服务器记住客户端信息。 简单来说,一种字符串化的状态,在每个请求中发送。 此字段通常用于强制执行某些特定的 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 }) }) ``` ## 响应 响应是从处理程序返回的数据。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .get('/response', () => { return { name: 'Jane Doe' } }, { response: t.Object({ name: t.String() }) }) ``` ### 按状态设置响应 响应可以按状态代码设置。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .get('/response', ({ status }) => { if (Math.random() > 0.5) return status(400, { error: '出了点问题' }) return { name: 'Jane Doe' } }, { response: { 200: t.Object({ name: t.String() }), 400: t.Object({ error: t.String() }) } }) ``` 这是 Elysia 特定的功能,允许我们使字段可选。 ## 错误提供程序 当验证失败时,有两种方式提供自定义错误消息: 1. 内联 `status` 属性 2. 使用 [onError](/essential/life-cycle.html#on-error) 事件 ### 错误属性 Elysia 提供了一个额外的 **error** 属性,允许我们在字段无效时返回自定义错误消息。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .post('/', () => 'Hello World!', { body: t.Object({ x: t.Number({ error: 'x 必须是一个数字' }) }) }) .listen(3000) ``` 以下是使用错误属性在各种类型上的示例: ```typescript t.String({ format: 'email', error: '无效的电子邮件 :(' }) ``` ``` 无效的电子邮件 :( ``` ```typescript t.Array( t.String(), { error: '所有成员必须是一个字符串' } ) ``` ``` 所有成员必须是一个字符串 ``` ```typescript t.Object({ x: t.Number() }, { error: '无效的对象 UwU' }) ``` ``` 无效的对象 UwU ``` ```typescript t.Object({ x: t.Number({ error({ errors, type, validation, value }) { return '期望 x 为数字' } }) }) ``` ``` 期望 x 为数字 ``` ## 自定义错误 TypeBox 提供了一个额外的 "**错误**" 属性,允许我们在字段无效时返回自定义错误消息。 ```typescript t.String({ format: 'email', error: '无效的电子邮箱 :( ' }) ``` ``` 无效的电子邮箱 :( ``` ```typescript t.Object({ x: t.Number() }, { error: '无效的对象 UwU' }) ``` ``` 无效的对象 UwU ``` ### 错误消息作为函数 除了字符串外,Elysia 类型的错误也可以接受一个函数,以程序化地为每个属性返回自定义错误。 错误函数接受与 `ValidationError` 相同的参数。 ```typescript import { Elysia, t } from 'elysia' new Elysia() .post('/', () => 'Hello World!', { body: t.Object({ x: t.Number({ error() { return '期望 x 为数字' } }) }) }) .listen(3000) ``` ::: tip 悬停在 `error` 上以查看类型 ::: ### 错误按字段调用 请注意,仅当字段无效时,错误函数才会被调用。 请考虑以下表: ```typescript t.Object({ x: t.Number({ error() { return '期望 x 为数字' } }) }) ``` ```json { x: "hello" } ``` ```typescript t.Object({ x: t.Number({ error() { return '期望 x 为数字' } }) }) ``` ```json "hello" ``` ```typescript t.Object( { x: t.Number({ error() { return '期望 x 为数字' } }) }, { error() { return '期望值为对象' } } ) ``` ```json "hello" ``` ### onError 我们可以根据 [onError](/essential/life-cycle.html#on-error) 事件自定义验证行为,缩小到一个错误代码 "**VALIDATION**"。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .onError(({ code, error }) => { if (code === 'VALIDATION') return error.message }) .listen(3000) ``` 缩小的错误类型将被表示为 `ValidationError` 从 **elysia/error** 导入。 **ValidationError** 暴露了名为 **validator** 的属性,类型为 [TypeCheck](https://github.com/sinclairzx81/typebox#typecheck),允许我们与 TypeBox 功能直接交互。 ```typescript twoslash import { Elysia, t } from 'elysia' new Elysia() .onError(({ code, error }) => { if (code === 'VALIDATION') return error.validator.Errors(error.value).First().message }) .listen(3000) ``` ### 错误列表 **ValidationError** 提供了一个方法 `ValidatorError.all`,允许我们列出所有的错误原因。 ```typescript twoslash 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) // 查找特定错误名称(路径符合 OpenAPI 架构) const name = error.all.find( (x) => x.summary && x.path === '/name' ) // 如果有验证错误,则记录它 if(name) console.log(name) } } }) .listen(3000) ``` 有关 TypeBox 的验证器的更多信息,请参见 [TypeCheck](https://github.com/sinclairzx81/typebox#typecheck)。 ## 引用模型 有时您可能会发现自己声明重复模型,或多次重用相同模型。 通过引用模型,我们可以为模型命名,并通过引用名称重复使用它们。 让我们从一个简单的场景开始。 假设我们有一个处理登录的控制器,使用同一个模型。 ```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' // 也许在不同的文件,例如 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 }) ``` 这种分离关注的方法是有效的,但随着应用的复杂性增加,我们可能会发现自己在不同的控制器中重用多个模型。 我们可以通过创建 "引用模型" 来解决此问题,允许我们命名模型并使用自动完成直接在 `schema` 中引用它,同时通过 `model` 注册模型。 ```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, { // 在现有模型名称的上下文中使用自动完成 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 // ---省略--- // index.ts import { Elysia } from 'elysia' import { authModel } from './auth.model' const app = new Elysia() .use(authModel) .post('/sign-in', ({ body }) => body, { // 在现有模型名称的上下文中使用自动完成 body: 'sign', response: 'sign' }) ``` 这不仅可以让我们分离关注,还可以在多个地方重用模型,同时将模型报告到 Swagger 文档中。 ### 多个模型 `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() }) }) ``` ### 命名约定 重复的模型名称会导致 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 提供了一种有见地的选项,供您决定以防止决策疲劳。