Bun + bun:sqlite + Vite/Vue 的 RSS 聚合在线阅读器。多用户、无 ORM、零原生依赖。
- 运行时:Bun(
Bun.serve路由、setInterval定时刷新、Bun.password鉴权、bun:sqlite存储) - 数据库:SQLite(WAL),手写 SQL + 序号迁移,FTS5
trigram全文索引 - 前端:Vite + Vue 3 + Vue Router + Pinia
- 抓取:
rss-parser(RSS/Atom),@mozilla/readability+linkedom(全文抓取)
bun install
bun run dev # API :3000 + Vite :5173打开 http://localhost:5173 ,首次会进入 setup 向导:创建的第一个账号即管理员,随后可选择性地添加订阅源。之后其他人在登录页注册的账号均为普通用户。
生产模式:
bun run build # 构建前端到 web/dist
bun run start # Bun.serve 同时托管 API + 前端静态资源(:3000)docker compose up # 首次会先 bun install + bun run build,需要等一会儿
# 打开 http://localhost:3000 —— 首次仍是 setup 向导
docker compose down- 直接用 Bun 官方镜像
oven/bun:1.4,启动命令是sh -c "bun install && bun run build && bun run start"。 - 只挂源码(
server/、migrations/、web/、package.json、bun.lock、tsconfig.json)。 node_modules和 SQLite 数据库分别放在命名卷bunrss-node_modules/bunrss-data,容器不会写进宿主仓库,也不会和本机bun run dev抢同一个库。- 容器是独立实例,首次打开仍是 setup 向导。重置:
docker compose down -v。 - 换端口:
BUNRSS_PORT=8080 docker compose up。 - 想要自包含的生产镜像(多阶段构建、只装生产依赖)就用
Dockerfile:docker build -t bunrss . && docker run -p 3000:3000 -v bunrss-data:/data bunrss
| 命令 | 作用 |
|---|---|
bun run dev |
同时启动后端(watch)与 Vite 开发服务器 |
bun run dev:server / dev:web |
单独启动后端 / 前端 |
bun run build |
构建前端 |
bun run start |
生产模式启动(含静态托管) |
bun run migrate |
应用 migrations/*.sql(服务启动时也会自动执行) |
bun run check |
临时库上自检 setup 首用户管理员 + 配额限制逻辑 |
bun run check:greader |
临时库上自检 Google Reader API 的 id 往返、items/contents、edit-tag |
bun run e2e |
无头浏览器跑一遍 setup 向导(构建 + 临时库 + 随机端口) |
bun run smoke |
对运行中的服务跑 API 冒烟测试 |
均可省略,默认值见下(写入 .env 可覆盖):
DATABASE_URL=./data/bunrss.db
PORT=3000
可选 SMTP(设置 SMTP_HOST 即启用发信,同时强制新注册用户验证邮箱后才可登录;已有账号视为已验证,不会被锁死):
APP_URL=https://rss.example.com # 验证邮件里链接的站点地址;浏览器请求会回落到自身 Origin
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false # 465 用 true(隐式 TLS);587/25 自动 STARTTLS
SMTP_USER=
SMTP_PASS=
SMTP_FROM=bunrss <noreply@example.com>
SMTP_SECURE=true 表示「连上就说 TLS」,只在 465 这种隐式 TLS 端口成立。把它配到 587/25 上,服务器会用明文 220 问候回应 ClientHello,日志里就是 ESOCKET ... WRONG_VERSION_NUMBER;此时端口优先,程序会在启动日志里告警并自动退回 STARTTLS。启动时还会做一次 verify(),配置错会在启动日志里直接报出来,而不是等到有人注册才发现。
填好 SMTP 后可以先用命令行验证发信,不必真的去注册一个账号:
bun run check:mail # 只做连接 + TLS + 认证,不发信
bun run check:mail you@example.com # 再真发一封测试邮件
连接断开、认证被拒、发件人未授权都会直接指出该改哪个变量;退出码 0 表示这条链路(与注册验证邮件同一段代码)已经通了,1 是 SMTP 报错,2 是收件人地址不像邮箱。
进不进垃圾箱,主要由收件方对发件域名的身份认证决定,而不是邮件措辞。check:mail 会顺手把这三个 DNS 记录查一遍:
- SPF:域名的
v=spf1必须包含邮件服务商(企业邮是include:spf.mail.qq.com)。 - DKIM:要在服务商后台开启,再把给出的公钥 TXT 加进 DNS。后台那个开关只是生成密钥,TXT 没发布就等于没开。两个坑:① 记录加在了注册商/DNSPod,而域名 NS 指向 Cloudflare,那样完全不生效;② selector 通常是随机串(企业邮会给形如
yfrh2602的名字),不是default,照抄示例记录名会让签名一直dkim=fail key not found。以控制台「主机记录」为准,或从收到的邮件头里读DKIM-Signature: … s=<selector>,再bun run check:mail --dkim-selector <selector>(也可写进SMTP_DKIM_SELECTOR)。 - DMARC:建议先
p=none观察,确认 SPF/DKIM 都过再收紧。p=reject下任何一项不过不是进垃圾箱,而是直接被拒收。
除此之外能做的:发件人地址固定,别频繁更换;第一次落进垃圾箱时点一次「这不是垃圾邮件」,国内邮箱会据此调整对该发件人的判定;邮件正文保持纯文本 + HTML 双份、完整成文(本项目的三种邮件都走同一个 htmlDocument() 模板)。
server/
db.ts bun:sqlite 连接 + PRAGMA + 启动时执行迁移
migrate.ts 极简迁移 runner(migrations/*.sql,记录在 _migrations)
http.ts JSON / cookie 辅助、错误 code、邮件链接的站点地址
i18n.ts 服务端错误消息目录(zh-CN / en)+ Accept-Language 解析
mail.ts SMTP 发信(nodemailer)+ 验证邮件 / 关注词提醒文案;SMTP_HOST 决定发信与强制验证是否开启
auth.ts 注册、登录(用户名或邮箱)、登出、session 校验、用户名设置;邮箱验证 token;首启 setup / needsSetup(Bun.password + 随机 token)
library.ts 订阅 / 已读星标 / 标签写入,唯一的配额校验点
settings.ts 实例设置(订阅上限、星标上限、注册用户上限、是否允许注册),0 = 不限
fetcher.ts 条件 GET 抓取、upsert 文章、全文抓取、定时刷新
index.ts Bun.serve 路由(/api/*、/api/admin/*)+ 前端静态托管
greader.ts Google Reader 兼容 API(/reader/api/0/*、/accounts/*),API token 鉴权
importexport.ts 文章数据迁移(中立 Google Reader JSON,导入 FreshRSS / Tiny Tiny RSS)
watch.ts 关注词匹配:新文章命中后写站内通知(配置 SMTP 时再发汇总邮件)
smoke.ts API 冒烟测试
core.check.ts setup + 配额 + 标签逻辑的自检(临时库,不需要起服务)
greader.check.ts Google Reader API 兼容性自检(临时库,不需要起服务)
migrations/ 0001_init.sql … 0014_multi_watch_rules.sql(按序号自动应用)
web/ Vite + Vue 前端(api.ts / store.ts / router.ts / i18n.ts / views/)
e2e.setup.ts setup 向导 + 管理后台的无头浏览器回归检查
bun run e2e 需要浏览器:首次执行 bunx playwright install chromium --only-shell。
多租户模型:feed 全局共享(同一 URL 只抓一次),用户通过 subscription 订阅,已读/星标存在 article_state。
- 首启 setup 向导:库中无用户时强制进入向导,创建的第一个账号为管理员(
user.is_admin = 1);向导第二步可挑选/粘贴订阅源。已有用户后/api/setup一律 409 - 多用户登录(注册 / 登录 / 登出,30 天 session cookie;向导之后注册的账号为普通用户);账号有唯一用户名,登录时用户名或邮箱均可
- 用户名:注册 / setup 时必填,3-32 位字母(保留大小写)、数字、
_或-;全局唯一且不区分大小写(Alice与alice视为同一用户名),创建后不可更改。登录时用户名或邮箱均可;历史空用户名账号可在设置页补设一次;Google Reader 客户端的 ClientLogin 同样接受用户名 - 邮箱验证(可选强制):配置
SMTP_HOST后,公开注册的账号会收到一封验证邮件,验证前无法登录(/api/auth/login返回 403email_unverified,登录页可一键重发);链接走POST /api/auth/verify,token 一次性、24 小时有效。setup 创建的首个管理员与后台创建的账号直接视为已验证,存量账号也默认已验证,因此开启该功能不会把实例锁死;未配置 SMTP 时验证逻辑完全不生效,注册行为与从前一致 - 订阅管理:添加 / 取消订阅,分组(category)归类,自定义标题
- 关注词通知(每个订阅源可配多条规则):在「设置 → 订阅 → 匹配规则」为每个源添加任意多条独立规则,每条规则有自己的包含词、排除词和匹配方式(任一包含词 / 全部包含词),可随时新增、编辑、删除;命中任意一条即通知。抓取到新文章时,标题 / 摘要 / 正文命中包含词、且未命中排除词,即写入站内通知(阅读页侧栏铃铛,可一键全部已读、点开直达文章);配置
SMTP_HOST后,每个用户每次抓取再发一封汇总邮件。只对抓取新增的文章通知,更新旧文章或首次订阅回填不会触发 - 文章列表:按订阅源 / 分组 / 未读 / 星标筛选,未读计数
- 阅读:正文渲染(DOMPurify 消毒)、标已读 / 标星、抓取全文
- 文章标签:自由命名的多对多标签(每用户独立,名称不区分大小写且唯一)。在阅读器给文章加 / 去标签,侧栏按标签浏览、显示未读数,设置页可重命名 / 删除
- 搜索:FTS5 全文索引(
trigram分词,支持中文子串) - OPML 导入 / 导出
- 文章数据导入 / 导出:Google Reader 中立格式 JSON(携带已读 / 星标 / 标签)。支持 FreshRSS 的「收藏 / 标签」导出、Tiny Tiny RSS 的
export_ttrss,以及二者的 ZIP 包(FreshRSS 合并导出 → 内含 OPML + JSON;tt-rssdata_migration→{articles:[…]}批次)。ZIP 里的 OPML 成员会导入订阅源列表,JSON 负责已读 / 星标 / 标签;目标里还没有的文章会连同内容一并写入,旧收藏不丢。反过来导出 bunrss 的已读 / 星标 / 标签文章,可回迁 FreshRSS。OPML 仍可单独导入 / 导出订阅(GET/POST /api/data、GET/POST /api/opml) - 书签快速订阅:「设置 → 浏览器书签快速订阅」把按钮拖到书签栏;在任意网站点击它,会打开订阅页并自动发现该页声明的 RSS / Atom 源(
<link rel="alternate">),可多选后一次订阅(服务端GET /api/discover) - 订阅源图标:抓取时读取站点首页声明的
<link rel="icon">(png / svg / apple-touch-icon 均可)并存到feed.icon_url,客户端优先用它、失败或未声明时回落到/favicon.ico;每个源只额外抓取一次主页 - 多语言:界面文案与 API 错误消息支持简体中文 / English;登录页与「设置 → 语言」可切换,选择保存在浏览器本地,默认简体中文。服务端按请求的
Accept-Language返回对应语言的错误消息 - 深色模式(浅色 / 深色 / 跟随系统,跟随系统时按 OS 偏好自动切换)、键盘快捷键
- 管理后台(
/admin,仅管理员):实例概览、用户列表(提升/降级管理员、删除用户)、设置配额 - 登录 IP 记录:注册 / 登录 / setup 建立 session 时记录来源 IP(
login_log),管理后台用户列表显示「最近登录」;超过 30 天的记录随 cron 清理 - 配额限制:每用户订阅数上限、星标数上限、注册用户总数上限(
0= 不限)。服务端在订阅、星标、OPML 导入三处统一校验;前台超出时给出明确提示,订阅栏会显示n / max - 移动端适配:窄屏(≤768px)下侧栏收起为抽屉,文章列表与正文二级切换(点开文章进正文,左上角返回);触控目标加大
- Google Reader 兼容 API:原生客户端(Reeder / NetNewsWire / FeedMe 等)可直接连接,见下
- 自动刷新:服务端每 15 分钟抓取一次过期源(
setInterval,跨平台);前端默认打开时拉取一次、每 5 分钟请求/api/refresh?stale=1拉新、每分钟同步订阅/未读/已读状态(跨客户端生效),可在「设置 → 自动刷新」关闭
管理员在「管理后台 → 用户行 Token」为用户生成 API token(仅显示一次);用户也可在「设置 → 客户端 Token」自行生成 / 撤销。客户端选择「Google Reader / FreshRSS」类型,服务器地址填 http(s)://站点/api/greader(设置页会直接显示该地址),用户名填邮箱,密码填该 token。
所有端点挂在 /api/greader 下:accounts/ClientLogin(也接受 GoogleLogin)、reader/api/0/token、reader/api/0/user-info、reader/api/0/subscription/list、subscription/edit、subscription/quickadd、tag/list、unread-count、stream/contents(含 /stream/contents/*)、stream/items/ids、edit-tag、mark-all-as-read。鉴权用 Authorization: GoogleLogin auth=<token>;token 与登录密码分离,可随时撤销。
注意:Fluent Reader 等客户端不接受 localhost 形式的端点(会提示「请正确输入 URL」),请改用 127.0.0.1 或局域网 IP。
j / k 下一篇 / 上一篇 · m 切换已读 · s 切换星标 · o 打开原文
- 搜索词 ≥ 3 个字符走 FTS5 全文索引;更短的走
LIKE(trigram 至少需要 3 字符)。 tokenize='trigram'(不是tokenizer)。- 没有 ORM:查询是手写 SQL,行类型手写接口;SQL 集中在
server/index.ts。 - 配额校验只写在
server/library.ts里,/api/feeds、/api/opml、文章星标三条路径共用,不重复实现。 - 登录 IP 取值顺序:优先
X-Forwarded-For第一跳(反代场景),否则用server.requestIP()的 socket 对端地址。没有反代时 XFF 可被客户端伪造,所以它是审计线索,不是安全控制。 - 管理员只是多一个
is_admin标记 +/admin页面;注册的账号都是普通用户。是否允许注册由setting.allow_registration控制(默认开启),注册总数由setting.max_users控制(0 = 不限),都在/admin的「配额限制」里切换。关闭 / 超限后/api/auth/register返回 403,登录页也隐藏注册入口(setup 向导不受影响,首个管理员始终可建)。用户数校验在server/auth.ts的事务内,与邮箱 / 用户名查重同批执行,避免并发超额。 - 用户名(
user.username)可空但唯一且不区分大小写(CREATE UNIQUE INDEX ... (username COLLATE NOCASE),NULL 互不冲突);大小写照原样存储,仅查询与索引用COLLATE NOCASE。登录按email = ? OR username = ? COLLATE NOCASE匹配。setUsername只允许给空用户名账号补设一次,已有用户名则返回 409,即用户名不可更改。 - 标签是
tag(每用户、名称COLLATE NOCASE唯一)+article_tag(多对多)两张表,删除用户 / 文章 / 标签都级联清理。所有标签读写和「文章归属」校验集中在server/library.ts,setArticleState与addArticleTag共用同一个ownsArticle守卫,不重复实现。