来源:notes/documents/个人助理网页版技术栈梳理.md

个人助理网页版技术栈梳理

结论

网页版(界面名 Folio)是一套刻意「不引框架」的 Web 标准栈:前端是 React 18 + TypeScript + Vite 5 手写的单页应用,不引路由库、状态库、UI 组件库与 CSS 框架;后端是 Vercel Functions(Node.js 运行时) 里的薄入口 + 纯 TypeScript 业务代码,handler 就是 export default { fetch(request) },没有 Express / Hono / Next 这类框架;数据分两条腿——笔记走构建期产物,日程走数据库投影。运行时依赖一共 6 个, 且全是「库」而不是「框架」。

梳理口径:只读 web/ 的代码与 web/docs/adr/,不含飞书同步与本地工作区那一侧;数字是 2026-09-23 实测。

全景

选型 版本 落点
视图 React + react-dom 18.3.1 frontend/src/
构建 Vite + @vitejs/plugin-react 5.4.1 / 4.3.1 vite.config.ts
语言 TypeScript,三个 tsconfig(project references) 5.5.3 tsconfig.app/node/server.json
样式 手写 CSS + 自定义属性做 token frontend/src/index.cssApp.css
后端运行时 Vercel Functions(Node.js) 平台托管 api/**/*.ts
后端框架 无,Web 标准 RequestResponse backend/src/
数据库 Neon Serverless Postgres(Lakebase,aws-ap-southeast-1 DATABASE_URL
ORM / 驱动 Drizzle ORM(drizzle-orm/neon-http)+ @neondatabase/serverless 0.45.2 / 1.1.0 backend/src/lib/db.ts
Markdown marked + DOMPurify 18.0.13 / 3.4.15 frontend/src/lib/markdown.ts
鉴权 GitHub OAuth 授权码 + 自签发 Session backend/src/auth/lib/session.ts
部署 Vercel 单项目,区域 sin1 vercel.json
测试 Node 内置 node:test + 类型剥离 Node 22.6+ tests/*.test.mjs
Lint ESLint 9 flat config + typescript-eslint + react-hooks 9 / 8 eslint.config.js

前端

  • React 18 SPA,一个入口main.tsx 只做 createRoot + StrictModeApp.tsx 兼做路由解析、 数据装配与模块分支,页面组件在 pages/,外壳在 shell/
  • 没有路由库:模块与选中态全部编码进查询参数(?m= 模块、?n= Note ID、?o= 其他文档路径、 ?d= 日期、?q= 搜索),lib/route.ts 直接调 History API——选中走 pushState,搜索输入走 replaceState,所以按一次返回是离开搜索,而不是退掉一个字符(docs/adr/0010)。
  • 没有状态库:全是 useState / useEffect / useMemo,服务端数据每次进页面现取;唯一的跨组件 状态是 auth/useSession.ts 那一个登录态。
  • 没有 UI 组件库、没有 CSS 框架index.css 用自定义属性定义五个颜色角色(纸 / 墨 / 淡 / 青 / 朱)、 两套主题与三个布局尺寸(脊 64 / 索引 300 / 页边 72),App.css 按外壳契约排版。改这三个尺寸前先改 docs/adr/0009
  • Markdown 渲染marked 出 HTML、DOMPurify 消毒,并在 afterSanitizeAttributes 钩子里只放行 https:// 与构建期重写过的 /note-assets/ 两种图片地址,顺手给每张图加 loading="lazy"decoding="async"
  • 取数:手写 fetch 封装(lib/api.tslib/notes.tslib/daily.ts),带 credentials: 'include',没有 axios,也没有 TanStack Query。

后端

  • Vercel Functions 薄入口api/ 下 11 个文件只做三件事——守卫(isOwnerRequest)、参数校验、 调 backend/src/json() 出去。逻辑全在 backend/src/,因为 api/ 是平台约定,业务不该长在里面。
  • 没有 Web 框架:handler 就是 Web 标准——默认导出 { async fetch(request) } 返回 Response, URL 路由由文件名决定(api/notes/entry.tsGET /api/notes/entry)。Cookie 解析与下发、302 跳转 都是手写的(backend/src/http.ts,55 行)。
  • 模块导入带 .js 后缀指向 .ts 文件:为 NodeNext 而写;本地预览用 scripts/lib/ts-alias-hook.mjs.js 解析回 .ts,靠的是 Node 自带的类型剥离。
  • 正文走查询参数,不走路径段api/ 的编译约定会把 [...path].ts 收成单段路由,带斜杠的路径 匹配不上,所以单篇是 GET /api/notes/entry?id=GET /api/notes/detail?path=docs/adr/0012)。
  • 服务端只在一处决定「哪天」backend/src/lib/daily.ts 把 Asia/Shanghai 的日 / 月换算成 UTC 区间; 时区表只有一份,前端那份镜像只用来知道「今天是几号」,不决定归属。
  • 搜索是内存扫描:进函数就扫 generated/notes.json(笔记 118 篇、正文 1.76 MiB,冷启动读取解析 约 80 ms,一次搜索约 20 ms),不是 Postgres ILIKE,也没上索引。

数据:两条腿

数据 权威源 运到线上的方式 网页端读法
笔记(notes/documents/ 工作区 Markdown 构建期生成 generated/notes.json,随函数包发布 函数读内存里的产物
其他文档(notes/ 其余 Markdown) 同上 同上 同上,按路径取
日程 / 日历条目 data/daily_log.sqlite3 web/scripts/project-calendar-entries.mjs 投影进 Neon 按月、按日查 calendar_entries
  • 判据是「可重建的进产物,不可重建的进库」:笔记的路径、标题、摘要、正文、哈希都能从 Markdown 重算,所以不入库;会话撤销不了,所以要存(docs/adr/0008)。
  • 日程是唯一例外:它要求「记完几分钟内可见」,走 commit + 部署做不到,于是进 Neon 投影表, 但仍不是事实源——整表可以随时 truncate 重灌(docs/adr/0011)。投影表同时放日志线与笔记线, 用 kinddaily / note)区分;DDL 在 scripts/migrate-calendar-entries.mjs,Drizzle 定义在 backend/src/lib/schema.ts,两处必须一致。
  • 数据库里只有四张表usersidentitiessessionscalendar_entries。前三张承载身份与会话, 第四张是投影;笔记没有表。
  • created_at 只能来自文件自己:构建容器是 git clone,文件系统出生时间等于检出时刻,所以 scripts/generate-notes.mjs 取 git 提交时间,只有未提交的笔记才退回 mtime——还要 -c core.quotePath=false, 否则非 ASCII 路径会被转义。
  • 图片随构建拷出来:笔记引用的图片按原相对路径拷到 frontend/public/note-assets/(gitignore), 正文里的地址被重写成 /note-assets/…;没被拷贝的引用原样留在 Markdown 里,读者最多看到缺图, 不会看到坏地址。
  • 前端 bundle 里没有笔记:笔记只进函数包,首屏只拉元数据(约 99 KiB),正文点开才取单篇。

鉴权

一条 OAuth 授权码流程,加一层自家 Session:

  1. GET /api/auth/login 生成随机 state 写进 httpOnly cookie(10 分钟),302 到 GitHub;scope 只有 read:user user:email,并带 allow_signup=false
  2. 回调 GET /api/auth/callback/github 校验 state、用 code 换 token、拉用户信息,再查允许清单 (命中 ALLOWED_GITHUB_IDS,否则回退按 ALLOWED_EMAILS 比邮箱)。
  3. 通过后把外部身份写进 identities(同一 subject 复用同一 users 行),再签发 Session:32 字节随机 token 只给浏览器,库里存的是它的 SHA-256。cookie 名 pa_session,httpOnly + SameSite=Lax + Secure, 30 天有效,超过 24 小时未活动就在下次访问时滚动续期。
  4. 撤销就是把 sessions 里那一行删掉——这正是选自签发 Session、而不直接用 GitHub token 或 JWT 的理由 (docs/adr/0003)。
  • 回调地址不写死:从请求的 Host 推导(requestOrigin)。代价是浏览器访问哪个域名,就得把那个域名的 回调注册进 GitHub OAuth App——目前只注册了生产域名 vite-react-tawny-one-14.vercel.app
  • 没有注册、没有密码:单用户,允许清单之外的人连登录入口都过不去(docs/adr/0004)。
  • 正因如此,生产环境关掉了 Vercel 的 Deployment Protection(ssoProtection.deploymentType = preview), 否则 Vercel 的登录墙会抢在应用鉴权前面。

构建、部署与本地预览

  • 一条命令npm run build = notes:generate(生成笔记产物)→ tsc -b(三个 tsconfig 全量类型检查) → vite build(产物落 frontend/dist)。类型检查不过,笔记也不会发布。
  • Vercel 单项目:项目名 vite-reactprj_ADXiHsHbTHzhLvu1e66QsRotElAt),Root Directory 为 web, Framework 预设 Vite,输出 frontend/dist,区域 sin1(与 Neon 新加坡同城);rewrites 把非 /api/ 的路径全部兜回 index.htmlfunctions.includeFiles = generated/** 把笔记产物打进函数包。
  • 项目名不能改:域名由项目名生成,GitHub OAuth 回调只注册了其中一个域名,改项目名就断登录。
  • 推送即部署:push 到 main 自动触发生产部署;要单独触发用 npm run deploy——它走 Vercel REST API 让平台自己拉 git 构建,因为本机 *.vercel.app 被 DNS 污染,vercel deploy 的本地上传常在几 MB 处失败。
  • 本地预览npm run preview:local 起静态服务 + 真实 handler,只有身份是假的 (scripts/lib/preview-identity.mjs 固定返回 owner),因此不需要 Session cookie、Neon 或 GitHub App; 需要数据库的日程接口能不能用,取决于 .env.local 里有没有 DATABASE_URL
  • 测试与 lint 都不引第三方运行器npm test = node --test --experimental-strip-types tests/*.test.mjs, 2026-09-23 为 9 个文件 / 53 个用例全绿;npm run lint 是 ESLint 9 的 flat config。脚本一律 node scripts/*.mjs,没有 tsx / ts-node / nodemon。

刻意不用的东西

没用 用了什么 理由
Next.js / Remix Vite SPA + Vercel Functions 一个人用的只读站点不需要 SSR 与全栈框架
React Router 查询参数 + History API URL 本身就是全部状态,不需要第二份本地状态
Redux / Zustand useState 没有需要跨页面共享的可变状态
Tailwind / CSS-in-JS 手写 CSS + 自定义属性 token 只有五个颜色角色、三个尺寸,手写比添一层构建依赖更短
组件库 手写组件 界面是「册子」不是控制台,通用组件库会带进不属于它的语汇
Express / Hono Web 标准 Request/Response 11 个只读接口,文件名即路由,不需要框架
Prisma Drizzle ORM + neon() 原始 SQL serverless 冷启动更轻;投影查询本来手写 SQL 更清楚
Vitest / Jest node:test 平台自带,配合类型剥离不需要构建步骤
Redis / 缓存层 构建期产物 + 内存扫描 数据冷、站点低频,缓存是多余的中间层

一句话记法

前端 React + Vite + 手写 CSS;后端是 Vercel Functions 里的纯 TS(无框架);数据靠 构建产物(笔记) + Neon Postgres 投影(日程);鉴权是 GitHub OAuth + 自签发 Session;测试与 lint 全用平台自带工具——整条链上没有一项「框架替你做决定」的依赖