aggrekite
v5.5.0
Published
A lightweight TypeScript BFF framework with Blueprint routing
Downloads
4,210
Maintainers
Readme
AggreKite
轻量级 TypeScript BFF 框架 —— Blueprint 蓝图 + 函数式路由 + 内置 BFF 聚合层
AggreKite 是一个面向 BFF(Backend For Frontend) 场景的 Node.js HTTP 框架。它以 Blueprint 蓝图 为路由组织单元,内置并发 BFF 聚合、参数校验、Session/Cookie、文件上传、IP 黑名单等能力,支持 ESM only、零装饰器、开箱即用。
适用场景:BFF 层、微服务网关、快速 API 开发、中台聚合服务。
1. 介绍
AggreKite 的核心理念是将 路由定义 和 业务逻辑 解耦:Blueprint 负责声明路由的 HTTP 方法、路径、校验规则和中间件,而 handler 可以内联编写,也可以通过 filepath 指向独立文件(配合柯里化 RouteRegistrar 注册)。
框架内置了 BFF 聚合层 bff(),能够将多个下游微服务调用并发执行、合并结果、支持降级、重试、缓存、链路追踪,大幅减少前端网络请求次数。
2. 安装
npm install aggrekite
or
npm install aggrekite -g要求:Node.js >= 18(BFF 聚合依赖原生 fetch)。
CLI 脚手架:
npx aggrekite create my-app
cd my-app
npm run devCLI 会自动从本地 AggreKite 包读取版本号,生成的项目依赖中已包含对应版本的
aggrekite,npm install由脚手架自动执行,无需手动安装。
3. 快速开始
最小可运行示例:
// app.ts
import { AggreKite, Blueprint } from 'aggrekite'
const app = new AggreKite()
const hello = new Blueprint({ name: 'hello', prefix: '' })
hello.get('/', (req) => {
return { data: { message: 'Hello AggreKite!' } }
})
app.mount(hello)
app.listen(3000)启动:
npx tsx app.ts访问 http://localhost:3000/ 即可看到响应:
{ "code": 200, "data": { "message": "Hello AggreKite!" }, "message": "success" }4. 核心概念
4.1 Blueprint 蓝图
Blueprint 是路由的组织单元。通过 name 自动生成路由前缀(默认在 name 后加 s),也可手动指定 prefix。
import { Blueprint } from 'aggrekite'
// name="user" → 自动前缀 /users
const users = new Blueprint({ name: 'user' })
// 手动指定前缀
const admin = new Blueprint({ name: 'admin', prefix: '/admin' })
// 根路径
const home = new Blueprint({ name: 'home', prefix: '' })两种路由注册方式
方式一:内联 handler
users.get('/:id', (req) => {
return { data: { id: req.params.id } }
})
users.post('/', (req) => {
return { data: { created: true } }
})
type()已在 v5.0.0 中移除,参数校验请通过 TypeScript 接口在泛型参数中定义类型约束。
方式二:filepath + 柯里化 RouteRegistrar
使用 $() 辅助函数指定相对路径(相对于蓝图文件所在目录),获得 IDE 路径补全。get/post/put/delete 返回一个柯里化函数,供 handler 文件 import 并调用注册:
// bpRoutes/index.ts
import { Blueprint, $ } from 'aggrekite'
const users = new Blueprint({ name: 'user' })
export const getUser = users.get('/:id', { filepath: $('../handlers/user/detail.ts') })
export const createUser = users.post('/', { filepath: $('../handlers/user/create.ts') })// handlers/user/detail.ts
import { getUser } from '../bpRoutes/index'
getUser((req) => {
return { data: { id: req.params.id, name: 'John' } }
})泛型类型推导(v5.0.0+):
通过 RouteOptions 接口 + 泛型参数,get/post<TRoute> 可自动推导 req.body / req.params / req.query 类型:
// bpRoutes/option.ts
export interface RouteOptions {
login: {
body: { username: string; password: string }
}
}
// bpRoutes/index.ts
export const loginCallable = users.post<RouteOptions['login']>('/login', { filepath: $('../handlers/login/index.ts') })
// handlers/login/index.ts
import { loginCallable } from '../bpRoutes/index'
loginCallable((req) => {
const { username, password } = req.body
// ^? string ^? string ← IDE 自动推导
return { token: '...', username }
})柯里化函数应在文件顶层同步调用。若 handler 文件被 import 但未调用柯里化函数注册 handler,框架会在控制台输出
[AggreKite]前缀的警告信息。
4.2 请求上下文 req
每个 handler 接收一个 Request 对象,包含以下属性/方法:
| 属性 / 方法 | 类型 | 说明 |
|---|---|---|
| req.params | Record<string, string> | 路径参数,如 /users/:id → { id: "1" } |
| req.query | Record<string, string> | 查询参数 ?page=1&size=10 |
| req.body | Record<string, unknown> | 请求体(JSON / 表单 / multipart 自动解析)。初始为 null,首次访问前会自动解析 |
| req.ip | string | 客户端 IP(trustProxy 开启时从 X-Forwarded-For 读取) |
| req.headers | IncomingHttpHeaders | 原始请求头 |
| req.method | string | HTTP 方法(大写) |
| req.path | string | 请求路径 |
| req.traceId | string | 链路追踪 ID(trace: true 时生效) |
| req.req | IncomingMessage | Node.js 原生请求对象 |
| req.res | ServerResponse | Node.js 原生响应对象 |
| req.global.token | string \| undefined | 从 Authorization 头解析的 token |
| req.global.get(key) | unknown | 读取请求级临时数据 |
| req.global.set(key, val) | void | 写入请求级临时数据 |
| req.global.delete(key) | void | 删除请求级临时数据 |
| req.cookies.get(name) | string \| undefined | 读取 Cookie |
| req.cookies.set(name, value, opts?) | void | 设置 Cookie |
| req.cookies.delete(name) | void | 删除 Cookie |
| req.session.get(key) | unknown | 读取 Session |
| req.session.set(key, value) | void | 写入 Session |
| req.session.destroy() | void | 销毁当前 Session |
| req.file(fieldname) | UploadedFile \| undefined | 获取单个上传文件 |
| req.files(fieldname) | UploadedFile[] | 获取多个同名上传文件 |
| req.allFiles() | Record<string, UploadedFile[]> | 获取所有上传文件 |
| req.fileStream() | IncomingMessage | 获取原始请求流 |
| req.onProgress(fn) | void | 上传进度回调 |
| req.setHeader(key, value) | void | 设置响应头 |
| req.status(code) | Request | 设置 HTTP 状态码,返回自身(链式调用) |
| req.parseBody() | Promise<Record<string, unknown>> | 手动解析请求体 |
4.3 响应方式
普通 JSON 响应
直接 return 一个对象,框架自动包裹为 { code, data, message } 格式:
return { data: { id: 1 } }
// → { code: 200, data: { id: 1 }, message: "success" }
return { code: 1001, data: {}, message: "业务错误" }
// → { code: 1001, data: {}, message: "业务错误" }自定义 HTTP 状态码、响应头、Cookie
通过特殊字段 __code__ / __handler__ / __cookie__ 控制:
return {
data: {},
__code__: 201,
__handler__: { 'X-Custom': 'hello', 'Cache-Control': 'no-cache' },
__cookie__: [
{ name: 'token', value: 'abc123', maxAge: 3600, httpOnly: true, secure: true }
],
}文件下载 / 流式响应 / 空响应 / 重定向
import { ResFiles, resRedirectUrl } from 'aggrekite'
// 文件下载
return ResFiles.returnFile('./report.xlsx', '报表.xlsx')
// 流式响应(视频、SSE 等)
const stream = fs.createReadStream('./video.mp4')
return ResFiles.stream(stream, 'video/mp4')
// 空响应 (204)
return ResFiles.empty()
// 重定向
return resRedirectUrl('/new-page', 301)SSR HTML 渲染
import { SSR } from 'aggrekite'
return SSR.html('<h1>Hello World</h1>')
// 配合 SSR 渲染器
return SSR.renderPage(MyComponent, { title: 'Home' })4.4 中间件与钩子
全局中间件
app.wrap() 兼容 Express 中间件签名 (req, res, next):
import cors from 'cors'
app.wrap(cors({ origin: '*' }))
app.wrap((req, res, next) => {
console.log(`${req.method} ${req.url}`)
next()
})Blueprint 级钩子
const users = new Blueprint({ name: 'user' })
// before 钩子:所有路由之前执行,支持 async
users.before(async (req) => {
const token = req.global.token
if (!token) throw new HttpException(401, '未登录')
})
// after 钩子:handler 执行之后触发
users.after((req, result) => {
console.log(`[after] ${req.method} ${req.path}`)
})
// 按 HTTP 状态码捕获错误
users.error(401, (req) => {
return { code: 401, data: {}, message: '请先登录' }
})
users.error(404, (req) => {
return { code: 404, data: {}, message: '资源不存在' }
})4.6 BFF 聚合层
bff() 是框架内置的下游服务聚合函数,能够并发调用多个微服务并将结果合并裁剪。
基本用法
import { bff } from 'aggrekite'
users.get('/:id/profile', bff({
calls: {
user: 'http://user-service/users/${params.id}',
orders: 'http://order-service/orders?userId=${params.id}',
},
map(user, orders) {
return {
code: 0,
data: {
user: (user as any).data,
orderCount: (orders as any).data?.length ?? 0,
},
}
},
}))calls中的所有调用 并发执行。map的参数顺序与calls定义顺序一致。单个调用失败时对应参数为null。- URL 支持以下模板语法(优先级从高到低):
| 语法 | 示例 | 替换来源 |
|---|---|---|
| ${params.x} | /users/${params.id} | req.params.id |
| ${query.x} | /search?q=${query.q} | req.query.q |
| ${body.x} | /api/${body.userId} | req.body.userId |
| :param | /users/:id | req.params.id(Express 风格) |
| {param} | /users/{id} | req.params.id(兜底语法) |
降级 fallback
当所有下游调用全部失败时,返回 fallback 指定的默认值:
bff({
calls: { user: 'http://user-service/users/${params.id}' },
map(user) { return { data: (user as any).data } },
fallback: { code: 0, data: { name: '未知用户' } },
})重试 retry
支持指数退避重试:第 n 次重试等待 delay × 2^(n-1) 毫秒。
bff({
calls: { product: 'http://api.example.com/product/1' },
map(product) { return { data: product } },
retry: { times: 3, delay: 200 },
// 第1次: 立即, 第2次: 200ms后, 第3次: 400ms后, 第4次: 800ms后
})缓存 cache
内存缓存,ttl 为毫秒。缓存键由请求路径 + calls 指纹 + 查询参数(前 10 个)组成。
bff({
calls: { stats: 'http://api.example.com/stats' },
map(stats) { return { data: stats } },
cache: { ttl: 60000 }, // 60 秒
})超时 timeout
单个调用超时时间(毫秒),默认 10000。
bff({
calls: { slow: 'http://slow-service/api' },
map(slow) { return { data: slow } },
timeout: 3000,
})链路追踪 trace
开启后自动向下游注入 x-trace-id 请求头:
bff({
calls: { user: 'http://user-service/users/1' },
map(user) { return { data: user } },
trace: true,
})请求去重 dedup(自动生效)
同一 BFF handler 内部,相同 URL 的并发调用自动合并为单次请求,无需手动配置。
自定义请求头
bff({
calls: { user: 'http://user-service/users/1' },
map(user) { return { data: user } },
headers: (req) => ({
Authorization: req.headers.authorization || '',
'X-Trace-Id': req.traceId,
}),
})4.7 文件上传
框架自动解析 multipart/form-data,提供三层文件访问 API。
upload.post('/', async (req) => {
// 单文件
const file = req.file('avatar')
if (file) {
const url = await file.save() // 保存到存储驱动
return { data: { url, size: file.size } }
}
// 多文件(同一字段名)
const files = req.files('attachments')
// 全部文件
const all = req.allFiles()
// 上传进度
req.onProgress((percent) => {
console.log(`上传进度: ${percent}%`)
})
})UploadedFile 对象:
| 属性 / 方法 | 类型 | 说明 |
|---|---|---|
| fieldname | string | 表单字段名 |
| originalname | string | 原始文件名 |
| encoding | string | 编码 |
| mimetype | string | MIME 类型 |
| size | number | 文件大小(字节) |
| buffer | Buffer | 文件内容 |
| save(name?) | Promise<string> | 保存到默认存储驱动,返回 URL |
| saveWith(driver, name) | Promise<string> | 保存到指定存储驱动 |
4.8 Session & Cookie
Session
配置 session 后自动通过 aggrekite_session Cookie 维护会话。默认使用 MemoryStore 内存存储。
const app = new AggreKite({
session: {
secret: 'my-secret-key',
maxAge: 86400, // 秒,默认 24 小时
},
})// 读取
const userId = req.session.get('userId')
// 写入
req.session.set('userId', 123)
// 销毁
req.session.destroy()Session ID 经过 HMAC-SHA256 签名,防止篡改。Cookie 属性默认 HttpOnly / SameSite=Lax / Path=/,当 trustProxy 为 true 时自动追加 Secure。
自定义 Session 存储(如 Redis)
实现 SessionStore 接口:
import type { SessionStore } from 'aggrekite'
class RedisStore implements SessionStore {
async get(id: string) { /* ... */ }
async set(id: string, data: Record<string, unknown>, maxAge: number) { /* ... */ }
async destroy(id: string) { /* ... */ }
}
const app = new AggreKite({
session: { secret: 'my-key', store: new RedisStore() },
})Cookie
// 读取
const theme = req.cookies.get('theme')
// 设置
req.cookies.set('theme', 'dark', {
maxAge: 3600,
httpOnly: false,
secure: true,
sameSite: 'Lax',
path: '/',
domain: '.example.com',
})
// 删除
req.cookies.delete('theme')4.9 IP 黑名单
全局黑名单配置文件 BU.json(项目根目录):
{
"blacklist": ["192.168.1.100", "10.0.0.5"]
}路由级启用:
// 使用 BU.json 全局黑名单
users.blacklist('/admin')
// 追加额外 IP
users.blacklist('/secret', ['1.2.3.4', '5.6.7.8'])命中返回 403,响应头 X-Blocked: true,日志字段 blocked: true。黑名单文件缓存 30 秒。
4.10 静态资源
配置 static 数组即可自动提供静态文件服务,支持条件请求 304。
const app = new AggreKite({
static: [
{ name: 'public', path: './public' },
{ name: 'assets', path: './dist/assets' },
],
})- 访问
/public/logo.png→ 映射到./public/logo.png - 访问
/assets/style.css→ 映射到./dist/assets/style.css - 自动设置
Content-Type、Content-Length - 内置路径穿越防护(用于目录遍历攻击)
- 请求根目录时自动寻找
index.html
4.11 集群模式(Cluster)
server.cluster 默认 true,框架在 listen() 时自动 fork 子进程以利用多核 CPU。
工作原理:
listen()内部判断当前进程角色:Primary 进程 fork N 个子进程(N = CPU 核心数),Worker 进程直接启动 HTTP 服务- 兼容 PM2:当检测到
NODE_APP_INSTANCE环境变量时自动跳过 fork,由 PM2 管理多进程 cluster: false时禁用集群,仅启动单进程- 重要:这是多进程,不是多线程。 每个 Worker 拥有独立的 V8 实例和内存空间,全局变量和内存 Map 互不相通。Session 默认使用
MemoryStore在单进程内有效,多进程部署必须改用 Redis 等共享存储。性能方面,单进程约 3-5 万 QPS,4 核集群理论可达 12-20 万
// aggrekite.config.json
{ "server": { "cluster": true } }mergeLogs(日志合并):
server.mergeLogs 默认 false。开启后,Worker 通过 process.send() 将日志发送给 Primary,Primary 按时间戳排序后统一输出。
[Worker-12345] GET /api/users 200 3ms
[Worker-12346] POST /api/orders 201 5ms- 排序依据:
res.on('finish')触发时刻的时间戳 cluster: false时mergeLogs自动忽略- PM2 环境下由 PM2 管理日志流,建议关闭
{ "server": { "cluster": true, "mergeLogs": true } }4.12 安全加固
Helmet 安全头
推荐通过 app.wrap() 集成 helmet 中间件,为所有响应自动注入安全 HTTP 头:
npm install helmetimport helmet from 'helmet'
app.wrap(helmet({
contentSecurityPolicy: false, // 按需开启
}))Helmet 会自动添加以下安全头:
Content-Security-Policy(需手动配置)Cross-Origin-Opener-PolicyCross-Origin-Resource-PolicyOrigin-Agent-ClusterReferrer-PolicyStrict-Transport-Security(HTTPS 环境)X-Content-Type-OptionsX-DNS-Prefetch-ControlX-Download-OptionsX-Frame-OptionsX-Permitted-Cross-Domain-PoliciesX-XSS-Protection
框架自身已注入
X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Referrer-Policy: strict-origin-when-cross-origin。Helmet 会补全其余头并覆盖框架默认值。
JWT Secret 管理
JWT Secret 严禁硬编码。推荐从环境变量读取:
const secret = process.env.JWT_SECRET || (() => {
throw new Error('JWT_SECRET 环境变量未设置')
})()使用 .env 文件存储,并确保 .env 已加入 .gitignore。
CORS 白名单
生产环境应限制允许的源,而非使用 *:
import cors from 'cors'
const allowedOrigins = ['https://example.com', 'https://admin.example.com']
app.wrap(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error('CORS not allowed'))
}
},
credentials: true,
}))5. 配置项
AggreKite 支持三层配置,优先级:aggrekite.config.json < new AggreKite({...}) < app.listen({ debug: true })。
AggreKiteConfig 完整表格
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| server.host | string | "0.0.0.0" | 监听地址 |
| server.port | number | 3000 | 监听端口 |
| server.keepAlive | number | 5000 | Keep-Alive 超时(毫秒) |
| server.timeout | number | 30000 | 请求超时(毫秒),超时返回 408 |
| server.trustProxy | boolean | false | 信任反向代理,从 X-Forwarded-For 取真实 IP;同时 session cookie 标记 Secure |
| server.bodyLimit | string \| number | "10mb" | 请求体大小上限,支持 "1kb" / "5mb" / 1048576 等 |
| server.compress | boolean \| { threshold } | { threshold: 4096 } | 响应压缩。false 关闭;true 默认阈值 4096B;自动选择 brotli > gzip > deflate |
| session.secret | string | "change-me" | Session 签名密钥 |
| session.maxAge | number | 86400 | Session 过期时间(秒) |
| session.store | SessionStore | MemoryStore | 自定义 Session 存储(如 Redis) |
| storage | StorageDriver | LocalStorageDriver("./uploads") | 文件存储驱动 |
| static | { name, path }[] | [{ name: "public", path: "./public" }] | 静态资源目录 |
| auth | string \| false | "Bearer" | Token 前缀。false 禁用自动解析 |
| trace | boolean | false | 链路追踪(生成 traceId)。与 app.log() 功能重叠,按需开启 |
| debug | boolean | false | 调试模式(暴露 /__routes、详细错误信息) |
| cors | — | — | CORS 通过 app.wrap(cors({...})) 手动配置 |
| ssr.render | (component, props?) => string | — | SSR 渲染器注入 |
aggrekite.config.json 示例
{
"server": {
"host": "0.0.0.0",
"port": 3000,
"keepAlive": 5000,
"timeout": 30000,
"bodyLimit": "10mb",
"trustProxy": false,
"compress": { "threshold": 4096 },
"cluster": false,
"mergeLogs": false
},
"debug": false,
"auth": "Bearer",
"trace": false,
"session": {
"secret": "change-me",
"maxAge": 86400
},
"static": [
{ "name": "public", "path": "./public" }
]
}6. 注意事项
- 必须 tsx 运行:框架使用 ESM only(
"type": "module"),不支持ts-node。推荐npx tsx app.ts。 - Node.js >= 18:BFF 聚合依赖原生
fetch,低版本无法运行。 - Blueprint name 自动生成前缀:
new Blueprint({ name: 'user' })→ 前缀/users。如需自定义前缀,显式传入prefix。 - filepath handler 必须通过柯里化函数注册:从 Blueprint 方法返回的
RouteRegistrar应在 handler 文件顶层同步调用。若 handler 文件被 import 但未调用柯里化函数,框架会输出[AggreKite]警告。 - handler 文件路径相对于 Blueprint 文件所在目录:框架通过
getCallerUrl()自动解析调用方文件路径,$()中的相对路径相对于蓝图文件所在目录进行解析,无需手动计算。 - Session 生产环境建议 Redis 存储:默认
MemoryStore仅在单进程有效,多进程部署需替换为共享存储。 - trustProxy 在反向代理后务必开启:否则
req.ip拿到的是代理 IP 而非真实客户端 IP。 - 静态文件路径防护自带,但不能替代 WAF:框架已内置
../路径穿越校验,但复杂攻击仍需网关层防护。 - BFF fallback 仅全部失败时触发:部分调用成功时不会降级,失败的那路在
map中为null。 - debug 模式仅供开发使用:生产环境必须关闭,否则
/__routes会暴露路由结构。 - 安全响应头自动注入:所有响应自动添加
X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Referrer-Policy: strict-origin-when-cross-origin。 - 响应压缩自动生效:Body >= 4096 字节时自动选择 brotli / gzip / deflate,可通过
compress配置关闭或调整阈值。
7. 完整项目示例
app.ts
import { AggreKite } from 'aggrekite'
import cors from 'cors'
import { users } from './router/users'
import { bffBP } from './router/bff'
const app = new AggreKite({
server: { trustProxy: true, bodyLimit: '50mb' },
session: { secret: 'prod-secret', maxAge: 86400 },
static: [{ name: 'public', path: './public' }],
})
app.wrap(cors({ origin: '*' }))
app.mount(users)
app.mount(bffBP)
app.listen(3000)router/users.ts — Blueprint + CRUD + 校验 + 中间件
import { Blueprint, HttpException } from 'aggrekite'
export const users = new Blueprint({ name: 'user' })
// 全局鉴权
users.before((req) => {
if (!req.global.token) throw new HttpException(401, '未登录')
})
// 错误处理
users.error(401, () => ({ code: 401, data: {}, message: '未登录' }))
users.error(404, () => ({ code: 404, data: {}, message: '用户不存在' }))
// GET /users?page=1&size=10
users.get('/', (req) => {
const { page = '1', size = '10' } = req.query
return { data: { list: [], total: 0, page: +page, size: +size } }
})
// GET /users/:id
users.get('/:id', (req) => {
return { data: { id: req.params.id, name: '张三', email: '[email protected]' } }
})
// POST /users
users.post('/', (req) => {
return { __code__: 201, data: { id: 1, ...req.body } }
})
// PUT /users/:id
users.put('/:id', (req) => {
return { data: { id: req.params.id, ...req.body } }
})
// DELETE /users/:id
users.delete('/:id', (req) => {
return { data: { deleted: req.params.id } }
})
// IP 黑名单
users.blacklist('/admin')router/bff.ts — BFF 聚合
import { Blueprint, bff } from 'aggrekite'
export const bffBP = new Blueprint({ name: 'bff', prefix: '/bff' })
// GET /bff/user-profile — 并发获取用户 + 订单
bffBP.get('/user-profile', bff({
calls: {
user: 'http://localhost:3000/users/1',
orders: 'http://localhost:3000/orders',
},
map(user, orders) {
return {
code: 0,
data: {
user: (user as any).data,
orders: (orders as any).data,
},
}
},
fallback: { code: 0, data: { user: null, orders: [] } },
timeout: 5000,
cache: { ttl: 30000 },
}))
// GET /bff/product-detail/:id — 带动态参数聚合
bffBP.get('/product-detail/:id', bff({
calls: {
product: 'http://localhost:3000/products/{id}',
reviews: 'http://localhost:3000/products/{id}/reviews',
},
map(product, reviews) {
return {
code: 0,
data: {
product: (product as any).data,
reviews: (reviews as any).data,
},
}
},
fallback: { code: 0, data: { product: null, reviews: [] } },
retry: { times: 2, delay: 500 },
timeout: 5000,
trace: true,
}))router/upload.ts — 文件上传
import { Blueprint } from 'aggrekite'
export const upload = new Blueprint({ name: 'upload', prefix: '/upload' })
upload.post('/', async (req) => {
const file = req.file('avatar')
if (!file) return { code: 400, message: 'No file uploaded' }
const url = await file.save()
return {
data: {
url,
fieldname: file.fieldname,
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
},
message: 'File uploaded',
}
})
upload.post('/multi', (req) => {
const files = req.files('attachments')
return {
data: files.map((f) => ({
originalname: f.originalname,
size: f.size,
})),
message: `${files.length} files uploaded`,
}
})