@weifuwujs/weifuwu
v2.7.0
Published
A minimal, type-safe fetch full-stack kernel for Node.js: routing, middleware, WebSocket, resources, React Router SSR and CLI.
Maintainers
Readme
@weifuwujs/weifuwu
一个基于 Node.js 与 Fetch API 的极简、类型安全 Web 框架。
请求处理函数就是 (req: Request, ctx) => Response | Promise<Response>,
没有自定义的请求/响应对象,也没有隐式魔法。
import { createApp, json } from '@weifuwujs/weifuwu'
const app = createApp()
app.get('/', () => new Response('Hello weifuwu!'))
app.get('/users/:id', (req, ctx) => json({ id: ctx.params.id }))
app.listen(3000, () => console.log('http://localhost:3000'))定位
Node 上最干净的 fetch 全栈内核。
内核只回答「请求如何流过类型安全的函数管线」,不回答「业务上你该有什么」:
- 管线:handler / 洋葱中间件 / 类型安全路由 /
app.fetch纯函数入口; - 实时:WebSocket 与 HTTP 共用路由、参数与中间件;
- 数据:
resource内核 + 懒加载ctx.sql/ctx.redis; - 全栈:React Router 单端口 SSR + CLI(init / dev / build / start / typegen / routes / doctor);
- 测试:
app.request()不需要监听端口。
业务能力(认证 / 数据 / 队列…)由使用者自选:npm 上的 @weifuwujs/* 1.x 包仍可用,但源码不再随本仓库维护——
本仓库自 2.0 起只维护内核。升级与替代见 docs/migrating.md F 章。
特性
- Fetch 原生:
Request/Response/Headers就是标准 Web API,app.fetch(request)是纯函数入口,方便测试与跨运行时复用。 - 类型安全的路由参数:
/users/:id会自动推导ctx.params.id: string。 - 洋葱模型中间件:
(req, ctx, next) => ...,可短路、可替换上下文。 - 上下文类型累积:
app.use(mw)注入的字段会一路传递到后面的 handler。 - 内核极简:路由 / 中间件 / 上下文只用 Node 内置模块;WebSocket 基于
ws,首次升级时懒加载;业务能力由使用者自选实现(本仓库只维护内核,不附带业务包)。 - WebSocket:基于
ws的服务端实现(握手 / 分片 / 关闭 / ping-pong 由成熟内核承担),与路由、参数、中间件风格一致。 - 数据资源:
resource内核 +./postgres、./ioredis子路径(懒加载ctx.sql/ctx.redis、优雅关闭),见 docs/data.md;脚手架默认带 Docker Compose(pg18+redis7)与 Drizzle(迁移脚本)+ BullMQ(任务队列),见 docs/jobs.md。 - 身份会话开箱可用:脚手架默认带
users/sessions表与注册/登录/登出/受保护页面(Drizzle + scrypt,零新增依赖);loader 里requireUser(db, request)一行守卫,见 docs/auth.md。 - AI 集成:用
resource注入模型注册表(ctx.ai),streamText流式 Response 直接返回;含useChat、结构化输出、错误映射与 embedding 缓存配方,见 docs/ai.md。脚手架(init)另带开箱可用的 AI 面:/ai对话页 +POST /ai/chat流式端点(默认模型deepseek-flash,凭据缺失给可读 503,离线假模型可测),见同文档「脚手架内置的 AI 面」。 - 内置中间件:serveStatic(生产静态资源,路径穿越防护 + SPA fallback)。
- 默认样式管线:脚手架(
init)自带 Tailwind CSS v4(@tailwindcss/vite)与已初始化的 shadcn/ui(主题变量 + 一个 Button 示例,可直接npx shadcn@latest add),dev/prod 一致;见 docs/styling.md。 - 原生 TypeScript:Node 24+ 可直接运行
.ts源码;发布产物为 ESM +.d.ts。 - 生产基线开箱可用:脚手架默认带 env fail-fast、
/healthz+/readyz、结构化日志 +x-request-id、 多阶段非 rootDockerfile+ 容器内迁移、doctor读.env;上线清单与故障矩阵见 docs/production.md。
2.0 变化(当前版本)
- 仓库收缩为仅内核:
packages/下 8 个生态包源码移出仓库,本仓库只维护@weifuwujs/weifuwu。 - 删除非核心中间件:
logger/requestId/cors/securityHeaders/bodyLimit/rateLimit/health/ready已移除(保留serveStatic)——逐条替代与最小示例见 docs/migrating.md F 章。 - CLI 输入面:
./cli子路径提供parseCliArgs()/runCli()/CliExit;weifuwu <pkg> …可转发本项目安装的任意生态包 CLI。 weifuwu doctor:只读前置体检(Node 版本 / 端口 / 数据库 / Redis / ESM / 历史子路径),stdout 纯 JSON, 退出码 0 全绿 / 1 有红 / 2 USAGE;npm run from-zero用它做「装完就能跑」的验收终判。
完整变更见 CHANGELOG.md [2.0.0]。
要求
- Node.js >= 24(用于原生运行 TypeScript,以及稳定的 Fetch API)。
React Router 全栈
@weifuwujs/weifuwu 内置 React Router v8(framework mode)集成:
单进程、单端口同时提供 RR SSR 页面、weifuwu API 与 WebSocket,
dev 与 prod 行为一致,路由类型由 RR typegen 生成。
# 1. 生成项目(内置模板:React Router + Tailwind + shadcn/ui + Drizzle/BullMQ,无需手工拷贝示例)
npx @weifuwujs/weifuwu init my-app
cd my-app
npm install
# 2. 本地依赖(模板自带 compose:Postgres 18 + Redis 7)与本地环境变量
cp .env.example .env # 本地默认值;生产必须显式提供(缺则启动失败)
docker compose up -d
# 3. 数据:应用模板自带的初始迁移(users/sessions——注册/登录开箱可用)
npm run db:migrate
# 4. 开发:Vite HMR 与 weifuwu API 共用一个端口
npm run dev # http://localhost:3000
# 5. 生产:构建 + 启动(同样一个端口)
npm run build
npm run start
# 6. 数据:改 app/db/schema.ts 后生成/应用新迁移
npm run db:generate && npm run db:migrate
# 7. 类型:生成 RR 路由类型并检查
npm run typecheck
# 8. 测试:Node 内置 runner,示例覆盖纯逻辑与就绪聚合(秒级、不需要 docker/DB)
npm run test接入本身只有一行——把 RR 作为 weifuwu 的 notFound 处理器:
import { createApp, json } from '@weifuwujs/weifuwu'
import { reactRouter } from '@weifuwujs/weifuwu/react-router'
export default createApp()
.get('/api/health', () => json({ ok: true }))
.notFound(reactRouter()) // 未命中 API 的请求交给 React Router- 详细设计:docs/react-router.md(单端口 HMR、 context bridge、typegen、保留命名空间、dev 降级、env/trustProxy/缓存、部署)
- 迁移指南:docs/migrating.md(官方 custom-server / 纯 weifuwu 两条路径)
| 命令 | 说明 |
| --- | --- |
| init | 生成新项目(React Router + Tailwind + shadcn/ui + Drizzle/BullMQ + 身份会话 + weifuwu) |
| dev | 启动开发服务器(Vite + React Router SSR) |
| build | 构建生产产物(react-router build) |
| start | 启动生产服务器 |
| typegen | 生成 React Router 类型 |
| routes | 打印路由表(react-router routes) |
| doctor | 体检:Node 版本 / 端口 / Postgres / Redis / ESM / 旧子路径(只读,stdout 纯 JSON) |
| 选项 | 说明 |
| --- | --- |
| --port <port> | 监听端口,默认 3000 |
| --host <host> | 监听地址 |
| --root <dir> | 项目根目录,默认当前目录 |
| --trust-proxy | 信任 X-Forwarded-Proto / X-Forwarded-Host(反向代理场景) |
子命令路由(weifuwu <pkg> …)
任何安装在本项目里的生态包 CLI 都可以从内核入口调用:参数与退出码原样转发,
weifuwu db migrate --dry-run 等价于 weifuwu-db migrate --dry-run。(db / tasks 仅作机制示例;本仓库自 2.0 起不再维护具体生态包。)
weifuwu --help # 「生态包子命令」一段列出**本项目已安装**的包子命令
weifuwu db --help # 走 weifuwu-db 自己的 help
weifuwu tasks stats # 等价于 weifuwu-tasks stats约定与边界(判负条件也在此写明):
- 子命令集合 ≠ 包集合:只列有
bin的包——例如@weifuwujs/api只提供库 API,weifuwu api会提示「没有可执行的 bin」并以 exit 2 结束。 - 版本语义 = 项目本地:路由解析的是项目目录下安装的那份(不是内核自带的那份),所以升级某个包不会牵连其它包。
- 未安装:
weifuwu <pkg> …⇒USAGE: @weifuwujs/<pkg> 未安装(npm i -D @weifuwujs/<pkg>)+ exit 2。 - 零字面量:内核不维护包名表;任何形如
^[a-z][a-z0-9-]*$且不是内核命令的词都按上面的规则解析——因此拼错命令得到的是「未安装」提示,而不是「未知命令」。 - 选项归属:内核选项(
--port/--root/--host/--trust-proxy)只作用于内核命令;weifuwu <pkg> …之后的参数全部交给子 CLI(子 CLI 自己解析,包括它自己的--root)。
自定义 CLI(生态包作者)
生态包 CLI 与内核共用同一份输入面实现(同一子路径也用于内核自身):parseCliArgs() 负责已知/未知选项、
缺值与「选项样值」校验、-- 终止符与透传;各包只声明选项集合、处理命令与输出。
import { CliUsageError, parseCliArgs } from '@weifuwujs/weifuwu/cli'
const { values, positionals } = parseCliArgs(process.argv.slice(2), {
options: {
config: { type: 'string' },
'dry-run': { type: 'boolean' },
help: { type: 'boolean', short: 'h' },
},
})
const [command, ...rest] = positionals
if (values.help === true) {
console.log('用法:<command> [选项]')
process.exit(0)
}
if (command !== 'migrate') throw new CliUsageError(`未知子命令 ${command ?? '(缺少)'}`)
// …执行命令;未知选项/缺值已由 CliUsageError 报出更省事的写法是 runCli():它把「解析 → help → 执行 → 输出 → 退出码」整条链收进一份实现,
包侧只写选项声明与命令处理;返回值非 undefined 会写 JSON.stringify(...) + '\n',返回 undefined 表示包自己写输出(表格/文本)。
import { CliExit, CliUsageError, runCli } from '@weifuwujs/weifuwu/cli'
await runCli({
usage: '用法:weifuwu-<pkg> <command> [选项]\n',
options: { 'dry-run': { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
run: ({ positionals, values }) => {
if (positionals[0] === 'apply' && values['dry-run'] === true) {
console.log('DRY RUN:零执行') // 包自己写输出
throw new CliExit(2) // 按指定退出码收尾(不写额外输出、不经过 onError)
}
return { ok: true } // 交给骨架写 JSON
},
})约定(由跨 CLI 守卫逐条检查):未知选项 ⇒ exit 2 + stderr 含 USAGE 且 stdout 为空;位置参数合法;
-h/--help 自己实现;成功路径才写 stdout。
稳定性
公共 API(exports 子路径与其具名导出)遵循 STABILITY.md 的 semver 与弃用承诺:
新增为 minor、破坏性变更为 major、弃用至少保留一个完整 minor;Node 支持、peer 范围与安全联系同样在该文件。
贡献流程与门禁命令见 CONTRIBUTING.md。
当前版本 2.0.0:仓库收缩的破坏性变更(中间件导出移除、生态包源码移出)已在守卫中显式登记, 迁移见 docs/migrating.md F 章。
安装
npm install @weifuwujs/weifuwu核心类型(节选)
export interface Context {
params: Record<string, string>
query: Record<string, string>
[key: string]: unknown // allow arbitrary middleware-injected data
}
export type Handler<T extends object = Context> = (
req: Request,
ctx: T,
) => Response | Promise<Response>
export type Middleware<In extends Context = Context, Out extends In = In> = {
(req: Request, ctx: In, next: Handler<Out>): Response | Promise<Response>
}路由
const app = createApp()
app.get('/users/:id', (req, ctx) => json({ id: ctx.params.id }))
app.post('/users', async (req) => json(await req.json(), { status: 201 }))
app.put('/users/:id', handler)
app.patch('/users/:id', handler)
app.delete('/users/:id', handler)
app.head('/users/:id', handler)
app.options('/users/:id', handler)
// 任意方法 / 所有标准方法
app.on('PURGE', '/cache/:key', handler)
app.all('/webhook', handler)路径语法:
| 写法 | 含义 | 示例 |
| --- | --- | --- |
| /users | 静态路径 | /users |
| /users/:id | 命名参数,写入 ctx.params.id | /users/42 |
| /files/* | 通配剩余路径,写入 ctx.params['*'] | /files/a/b.txt |
| /files/*rest | 带名字的通配 | /files/a/b.txt → params.rest |
匹配优先级为:静态段 > :param > *。尾斜杠会被归一化(/a/ 等价于 /a)。
自动行为:
HEAD请求在缺少HEAD路由时回退到GET,且不发送响应体。- 路径存在但方法不匹配时返回
405,并带上Allow头。 - 未显式注册
OPTIONS时自动返回204+Allow。
路由分组
app.group('/api', (api) => {
api.get('/users/:id', (req, ctx) => json(ctx.params.id)) // GET /api/users/:id
api.group('/v2', (v2) => {
v2.get('/ping', () => new Response('pong')) // GET /api/v2/ping
})
})分组前缀中的参数也会进入类型:
app.group('/api/:version', (api) => {
api.get('/users/:id', (req, ctx) => {
ctx.params.version // string
ctx.params.id // string
return json(ctx.params)
})
})中间件
中间件按注册顺序执行,形成洋葱模型:
const app = createApp()
.use(async (req, ctx, next) => {
const start = Date.now()
const res = await next(req, ctx) // 交给下一层
res.headers.set('x-duration', `${Date.now() - start}ms`)
return res
})
.get('/', () => new Response('ok'))- 不调用
next即可短路(例如鉴权失败直接返回 401)。 next(req, ctx)可以传入新的req/ctx,实现函数式的上下文替换;不传则沿用当前值。- 同一个中间件重复调用
next会返回 500。
注入上下文并获得类型
推荐把中间件声明为带 In / Out 泛型的常量,Out 中的字段会累积到后续 handler:
import type { Context, Middleware } from '@weifuwujs/weifuwu'
const withUser: Middleware<Context, Context & { user: User }> = async (
req,
ctx,
next,
) => {
const user = await verify(req)
return next(req, { ...ctx, user })
}
const app = createApp().use(withUser)
app.get('/me', (req, ctx) => json(ctx.user)) // ctx.user: User注意:TypeScript 无法从内联中间件内部对
next()的调用来推断Out。 需要先把中间件赋值给带类型的变量/常量(如上),再传给use/ 路由。
路由级中间件同样支持类型累积(最多显式推导两个中间件,更多则回退为不累积类型):
app.get('/admin/:id', withUser, requireAdmin, (req, ctx) => {
ctx.params.id // string
ctx.user // User
return json(ctx.user)
})路径中间件
app.use('/api', authMiddleware)路径中间件在进入时会剥离前缀(Express 风格),调用 next() 时还原;
因此可以这样挂载静态资源:
app.use('/assets', serveStatic('./public')) // /assets/a.png -> ./public/a.png上下文
app.get('/search', (req, ctx) => {
ctx.params // 路由参数
ctx.query // { q: 'hello' },重复的 key 取第一个值
return json({ q: ctx.query.q })
})由于 Context 带有索引签名,中间件可以注入任意字段而不需要改动框架类型。
也可以使用声明合并让字段全局可见:
declare module '@weifuwujs/weifuwu' {
interface Context {
user: User
}
}错误处理
HttpError 会被自动转换为对应状态的响应,响应体是结构化 JSON(自 1.4 起):
{
"error": "not_found", // 机器码(蛇形小写):客户端按它分支、运维按它聚合
"message": "Not Found", // 人类文案(5xx 不暴露内部信息)
"retryAfterSeconds": 3 // 可选:细节平铺在顶层
}import { HttpError } from '@weifuwujs/weifuwu'
app.get('/users/:id', async (req, ctx) => {
const user = await db.find(ctx.params.id)
if (!user) throw new HttpError(404, '用户不存在') // error → not_found
if (!ctx.user) throw new HttpError(401, '请先登录') // error → unauthorized
})
// 自定义机器码与结构化细节(客户端/前端按 code 处理,而不是匹配文案)
app.post('/api/export', async (req, ctx) => {
if (await overQuota(ctx)) {
throw new HttpError(429, '今日额度已用完', {
code: 'quota_exceeded',
details: { limit: 20, retryAfterSeconds: 120 },
headers: { 'retry-after': '120' },
})
}
})约定与不变量:
error是机器码(/^[a-z][a-z0-9_]*$/);人类文案一律放message——这是客户端可编程处理的前提。code不传时由状态码推导(413 → payload_too_large、未知状态 →http_<status>);非法形态会在构造时立刻抛错。expose语义不变:4xx 暴露message,5xx 退化为状态文案(不泄露内部细节)。details平铺进响应体顶层,但不能覆盖error/message。- 内核自己生成的错误响应(404 / 405 / 500 / 静态资源拒绝)同样遵守该契约(含
allow、retry-after等头)。
自定义错误响应与 404:
app.onError((error, req, ctx) => {
console.error(error)
return json({ error: 'upstream_unavailable', message: '依赖暂时不可用' }, { status: 503 })
})
app.notFound((req) => json({ error: 'not_found', message: '接口不存在' }, { status: 404 }))内置中间件
从子路径导入,避免核心包引入 node:fs:
import { serveStatic } from '@weifuwujs/weifuwu/middleware'serveStatic(root, options?)
| 选项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| index | string \| false | 'index.html' | 目录默认文件 |
| fallback | string | — | 文件不存在时回退(SPA) |
| maxAge | number | — | Cache-Control: max-age 秒数 |
| immutable | boolean | false | 追加 immutable |
| dotfiles | 'allow' \| 'ignore' \| 'deny' | 'ignore' | 点文件处理策略 |
| headers | Record<string, string> | — | 附加响应头 |
| etag | boolean | true | 生成并为匹配请求返回 304 |
未命中文件时调用 next(),因此可以与 API 路由共存;同时内置路径穿越防护。
WebSocket
基于 ws 的服务端实现,路由与 HTTP 共用一套路径语法(/chat/:room、* 通配、分组前缀),
握手/分片/关闭/ping-pong 由成熟内核承担。
app.ws(path, ..., factory) 的终端是一个工厂:每连接调用一次,可以异步,返回生命周期回调。
工厂体就是 open 钩子,闭包就是连接级状态:
const app = createApp()
app.ws('/chat/:room', async (socket, ctx) => {
const room = ctx.params.room
await join(room, socket) // 异步初始化安全:期间消息会被缓冲
const timer = setInterval(() => socket.ping(), 30_000)
return {
onMessage: (socket, message) => broadcast(room, message.data), // 串行 await,自带背压
onClose: () => { clearInterval(timer); leave(room, socket) }, // 恰好一次
onError: (socket, error) => log.warn(error),
}
})
app.listen(3000)框架保证:
- 工厂结算前消息不丢(缓冲后按序投递),因此
await之后再处理消息是安全的; onMessage按到达顺序串行 await;抛错走onError→app.onError,默认以 1011 关闭;onClose恰好一次(正常关闭 / 错误 / terminate),且排在已入队的onMessage之后;- 工厂返回
void表示纯流式模式(自行socket.on/for await);dev 下若既没返回生命周期 也没有消息消费者,会给出警告,避免消息被静默丢弃。
// 纯流式:自行消费(for await 放在分离任务里,避免阻塞缓冲投递)
app.ws('/echo', (socket) => {
void (async () => {
for await (const message of socket) socket.send(message.data)
})()
})连接 API
回调之外,socket 仍暴露完整的事件与异步迭代能力(底层逃生口):
| 成员 | 说明 |
| --- | --- |
| socket.send(data) | 发送 string(文本)或 Uint8Array/ArrayBuffer(二进制);返回 false 表示缓冲区已满 |
| socket.ping(data?) / socket.pong(data?) | 控制帧;收到 ping 默认自动回 pong |
| socket.close(code?, reason?) | 发起关闭握手,超时(默认 5s)后强制断开 |
| socket.terminate() | 立即断开 |
| socket.readyState | 'open' / 'closing' / 'closed' |
| socket.protocol | 协商出的子协议 |
| socket.remoteAddress / socket.bufferedAmount | 对端地址 / 待发送字节数 |
| 事件 | message / close / error / ping / pong(支持 on / once / off)|
| for await (const { data, binary } of socket) | 异步迭代消息 |
中间件与拒绝升级
HTTP 与 WebSocket 共用 Middleware 类型;同一个中间件实例可以两处复用
(结果类型写 Response | void),返回 Response 即拒绝升级(写回 HTTP 响应):
import type { Context, Middleware } from '@weifuwujs/weifuwu'
const withToken: Middleware<Context, Context & { token: string }, Response | void> =
async (req, ctx, next) => {
const token = req.headers.get('authorization')
if (!token) return new Response('Unauthorized', { status: 401 })
return next(req, { ...ctx, token })
}
app.get('/me', withToken, (req, ctx) => json(ctx.token)) // HTTP:401 或继续
app.ws('/secure/:id', withToken, (socket, ctx) => { // WS:401 拒绝升级或继续
ctx.token // string
ctx.params.id // string
socket.send(ctx.token)
return {}
})
// 全局 WS 中间件(只作用于 upgrade,HTTP 中间件不会自动作用)
app.wsUse(async (req, ctx, next) => next(req, ctx))HTTP 专用中间件保持默认(R = Response),因此可以直接读取下游响应:
const withHeader: Middleware<Context, Context> = async (req, ctx, next) => {
const response = await next(req, ctx)
response.headers.set('x-trace', '1')
return response
}路由选项
app.ws(
'/room/:id',
{
protocols: ['chat.v2'], // 子协议,按客户端优先级挑选
maxMessageSize: 1 << 20, // 单条消息上限,默认 4 MiB,超出关闭码 1009
autoPong: true, // 自动回 pong,默认 true
closeTimeout: 5000, // 关闭握手超时
pingInterval: 30_000, // 服务端心跳 ping
idleTimeout: 120_000, // 空闲超时
headers: { 'x-custom': '1' }, // 追加到 101 响应的头
},
(socket, ctx) => {
socket.send(ctx.params.id)
return {}
},
)接入方式
app.listen() 与 serve() 会自动挂载 request + upgrade;手动创建 server 时用 app.attach(server):
import { createServer } from 'node:http'
const server = createServer()
app.attach(server) // HTTP + WebSocket
server.listen(3000)也可以直接调用 app.upgrade(request, socket, head) 自行接入任意 HTTP 服务器。
部署到 Node
import { createApp } from '@weifuwujs/weifuwu'
const app = createApp().get('/', () => new Response('ok'))
// 方式一:直接监听
app.listen(3000, () => console.log('ready'))
// 方式二:手动创建 server
import { createServer } from 'node:http'
createServer(app.handler).listen(3000)
// 方式三:serve 辅助函数(支持反向代理)
import { serve } from '@weifuwujs/weifuwu/node'
serve(app, { port: 3000, trustProxy: true })app.fetch(request):纯 Fetch 入口,可用于测试或部署到任意 Fetch 运行时。app.handler:Node 的(req, res)处理函数。serve/app.listen:创建并启动http.Server。trustProxy: true时会读取X-Forwarded-Proto/X-Forwarded-Host。serve(app, { maxBodyBytes })是请求体量的硬上限(不设 = 不限制):chunked请求没有content-length,且Request的 body 是只读流、应用层换不掉它,只有适配层能拦住流式请求。超限 ⇒ 请求体被中断、handler 收到HttpError(413)(不是 500)、 该连接不再复用(响应带Connection: close),且不把残留负载读完(实测 4MB 上传只读取一小部分就拒绝—— 否则等于替攻击者把负载下载一遍);客户端会在新连接上发后续请求。
请求生命周期与关闭
request.signal反映「客户端断开」:客户端在响应写完前离开(包括流式响应中途断开)时, 内核会 abort 该请求的AbortSignal。可以直接把它传给在途工作:app.get('/stream', (req) => { streamText({ abortSignal: req.signal }) // 客户端断开即中止上游 // … })语义边界:只反映断开,不做超时(超时属应用/编排策略);正常完成不得误 abort (keep-alive 下
res的close也会触发,内核用writableFinished区分)。 WebSocket 升级请求的signal在 socket 关闭时 abort。app.close()返回关闭报告:按 LIFO 运行onClose钩子(含resource()资源), 单个钩子失败只记日志、不阻断其它;返回值可编程观测失败:app.onClose(() => cache.dispose(), 'cache') // 可选名字,用于失败归因 const report = await app.close() if (report.failed.length > 0) console.warn('关闭不完整', report.failed)await app.close()的既有用法不变(运行期向后兼容)。CLI 给app.close()的预算是 1 秒: 超时会在 stderr 打一行「超过 1000ms 预算」并继续清理(退出码语义不变,仍为 0); 有钩子失败时另打一行汇总。慢关机等于没关——所以超时要看得见。
环境变量(CLI)
weifuwu dev 与 weifuwu start 都会加载 .env 系列到 process.env,
真实环境变量优先(文件只补缺)。优先级从低到高:
.env < .env.local < .env.${mode} < .env.${mode}.localmode 由 NODE_ENV 决定(dev 默认 development,start 默认 production)。
降级模式(Vite 初始化失败)同样适用,与正常启动保持一致。
dev 模式另有一个可选逃生口:
WEIFUWU_FS_ALLOW:逗号分隔的额外文件系统路径,追加进 Vite dev 白名单。 仅在依赖被提升到项目根之外(monorepo、file:链接)仍报 403 时使用;正常项目无需设置。
先体检再排查
装完跑不起来时,先跑 weifuwu doctor:只读体检(读文件 + TCP 探测 + 临时 bind,不写文件、不起服务器、不连库改数据),
stdout 纯 JSON,退出码 0 全绿 / 1 有红 / 2 用法错误(可直接进 CI 前置步骤)。
weifuwu doctor # 体检当前目录(PORT 默认 3000)
weifuwu doctor --port 3001 # 换端口探测七类已知卡点(ok / fail / skip 三态,skip 也带原因):
| 检查项 id | 判什么 | 红了怎么办(hint 摘要) |
| --- | --- | --- |
| node-version | package.json engines.node(只支持 >=X.Y.Z)vs 当前 Node;不可静态判定 → skip | 升 Node 或改 engines |
| port | PORT/--port(默认 3000)能不能绑定;非整数直接红 | 换端口或结束占用进程(lsof -i :3000) |
| database | DATABASE_URL 的 host:port TCP 可达性(不 import pg、不验库权限) | 起 Postgres(docker compose up -d)或改 URL |
| redis | REDIS_URL 同上 | 起 Redis 或改 URL |
| esm | package.json type 是否 module(内核只支持 ESM) | 加 "type": "module" |
| legacy-subpaths | 源码里是否还有 0.8 内置子路径(@weifuwujs/weifuwu/{auth,tasks,messages,api},0.9 硬切断) | 按 docs/migrating.md 换独立包 |
| optional-deps | 8 个可选 peer(vite / @react-router/dev / ws / react / react-dom / react-router / ioredis / postgres)装没装;装 = ok + 版本,缺 = skip(不算红,API-only 项目是合法形态) | 用到时安装:hint 直接给出 npm i -D vite / npm i ws 等命令 |
想把「装完到跑起来」复跑一遍(产物优先:装本地 npm pack 产物、由 Node 直接跑 .ts 零构建、真 HTTP 200、安装后的 weifuwu doctor 退出 0),
npm run from-zero 会打印一份纯 JSON 漏斗报告;FROM_ZERO_RR=1 追加官方脚手架路径(init → npm i → build → start → / 200,需要网络,离线时显式 skip)。
npm run scaffold:e2e 用本地 npm pack 产物(依赖改 file:<tgz>)复跑脚手架全链路:
init → npm i → dev 200 → build → start 200——不依赖 registry 上是否已发布该版本;需要网络,离线时显式 skip(退出码 2)。
诚实边界:doctor 只做前置体检——进程能起来但运行期才炸的情况(中间件抛错、SQL 写错、权限被拒)它不负责;
database/redis 只验「连得上」,不验「账号有权限 / 库存在 / 表已迁移」。
测试
单层 test/core(内核行为、边界、文档与公共面守卫):164 条、秒级、无网络 / 浏览器 / 构建。
npm test # build + test:core
npm run test:core # node --test,只跑内核契约
npm run test:all # build + typecheck + test:core(发布门禁同款)
npm run check:dist # src ↔ dist 产物新鲜度(逐文件比对,排除 *.map)React Router dev / build / start / typegen 链路依赖上游实现,不在本仓库重复测试 (原
test/fullstack层随仓库收缩移除)。
app.request() 直接走内部 fetch,不需要启动端口:
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createApp, json } from '@weifuwujs/weifuwu'
test('GET /users/:id', async () => {
const app = createApp().get('/users/:id', (req, ctx) => json({ id: ctx.params.id }))
const res = await app.request('/users/42')
assert.deepEqual(await res.json(), { id: '42' })
})项目结构
src/
index.ts 公共入口(createApp / resource / HttpError / 响应助手 / Node 与 WS 适配 re-export)
types.ts Handler / Middleware / Context / 类型工具
app.ts App:路由注册、中间件、mount、fetch / upgrade / listen
group.ts 路由分组
router.ts 路径树路由器
compose.ts 洋葱模型组合(HTTP / WebSocket)
path.ts 路径工具
helpers.ts json / text / html / redirect / HttpError
resources.ts 资源中间件内核(懒初始化 / 单例 / 优雅关闭)
postgres.ts ./postgres:ctx.sql(postgres.js,懒加载)
ioredis.ts ./ioredis:ctx.redis(ioredis,懒加载)
env.ts .env 加载(dev / start 使用)
node.ts Node HTTP 适配(Request/Response <-> IncomingMessage,upgrade)
cli.ts weifuwu CLI:init / dev / build / start / typegen / routes / doctor
cli-args.ts ./cli:parseCliArgs / CliUsageError / runCli / CliExit(共享输入面)
doctor.ts weifuwu doctor 体检内核(只读:七类卡点;可注入探针)
react-router.ts React Router SSR 中间件(虚拟模块加载 build)
vite.ts defineConfig:注册 RR 插件与 SSR 入口
middleware/ serveStatic(生产静态资源:路径穿越防护 / SPA fallback / ETag)
websocket/ 基于 ws:握手校验、连接适配、关闭码
test/ node:test 契约(仅 test/core:内核行为 / 边界 / 文档与公共面守卫)
templates/ weifuwu init 的工程模板(React Router + Tailwind + shadcn/ui + Drizzle/BullMQ)
docs/ 使用文档:production / data / jobs / ai / react-router / styling / migrating
scripts/ 发布与归档工具:check-dist / coverage-baseline / verify-release / from-zero / bench / cold-start / surface-table
plan/ 计划与审计归档(plan.md 为写作规范)文档守卫(贡献者须知)
本文件与文档被 test/core 的守卫盯着,改动时注意:
docs-coverage.test.ts:每个exports子路径都必须在 README 或docs/*.md里有精确 specifier 的 import 示例;cli-surface.test.ts:<!-- cli -->块里的命令 / 选项表与weifuwu --help逐项对账(改 CLI 必须同步改这里);promise-docs.test.ts:STABILITY / CONTRIBUTING 的必需章节、引用脚本与路径必须真实存在;kernel-boundary.test.ts:单包内核形态(无workspaces/packages/,模板仅react-router)。
代码块标签(bash quickstart、ts fragment 等)保留为阅读约定:片段若非自足可运行,必须写明依赖前后文。
开发
npm test # build + node --test(test/core,秒级)
npm run test:all # build + typecheck + test:core(发布门禁同款)
npm run typecheck # 类型检查(含类型级测试)
npm run check:dist # src ↔ dist 产物新鲜度
npm run coverage:baseline # 覆盖率实测(--check 与 plan/coverage-baseline.md 对账)
npm run bench # 微基准(路由 / 中间件链 / app.fetch / WS echo),数字归档见 plan/perf-baseline.md
npm run bench:check # 与归档中位对账(任一指标 < 50% 即失败;强制 full,quick 只冒烟)
npm run cold-start # 冷启动三条(内核 import / ./node import / cli --help),只归档不设阈值
npm run surface:table # 重新生成公共面决策表(plan/public-surface.md:88 项证据 + 决策)
npm run from-zero # 装完到 HTTP 200 的端到端漏斗(doctor 终判)
npm run scaffold:e2e # 脚手架全链路:本地 tarball → dev 200 → build → start 200(需要网络)
npm run build # 输出 ESM + d.ts 到 dist/License
ISC
