71 Commits

Author SHA1 Message Date
88326c4992 refactor(server): 改用 Vite 原生 tsconfig 路径解析 2026-03-22 01:27:47 +08:00
4e2bc5b8dc chore(deps): 更新 bun lock 2026-03-22 00:39:25 +08:00
9da3df6ad7 chore: 升级 monorepo 依赖版本 2026-03-22 00:02:55 +08:00
9d8a38a4c4 fix: 修正 ORPC handler 语义、加固 Electron 安全、优化构建与运行时配置
- todo.router: create 错误码 NOT_FOUND → INTERNAL_SERVER_ERROR,remove 增加存在性检查
- __root: devtools 仅在 DEV 环境渲染
- Electron: 添加 will-navigate 导航拦截、显式安全 webPreferences、deny-all 权限请求
- sidecar: 空 catch 块补充意图注释,新增 lastResolvedUrl getter
- todo.contract: 硬编码 omit 改用 generatedFieldKeys
- router: QueryClient 添加 staleTime/retry 默认值
- turbo: build 任务精细化 inputs 提升缓存命中率
- fields: id() 改为模块私有
2026-03-05 14:06:43 +08:00
cd7448c3b3 docs: 统一使用 bun run <script> 避免与 Bun 内置子命令冲突
bun build 会调用 Bun 内置 bundler 而非 package.json script,
将所有文档中的 bun <script> 改为 bun run <script> 以避免歧义。
bun test 保留不变(直接使用 Bun 内置 test runner)。
2026-03-05 12:57:26 +08:00
58d7a453b6 style: 将 biome lineWidth 从默认 80 调整为 120 2026-03-05 12:28:18 +08:00
afc3b66efa refactor: 移除根 package.json 中冗余的 --filter 参数
Turbo 会自动只在定义了对应 script 的包上执行任务,无需手动指定 filter。
2026-03-05 12:08:48 +08:00
3c97e9c3eb refactor: 移除根 turbo.json 中冗余的 compile/dist 任务定义
子包 turbo.json(extends root)已各自定义了完整配置,
根级重复注册无实际作用。
2026-03-05 12:06:11 +08:00
58620b4d4b feat: 补充 root compile/dist 脚本,通过 Turbo filter 委托到对应 app 2026-03-05 11:56:49 +08:00
04b8dedb3e fix: 修正 middleware 导入路径、清理 catalog 冗余项、同步文档 2026-03-05 11:22:49 +08:00
02bdfffe79 refactor(client): 合并 orpc.ts 和 query-client.ts 为单文件,遵循 ORPC 官方模式 2026-03-05 11:05:53 +08:00
0cd8b57d24 refactor: 优化项目结构 — 修复拼写、提取共享 interceptor、扁平化 db 目录、清理空包 2026-03-05 10:58:55 +08:00
0438b52c93 refactor(db): 移除 drizzle() 多余的 schema 参数,RQBv2 只需 relations 2026-03-05 10:37:47 +08:00
fd9478d64e docs: 同步 AGENTS.md 至 Drizzle v1 beta 并添加开发原则
- 所有 AGENTS.md 新增「开发原则」:不向后兼容、改代码必须同步文档、前向迁移
- 根 AGENTS.md: 更新 Database 段落为 Drizzle v1 beta + postgres-js + RQBv2
- server AGENTS.md: 更新 tech stack、目录结构、ORPC 示例、数据库段落
  - drizzle-zod → drizzle-orm/zod
  - bun-sql → postgres-js
  - RQBv1 回调 → RQBv2 对象语法
  - 新增 relations.ts 和 DB instance 示例
- desktop AGENTS.md: 添加开发原则和文档同步规则
2026-03-05 10:21:31 +08:00
73614204f7 chore(deps): 升级 Drizzle ORM 到 1.0 beta 并迁移至 RQBv2
- drizzle-orm/drizzle-kit 从 0.45.1/0.31.9 升级到 1.0.0-beta.15
- 移除独立的 drizzle-zod 包,改用 drizzle-orm/zod 内置导入
- DB driver 从 bun-sql 切换到 postgres-js
- 新增 defineRelations 入口 (RQBv2)
- 查询语法迁移到 RQBv2 对象风格 orderBy
2026-03-05 10:17:10 +08:00
61e7a1b621 chore(deps): 升级依赖并同步 VSCode 配置 2026-03-05 10:00:13 +08:00
5ccde0a121 fix(server): 避免 SSR 导入 *.client 模块导致构建失败 2026-02-26 12:09:45 +08:00
0553347bfe chore(deps): 升级 TanStack Start 与构建相关依赖 2026-02-26 12:09:37 +08:00
52af81b079 ci(gitea): 移除 Gitea Actions 工作流 2026-02-17 18:30:38 +08:00
527c1d1020 ci(gitea): 将 dist 工作流重命名为 release 并上传 AppImage 产物
All checks were successful
Release / release (push) Successful in 52s
2026-02-17 18:21:54 +08:00
4ed961760a ci(gitea): 升级 mise action 并补充版本输出
All checks were successful
Build Dist / dist (push) Successful in 1m5s
2026-02-17 18:00:32 +08:00
c54b7d27a6 ci(gitea): 新增 mise + turbo dist 构建工作流
Some checks failed
Build Dist / dist (push) Has been cancelled
2026-02-17 17:52:28 +08:00
d478b94c13 chore(server): 切换 Bun 运行链路并同步升级核心依赖 2026-02-17 17:43:07 +08:00
908b369732 fix(server): 使用 SubmitEventHandler 消除 React 19 弃用告警 2026-02-16 05:30:44 +08:00
51724a7936 feat(desktop): 调整启动页 logo 与加载动画视觉 2026-02-16 05:18:27 +08:00
93a2519012 feat(desktop): 迁移启动页到 React 并接入 Motion 动画 2026-02-16 05:10:15 +08:00
5edab0ba1d feat(desktop): 恢复启动加载页并在服务就绪后切换 2026-02-16 04:28:37 +08:00
a451e08209 fix(server): 移除重复的 NODE_ENV 环境变量声明 2026-02-16 04:09:14 +08:00
e76a03d0f4 feat(desktop): 拆分 sidecar 管理并接入健康检查路由 2026-02-16 04:06:41 +08:00
aa1e2c81c6 chore: remove unused fingerprint utility and stale deps 2026-02-16 03:03:33 +08:00
7e2621ae37 chore(build): 调整脚本顺序并移除多余空行 2026-02-16 00:05:22 +08:00
94a9122f34 feat(build): 统一编译命令并默认启用双架构 2026-02-15 23:48:37 +08:00
275c8e4795 docs(agents): 同步多架构构建与打包命令说明 2026-02-15 23:32:32 +08:00
8245abe217 feat(build): 支持桌面端多架构打包矩阵 2026-02-15 23:26:00 +08:00
627e6f9dd3 chore: bump dependency catalog and lockfile versions 2026-02-15 22:01:03 +08:00
e59e085217 chore(vscode): remove unused extension recommendations 2026-02-15 21:51:36 +08:00
cd9826ded3 chore(desktop): tweak electron-vite dev watch and remove redundant --config flags 2026-02-09 04:19:53 +08:00
2efc57d9ee feat(desktop): show native error dialogs on startup failures
Replace silent console.error + app.quit() with dialog.showErrorBox()
so users actually see why the app failed to start instead of it just
disappearing. Covers server spawn errors, timeout, port allocation
failure, mid-session server crashes, and window creation failures.
2026-02-09 03:35:24 +08:00
1f5940438a fix(desktop): use array format for win target in electron-builder config 2026-02-09 03:16:48 +08:00
0bab6372ac chore(desktop): reorganize electron-builder config and refine packaging targets 2026-02-09 03:15:01 +08:00
5f0c9d33cb chore 2026-02-09 02:58:43 +08:00
73982939a8 chore(desktop): add app icon and track resources directory 2026-02-09 02:51:56 +08:00
10c2d61523 fix(desktop): use CJS for preload script to fix sandbox loading error 2026-02-09 02:17:57 +08:00
18ce05854a feat(server): add NODE_ENV to shared env schema 2026-02-09 01:59:45 +08:00
7eccef5d8f chore(desktop): remove redundant config fields for KISS 2026-02-09 01:41:34 +08:00
41667cb33b refactor(desktop): simplify main process logic and improve naming
- Remove logLifecycle wrapper, inline the conditional logging
- Remove redundant shouldAbortWindowLoad check before final loadURL
- Rename getServerUrl to resolveServerUrl to reflect side effects
- Add .catch on createWindow to prevent silent async failures
2026-02-09 01:27:29 +08:00
00c944e1b5 refactor(desktop): 精简主进程启动与退出逻辑并减少打包态日志噪音 2026-02-09 01:13:08 +08:00
f9edfd0058 fix(desktop): guard shutdown race and kill sidecar process tree 2026-02-09 00:57:30 +08:00
9aea89e16d fix(desktop): force app exit on windows window close 2026-02-09 00:40:01 +08:00
26b74b25f2 fix(desktop): use stdio ignore for sidecar to prevent process hang on quit
Piped stdio handles kept the event loop alive on Windows after killing
the sidecar process, preventing the Electron app from exiting.
2026-02-09 00:23:05 +08:00
ccf220fc29 fix(desktop): ensure sidecar process stops on app shutdown 2026-02-08 23:59:54 +08:00
a585069cdc refactor: rename compile:mac/win to compile:darwin/windows to match Bun target names 2026-02-08 23:39:30 +08:00
b149cc5dc0 refactor: decentralize turbo task config — move compile/dist to workspace turbo.json
Sink package-specific tasks from root turbo.json into workspace configs:
- compile/compile:* → apps/server/turbo.json (only server compiles binaries)
- dist/dist:* → apps/desktop/turbo.json (only desktop distributes)
- Cross-package deps (desktop→server#compile) owned by desktop config
- Desktop dist scripts no longer bypass Turbo by calling bun run build

Root turbo.json reduced from 16 to 4 generic lifecycle tasks.
2026-02-08 23:26:24 +08:00
9f38636d76 refactor(server): use Bun.Build.CompileTarget and derive host target instead of manual map 2026-02-08 22:47:39 +08:00
63906ec09b refactor(server): use util.parseArgs for declarative CLI arg parsing in compile.ts 2026-02-08 22:38:49 +08:00
8c4e4ad150 refactor(server): use type guard to eliminate as Target casts in compile.ts 2026-02-08 22:33:02 +08:00
e171db8196 refactor: simplify compile.ts to single-target and add per-platform compile scripts
- Rewrite compile.ts from 112 to 66 lines: single target with auto-detect host, remove multi-target batch logic
- Add compile:linux/mac/win scripts to server, root, and turbo configs
- Wire desktop dist:* to depend on matching server compile:* (avoid unnecessary cross-platform compilation)
- Update AGENTS.md docs across root, server, and desktop
2026-02-08 22:25:30 +08:00
dac6bb1643 refactor: 统一打包命令为 dist 体系,build 仅编译不打包
- build:linux/mac/win → dist/dist:linux/dist:mac/dist:win
- Turbo 任务依赖:desktop#dist:* → server#compile → server#build
- 根目录 bun dist 一条命令完成完整打包流水线
- 更新 AGENTS.md 文档同步命令变更
2026-02-08 20:48:58 +08:00
8c0ea632d7 style(desktop): 规范 package.json 字段排序 2026-02-08 20:31:17 +08:00
db23ee42fc chore: 更新 lockfile 依赖版本 2026-02-08 20:26:16 +08:00
0784546e50 fix(desktop): 修复 artifactName 路径错误并移除 deb 构建目标
scoped package name 中的 / 导致 ${name} 作为路径出错,改用 ${productName};
移除 deb target(fpm 依赖不可用);补充 package.json 元信息字段
2026-02-08 20:03:47 +08:00
2fe3e15659 refactor: 重命名 build.ts → compile.ts,统一脚本与文件命名
- compile 脚本调用 compile.ts,消除与 build 的歧义
- desktop turbo.json 添加 build outputs 缓存配置
2026-02-08 19:46:04 +08:00
ed02993350 style(desktop): loading 页面改为白色轻盈主题 2026-02-08 19:40:20 +08:00
e4e5ff2211 chore: 移除 useSortedClasses 规则,等待 nursery 毕业后再启用 2026-02-08 19:19:18 +08:00
d69a573a33 feat: 为 desktop/server 添加 Tailwind CSS 支持和 Biome 集成
- desktop renderer 接入 @tailwindcss/vite,loading 页面改用 Tailwind 类
- 两个 app 添加 biome.json 继承配置:tailwindDirectives + useSortedClasses
2026-02-08 19:17:53 +08:00
6cc1bc6834 refactor(desktop): 用类型收窄替代 as AddressInfo 断言 2026-02-08 19:03:13 +08:00
894fd17d1a fix(desktop): 动态分配 sidecar 端口替代硬编码,避免端口冲突
使用 net.createServer().listen(0) 探测可用端口,通过 PORT 环境变量
传递给 sidecar binary(VS Code language server 同款模式)
2026-02-08 18:38:45 +08:00
888f20fdab fix(desktop): 加载 loading 页面作为启动屏,配置平台级 extraResources 对接交叉编译
- main process 启动时先加载 renderer/index.html 显示 spinner
- electron-builder 按平台引用 server/out/ 下对应架构的 sidecar binary
- 移除 mise.toml 中无关的 rust 工具配置
2026-02-08 18:21:40 +08:00
7318600e20 refactor(desktop): 替换 WebUI 为 Electron + electron-vite 桌面壳方案
- 使用 electron-vite 构建 main/preload,electron-builder 打包分发
- main process: dev 模式直连 localhost:3000,生产模式 spawn sidecar binary
- 添加 loading 页面,server 就绪前显示加载动画
- 更新 catalog 依赖: electron, electron-vite, electron-builder
- 移除 @webui-dev/bun-webui 依赖
2026-02-08 18:16:13 +08:00
e8e473b357 refactor(desktop): 替换 Electrobun 为 WebUI 作为桌面窗口方案
Electrobun 太不稳定,改用 webui-dev/webui(轻量 C 库,~300KB)通过
系统浏览器或 WebView 提供桌面窗口。已验证 bun:ffi 加载和
bun build --compile 均正常工作。

- 移除 electrobun 依赖和配置
- 添加 @webui-dev/bun-webui 依赖
- 重写桌面入口为 WebUI 窗口方案
- 移除 Conveyor 打包工具(mise.toml)
2026-02-08 04:15:34 +08:00
41d97ca312 refactor(deps): 将 @hydraulic/conveyor 从 bun 依赖迁移到 mise 全局工具 2026-02-08 03:21:38 +08:00
59 changed files with 2122 additions and 1233 deletions

View File

@@ -2,11 +2,8 @@
"recommendations": [ "recommendations": [
"biomejs.biome", "biomejs.biome",
"hverlin.mise-vscode", "hverlin.mise-vscode",
"mikestead.dotenv",
"oven.bun-vscode", "oven.bun-vscode",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"rust-lang.rust-analyzer", "tamasfe.even-better-toml"
"tamasfe.even-better-toml",
"tauri-apps.tauri-vscode"
] ]
} }

