npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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、 多阶段非 root Dockerfile + 容器内迁移、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}.local

mode 由 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