43
.vscode/settings.json vendored
View File

@@ -1,49 +1,42 @@
{ {
// Disable the default formatter & linter, use biome instead
"prettier.enable": false,
"eslint.enable": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[javascriptreact]": { "[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": { "[json]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[jsonc]": { "[jsonc]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"[toml]": { "[toml]": {
"editor.defaultFormatter": "tamasfe.even-better-toml" "editor.defaultFormatter": "tamasfe.even-better-toml"
}, },
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"files.associations": { "files.associations": {
".env": "dotenv", ".env": "dotenv",
".env.*": "dotenv", ".env.*": "dotenv",
"**/tsconfig.json": "jsonc",
"**/tsconfig.*.json": "jsonc",
"**/biome.json": "jsonc", "**/biome.json": "jsonc",
"**/opencode.json": "jsonc" "**/opencode.json": "jsonc",
"**/tsconfig.*.json": "jsonc",
"**/tsconfig.json": "jsonc"
}, },
// TanStack Router
"files.readonlyInclude": { "files.readonlyInclude": {
"**/routeTree.gen.ts": true "**/routeTree.gen.ts": true
}, },

109
AGENTS.md
View File

@@ -4,48 +4,71 @@ Guidelines for AI agents working in this Bun monorepo.
## Project Overview ## Project Overview
> **This project uses [Bun](https://bun.sh) exclusively as both the JavaScript runtime and package manager. Do NOT use Node.js / npm / yarn / pnpm. All commands start with `bun` — use `bun install` for dependencies and `bun run` / `bun <script>` for scripts. Never use `npm`, `npx`, or `node`.** > **This project uses [Bun](https://bun.sh) exclusively as both the JavaScript runtime and package manager. Do NOT use Node.js / npm / yarn / pnpm. All commands start with `bun` — use `bun install` for dependencies and `bun run <script>` for scripts. Always prefer `bun run <script>` over `bun <script>` to avoid conflicts with Bun built-in subcommands (e.g. `bun build` invokes Bun's bundler, NOT your package.json script). Never use `npm`, `npx`, or `node`.**
- **Monorepo**: Bun workspaces + Turborepo orchestration - **Monorepo**: Bun workspaces + Turborepo orchestration
- **Runtime**: Bun (see `mise.toml` for version) — **NOT Node.js** - **Runtime**: Bun (see `mise.toml` for version) — **NOT Node.js**
- **Package Manager**: Bun — **NOT npm / yarn / pnpm** - **Package Manager**: Bun — **NOT npm / yarn / pnpm**
- **Apps**: - **Apps**:
- `apps/server` - TanStack Start fullstack web app (see `apps/server/AGENTS.md`) - `apps/server` - TanStack Start fullstack web app (see `apps/server/AGENTS.md`)
- `apps/desktop` - Electrobun desktop shell, loads server in native window (see `apps/desktop/AGENTS.md`) - `apps/desktop` - Electron desktop shell, sidecar server pattern (see `apps/desktop/AGENTS.md`)
- **Packages**: `packages/utils`, `packages/tsconfig` (shared configs) - **Packages**: `packages/tsconfig` (shared TS configs)
## Build / Lint / Test Commands ## Build / Lint / Test Commands
### Root Commands (via Turbo) ### Root Commands (via Turbo)
```bash ```bash
bun dev # Start all apps in dev mode bun run dev # Start all apps in dev mode
bun build # Build all apps bun run build # Build all apps
bun fix # Lint + format (Biome auto-fix) bun run compile # Compile server to standalone binary (current platform)
bun typecheck # TypeScript check across monorepo bun run compile:darwin # Compile server for macOS (arm64 + x64)
bun run compile:linux # Compile server for Linux (x64 + arm64)
bun run compile:windows # Compile server for Windows x64
bun run dist # Package desktop distributable (current platform)
bun run dist:linux # Package desktop for Linux (x64 + arm64)
bun run dist:mac # Package desktop for macOS (arm64 + x64)
bun run dist:win # Package desktop for Windows x64
bun run fix # Lint + format (Biome auto-fix)
bun run typecheck # TypeScript check across monorepo
``` ```
### Server App (`apps/server`) ### Server App (`apps/server`)
```bash ```bash
bun dev # Vite dev server (localhost:3000) bun run dev # Vite dev server (localhost:3000)
bun build # Production build -> .output/ bun run build # Production build -> .output/
bun compile # Compile to standalone binary bun run compile # Compile to standalone binary (current platform)
bun fix # Biome auto-fix bun run compile:darwin # Compile for macOS (arm64 + x64)
bun typecheck # TypeScript check bun run compile:darwin:arm64 # Compile for macOS arm64
bun run compile:darwin:x64 # Compile for macOS x64
bun run compile:linux # Compile for Linux (x64 + arm64)
bun run compile:linux:arm64 # Compile for Linux arm64
bun run compile:linux:x64 # Compile for Linux x64
bun run compile:windows # Compile for Windows (default: x64)
bun run compile:windows:x64 # Compile for Windows x64
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
# Database (Drizzle) # Database (Drizzle)
bun db:generate # Generate migrations from schema bun run db:generate # Generate migrations from schema
bun db:migrate # Run migrations bun run db:migrate # Run migrations
bun db:push # Push schema (dev only) bun run db:push # Push schema (dev only)
bun db:studio # Open Drizzle Studio bun run db:studio # Open Drizzle Studio
``` ```
### Desktop App (`apps/desktop`) ### Desktop App (`apps/desktop`)
```bash ```bash
bun dev # Start Electrobun dev mode (requires server dev running) bun run dev # electron-vite dev mode (requires server dev running)
bun build # Build canary release bun run build # electron-vite build (main + preload)
bun build:stable # Build stable release bun run dist # Build + package for current platform
bun fix # Biome auto-fix bun run dist:linux # Build + package for Linux (x64 + arm64)
bun typecheck # TypeScript check bun run dist:linux:x64 # Build + package for Linux x64
bun run dist:linux:arm64 # Build + package for Linux arm64
bun run dist:mac # Build + package for macOS (arm64 + x64)
bun run dist:mac:arm64 # Build + package for macOS arm64
bun run dist:mac:x64 # Build + package for macOS x64
bun run dist:win # Build + package for Windows x64
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
``` ```
### Testing ### Testing
@@ -97,7 +120,13 @@ import type { ReactNode } from 'react'
- ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `INPUT_VALIDATION_FAILED`) - ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `INPUT_VALIDATION_FAILED`)
- Never use empty catch blocks - Never use empty catch blocks
## Database (Drizzle ORM) ## Database (Drizzle ORM v1 beta + postgres-js)
- **ORM**: Drizzle ORM `1.0.0-beta` (RQBv2)
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
- **Validation**: `drizzle-orm/zod` (built-in, NOT separate `drizzle-zod` package)
- **Relations**: Defined via `defineRelations()` in `src/server/db/relations.ts` (contains schema info, so `drizzle()` only needs `{ relations }`)
- **Query style**: RQBv2 object syntax (`orderBy: { createdAt: 'desc' }`, `where: { id: 1 }`)
```typescript ```typescript
export const myTable = pgTable('my_table', { export const myTable = pgTable('my_table', {
@@ -120,13 +149,22 @@ export const myTable = pgTable('my_table', {
- Workspace packages use `"catalog:"` — never hardcode versions - Workspace packages use `"catalog:"` — never hardcode versions
- Internal packages use `"workspace:*"` references - Internal packages use `"workspace:*"` references
## Development Principles
> **These principles apply to ALL code changes. Agents MUST follow them on every task.**
1. **No backward compatibility** — This project is in rapid iteration. Always use the latest API and patterns. Never keep deprecated code paths or old API fallbacks "just in case".
2. **Always sync documentation** — When code changes, immediately update all related documentation (`AGENTS.md`, `README.md`, inline code examples). Code and docs must never drift apart. This includes updating code snippets in docs when imports, APIs, or patterns change.
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns in the same codebase.
## Critical Rules ## Critical Rules
**DO:** **DO:**
- Run `bun fix` before committing - Run `bun run fix` before committing
- Use `@/*` path aliases (not relative imports) - Use `@/*` path aliases (not relative imports)
- Include `createdAt`/`updatedAt` on all tables - Include `createdAt`/`updatedAt` on all tables
- Use `catalog:` for dependency versions - Use `catalog:` for dependency versions
- Update `AGENTS.md` and other docs whenever code patterns change
**DON'T:** **DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx` - Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
@@ -135,13 +173,14 @@ export const myTable = pgTable('my_table', {
- Commit `.env` files - Commit `.env` files
- Use empty catch blocks `catch(e) {}` - Use empty catch blocks `catch(e) {}`
- Hardcode dependency versions in workspace packages - Hardcode dependency versions in workspace packages
- Leave docs out of sync with code changes
## Git Workflow ## Git Workflow
1. Make changes following style guide 1. Make changes following style guide
2. `bun fix` - auto-format and lint 2. `bun run fix` - auto-format and lint
3. `bun typecheck` - verify types 3. `bun run typecheck` - verify types
4. `bun dev` - test locally 4. `bun run dev` - test locally
5. Commit with descriptive message 5. Commit with descriptive message
## Directory Structure ## Directory Structure
@@ -151,22 +190,24 @@ export const myTable = pgTable('my_table', {
├── apps/ ├── apps/
│ ├── server/ # TanStack Start fullstack app │ ├── server/ # TanStack Start fullstack app
│ │ ├── src/ │ │ ├── src/
│ │ │ ├── client/ # ORPC client, Query client │ │ │ ├── client/ # ORPC client + TanStack Query utils
│ │ │ ├── components/ │ │ │ ├── components/
│ │ │ ├── routes/ # File-based routing │ │ │ ├── routes/ # File-based routing
│ │ │ └── server/ # API layer + database │ │ │ └── server/ # API layer + database
│ │ │ ├── api/ # ORPC contracts, routers, middlewares │ │ │ ├── api/ # ORPC contracts, routers, middlewares
│ │ │ └── db/ # Drizzle schema │ │ │ └── db/ # Drizzle schema
│ │ └── AGENTS.md │ │ └── AGENTS.md
│ └── desktop/ # Electrobun desktop shell │ └── desktop/ # Electron desktop shell
│ ├── src/ │ ├── src/
│ │ ── bun/ │ │ ── main/
│ │ └── index.ts # Main process entry │ │ └── index.ts # Main process entry
├── electrobun.config.ts # Electrobun configuration │ └── preload/
│ │ └── index.ts # Preload script
│ ├── electron.vite.config.ts
│ ├── electron-builder.yml # Packaging config
│ └── AGENTS.md │ └── AGENTS.md
├── packages/ ├── packages/
── tsconfig/ # Shared TS configs ── tsconfig/ # Shared TS configs
│ └── utils/ # Shared utilities
├── biome.json # Linting/formatting config ├── biome.json # Linting/formatting config
├── turbo.json # Turbo task orchestration ├── turbo.json # Turbo task orchestration
└── package.json # Workspace root + dependency catalog └── package.json # Workspace root + dependency catalog
@@ -175,4 +216,4 @@ export const myTable = pgTable('my_table', {
## See Also ## See Also
- `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns - `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns
- `apps/desktop/AGENTS.md` - Electrobun desktop development guide - `apps/desktop/AGENTS.md` - Electron desktop development guide

View File

@@ -1,3 +1,3 @@
# Electrobun build output # electron-vite build output
build/ out/
artifacts/ dist/

View File

@@ -1,36 +1,39 @@
# AGENTS.md - Desktop App Guidelines # AGENTS.md - Desktop App Guidelines
Thin Electrobun shell hosting the fullstack server app. Thin Electron shell hosting the fullstack server app.
## Tech Stack ## Tech Stack
> **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Never use `npm`, `npx`, or `node`.** > **⚠️ This project uses Bun as the package manager. Runtime is Electron (Node.js). Always use `bun run <script>` (not `bun <script>`) to avoid conflicts with Bun built-in subcommands. Never use `npm`, `npx`, `yarn`, or `pnpm`.**
- **Type**: Electrobun desktop application - **Type**: Electron desktop shell
- **Design**: Server-driven desktop (thin native shell hosting web app) - **Design**: Server-driven desktop (thin native window hosting web app)
- **Runtime**: Bun (Main process) + CEF (Chromium Embedded Framework) - **Runtime**: Electron (Main/Renderer) + Sidecar server binary (Bun-compiled)
- **Framework**: Electrobun - **Build Tool**: electron-vite (Vite-based, handles main + preload builds)
- **Build**: Electrobun CLI + Turborepo - **Packager**: electron-builder (installers, signing, auto-update)
- **Orchestration**: Turborepo
## Architecture ## Architecture
- **Server-driven design**: The desktop app is a "thin" native shell. It does not contain UI or business logic; it merely hosts the `apps/server` TanStack Start application in a native window. - **Server-driven design**: The desktop app is a "thin" native shell. It does not contain UI or business logic; it opens a BrowserWindow pointing to the `apps/server` TanStack Start application.
- **Dev mode**: Connects to an external Vite dev server at `localhost:3000`. Requires `apps/server` to be running separately. - **Dev mode**: Opens a BrowserWindow pointing to `localhost:3000`. Requires `apps/server` to be running separately (Turbo handles this).
- **Prod mode**: Spawns an embedded TanStack Start server (Nitro) as a child process and loads the dynamically assigned local URL. - **Production mode**: Spawns a compiled server binary (from `resources/`) as a sidecar process, waits for readiness, then loads its URL.
- **Config**: `electrobun.config.ts` manages app metadata (identifier, name), build entries, and asset bundling.
## Commands ## Commands
```bash ```bash
# Development bun run dev # electron-vite dev (requires server dev running)
bun dev # Start Electrobun dev mode (requires server dev running) bun run build # electron-vite build (main + preload)
bun run dist # Build + package for current platform
# Build bun run dist:linux # Build + package for Linux (x64 + arm64)
bun build # Build stable release (all platforms) bun run dist:linux:x64 # Build + package for Linux x64
bun run dist:linux:arm64 # Build + package for Linux arm64
# Code Quality bun run dist:mac # Build + package for macOS (arm64 + x64)
bun fix # Biome auto-fix bun run dist:mac:arm64 # Build + package for macOS arm64
bun typecheck # TypeScript check bun run dist:mac:x64 # Build + package for macOS x64
bun run dist:win # Build + package for Windows x64
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
``` ```
## Directory Structure ## Directory Structure
@@ -38,94 +41,55 @@ bun typecheck # TypeScript check
``` ```
. .
├── src/ ├── src/
── bun/ ── main/
└── index.ts # Main process entry (Window management + server lifecycle) └── index.ts # Main process (server lifecycle + BrowserWindow)
├── electrobun.config.ts # App metadata and build/copy configuration │ └── preload/
├── package.json # Scripts and dependencies │ └── index.ts # Preload script (security isolation)
├── turbo.json # Build pipeline dependencies (depends on server build) ├── resources/ # Sidecar binaries (gitignored, copied from server build)
── AGENTS.md # Desktop guidelines (this file) ── out/ # electron-vite build output (gitignored)
├── electron.vite.config.ts
├── electron-builder.yml # Packaging configuration
├── package.json
├── turbo.json
└── AGENTS.md
``` ```
## Development Workflow ## Development Workflow
1. **Start server**: In `apps/server`, run `bun dev`. 1. **Start server**: `bun run dev` in `apps/server` (or use root `bun run dev` via Turbo).
2. **Start desktop**: In `apps/desktop`, run `bun dev`. 2. **Start desktop**: `bun run dev` in `apps/desktop`.
3. **Connection**: The desktop app polls `localhost:3000` until responsive, then opens the native window. 3. **Connection**: Main process polls `localhost:3000` until responsive, then opens BrowserWindow.
## Production Architecture ## Production Build Workflow
### Build Pipeline From monorepo root, run `bun run dist` to execute the full pipeline automatically (via Turbo task dependencies):
The desktop build is orchestrated by Turbo. It depends on the server's production build:
- `turbo.json`: `@furtherverse/desktop#build` depends on `@furtherverse/server#build`.
- `electrobun.config.ts`: Copies `../server/.output` to `server-output` folder within the app bundle.
### Server Lifecycle 1. **Build server**: `apps/server``vite build``.output/`
In production, the main process manages the embedded server: 2. **Compile server**: `apps/server``bun compile.ts --target ...``out/server-{os}-{arch}`
- **Spawn**: Spawns server from `server-output/server/index.mjs` using `Bun.spawn`. 3. **Package desktop**: `apps/desktop``electron-vite build` + `electron-builder` → distributable
- **Port Allocation**: A free port is pre-allocated via `node:net` (`createServer` on `127.0.0.1:0`), then passed to the server as the `PORT` environment variable.
- **Readiness Check**: The main process polls the server URL with `fetch` until it responds, rather than parsing stdout.
- **Retry**: If the server fails to become ready (timeout or early exit), the process is killed and a new attempt is made with a fresh port (up to 3 retries).
- **Lifecycle**: The server process is tied to the app; it is killed on `SIGTERM`, `SIGINT`, or app exit. If the server process crashes, the app exits with an error.
## Environment Detection The `electron-builder.yml` `extraResources` config reads binaries directly from `../server/out/`, no manual copy needed.
The application determines its environment via the `ELECTROBUN_BUILD_ENV` variable, automatically set by the Electrobun CLI: To build for a specific platform explicitly, use `bun run dist:linux` / `bun run dist:mac` / `bun run dist:win` in `apps/desktop`.
For single-arch output, use `bun run dist:linux:x64`, `bun run dist:linux:arm64`, `bun run dist:mac:x64`, or `bun run dist:mac:arm64`.
```typescript ## Development Principles
const isDev = () => {
const env = process.env.ELECTROBUN_BUILD_ENV
return !env || env === 'dev'
}
```
- **dev**: Development mode (connects to external host). > **These principles apply to ALL code changes. Agents MUST follow them on every task.**
- **canary / stable**: Production mode (starts embedded server).
## Environment Variables 1. **No backward compatibility** — This project is in rapid iteration. Always use the latest API and patterns. Never keep deprecated code paths or old API fallbacks.
2. **Always sync documentation** — When code changes, immediately update all related documentation (`AGENTS.md`, `README.md`, inline code examples). Code and docs must never drift apart.
- `ELECTROBUN_BUILD_ENV`: Auto-set by CLI. Determines whether to use local dev server or embedded server. 3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns.
- `DATABASE_URL`: Required by the server process. Must be passed through from the parent environment to the spawned child process.
## Electrobun Patterns
### BrowserWindow Configuration
The main window uses the CEF renderer for consistency across platforms.
```typescript
new BrowserWindow({
title: 'Furtherverse',
url: serverUrl,
frame: {
x: 100,
y: 100,
width: 1200,
height: 800,
},
renderer: 'cef',
})
```
### Path Aliases
The main process uses `electrobun/bun` for native APIs and `PATHS` for locating bundled assets.
```typescript
import { BrowserWindow, PATHS } from 'electrobun/bun'
// Locate the embedded server bundle
const serverEntryPath = join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server', 'index.mjs')
```
## Critical Rules ## Critical Rules
**DO:** **DO:**
- Use arrow functions for all components and utility functions. - Use arrow functions for all utility functions.
- Ensure `apps/server` is built before building `apps/desktop` (handled by Turbo). - Keep the desktop app as a thin shell — no UI or business logic.
- Pre-allocate a free port and pass it via `PORT` env var instead of parsing stdout.
- Handle server process termination to avoid orphan processes.
- Use `catalog:` for all dependency versions in `package.json`. - Use `catalog:` for all dependency versions in `package.json`.
**DON'T:** **DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, or `pnpm`. Always use `bun`. - Use `npm`, `npx`, `yarn`, or `pnpm`. Use `bun` for package management.
- Hardcode `localhost:3000` for production builds. - Include UI components or business logic in the desktop app.
- Include UI components or business logic in the desktop app (keep it thin).
- Use `as any` or `@ts-ignore`. - Use `as any` or `@ts-ignore`.
- Leave docs out of sync with code changes.

9
apps/desktop/biome.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": "//",
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

BIN
apps/desktop/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -1,28 +0,0 @@
import type { ElectrobunConfig } from 'electrobun'
import { version } from './package.json'
export default {
app: {
name: 'Desktop',
identifier: 'com.furtherverse.desktop',
version,
},
build: {
useAsar: true,
bun: {
entrypoint: 'src/bun/index.ts',
},
copy: {
'../server/.output': 'server-output',
},
linux: {
bundleCEF: true,
},
mac: {
bundleCEF: true,
},
win: {
bundleCEF: true,
},
},
} satisfies ElectrobunConfig

View File

@@ -0,0 +1,48 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/electron-userland/electron-builder/refs/heads/master/packages/app-builder-lib/scheme.json
appId: com.furtherverse.desktop
productName: Furtherverse
executableName: furtherverse
npmRebuild: false
asarUnpack:
- resources/**
files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!{.env,.env.*,bun.lock}"
- "!{tsconfig.json,tsconfig.node.json}"
- "!{AGENTS.md,README.md,CHANGELOG.md}"
# macOS
mac:
target:
- dmg
category: public.app-category.productivity
extraResources:
- from: ../server/out/server-darwin-${arch}
to: server
dmg:
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
# Windows
win:
target:
- portable
extraResources:
- from: ../server/out/server-windows-${arch}.exe
to: server.exe
portable:
artifactName: ${productName}-${version}-${os}-${arch}-Portable.${ext}
# Linux
linux:
target:
- AppImage
category: Utility
extraResources:
- from: ../server/out/server-linux-${arch}
to: server
appImage:
artifactName: ${productName}-${version}-${os}-${arch}.${ext}

View File

@@ -0,0 +1,11 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'electron-vite'
export default defineConfig({
main: {},
preload: {},
renderer: {
plugins: [react(), tailwindcss()],
},
})

View File

@@ -2,19 +2,36 @@
"name": "@furtherverse/desktop", "name": "@furtherverse/desktop",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "main": "out/main/index.js",
"scripts": { "scripts": {
"build": "electrobun build --env=stable --targets=win-x64", "build": "electron-vite build",
"dev": "electrobun build --env=dev && electrobun dev", "dev": "electron-vite dev --watch",
"dist": "electron-builder",
"dist:linux": "bun run dist:linux:x64 && bun run dist:linux:arm64",
"dist:linux:arm64": "electron-builder --linux --arm64",
"dist:linux:x64": "electron-builder --linux --x64",
"dist:mac": "bun run dist:mac:arm64 && bun run dist:mac:x64",
"dist:mac:arm64": "electron-builder --mac --arm64",
"dist:mac:x64": "electron-builder --mac --x64",
"dist:win": "electron-builder --win --x64",
"fix": "biome check --write", "fix": "biome check --write",
"typecheck": "tsc --noEmit" "typecheck": "tsc -b"
}, },
"dependencies": { "dependencies": {
"@hydraulic/conveyor": "catalog:", "motion": "catalog:",
"electrobun": "catalog:" "react": "catalog:",
"react-dom": "catalog:",
"tree-kill": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@furtherverse/tsconfig": "workspace:*", "@furtherverse/tsconfig": "workspace:*",
"@types/bun": "catalog:" "@tailwindcss/vite": "catalog:",
"@types/node": "catalog:",
"@vitejs/plugin-react": "catalog:",
"electron": "catalog:",
"electron-builder": "catalog:",
"electron-vite": "catalog:",
"tailwindcss": "catalog:",
"vite": "catalog:"
} }
} }

View File

View File

@@ -1,166 +0,0 @@
import { createServer } from 'node:net'
import { dirname, join } from 'node:path'
import { BrowserWindow, PATHS } from 'electrobun/bun'
const DEV_SERVER_URL = 'http://localhost:3000'
const SERVER_READY_TIMEOUT_MS = 5_000
const MAX_SPAWN_RETRIES = 3
const isDev = (): boolean => {
const env = process.env.ELECTROBUN_BUILD_ENV
return !env || env === 'dev'
}
const getServerEntryPath = (): string => {
return join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server', 'index.mjs')
}
const getFreePort = (): Promise<number> => {
return new Promise((resolve, reject) => {
const srv = createServer()
srv.unref()
srv.once('error', reject)
srv.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => {
const addr = srv.address()
if (addr && typeof addr === 'object') {
const port = addr.port
srv.close((err) => (err ? reject(err) : resolve(port)))
} else {
srv.close(() => reject(new Error('Unexpected address() result')))
}
})
})
}
const waitForServer = async (
url: string,
timeoutMs = SERVER_READY_TIMEOUT_MS,
): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
const response = await fetch(url, { method: 'HEAD' })
if (response.ok) return true
} catch (_) {
// Server not up yet, retry after sleep
}
await Bun.sleep(100)
}
return false
}
const spawnServer = async (): Promise<{
process: ReturnType<typeof Bun.spawn>
url: string
}> => {
const serverEntryPath = getServerEntryPath()
const serverDir = dirname(serverEntryPath)
for (let attempt = 1; attempt <= MAX_SPAWN_RETRIES; attempt++) {
const port = await getFreePort()
const url = `http://127.0.0.1:${port}`
const serverProc = Bun.spawn([process.execPath, serverEntryPath], {
cwd: serverDir,
env: {
...process.env,
PORT: String(port),
HOST: '127.0.0.1',
},
stdio: ['ignore', 'inherit', 'inherit'],
})
const ready = await Promise.race([
waitForServer(url),
serverProc.exited.then((code) => {
throw new Error(`Server exited with code ${code} before becoming ready`)
}),
])
if (ready) {
return { process: serverProc, url }
}
serverProc.kill()
await serverProc.exited
console.warn(
`Server failed to become ready on port ${port} (attempt ${attempt}/${MAX_SPAWN_RETRIES})`,
)
}
throw new Error(`Server failed to start after ${MAX_SPAWN_RETRIES} attempts`)
}
const main = async () => {
console.log('Starting Furtherverse Desktop...')
let serverUrl: string
let serverProcess: ReturnType<typeof Bun.spawn> | null = null
if (isDev()) {
console.log('Dev mode: waiting for external server at', DEV_SERVER_URL)
const ready = await waitForServer(DEV_SERVER_URL)
if (!ready) {
console.error(
'Dev server not responding. Make sure to run: bun dev in apps/server',
)
process.exit(1)
}
console.log('Dev server ready!')
serverUrl = DEV_SERVER_URL
} else {
console.log('Production mode: starting embedded server...')
try {
const server = await spawnServer()
serverProcess = server.process
serverUrl = server.url
console.log('Server ready at', serverUrl)
} catch (err) {
console.error('Failed to start embedded server:', err)
process.exit(1)
}
}
new BrowserWindow({
title: 'Furtherverse',
url: serverUrl,
frame: {
x: 100,
y: 100,
width: 1200,
height: 800,
},
renderer: 'cef',
})
if (serverProcess) {
const cleanup = () => {
if (serverProcess) {
serverProcess.kill()
serverProcess = null
}
}
process.on('exit', cleanup)
process.on('SIGTERM', () => {
cleanup()
process.exit(0)
})
process.on('SIGINT', () => {
cleanup()
process.exit(0)
})
serverProcess.exited.then((code) => {
if (serverProcess) {
console.error(`Server exited unexpectedly with code ${code}`)
process.exit(1)
}
})
}
}
main().catch((error) => {
console.error('Failed to start:', error)
process.exit(1)
})

View File

@@ -0,0 +1,198 @@
import { join } from 'node:path'
import { app, BrowserWindow, dialog, session, shell } from 'electron'
import { createSidecarRuntime } from './sidecar'
const DEV_SERVER_URL = 'http://localhost:3000'
const SAFE_EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:'])
let mainWindow: BrowserWindow | null = null
let windowCreationPromise: Promise<void> | null = null
let isQuitting = false
const showErrorAndQuit = (title: string, detail: string) => {
if (isQuitting) {
return
}
dialog.showErrorBox(title, detail)
app.quit()
}
const sidecar = createSidecarRuntime({
devServerUrl: DEV_SERVER_URL,
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
isQuitting: () => isQuitting,
onUnexpectedStop: (detail) => {
showErrorAndQuit('Service Stopped', detail)
},
})
const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
const canOpenExternally = (url: string): boolean => {
try {
const parsed = new URL(url)
return SAFE_EXTERNAL_PROTOCOLS.has(parsed.protocol)
} catch {
return false
}
}
const loadSplash = async (windowRef: BrowserWindow) => {
if (process.env.ELECTRON_RENDERER_URL) {
await windowRef.loadURL(process.env.ELECTRON_RENDERER_URL)
return
}
await windowRef.loadFile(join(__dirname, '../renderer/index.html'))
}
const createWindow = async () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.focus()
return
}
const windowRef = new BrowserWindow({
width: 1200,
height: 800,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
},
})
mainWindow = windowRef
windowRef.webContents.setWindowOpenHandler(({ url }) => {
if (!canOpenExternally(url)) {
if (!app.isPackaged) {
console.warn(`Blocked external URL: ${url}`)
}
return { action: 'deny' }
}
void shell.openExternal(url)
return { action: 'deny' }
})
windowRef.webContents.on('will-navigate', (event, url) => {
const allowed = [DEV_SERVER_URL, sidecar.lastResolvedUrl].filter((v): v is string => v != null)
const isAllowed = allowed.some((origin) => url.startsWith(origin))
if (!isAllowed) {
event.preventDefault()
if (canOpenExternally(url)) {
void shell.openExternal(url)
} else if (!app.isPackaged) {
console.warn(`Blocked navigation to: ${url}`)
}
}
})
windowRef.on('closed', () => {
if (mainWindow === windowRef) {
mainWindow = null
}
})
try {
await loadSplash(windowRef)
} catch (error) {
if (mainWindow === windowRef) {
mainWindow = null
}
if (!windowRef.isDestroyed()) {
windowRef.destroy()
}
throw error
}
if (!windowRef.isDestroyed()) {
windowRef.show()
}
const targetUrl = await sidecar.resolveUrl()
if (isQuitting || windowRef.isDestroyed()) {
return
}
try {
await windowRef.loadURL(targetUrl)
} catch (error) {
if (mainWindow === windowRef) {
mainWindow = null
}
if (!windowRef.isDestroyed()) {
windowRef.destroy()
}
throw error
}
}
const ensureWindow = async () => {
if (windowCreationPromise) {
return windowCreationPromise
}
windowCreationPromise = createWindow().finally(() => {
windowCreationPromise = null
})
return windowCreationPromise
}
const beginQuit = () => {
isQuitting = true
sidecar.stop()
}
const handleWindowCreationError = (error: unknown, context: string) => {
console.error(`${context}:`, error)
showErrorAndQuit(
"App Couldn't Start",
app.isPackaged
? 'A required component failed to start. Please reinstall the app.'
: `${context}: ${toErrorMessage(error)}`,
)
}
app
.whenReady()
.then(() => {
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false)
})
return ensureWindow()
})
.catch((error) => {
handleWindowCreationError(error, 'Failed to create window')
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (isQuitting || BrowserWindow.getAllWindows().length > 0) {
return
}
ensureWindow().catch((error) => {
handleWindowCreationError(error, 'Failed to re-create window')
})
})
app.on('before-quit', beginQuit)

View File

@@ -0,0 +1,256 @@
import { type ChildProcess, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { createServer } from 'node:net'
import { join } from 'node:path'
import killProcessTree from 'tree-kill'
const SERVER_HOST = '127.0.0.1'
const SERVER_READY_TIMEOUT_MS = 10_000
const SERVER_REQUEST_TIMEOUT_MS = 1_500
const SERVER_POLL_INTERVAL_MS = 250
const SERVER_PROBE_PATHS = ['/api/health', '/']
type SidecarState = {
process: ChildProcess | null
startup: Promise<string> | null
url: string | null
}
type SidecarRuntimeOptions = {
devServerUrl: string
isPackaged: boolean
resourcesPath: string
isQuitting: () => boolean
onUnexpectedStop: (detail: string) => void
}
type SidecarRuntime = {
resolveUrl: () => Promise<string>
stop: () => void
lastResolvedUrl: string | null
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const isProcessAlive = (processToCheck: ChildProcess | null): processToCheck is ChildProcess => {
if (!processToCheck || !processToCheck.pid) {
return false
}
return processToCheck.exitCode === null && !processToCheck.killed
}
const getAvailablePort = (): Promise<number> =>
new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const addr = server.address()
if (!addr || typeof addr === 'string') {
server.close()
reject(new Error('Failed to resolve port'))
return
}
server.close(() => resolve(addr.port))
})
server.on('error', reject)
})
const isServerReady = async (url: string): Promise<boolean> => {
for (const probePath of SERVER_PROBE_PATHS) {
try {
const probeUrl = new URL(probePath, `${url}/`)
const response = await fetch(probeUrl, {
method: 'GET',
cache: 'no-store',
signal: AbortSignal.timeout(SERVER_REQUEST_TIMEOUT_MS),
})
if (response.status < 500) {
if (probePath === '/api/health' && response.status === 404) {
continue
}
return true
}
} catch {
// Expected: probe request fails while server is still starting up
}
}
return false
}
const waitForServer = async (url: string, isQuitting: () => boolean, processRef?: ChildProcess): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < SERVER_READY_TIMEOUT_MS && !isQuitting()) {
if (processRef && processRef.exitCode !== null) {
return false
}
if (await isServerReady(url)) {
return true
}
await sleep(SERVER_POLL_INTERVAL_MS)
}
return false
}
const resolveBinaryPath = (resourcesPath: string): string => {
const binaryName = process.platform === 'win32' ? 'server.exe' : 'server'
return join(resourcesPath, binaryName)
}
const formatUnexpectedStopMessage = (
isPackaged: boolean,
code: number | null,
signal: NodeJS.Signals | null,
): string => {
if (isPackaged) {
return 'The background service stopped unexpectedly. Please restart the app.'
}
return `Server process exited unexpectedly (code ${code ?? 'unknown'}, signal ${signal ?? 'none'}).`
}
export const createSidecarRuntime = (options: SidecarRuntimeOptions): SidecarRuntime => {
const state: SidecarState = {
process: null,
startup: null,
url: null,
}
const resetState = (processRef?: ChildProcess) => {
if (processRef && state.process !== processRef) {
return
}
state.process = null
state.url = null
}
const stop = () => {
const runningServer = state.process
resetState()
if (!runningServer?.pid || runningServer.exitCode !== null) {
return
}
killProcessTree(runningServer.pid, 'SIGTERM', (error?: Error) => {
if (error) {
console.error('Failed to stop server process:', error)
}
})
}
const attachLifecycleHandlers = (processRef: ChildProcess) => {
processRef.on('error', (error) => {
if (state.process !== processRef) {
return
}
const hadReadyServer = state.url !== null
resetState(processRef)
if (!options.isQuitting() && hadReadyServer) {
options.onUnexpectedStop('The background service crashed unexpectedly. Please restart the app.')
return
}
console.error('Failed to start server process:', error)
})
processRef.on('exit', (code, signal) => {
if (state.process !== processRef) {
return
}
const hadReadyServer = state.url !== null
resetState(processRef)
if (!options.isQuitting() && hadReadyServer) {
options.onUnexpectedStop(formatUnexpectedStopMessage(options.isPackaged, code, signal))
}
})
}
const startPackagedServer = async (): Promise<string> => {
if (state.url && isProcessAlive(state.process)) {
return state.url
}
if (state.startup) {
return state.startup
}
state.startup = (async () => {
const binaryPath = resolveBinaryPath(options.resourcesPath)
if (!existsSync(binaryPath)) {
throw new Error(`Sidecar server binary is missing: ${binaryPath}`)
}
if (options.isQuitting()) {
throw new Error('Application is shutting down.')
}
const port = await getAvailablePort()
const nextServerUrl = `http://${SERVER_HOST}:${port}`
const processRef = spawn(binaryPath, [], {
env: {
...process.env,
HOST: SERVER_HOST,
PORT: String(port),
},
stdio: 'ignore',
windowsHide: true,
})
processRef.unref()
state.process = processRef
attachLifecycleHandlers(processRef)
const ready = await waitForServer(nextServerUrl, options.isQuitting, processRef)
if (ready && isProcessAlive(processRef)) {
state.url = nextServerUrl
return nextServerUrl
}
const failureReason =
processRef.exitCode !== null
? `The service exited early (code ${processRef.exitCode}).`
: `The service did not respond at ${nextServerUrl} within 10 seconds.`
stop()
throw new Error(failureReason)
})().finally(() => {
state.startup = null
})
return state.startup
}
const resolveUrl = async (): Promise<string> => {
if (options.isPackaged) {
return startPackagedServer()
}
const ready = await waitForServer(options.devServerUrl, options.isQuitting)
if (!ready) {
throw new Error('Dev server not responding. Run `bun dev` in apps/server first.')
}
state.url = options.devServerUrl
return options.devServerUrl
}
return {
resolveUrl,
stop,
get lastResolvedUrl() {
return state.url
},
}
}

View File

@@ -0,0 +1 @@
export {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -0,0 +1,33 @@
import { motion } from 'motion/react'
import logoImage from '../assets/logo.png'
export const SplashApp = () => {
return (
<main className="m-0 flex h-screen w-screen cursor-default select-none items-center justify-center overflow-hidden bg-white font-sans antialiased">
<motion.section
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center gap-8"
initial={{ opacity: 0, y: 4 }}
transition={{
duration: 1,
ease: [0.16, 1, 0.3, 1],
}}
>
<img alt="Logo" className="h-20 w-auto object-contain" draggable={false} src={logoImage} />
<div className="relative h-[4px] w-36 overflow-hidden rounded-full bg-zinc-100">
<motion.div
animate={{ x: '100%' }}
className="h-full w-full bg-zinc-800"
initial={{ x: '-100%' }}
transition={{
duration: 2,
ease: [0.4, 0, 0.2, 1],
repeat: Infinity,
}}
/>
</div>
</motion.section>
</main>
)
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Furtherverse</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SplashApp } from './components/SplashApp'
import './styles.css'
// biome-ignore lint/style/noNonNullAssertion: 一定存在
createRoot(document.getElementById('root')!).render(
<StrictMode>
<SplashApp />
</StrictMode>,
)

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"extends": "@furtherverse/tsconfig/react.json",
"compilerOptions": {
"composite": true,
"types": ["vite/client"]
},
"include": ["src/renderer/**/*"]
}

View File

@@ -1,6 +1,11 @@
{ {
"extends": "@furtherverse/tsconfig/bun.json", "files": [],
"compilerOptions": { "references": [
"lib": ["ESNext", "DOM", "DOM.Iterable"] {
} "path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
} }

View File

@@ -0,0 +1,8 @@
{
"extends": "@furtherverse/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"types": ["node"]
},
"include": ["src/main/**/*", "src/preload/**/*", "electron.vite.config.ts"]
}

View File

@@ -3,8 +3,39 @@
"extends": ["//"], "extends": ["//"],
"tasks": { "tasks": {
"build": { "build": {
"dependsOn": ["@furtherverse/server#build"], "outputs": ["out/**"]
"outputs": ["build/**", "artifacts/**"] },
"dist": {
"dependsOn": ["build", "@furtherverse/server#compile"],
"outputs": ["dist/**"]
},
"dist:linux": {
"dependsOn": ["build", "@furtherverse/server#compile:linux:arm64", "@furtherverse/server#compile:linux:x64"],
"outputs": ["dist/**"]
},
"dist:linux:arm64": {
"dependsOn": ["build", "@furtherverse/server#compile:linux:arm64"],
"outputs": ["dist/**"]
},
"dist:linux:x64": {
"dependsOn": ["build", "@furtherverse/server#compile:linux:x64"],
"outputs": ["dist/**"]
},
"dist:mac": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin:arm64", "@furtherverse/server#compile:darwin:x64"],
"outputs": ["dist/**"]
},
"dist:mac:arm64": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin:arm64"],
"outputs": ["dist/**"]
},
"dist:mac:x64": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin:x64"],
"outputs": ["dist/**"]
},
"dist:win": {
"dependsOn": ["build", "@furtherverse/server#compile:windows:x64"],
"outputs": ["dist/**"]
} }
} }
} }

View File

@@ -4,14 +4,14 @@ TanStack Start fullstack web app with ORPC (contract-first RPC).
## Tech Stack ## Tech Stack
> **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Never use `npm`, `npx`, or `node`.** > **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Always use `bun run <script>` (not `bun <script>`) to avoid conflicts with Bun built-in subcommands. Never use `npm`, `npx`, or `node`.**
- **Framework**: TanStack Start (React 19 SSR, file-based routing) - **Framework**: TanStack Start (React 19 SSR, file-based routing)
- **Runtime**: Bun — **NOT Node.js** - **Runtime**: Bun — **NOT Node.js**
- **Package Manager**: Bun — **NOT npm / yarn / pnpm** - **Package Manager**: Bun — **NOT npm / yarn / pnpm**
- **Language**: TypeScript (strict mode) - **Language**: TypeScript (strict mode)
- **Styling**: Tailwind CSS v4 - **Styling**: Tailwind CSS v4
- **Database**: PostgreSQL + Drizzle ORM - **Database**: PostgreSQL + Drizzle ORM v1 beta (`drizzle-orm/postgres-js`, RQBv2)
- **State**: TanStack Query v5 - **State**: TanStack Query v5
- **RPC**: ORPC (contract-first, type-safe) - **RPC**: ORPC (contract-first, type-safe)
- **Build**: Vite + Nitro - **Build**: Vite + Nitro
@@ -20,25 +20,33 @@ TanStack Start fullstack web app with ORPC (contract-first RPC).
```bash ```bash
# Development # Development
bun dev # Vite dev server (localhost:3000) bun run dev # Vite dev server (localhost:3000)
bun db:studio # Drizzle Studio GUI bun run db:studio # Drizzle Studio GUI
# Build # Build
bun build # Production build → .output/ bun run build # Production build → .output/
bun compile # Compile to standalone binary bun run compile # Compile to standalone binary (current platform, depends on build)
bun run compile:darwin # Compile for macOS (arm64 + x64)
bun run compile:darwin:arm64 # Compile for macOS arm64
bun run compile:darwin:x64 # Compile for macOS x64
bun run compile:linux # Compile for Linux (x64 + arm64)
bun run compile:linux:arm64 # Compile for Linux arm64
bun run compile:linux:x64 # Compile for Linux x64
bun run compile:windows # Compile for Windows (default: x64)
bun run compile:windows:x64 # Compile for Windows x64
# Code Quality # Code Quality
bun fix # Biome auto-fix bun run fix # Biome auto-fix
bun typecheck # TypeScript check bun run typecheck # TypeScript check
# Database # Database
bun db:generate # Generate migrations from schema bun run db:generate # Generate migrations from schema
bun db:migrate # Run migrations bun run db:migrate # Run migrations
bun db:push # Push schema directly (dev only) bun run db:push # Push schema directly (dev only)
# Testing (not yet configured) # Testing (not yet configured)
bun test path/to/test.ts # Run single test bun test path/to/test.ts # Run single test
bun test -t "pattern" # Run tests matching pattern bun test -t "pattern" # Run tests matching pattern
``` ```
## Directory Structure ## Directory Structure
@@ -46,25 +54,29 @@ bun test -t "pattern" # Run tests matching pattern
``` ```
src/ src/
├── client/ # Client-side code ├── client/ # Client-side code
── orpc.client.ts # ORPC isomorphic client ── orpc.ts # ORPC client + TanStack Query utils (single entry point)
│ └── query-client.ts # TanStack Query client
├── components/ # React components ├── components/ # React components
├── routes/ # TanStack Router file routes ├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout │ ├── __root.tsx # Root layout
│ ├── index.tsx # Home page │ ├── index.tsx # Home page
│ └── api/ │ └── api/
── rpc.$.ts # ORPC HTTP endpoint ── $.ts # OpenAPI handler + Scalar docs
│ ├── health.ts # Health check endpoint
│ └── rpc.$.ts # ORPC RPC handler
├── server/ # Server-side code ├── server/ # Server-side code
│ ├── api/ # ORPC layer │ ├── api/ # ORPC layer
│ │ ├── contracts/ # Input/output schemas (Zod) │ │ ├── contracts/ # Input/output schemas (Zod)
│ │ ├── middlewares/ # Middleware (db provider, auth) │ │ ├── middlewares/ # Middleware (db provider, auth)
│ │ ├── routers/ # Handler implementations │ │ ├── routers/ # Handler implementations
│ │ ├── interceptors.ts # Shared error interceptors
│ │ ├── context.ts # Request context │ │ ├── context.ts # Request context
│ │ ├── server.ts # ORPC server instance │ │ ├── server.ts # ORPC server instance
│ │ └── types.ts # Type exports │ │ └── types.ts # Type exports
│ └── db/ │ └── db/
│ ├── schema/ # Drizzle table definitions │ ├── schema/ # Drizzle table definitions
── index.ts # Database instance ── fields.ts # Shared field builders (id, createdAt, updatedAt)
│ ├── relations.ts # Drizzle relations (defineRelations, RQBv2)
│ └── index.ts # Database instance (postgres-js driver)
├── env.ts # Environment variable validation ├── env.ts # Environment variable validation
├── router.tsx # Router configuration ├── router.tsx # Router configuration
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT) ├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
@@ -76,7 +88,7 @@ src/
### 1. Define Contract (`src/server/api/contracts/feature.contract.ts`) ### 1. Define Contract (`src/server/api/contracts/feature.contract.ts`)
```typescript ```typescript
import { oc } from '@orpc/contract' import { oc } from '@orpc/contract'
import { createSelectSchema } from 'drizzle-zod' import { createSelectSchema } from 'drizzle-orm/zod'
import { z } from 'zod' import { z } from 'zod'
import { featureTable } from '@/server/db/schema' import { featureTable } from '@/server/db/schema'
@@ -93,7 +105,9 @@ import { db } from '../middlewares'
import { os } from '../server' import { os } from '../server'
export const list = os.feature.list.use(db).handler(async ({ context }) => { export const list = os.feature.list.use(db).handler(async ({ context }) => {
return await context.db.query.featureTable.findMany() return await context.db.query.featureTable.findMany({
orderBy: { createdAt: 'desc' },
})
}) })
``` ```
@@ -111,14 +125,20 @@ export const router = os.router({ feature })
### 4. Use in Components ### 4. Use in Components
```typescript ```typescript
import { useSuspenseQuery, useMutation } from '@tanstack/react-query' import { useSuspenseQuery, useMutation } from '@tanstack/react-query'
import { orpc } from '@/client/orpc.client' import { orpc } from '@/client/orpc'
const { data } = useSuspenseQuery(orpc.feature.list.queryOptions()) const { data } = useSuspenseQuery(orpc.feature.list.queryOptions())
const mutation = useMutation(orpc.feature.create.mutationOptions()) const mutation = useMutation(orpc.feature.create.mutationOptions())
``` ```
## Database Schema (Drizzle) ## Database (Drizzle ORM v1 beta)
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
- **Validation**: `drizzle-orm/zod` (built-in, NOT separate `drizzle-zod` package)
- **Relations**: Defined via `defineRelations()` in `src/server/db/relations.ts`
- **Query**: RQBv2 — use `db.query.tableName.findMany()` with object-style `orderBy` and `where`
### Schema Definition
```typescript ```typescript
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core' import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
@@ -131,6 +151,43 @@ export const myTable = pgTable('my_table', {
}) })
``` ```
### Relations (RQBv2)
```typescript
// src/server/db/relations.ts
import { defineRelations } from 'drizzle-orm'
import * as schema from './schema'
export const relations = defineRelations(schema, (r) => ({
// Define relations here using r.one / r.many / r.through
}))
```
### DB Instance
```typescript
// src/server/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import { relations } from '@/server/db/relations'
// In RQBv2, relations already contain schema info — no separate schema import needed
const db = drizzle({
connection: env.DATABASE_URL,
relations,
})
```
### RQBv2 Query Examples
```typescript
// Object-style orderBy (NOT callback style)
const todos = await db.query.todoTable.findMany({
orderBy: { createdAt: 'desc' },
})
// Object-style where
const todo = await db.query.todoTable.findFirst({
where: { id: someId },
})
```
## Code Style ## Code Style
### Formatting (Biome) ### Formatting (Biome)
@@ -189,13 +246,24 @@ export const env = createEnv({
}) })
``` ```
## Development Principles
> **These principles apply to ALL code changes. Agents MUST follow them on every task.**
1. **No backward compatibility** — This project is in rapid iteration. Always use the latest API and patterns. Never keep deprecated code paths or old API fallbacks.
2. **Always sync documentation** — When code changes, immediately update all related documentation (`AGENTS.md`, `README.md`, inline code examples). Code and docs must never drift apart.
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns.
## Critical Rules ## Critical Rules
**DO:** **DO:**
- Run `bun fix` before committing - Run `bun run fix` before committing
- Use `@/*` path aliases - Use `@/*` path aliases
- Include `createdAt`/`updatedAt` on all tables - Include `createdAt`/`updatedAt` on all tables
- Use `ORPCError` with proper codes - Use `ORPCError` with proper codes
- Use `drizzle-orm/zod` (NOT `drizzle-zod`) for schema validation
- Use RQBv2 object syntax for `orderBy` and `where`
- Update `AGENTS.md` and other docs whenever code patterns change
**DON'T:** **DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx` - Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
@@ -203,3 +271,9 @@ export const env = createEnv({
- Use `as any`, `@ts-ignore`, `@ts-expect-error` - Use `as any`, `@ts-ignore`, `@ts-expect-error`
- Commit `.env` files - Commit `.env` files
- Use empty catch blocks - Use empty catch blocks
- Import from `drizzle-zod` (use `drizzle-orm/zod` instead)
- Use RQBv1 callback-style `orderBy` / old `relations()` API
- Use `drizzle-orm/bun-sql` driver (use `drizzle-orm/postgres-js`)
- Pass `schema` to `drizzle()` constructor (only `relations` is needed in RQBv2)
- Import `os` from `@orpc/server` in middleware — use `@/server/api/server` (the local typed instance)
- Leave docs out of sync with code changes

View File

@@ -3,5 +3,10 @@
"extends": "//", "extends": "//",
"files": { "files": {
"includes": ["**", "!**/routeTree.gen.ts"] "includes": ["**", "!**/routeTree.gen.ts"]
},
"css": {
"parser": {
"tailwindDirectives": true
}
} }
} }

View File

@@ -1,111 +0,0 @@
import { rm } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const ALL_TARGETS = [
'bun-windows-x64',
'bun-darwin-arm64',
'bun-darwin-x64',
'bun-linux-x64',
'bun-linux-arm64',
] as const
type BunTarget = (typeof ALL_TARGETS)[number]
const ENTRYPOINT = '.output/server/index.mjs'
const OUTDIR = 'out'
const OUTFILE_BASE = 'server'
const DEFAULT_TARGETS: BunTarget[] = [
'bun-windows-x64',
'bun-darwin-arm64',
'bun-linux-x64',
]
const suffixFor = (target: BunTarget) => target.replace('bun-', '')
const isTarget = (value: string): value is BunTarget =>
(ALL_TARGETS as readonly string[]).includes(value)
const parseTargets = (): BunTarget[] => {
const args = process.argv.slice(2)
const targets: string[] = []
for (let i = 0; i < args.length; i++) {
const arg = args[i]
const next = args[i + 1]
if (arg === '--target' && next) {
targets.push(next)
i++
} else if (arg === '--targets' && next) {
targets.push(...next.split(','))
i++
}
}
if (targets.length === 0) return DEFAULT_TARGETS
const invalid = targets.filter((t) => !isTarget(t))
if (invalid.length) {
throw new Error(
`Unknown target(s): ${invalid.join(', ')}\nAllowed: ${ALL_TARGETS.join(', ')}`,
)
}
return targets as BunTarget[]
}
const buildOne = async (target: BunTarget) => {
const suffix = suffixFor(target)
const outfile = `${OUTFILE_BASE}-${suffix}`
const result = await Bun.build({
entrypoints: [ENTRYPOINT],
outdir: OUTDIR,
compile: {
outfile,
target,
},
})
if (!result.success) {
throw new Error(
`Build failed for ${target}:\n${result.logs.map(String).join('\n')}`,
)
}
return {
target,
outputs: result.outputs.map((o) => path.relative('.', o.path)),
}
}
const main = async () => {
const targets = parseTargets()
await rm(OUTDIR, { recursive: true, force: true })
console.log(`✓ 已清理输出目录: ${OUTDIR}`)
// Bun cross-compile 不支持真正并行,逐目标串行构建
const results: Awaited<ReturnType<typeof buildOne>>[] = []
for (const target of targets) {
const start = Date.now()
process.stdout.write(`🔨 构建 ${target}... `)
const result = await buildOne(target)
results.push(result)
console.log(`完成 (${Date.now() - start}ms)`)
}
console.log('\n📦 构建完成:')
for (const r of results) {
console.log(` ${r.target}:`)
for (const p of r.outputs) {
console.log(` - ${p}`)
}
}
}
main().catch((err) => {
console.error('\n❌ 构建失败:')
console.error(err instanceof Error ? err.message : err)
process.exit(1)
})

64
apps/server/compile.ts Normal file
View File

@@ -0,0 +1,64 @@
import { mkdir, rm } from 'node:fs/promises'
import { parseArgs } from 'node:util'
const ENTRYPOINT = '.output/server/index.mjs'
const OUTDIR = 'out'
const SUPPORTED_TARGETS: readonly Bun.Build.CompileTarget[] = [
'bun-windows-x64',
'bun-darwin-arm64',
'bun-darwin-x64',
'bun-linux-x64',
'bun-linux-arm64',
]
const isSupportedTarget = (value: string): value is Bun.Build.CompileTarget =>
(SUPPORTED_TARGETS as readonly string[]).includes(value)
const { values } = parseArgs({
options: { target: { type: 'string' } },
strict: true,
allowPositionals: false,
})
const resolveTarget = (): Bun.Build.CompileTarget => {
if (values.target !== undefined) {
if (!isSupportedTarget(values.target)) {
throw new Error(`Invalid target: ${values.target}\nAllowed: ${SUPPORTED_TARGETS.join(', ')}`)
}
return values.target
}
const os = process.platform === 'win32' ? 'windows' : process.platform
const candidate = `bun-${os}-${process.arch}`
if (!isSupportedTarget(candidate)) {
throw new Error(`Unsupported host: ${process.platform}-${process.arch}`)
}
return candidate
}
const main = async () => {
const target = resolveTarget()
const suffix = target.replace('bun-', '')
const outfile = `server-${suffix}`
await mkdir(OUTDIR, { recursive: true })
await Promise.all([rm(`${OUTDIR}/${outfile}`, { force: true }), rm(`${OUTDIR}/${outfile}.exe`, { force: true })])
const result = await Bun.build({
entrypoints: [ENTRYPOINT],
outdir: OUTDIR,
compile: { outfile, target },
})
if (!result.success) {
throw new Error(result.logs.map(String).join('\n'))
}
console.log(`${target}${OUTDIR}/${outfile}`)
}
main().catch((err) => {
console.error('❌', err instanceof Error ? err.message : err)
process.exit(1)
})

View File

@@ -4,18 +4,25 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "bunx --bun vite build",
"compile": "bun build.ts", "compile": "bun compile.ts",
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
"compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64",
"compile:darwin:x64": "bun compile.ts --target bun-darwin-x64",
"compile:linux": "bun run compile:linux:x64 && bun run compile:linux:arm64",
"compile:linux:arm64": "bun compile.ts --target bun-linux-arm64",
"compile:linux:x64": "bun compile.ts --target bun-linux-x64",
"compile:windows": "bun run compile:windows:x64",
"compile:windows:x64": "bun compile.ts --target bun-windows-x64",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"dev": "vite dev", "dev": "bunx --bun vite dev",
"fix": "biome check --write", "fix": "biome check --write",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@furtherverse/utils": "workspace:*",
"@orpc/client": "catalog:", "@orpc/client": "catalog:",
"@orpc/contract": "catalog:", "@orpc/contract": "catalog:",
"@orpc/openapi": "catalog:", "@orpc/openapi": "catalog:",
@@ -28,7 +35,6 @@
"@tanstack/react-router-ssr-query": "catalog:", "@tanstack/react-router-ssr-query": "catalog:",
"@tanstack/react-start": "catalog:", "@tanstack/react-start": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"postgres": "catalog:", "postgres": "catalog:",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
@@ -48,7 +54,6 @@
"drizzle-kit": "catalog:", "drizzle-kit": "catalog:",
"nitro": "catalog:", "nitro": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"vite": "catalog:", "vite": "catalog:"
"vite-tsconfig-paths": "catalog:"
} }
} }

View File

@@ -1,24 +0,0 @@
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import { createRouterClient } from '@orpc/server'
import { createIsomorphicFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'
import { router } from '@/server/api/routers'
import type { RouterClient } from '@/server/api/types'
const getORPCClient = createIsomorphicFn()
.server(() =>
createRouterClient(router, {
context: () => ({
headers: getRequestHeaders(),
}),
}),
)
.client(() => {
const link = new RPCLink({
url: `${window.location.origin}/api/rpc`,
})
return createORPCClient<RouterClient>(link)
})
export const orpc: RouterClient = getORPCClient()

View File

@@ -0,0 +1,53 @@
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import { createRouterClient } from '@orpc/server'
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { createIsomorphicFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'
import { router } from '@/server/api/routers'
import type { RouterClient } from '@/server/api/types'
const getORPCClient = createIsomorphicFn()
.server(() =>
createRouterClient(router, {
context: () => ({
headers: getRequestHeaders(),
}),
}),
)
.client(() => {
const link = new RPCLink({
url: `${window.location.origin}/api/rpc`,
})
return createORPCClient<RouterClient>(link)
})
const client: RouterClient = getORPCClient()
export const orpc = createTanstackQueryUtils(client, {
experimental_defaults: {
todo: {
create: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
update: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
remove: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
},
},
})

View File

@@ -1,30 +0,0 @@
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { orpc as orpcClient } from './orpc.client'
export const orpc = createTanstackQueryUtils(orpcClient, {
experimental_defaults: {
todo: {
create: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
update: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
remove: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
},
},
})

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as ApiSplatRouteImport } from './routes/api/$' import { Route as ApiSplatRouteImport } from './routes/api/$'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
@@ -18,6 +19,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSplatRoute = ApiSplatRouteImport.update({ const ApiSplatRoute = ApiSplatRouteImport.update({
id: '/api/$', id: '/api/$',
path: '/api/$', path: '/api/$',
@@ -32,30 +38,34 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute '/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute '/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute '/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/api/$' | '/api/rpc/$' fullPaths: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/api/$' | '/api/rpc/$' to: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
id: '__root__' | '/' | '/api/$' | '/api/rpc/$' id: '__root__' | '/' | '/api/$' | '/api/health' | '/api/rpc/$'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
ApiSplatRoute: typeof ApiSplatRoute ApiSplatRoute: typeof ApiSplatRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute
} }
@@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/api/$': { '/api/$': {
id: '/api/$' id: '/api/$'
path: '/api/$' path: '/api/$'
@@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
ApiSplatRoute: ApiSplatRoute, ApiSplatRoute: ApiSplatRoute,
ApiHealthRoute: ApiHealthRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@@ -5,7 +5,14 @@ import type { RouterContext } from './routes/__root'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
export const getRouter = () => { export const getRouter = () => {
const queryClient = new QueryClient() const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
})
const router = createRouter({ const router = createRouter({
routeTree, routeTree,

View File

@@ -1,15 +1,11 @@
import { TanStackDevtools } from '@tanstack/react-devtools' import { TanStackDevtools } from '@tanstack/react-devtools'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
import { import { createRootRouteWithContext, HeadContent, Scripts } from '@tanstack/react-router'
createRootRouteWithContext,
HeadContent,
Scripts,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { ErrorComponent } from '@/components/Error' import { ErrorComponent } from '@/components/Error'
import { NotFoundComponent } from '@/components/NotFount' import { NotFoundComponent } from '@/components/NotFound'
import appCss from '@/styles.css?url' import appCss from '@/styles.css?url'
export interface RouterContext { export interface RouterContext {
@@ -50,21 +46,23 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
</head> </head>
<body> <body>
{children} {children}
<TanStackDevtools {import.meta.env.DEV && (
config={{ <TanStackDevtools
position: 'bottom-right', config={{
}} position: 'bottom-right',
plugins={[ }}
{ plugins={[
name: 'TanStack Router', {
render: <TanStackRouterDevtoolsPanel />, name: 'TanStack Router',
}, render: <TanStackRouterDevtoolsPanel />,
{ },
name: 'TanStack Query', {
render: <ReactQueryDevtoolsPanel />, name: 'TanStack Query',
}, render: <ReactQueryDevtoolsPanel />,
]} },
/> ]}
/>
)}
<Scripts /> <Scripts />
</body> </body>
</html> </html>

View File

@@ -1,10 +1,10 @@
import { OpenAPIHandler } from '@orpc/openapi/fetch' import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
import { ORPCError, onError, ValidationError } from '@orpc/server' import { onError } from '@orpc/server'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4' import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { name, version } from '@/../package.json' import { name, version } from '@/../package.json'
import { handleValidationError, logError } from '@/server/api/interceptors'
import { router } from '@/server/api/routers' import { router } from '@/server/api/routers'
const handler = new OpenAPIHandler(router, { const handler = new OpenAPIHandler(router, {
@@ -17,55 +17,13 @@ const handler = new OpenAPIHandler(router, {
title: name, title: name,
version, version,
}, },
// components: {
// securitySchemes: {
// bearerAuth: {
// type: 'http',
// scheme: 'bearer',
// },
// },
// },
}, },
docsPath: '/docs', docsPath: '/docs',
specPath: '/spec.json', specPath: '/spec.json',
}), }),
], ],
interceptors: [ interceptors: [onError(logError)],
onError((error) => { clientInterceptors: [onError(handleValidationError)],
console.error(error)
}),
],
clientInterceptors: [
onError((error) => {
if (
error instanceof ORPCError &&
error.code === 'BAD_REQUEST' &&
error.cause instanceof ValidationError
) {
// If you only use Zod you can safely cast to ZodIssue[]
const zodError = new z.ZodError(
error.cause.issues as z.core.$ZodIssue[],
)
throw new ORPCError('INPUT_VALIDATION_FAILED', {
status: 422,
message: z.prettifyError(zodError),
data: z.flattenError(zodError),
cause: error.cause,
})
}
if (
error instanceof ORPCError &&
error.code === 'INTERNAL_SERVER_ERROR' &&
error.cause instanceof ValidationError
) {
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
cause: error.cause,
})
}
}),
],
}) })
export const Route = createFileRoute('/api/$')({ export const Route = createFileRoute('/api/$')({

View File

@@ -0,0 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'
import { name, version } from '@/../package.json'
const createHealthResponse = (): Response =>
Response.json(
{
status: 'ok',
service: name,
version,
timestamp: new Date().toISOString(),
},
{
status: 200,
headers: {
'cache-control': 'no-store',
},
},
)
export const Route = createFileRoute('/api/health')({
server: {
handlers: {
GET: async () => createHealthResponse(),
HEAD: async () => new Response(null, { status: 200 }),
},
},
})

View File

@@ -1,46 +1,12 @@
import { ORPCError, onError, ValidationError } from '@orpc/server' import { onError } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch' import { RPCHandler } from '@orpc/server/fetch'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod' import { handleValidationError, logError } from '@/server/api/interceptors'
import { router } from '@/server/api/routers' import { router } from '@/server/api/routers'
const handler = new RPCHandler(router, { const handler = new RPCHandler(router, {
interceptors: [ interceptors: [onError(logError)],
onError((error) => { clientInterceptors: [onError(handleValidationError)],
console.error(error)
}),
],
clientInterceptors: [
onError((error) => {
if (
error instanceof ORPCError &&
error.code === 'BAD_REQUEST' &&
error.cause instanceof ValidationError
) {
// If you only use Zod you can safely cast to ZodIssue[]
const zodError = new z.ZodError(
error.cause.issues as z.core.$ZodIssue[],
)
throw new ORPCError('INPUT_VALIDATION_FAILED', {
status: 422,
message: z.prettifyError(zodError),
data: z.flattenError(zodError),
cause: error.cause,
})
}
if (
error instanceof ORPCError &&
error.code === 'INTERNAL_SERVER_ERROR' &&
error.cause instanceof ValidationError
) {
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
cause: error.cause,
})
}
}),
],
}) })
export const Route = createFileRoute('/api/rpc/$')({ export const Route = createFileRoute('/api/rpc/$')({

View File

@@ -1,8 +1,8 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import type { ChangeEventHandler, FormEventHandler } from 'react' import type { ChangeEventHandler, SubmitEventHandler } from 'react'
import { useState } from 'react' import { useState } from 'react'
import { orpc } from '@/client/query-client' import { orpc } from '@/client/orpc'
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
component: Todos, component: Todos,
@@ -19,7 +19,7 @@ function Todos() {
const updateMutation = useMutation(orpc.todo.update.mutationOptions()) const updateMutation = useMutation(orpc.todo.update.mutationOptions())
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions()) const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => { const handleCreateTodo: SubmitEventHandler<HTMLFormElement> = (e) => {
e.preventDefault() e.preventDefault()
if (newTodoTitle.trim()) { if (newTodoTitle.trim()) {
createMutation.mutate({ title: newTodoTitle.trim() }) createMutation.mutate({ title: newTodoTitle.trim() })
@@ -53,9 +53,7 @@ function Todos() {
{/* Header */} {/* Header */}
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight"> <h1 className="text-3xl font-bold text-slate-900 tracking-tight"></h1>
</h1>
<p className="text-slate-500 mt-1"></p> <p className="text-slate-500 mt-1"></p>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -63,9 +61,7 @@ function Todos() {
{completedCount} {completedCount}
<span className="text-slate-400 text-lg">/{totalCount}</span> <span className="text-slate-400 text-lg">/{totalCount}</span>
</div> </div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider"> <div className="text-xs font-medium text-slate-400 uppercase tracking-wider"></div>
</div>
</div> </div>
</div> </div>
@@ -112,18 +108,11 @@ function Todos() {
stroke="currentColor" stroke="currentColor"
aria-hidden="true" aria-hidden="true"
> >
<path <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg> </svg>
</div> </div>
<p className="text-slate-500 text-lg font-medium"></p> <p className="text-slate-500 text-lg font-medium"></p>
<p className="text-slate-400 text-sm mt-1"> <p className="text-slate-400 text-sm mt-1"></p>
</p>
</div> </div>
) : ( ) : (
todos.map((todo) => ( todos.map((todo) => (
@@ -151,11 +140,7 @@ function Todos() {
strokeWidth={3} strokeWidth={3}
aria-hidden="true" aria-hidden="true"
> >
<path <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg> </svg>
)} )}
</button> </button>

View File

@@ -1,25 +1,14 @@
import { oc } from '@orpc/contract' import { oc } from '@orpc/contract'
import { import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod'
createInsertSchema,
createSelectSchema,
createUpdateSchema,
} from 'drizzle-zod'
import { z } from 'zod' import { z } from 'zod'
import { generatedFieldKeys } from '@/server/db/fields'
import { todoTable } from '@/server/db/schema' import { todoTable } from '@/server/db/schema'
const selectSchema = createSelectSchema(todoTable) const selectSchema = createSelectSchema(todoTable)
const insertSchema = createInsertSchema(todoTable).omit({ const insertSchema = createInsertSchema(todoTable).omit(generatedFieldKeys)
id: true,
createdAt: true,
updatedAt: true,
})
const updateSchema = createUpdateSchema(todoTable).omit({ const updateSchema = createUpdateSchema(todoTable).omit(generatedFieldKeys)
id: true,
createdAt: true,
updatedAt: true,
})
export const list = oc.input(z.void()).output(z.array(selectSchema)) export const list = oc.input(z.void()).output(z.array(selectSchema))

View File

@@ -0,0 +1,26 @@
import { ORPCError, ValidationError } from '@orpc/server'
import { z } from 'zod'
export const logError = (error: unknown) => {
console.error(error)
}
export const handleValidationError = (error: unknown) => {
if (error instanceof ORPCError && error.code === 'BAD_REQUEST' && error.cause instanceof ValidationError) {
// If you only use Zod you can safely cast to ZodIssue[] (per ORPC official docs)
const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[])
throw new ORPCError('INPUT_VALIDATION_FAILED', {
status: 422,
message: z.prettifyError(zodError),
data: z.flattenError(zodError),
cause: error.cause,
})
}
if (error instanceof ORPCError && error.code === 'INTERNAL_SERVER_ERROR' && error.cause instanceof ValidationError) {
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
cause: error.cause,
})
}
}

View File

@@ -1,4 +1,4 @@
import { os } from '@orpc/server' import { os } from '@/server/api/server'
import { getDB } from '@/server/db' import { getDB } from '@/server/db'
export const db = os.middleware(async ({ context, next }) => { export const db = os.middleware(async ({ context, next }) => {

View File

@@ -6,44 +6,35 @@ import { os } from '../server'
export const list = os.todo.list.use(db).handler(async ({ context }) => { export const list = os.todo.list.use(db).handler(async ({ context }) => {
const todos = await context.db.query.todoTable.findMany({ const todos = await context.db.query.todoTable.findMany({
orderBy: (todos, { desc }) => [desc(todos.createdAt)], orderBy: { createdAt: 'desc' },
}) })
return todos return todos
}) })
export const create = os.todo.create export const create = os.todo.create.use(db).handler(async ({ context, input }) => {
.use(db) const [newTodo] = await context.db.insert(todoTable).values(input).returning()
.handler(async ({ context, input }) => {
const [newTodo] = await context.db
.insert(todoTable)
.values(input)
.returning()
if (!newTodo) { if (!newTodo) {
throw new ORPCError('NOT_FOUND') throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create todo' })
} }
return newTodo return newTodo
}) })
export const update = os.todo.update export const update = os.todo.update.use(db).handler(async ({ context, input }) => {
.use(db) const [updatedTodo] = await context.db.update(todoTable).set(input.data).where(eq(todoTable.id, input.id)).returning()
.handler(async ({ context, input }) => {
const [updatedTodo] = await context.db
.update(todoTable)
.set(input.data)
.where(eq(todoTable.id, input.id))
.returning()
if (!updatedTodo) { if (!updatedTodo) {
throw new ORPCError('NOT_FOUND') throw new ORPCError('NOT_FOUND')
} }
return updatedTodo return updatedTodo
}) })
export const remove = os.todo.remove export const remove = os.todo.remove.use(db).handler(async ({ context, input }) => {
.use(db) const [deleted] = await context.db.delete(todoTable).where(eq(todoTable.id, input.id)).returning({ id: todoTable.id })
.handler(async ({ context, input }) => {
await context.db.delete(todoTable).where(eq(todoTable.id, input.id)) if (!deleted) {
}) throw new ORPCError('NOT_FOUND')
}
})

View File

@@ -1,8 +1,4 @@
import type { import type { ContractRouterClient, InferContractRouterInputs, InferContractRouterOutputs } from '@orpc/contract'
ContractRouterClient,
InferContractRouterInputs,
InferContractRouterOutputs,
} from '@orpc/contract'
import type { Contract } from './contracts' import type { Contract } from './contracts'
export type RouterClient = ContractRouterClient<Contract> export type RouterClient = ContractRouterClient<Contract>

View File

@@ -4,7 +4,7 @@ import { v7 as uuidv7 } from 'uuid'
// id // id
export const id = (name: string) => uuid(name) const id = (name: string) => uuid(name)
export const pk = (name: string, strategy?: 'native' | 'extension') => { export const pk = (name: string, strategy?: 'native' | 'extension') => {
switch (strategy) { switch (strategy) {
// PG 18+ // PG 18+
@@ -25,8 +25,7 @@ export const pk = (name: string, strategy?: 'native' | 'extension') => {
// timestamp // timestamp
export const createdAt = (name = 'created_at') => export const createdAt = (name = 'created_at') => timestamp(name, { withTimezone: true }).notNull().defaultNow()
timestamp(name, { withTimezone: true }).notNull().defaultNow()
export const updatedAt = (name = 'updated_at') => export const updatedAt = (name = 'updated_at') =>
timestamp(name, { withTimezone: true }) timestamp(name, { withTimezone: true })
@@ -43,9 +42,7 @@ export const generatedFields = {
} }
// Helper to create omit keys from generatedFields // Helper to create omit keys from generatedFields
const createGeneratedFieldKeys = <T extends Record<string, unknown>>( const createGeneratedFieldKeys = <T extends Record<string, unknown>>(fields: T): Record<keyof T, true> => {
fields: T,
): Record<keyof T, true> => {
return Object.keys(fields).reduce( return Object.keys(fields).reduce(
(acc, key) => { (acc, key) => {
acc[key as keyof T] = true acc[key as keyof T] = true

View File

@@ -1,14 +1,11 @@
import { drizzle } from 'drizzle-orm/postgres-js' import { drizzle } from 'drizzle-orm/postgres-js'
import { env } from '@/env' import { env } from '@/env'
import * as schema from '@/server/db/schema' import { relations } from '@/server/db/relations'
export const createDB = () => export const createDB = () =>
drizzle({ drizzle({
connection: { connection: env.DATABASE_URL,
url: env.DATABASE_URL, relations,
prepare: true,
},
schema,
}) })
export type DB = ReturnType<typeof createDB> export type DB = ReturnType<typeof createDB>

View File

@@ -0,0 +1,4 @@
import { defineRelations } from 'drizzle-orm'
import * as schema from './schema'
export const relations = defineRelations(schema, (_r) => ({}))

View File

@@ -1,5 +1,5 @@
import { boolean, pgTable, text } from 'drizzle-orm/pg-core' import { boolean, pgTable, text } from 'drizzle-orm/pg-core'
import { generatedFields } from './utils/field' import { generatedFields } from '../fields'
export const todoTable = pgTable('todo', { export const todoTable = pgTable('todo', {
...generatedFields, ...generatedFields,

View File

@@ -4,9 +4,43 @@
"tasks": { "tasks": {
"build": { "build": {
"env": ["NODE_ENV", "VITE_*"], "env": ["NODE_ENV", "VITE_*"],
"inputs": ["src/**", "public/**", "package.json", "tsconfig.json", "vite.config.ts"],
"outputs": [".output/**"] "outputs": [".output/**"]
}, },
"compile": { "compile": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin:arm64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin:x64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux:arm64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux:x64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:windows": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:windows:x64": {
"dependsOn": ["build"],
"outputs": ["out/**"] "outputs": ["out/**"]
} }
} }

View File

@@ -4,14 +4,12 @@ import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { nitro } from 'nitro/vite' import { nitro } from 'nitro/vite'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
tanstackDevtools(), tanstackDevtools(),
tailwindcss(), tailwindcss(),
tsconfigPaths(),
tanstackStart(), tanstackStart(),
react({ react({
babel: { babel: {
@@ -23,6 +21,9 @@ export default defineConfig({
serveStatic: 'inline', serveStatic: 'inline',
}), }),
], ],
resolve: {
tsconfigPaths: true,
},
server: { server: {
port: 3000, port: 3000,
strictPort: true, strictPort: true,

View File

@@ -11,7 +11,8 @@
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "space", "indentStyle": "space",
"lineEnding": "lf" "lineEnding": "lf",
"lineWidth": 120
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,

1227
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -10,54 +10,57 @@
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"compile": "turbo run compile", "compile": "turbo run compile",
"compile:darwin": "turbo run compile:darwin",
"compile:linux": "turbo run compile:linux",
"compile:windows": "turbo run compile:windows",
"dev": "turbo run dev", "dev": "turbo run dev",
"dist": "turbo run dist",
"dist:linux": "turbo run dist:linux",
"dist:mac": "turbo run dist:mac",
"dist:win": "turbo run dist:win",
"fix": "turbo run fix", "fix": "turbo run fix",
"typecheck": "turbo run typecheck" "typecheck": "turbo run typecheck"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.14", "@biomejs/biome": "^2.4.8",
"turbo": "^2.8.3", "turbo": "^2.8.20",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"catalog": { "catalog": {
"@biomejs/biome": "^2.3.11", "@orpc/client": "^1.13.9",
"@orpc/client": "^1.13.4", "@orpc/contract": "^1.13.9",
"@orpc/contract": "^1.13.4", "@orpc/openapi": "^1.13.9",
"@orpc/openapi": "^1.13.4", "@orpc/server": "^1.13.9",
"@orpc/server": "^1.13.4", "@orpc/tanstack-query": "^1.13.9",
"@orpc/tanstack-query": "^1.13.4", "@orpc/zod": "^1.13.9",
"@orpc/zod": "^1.13.4",
"@t3-oss/env-core": "^0.13.10", "@t3-oss/env-core": "^0.13.10",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.2.2",
"@tanstack/devtools-vite": "^0.5.1", "@tanstack/devtools-vite": "^0.5.5",
"@tanstack/react-devtools": "^0.9.5", "@tanstack/react-devtools": "^0.9.13",
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.94.4",
"@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-query-devtools": "^5.94.4",
"@tanstack/react-router": "^1.158.4", "@tanstack/react-router": "^1.168.1",
"@tanstack/react-router-devtools": "^1.158.4", "@tanstack/react-router-devtools": "^1.166.10",
"@tanstack/react-router-ssr-query": "^1.158.4", "@tanstack/react-router-ssr-query": "^1.166.10",
"@tanstack/react-start": "^1.159.0", "@tanstack/react-start": "^1.167.2",
"@types/bun": "^1.3.8", "@types/bun": "^1.3.11",
"@types/node": "^24.3.0", "@types/node": "^24.12.0",
"@vitejs/plugin-react": "^5.1.3", "@vitejs/plugin-react": "^5.2.0",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"drizzle-kit": "^0.31.8", "drizzle-kit": "1.0.0-beta.15-859cf75",
"drizzle-orm": "^0.45.1", "drizzle-orm": "1.0.0-beta.15-859cf75",
"drizzle-zod": "^0.8.3", "electron": "^34.0.0",
"@hydraulic/conveyor": "^21.1.0", "electron-builder": "^26.8.1",
"electrobun": "^1.12.0-beta.1", "electron-vite": "^5.0.0",
"nitro": "npm:nitro-nightly@3.0.1-20260206-171553-bc737c0c", "motion": "^12.38.0",
"ohash": "^2.0.11", "nitro": "npm:nitro-nightly@3.0.1-20260320-182900-2218d454",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"systeminformation": "^5.30.7", "tailwindcss": "^4.2.2",
"tailwindcss": "^4.1.18", "tree-kill": "^1.2.2",
"turbo": "^2.7.5",
"typescript": "^5.9.3",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"vite": "^8.0.0-beta.13", "vite": "^8.0.1",
"vite-tsconfig-paths": "^6.1.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"overrides": { "overrides": {

View File

@@ -1,20 +0,0 @@
{
"name": "@furtherverse/utils",
"version": "1.0.0",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"dependencies": {
"ohash": "catalog:",
"systeminformation": "catalog:"
},
"devDependencies": {
"@furtherverse/tsconfig": "workspace:*"
}
}

View File

@@ -1,29 +0,0 @@
import { hash } from 'ohash'
import si from 'systeminformation'
async function getSystemInfo() {
const [uuid, baseboard, bios, system, diskLayout, networkInterfaces] =
await Promise.all([
si.uuid(),
si.baseboard(),
si.bios(),
si.system(),
si.diskLayout(),
si.networkInterfaces(),
])
return {
uuid,
baseboard,
bios,
system,
diskLayout,
networkInterfaces,
}
}
export async function getHardwareFingerprint() {
const systemInfo = await getSystemInfo()
return hash(systemInfo)
}

View File

@@ -1 +0,0 @@
export * from './fingerprint'

View File

@@ -6,9 +6,6 @@
"build": { "build": {
"dependsOn": ["^build"] "dependsOn": ["^build"]
}, },
"compile": {
"dependsOn": ["build"]
},
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true "persistent": true
@@ -17,12 +14,7 @@
"cache": false "cache": false
}, },
"typecheck": { "typecheck": {
"inputs": [ "inputs": ["package.json", "tsconfig.json", "tsconfig.*.json", "**/*.{ts,tsx,d.ts}"],
"package.json",
"tsconfig.json",
"tsconfig.*.json",
"**/*.{ts,tsx,d.ts}"
],
"outputs": [] "outputs": []
} }
}, },