1
0

feat: 搭建前后端可执行程序示例

This commit is contained in:
2026-05-09 12:25:39 +08:00
commit 5b412c624d
27 changed files with 1860 additions and 0 deletions

423
.gitignore vendored Normal file
View File

@@ -0,0 +1,423 @@
### Git.gitignore ###
# Created by git for backups. To disable backups in Git:
# $ git config --global mergetool.keepBackup false
*.orig
# Created by git when using merge tools for conflicts
*.BACKUP.*
*.BASE.*
*.LOCAL.*
*.REMOTE.*
*_BACKUP_*.txt
*_BASE_*.txt
*_LOCAL_*.txt
*_REMOTE_*.txt
### Go.gitignore ###
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
### Go.patch ###
/vendor/
/Godeps/
### JetBrains+all.gitignore ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### JetBrains+all.patch ###
# Ignores the whole .idea folder and all .iml files
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
.idea/
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
*.iml
modules.xml
.idea/misc.xml
*.ipr
# Sonarlint plugin
.idea/sonarlint
### Linux.gitignore ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Node.gitignore ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Test
playwright-report
test-results
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
### VisualStudioCode.gitignore ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
### VisualStudioCode.patch ###
# Ignore all local history of files
.history
### Windows.gitignore ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
### macOS.gitignore ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Python.gitignore ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Environments
.venv/
venv/
ENV/
env/
.python-version
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# Pyre
.pyre/
# pytype
.pytype/
# Cython debug symbols
cython_debug/
# Custom
.claude/*
!.claude/settings.json
.opencode
.codex
openspec/changes/archive
temp
.agents
skills-lock.json
.worktrees
!scripts/build/
backend/bin
backend/server
backend/desktop
# Embedfs generated
embedfs/assets/
embedfs/frontend-dist/
backend/cmd/desktop/rsrc_windows_*.syso
# Bun
.build/
*.bun-build

1
AGENTS.md Normal file
View File

@@ -0,0 +1 @@
严格遵守openspec/config.yaml中context声明的项目规范

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
严格遵守openspec/config.yaml中context声明的项目规范

102
README.md Normal file
View File

@@ -0,0 +1,102 @@
# Gateway Checker
基于 Bun + TypeScript 的前后端一体化 demo。开发期使用 Vite + React 提供前端 HMR后端由 Bun 提供 API生产期先构建前端静态资源再将前端资源和 Bun 后端打包为单个 executable。
## 项目结构
```text
src/
server/ Bun 后端运行时、API、静态资源 fallback
shared/ 前后端共享 TypeScript 类型
web/ Vite + React 前端 demo
scripts/ 开发、构建和 smoke test 脚本
tests/ Bun test 测试
openspec/ OpenSpec 变更与规格文档
```
## 开发命令
```bash
bun install
bun run dev
```
`bun run dev` 会同时启动:
- Bun 后端:默认 `http://127.0.0.1:3000`
- Vite 前端:默认 `http://127.0.0.1:5173`
开发期请打开 Vite 前端地址。前端通过相对路径 `/api/demo` 调用后端Vite 会把 `/api/*` 代理到 Bun 后端,因此浏览器不需要 CORS 配置。
也可以分别运行:
```bash
bun run dev:server
bun run dev:web
```
## Demo 验证
开发期打开 `http://127.0.0.1:5173`,页面应显示 `/api/demo` 返回的后端 message、Bun 版本、平台和响应时间。
直接验证 API
```bash
curl http://127.0.0.1:3000/api/demo
curl http://127.0.0.1:3000/health
```
## 构建 executable
```bash
bun run build
```
构建流程:
- 运行 `vite build`,输出前端资源到 `dist/web`
- 生成临时 `.build/static-assets.ts`,用 Bun file import 嵌入 Vite 产物
- 运行 `Bun.build({ compile })`,输出 `dist/gateway-checker`
运行 executable
```bash
./dist/gateway-checker
```
生产期默认访问 `http://127.0.0.1:3000`。同一个 executable 会服务 `/api/demo``/health``/assets/*` 和前端 SPA fallback。
## 运行参数
默认配置:
- `HOST=127.0.0.1`
- `PORT=3000`
可以通过环境变量或 CLI 参数覆盖:
```bash
PORT=4000 ./dist/gateway-checker
./dist/gateway-checker --host 0.0.0.0 --port 4000
```
## 测试
```bash
bun run typecheck
bun test
bun run build
bun run test:smoke
```
`test:smoke` 会启动生成的 executable并检查 `/api/demo``/health`、前端根路径、静态资源和 SPA fallback。
## 前后端边界
前端只通过 HTTP 调用后端,默认 API 路径为相对 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。
这保证了当前可以单文件部署,也保留未来将前端拆到 CDN 或独立静态站点的路径。
## 已知限制
当前 demo 不包含数据库、认证、SSR、React Router 或 UI 组件库。单 executable 是按目标平台构建的产物,不是一个文件同时覆盖 macOS、Linux 和 Windows。

140
bun.lock Normal file
View File

@@ -0,0 +1,140 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "gateway-checker",
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6",
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "^6.0.3",
"vite": "^8.0.11",
},
},
},
"packages": {
"@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@oxc-project/types": ["@oxc-project/types@0.128.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.128.0.tgz", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", { "os": "none", "cpu": "arm64" }, "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", { "os": "win32", "cpu": "x64" }, "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/bun": ["@types/bun@1.3.13", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.13.tgz", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.6.2", "https://registry.npmmirror.com/@types/node/-/node-25.6.2.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
"@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"bun-types": ["bun-types@1.3.13", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.13.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"react": ["react@19.2.6", "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"react-dom": ["react-dom@19.2.6", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
"rolldown": ["rolldown@1.0.0-rc.18", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.18.tgz", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="],
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.16", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@6.0.3", "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"vite": ["vite@8.0.11", "https://registry.npmmirror.com/vite/-/vite-8.0.11.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="],
}
}

1
index.ts Normal file
View File

@@ -0,0 +1 @@
import "./src/server/dev.ts";

21
openspec/config.yaml Normal file
View File

@@ -0,0 +1,21 @@
schema: spec-driven
context: |
- 使用中文(注释、文档、交流),面向中文开发者
- openspec文档的关键字按openspec规范使用不要翻译为中文
- **优先阅读README.md**获取项目结构与开发规范所有代码风格、命名、注解、依赖、API等规范以README为准
- 涉及模块结构、API、实体等变更时同步更新README.md
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行
- 优先使用提问工具对用户进行提问
rules:
proposal:
- 仔细审查每一个过往spec判断是否存在Modified Capabilities
design:
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
task:
- 一行一个任务,严禁任务内容跨行

View File

@@ -0,0 +1,67 @@
## Purpose
定义 Vite + React + TypeScript 前端开发工作流、开发期 API 代理、共享契约和端到端 demo 的行为要求。
## Requirements
### Requirement: Vite React 开发服务器
系统 SHALL 提供基于 Vite + React + TypeScript 的前端开发工作流,并支持热模块替换。
#### Scenario: 启动前端开发服务器
- **WHEN** 开发者启动前端开发命令
- **THEN** 前端 SHALL 由 Vite 提供服务,并启用 React 热模块替换
#### Scenario: 构建前端静态资源
- **WHEN** 开发者运行前端生产构建命令
- **THEN** 系统 SHALL 产出可由 Bun 后端服务的前端静态资源
### Requirement: 前端开发期 API 代理
前端开发服务器 SHALL 在本地开发期间将 `/api/*` 请求代理到 Bun 后端服务。
#### Scenario: 前端开发期调用 API
- **WHEN** 浏览器从 Vite 开发源请求 `/api/demo`
- **THEN** Vite SHALL 将请求转发到 Bun 后端服务,且不需要浏览器 CORS 配置
#### Scenario: 开发期访问非 API 前端路由
- **WHEN** 浏览器从 Vite 开发源请求非 API 前端路由
- **THEN** Vite SHALL 将该请求作为前端应用流量处理,而不是转发到后端
### Requirement: 前端使用相对 API 路径
除非有文档化的部署配置覆盖该行为,前端代码 MUST 通过相对 `/api/*` URL 调用后端 API。
#### Scenario: 前端获取后端数据
- **WHEN** 前端代码调用后端 API
- **THEN** 请求 URL 默认 MUST 使用相对 `/api/*` 路径
#### Scenario: 运行环境变化
- **WHEN** host 或 port 在开发环境和生产环境之间变化
- **THEN** 前端 API 调用 SHALL 无需修改源码即可继续工作
### Requirement: 端到端开发 demo
项目 SHALL 提供一个可见的开发 demo用于证明 React 前端可以通过 Vite 代理调用 Bun 后端。
#### Scenario: Demo 页面展示后端响应
- **WHEN** 开发者启动文档化的开发命令并打开前端 URL
- **THEN** 页面 SHALL 调用 `/api/demo` 并展示 Bun 后端返回的数据
#### Scenario: 开发期后端不可用
- **WHEN** 前端 demo 无法访问 `/api/demo`
- **THEN** 页面 SHALL 展示清晰的错误状态,而不是静默显示为成功
### Requirement: 集成开发命令
项目 SHALL 提供一个文档化命令,用于在 demo 开发期间同时运行前端和后端。
#### Scenario: 启动全栈开发
- **WHEN** 开发者运行文档化的全栈开发命令
- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 `/api/demo` 所需的 Bun 后端服务器
### Requirement: 共享 TypeScript 契约
项目 SHALL 为前端和后端共同使用的请求与响应类型提供共享 TypeScript 边界。
#### Scenario: 定义 API 响应结构
- **WHEN** 前端和后端都需要某个 API 响应类型
- **THEN** 该类型 SHALL 定义在 shared 模块中,而不是在两端重复定义
#### Scenario: 前端导入共享类型
- **WHEN** 前端代码导入共享 API 类型
- **THEN** 该导入 SHALL 不要求将后端运行时实现打包进前端

View File

@@ -0,0 +1,71 @@
## Purpose
定义 Bun 全栈应用运行时的 HTTP server、API 命名空间、健康检查、生产静态资源服务和 SPA fallback 行为。
## Requirements
### Requirement: Bun HTTP 运行时
系统 SHALL 运行一个 Bun HTTP server由单个进程提供后端 API、健康检查、生产静态资源和 SPA fallback 行为。
#### Scenario: 启动运行时服务器
- **WHEN** server 进程成功启动
- **THEN** 它 SHALL 监听配置的 host 和 port并记录实际 server URL
#### Scenario: 提供运行时配置
- **WHEN** 通过支持的运行时配置提供 host 或 port
- **THEN** server SHALL 使用该值,且不需要重新构建
### Requirement: API 路由命名空间
系统 MUST 将 `/api/*` 保留给后端 API 路由。
#### Scenario: API 路由匹配
- **WHEN** 请求匹配已注册的 `/api/*` 路由
- **THEN** Bun server SHALL 返回 API handler 的响应
#### Scenario: API 路由未命中
- **WHEN** 请求访问未注册的 `/api/*` 路由
- **THEN** Bun server MUST 返回 JSON 404 响应,而不是前端 HTML 文档
### Requirement: Demo API 端点
系统 SHALL 暴露 `/api/demo` 作为稳定 demo 端点,用于证明前后端集成可用。
#### Scenario: Demo API 成功响应
- **WHEN** 客户端请求 `/api/demo`
- **THEN** Bun server SHALL 返回包含可读 message 和 runtime metadata 的 JSON 响应
#### Scenario: Demo API 内容类型
- **WHEN** 客户端请求 `/api/demo`
- **THEN** Bun server SHALL 返回 JSON content type 的响应
### Requirement: 健康检查端点
系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。
#### Scenario: 健康检查成功
- **WHEN** 客户端请求 `/health`
- **THEN** Bun server SHALL 返回成功的、机器可读的健康检查响应
### Requirement: 生产静态资源服务
系统 SHALL 在生产模式下由 Bun runtime 服务 Vite 生产资源。
#### Scenario: 请求构建后的资源
- **WHEN** 客户端请求构建后的前端资源,例如 `/assets/app.js`
- **THEN** Bun server SHALL 返回该资源并带有适当的 content type
#### Scenario: 请求前端根路径
- **WHEN** 客户端请求 `/`
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
#### Scenario: 生产 demo 页面调用 API
- **WHEN** 客户端从生产 Bun runtime 打开前端页面
- **THEN** demo 页面 SHALL 能够从同源调用 `/api/demo` 并展示后端响应
### Requirement: SPA fallback 行为
系统 SHALL 在生产环境中为非 API、非静态资源的前端路由返回前端入口 HTML 文档。
#### Scenario: 刷新前端路由
- **WHEN** 客户端请求前端路由,例如 `/dashboard`
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
#### Scenario: 保留 API 错误语义
- **WHEN** 客户端请求未知的 `/api/*` 路由
- **THEN** Bun server MUST NOT 返回前端入口 HTML 文档

View File

@@ -0,0 +1,53 @@
## Purpose
定义将 Vite 前端资源与 Bun 后端打包为单个 standalone executable 的生产构建、运行配置和验证要求。
## Requirements
### Requirement: 生产构建顺序
生产构建 MUST 在编译 Bun 后端 executable 之前先构建 Vite 前端。
#### Scenario: 运行生产构建
- **WHEN** 开发者运行生产构建命令
- **THEN** 系统 MUST 在调用 Bun standalone executable 编译之前生成前端静态资源
#### Scenario: 前端构建失败
- **WHEN** 前端生产构建失败
- **THEN** 系统 MUST 停止生产构建,且不能输出 stale executable
### Requirement: 单 executable 输出
生产构建 SHALL 输出一个 standalone executable其中包含 Bun 后端、必要 server 依赖和构建后的前端资源。
#### Scenario: 在目标机器运行 executable
- **WHEN** 生成的 executable 在兼容目标平台上运行
- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun、Vite 或 `node_modules`
#### Scenario: 服务嵌入的前端
- **WHEN** executable 收到前端根路径请求
- **THEN** 它 SHALL 从 executable 内包含的资源服务前端,且不需要外部 `dist/` 目录
#### Scenario: 服务嵌入 demo API 和页面
- **WHEN** 生成的 executable 启动,且浏览器打开前端根路径
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/demo` 返回的数据
### Requirement: 外部运行时配置
executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。
#### Scenario: 修改监听端口
- **WHEN** 操作者修改受支持的 port 配置
- **THEN** 同一个 executable SHALL 在不重新构建的情况下监听新端口
#### Scenario: 缺少可选配置
- **WHEN** 可选运行时配置被省略
- **THEN** executable SHALL 使用文档化的默认值
### Requirement: 构建验证
项目 SHALL 提供验证,证明生产 executable 可以服务 API、健康检查、静态资源和 SPA fallback 路由。
#### Scenario: 验证 executable 路由
- **WHEN** 构建验证针对生成的 executable 运行
- **THEN** 它 SHALL 检查 `/api/demo``/health`、前端根路径、静态资源和前端 fallback 请求
#### Scenario: 验证失败
- **WHEN** 任一代表性生产路由检查失败
- **THEN** 验证 SHALL 使构建或测试命令失败

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "gateway-checker",
"module": "src/server/dev.ts",
"type": "module",
"private": true,
"scripts": {
"dev": "bun run scripts/dev.ts",
"dev:server": "bun --watch src/server/dev.ts",
"dev:web": "bunx --bun vite --host 127.0.0.1",
"build:web": "bunx --bun vite build",
"build": "bun run scripts/build.ts",
"start": "bun src/server/dev.ts",
"test": "bun test",
"test:smoke": "bun run scripts/smoke.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "^6.0.3",
"vite": "^8.0.11"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
}
}

122
scripts/build.ts Normal file
View File

@@ -0,0 +1,122 @@
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { dirname, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { $ } from "bun";
const rootDir = fileURLToPath(new URL("../", import.meta.url));
const buildDir = fileURLToPath(new URL("../.build/", import.meta.url));
const webDistDir = fileURLToPath(new URL("../dist/web/", import.meta.url));
const executablePath = fileURLToPath(new URL("../dist/gateway-checker", import.meta.url));
const generatedAssetsPath = fileURLToPath(new URL("../.build/static-assets.ts", import.meta.url));
const generatedEntryPath = fileURLToPath(new URL("../.build/server-entry.ts", import.meta.url));
await rm(buildDir, { recursive: true, force: true });
await rm(executablePath, { force: true });
await mkdir(buildDir, { recursive: true });
await $`bunx --bun vite build`;
const files = await listFiles(webDistDir);
const indexPath = files.find((file) => normalize(relative(webDistDir, file)) === "index.html");
if (!indexPath) {
throw new Error("Vite build 未生成 dist/web/index.html");
}
const assetFiles = files.filter((file) => file !== indexPath);
await writeGeneratedAssets(indexPath, assetFiles);
await writeGeneratedEntry();
const target = process.env.BUN_TARGET ?? process.env.BUILD_TARGET;
const result = await Bun.build({
entrypoints: [generatedEntryPath],
compile: target
? {
target: target as Bun.Build.CompileTarget,
outfile: executablePath,
autoloadDotenv: true,
autoloadBunfig: true,
}
: {
outfile: executablePath,
autoloadDotenv: true,
autoloadBunfig: true,
},
minify: true,
sourcemap: "linked",
});
if (!result.success) {
await rm(executablePath, { force: true });
throw new Error("Bun executable 构建失败");
}
console.log(`Built executable: ${executablePath}`);
async function listFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const path = `${directory.replace(/\/$/, "")}/${entry.name}`;
if (entry.isDirectory()) {
return listFiles(path);
}
return [path];
}),
);
return files.flat();
}
async function writeGeneratedAssets(indexPath: string, assetFiles: string[]) {
const imports = [
`import type { StaticAssets } from "../src/server/app";`,
`import indexPath from "${toImportPath(indexPath)}" with { type: "file" };`,
...assetFiles.map(
(file, index) => `import asset${index}Path from "${toImportPath(file)}" with { type: "file" };`,
),
];
const assetEntries = assetFiles.map((file, index) => {
const urlPath = `/${normalize(relative(webDistDir, file))}`;
return ` ${JSON.stringify(urlPath)}: Bun.file(asset${index}Path),`;
});
const source = `${imports.join("\n")}
export const staticAssets: StaticAssets = {
indexHtml: Bun.file(indexPath),
files: {
${assetEntries.join("\n")}
},
};
`;
await mkdir(dirname(generatedAssetsPath), { recursive: true });
await writeFile(generatedAssetsPath, source);
}
async function writeGeneratedEntry() {
await writeFile(
generatedEntryPath,
`import { readRuntimeConfig } from "../src/server/config";
import { startServer } from "../src/server/server";
import { staticAssets } from "./static-assets";
startServer({
config: readRuntimeConfig(),
mode: "production",
staticAssets,
});
`,
);
}
function toImportPath(path: string): string {
const rel = normalize(relative(buildDir, path));
return rel.startsWith(".") ? rel : `./${rel}`;
}
function normalize(path: string): string {
return path.split(sep).join("/");
}

55
scripts/dev.ts Normal file
View File

@@ -0,0 +1,55 @@
interface ChildProcessInfo {
name: string;
process: Bun.Subprocess;
}
const env = {
...process.env,
BACKEND_PORT: process.env.BACKEND_PORT ?? process.env.PORT ?? "3000",
};
const children: ChildProcessInfo[] = [
{
name: "server",
process: Bun.spawn(["bun", "run", "dev:server"], {
env,
stdout: "inherit",
stderr: "inherit",
}),
},
{
name: "web",
process: Bun.spawn(["bun", "run", "dev:web"], {
env,
stdout: "inherit",
stderr: "inherit",
}),
},
];
const stopChildren = () => {
for (const child of children) {
child.process.kill();
}
};
process.on("SIGINT", () => {
stopChildren();
process.exit(130);
});
process.on("SIGTERM", () => {
stopChildren();
process.exit(143);
});
const firstExit = await Promise.race(
children.map(async (child) => ({ name: child.name, code: await child.process.exited })),
);
stopChildren();
if (firstExit.code !== 0) {
console.error(`${firstExit.name} exited with code ${firstExit.code}`);
process.exit(firstExit.code ?? 1);
}

130
scripts/smoke.ts Normal file
View File

@@ -0,0 +1,130 @@
import { access } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { DemoResponse, HealthResponse } from "../src/shared/api";
const executablePath = process.argv[2] ?? fileURLToPath(new URL("../dist/gateway-checker", import.meta.url));
await assertExecutableExists(executablePath);
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const app = Bun.spawn([executablePath, "--host", "127.0.0.1", "--port", String(port)], {
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
HOST: "127.0.0.1",
PORT: String(port),
},
});
const stdout = readStream(app.stdout);
const stderr = readStream(app.stderr);
try {
await waitForServer(`${baseUrl}/health`);
const health = await expectJson<HealthResponse>(`${baseUrl}/health`, 200);
assert(health.ok === true, "健康检查响应缺少 ok=true");
const demo = await expectJson<DemoResponse>(`${baseUrl}/api/demo`, 200);
assert(demo.message.includes("/api/demo"), "demo 响应未包含预期 message");
const missingApi = await fetch(`${baseUrl}/api/not-found`);
assert(missingApi.status === 404, "未知 API 应返回 404");
assert(
missingApi.headers.get("content-type")?.includes("application/json") === true,
"未知 API 应返回 JSON",
);
const rootHtml = await expectText(`${baseUrl}/`, 200);
assert(rootHtml.includes("Gateway Checker Demo"), "前端根页面缺少 demo 标题");
const fallbackHtml = await expectText(`${baseUrl}/dashboard`, 200);
assert(fallbackHtml.includes("Gateway Checker Demo"), "SPA fallback 未返回前端入口页面");
const assetPath = rootHtml.match(/(?:src|href)="(\/assets\/[^"]+)"/)?.[1];
assert(assetPath !== undefined, "前端入口页面未引用 /assets/* 资源");
const asset = await fetch(`${baseUrl}${assetPath}`);
assert(asset.status === 200, `静态资源 ${assetPath} 未返回 200`);
console.log(`Smoke test passed: ${baseUrl}`);
} catch (error) {
app.kill();
const [out, err] = await Promise.all([stdout, stderr]);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`executable smoke test 失败: ${message}\nstdout:\n${out}\nstderr:\n${err}`);
} finally {
app.kill();
}
async function assertExecutableExists(path: string) {
try {
await access(path);
} catch {
throw new Error(`找不到 executable: ${path},请先运行 bun run build`);
}
}
async function getFreePort(): Promise<number> {
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => new Response("ok"),
});
const port = server.port;
server.stop(true);
if (port === undefined) {
throw new Error("无法分配 smoke test 端口");
}
return port;
}
async function waitForServer(url: string) {
const deadline = Date.now() + 8_000;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
await Bun.sleep(100);
}
}
throw new Error(`服务未在超时时间内启动: ${url}`);
}
async function expectJson<T>(url: string, status: number): Promise<T> {
const response = await fetch(url);
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
assert(response.headers.get("content-type")?.includes("application/json") === true, `${url} 应返回 JSON`);
return (await response.json()) as T;
}
async function expectText(url: string, status: number): Promise<string> {
const response = await fetch(url);
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
return response.text();
}
function assert(condition: boolean, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
if (!stream) return "";
return new Response(stream).text();
}

111
src/server/app.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { ApiErrorResponse, DemoResponse, HealthResponse, RuntimeMode } from "../shared/api";
export interface StaticAssets {
indexHtml: Blob;
files: Record<string, Blob>;
}
export interface AppOptions {
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
export function createFetchHandler(options: AppOptions) {
return (request: Request): Response => {
const url = new URL(request.url);
if (url.pathname === "/health") {
return Response.json(createHealthResponse());
}
if (url.pathname === "/api/demo") {
return Response.json(createDemoResponse(options.mode));
}
if (url.pathname.startsWith("/api/")) {
return Response.json(createApiError("API route not found", 404), { status: 404 });
}
if (options.staticAssets) {
return serveStaticAsset(url.pathname, options.staticAssets);
}
return new Response("开发期请通过 Vite 前端地址访问页面。", {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
};
}
function createDemoResponse(mode: RuntimeMode): DemoResponse {
return {
message: "Bun 后端已通过 /api/demo 连接到 React 前端。",
runtime: {
mode,
bunVersion: Bun.version,
platform: process.platform,
arch: process.arch,
timestamp: new Date().toISOString(),
},
};
}
function createHealthResponse(): HealthResponse {
return {
ok: true,
service: "gateway-checker",
timestamp: new Date().toISOString(),
};
}
function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
}
function serveStaticAsset(pathname: string, staticAssets: StaticAssets): Response {
if (pathname === "/") {
return htmlResponse(staticAssets.indexHtml);
}
const asset = staticAssets.files[pathname];
if (asset) {
return new Response(asset, {
headers: {
"Content-Type": contentTypeFor(pathname),
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) {
return new Response("Not Found", { status: 404 });
}
return htmlResponse(staticAssets.indexHtml);
}
function htmlResponse(indexHtml: Blob): Response {
return new Response(indexHtml, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache",
},
});
}
function hasFileExtension(pathname: string): boolean {
return /\/[^/]+\.[^/]+$/.test(pathname);
}
function contentTypeFor(pathname: string): string {
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8";
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
if (pathname.endsWith(".svg")) return "image/svg+xml";
if (pathname.endsWith(".json")) return "application/json; charset=utf-8";
if (pathname.endsWith(".png")) return "image/png";
if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg";
if (pathname.endsWith(".ico")) return "image/x-icon";
return "application/octet-stream";
}

39
src/server/config.ts Normal file
View File

@@ -0,0 +1,39 @@
export interface RuntimeConfig {
host: string;
port: number;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
export function readRuntimeConfig(
argv: string[] = process.argv.slice(2),
env: Record<string, string | undefined> = Bun.env,
): RuntimeConfig {
const host = readOption(argv, "host") ?? env.HOST ?? DEFAULT_HOST;
const portValue = readOption(argv, "port") ?? env.PORT ?? String(DEFAULT_PORT);
const port = Number(portValue);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`无效端口: ${portValue}`);
}
return { host, port };
}
function readOption(argv: string[], name: string): string | undefined {
const prefix = `--${name}=`;
const inline = argv.find((value) => value.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length);
}
const index = argv.indexOf(`--${name}`);
if (index >= 0) {
return argv[index + 1];
}
return undefined;
}

7
src/server/dev.ts Normal file
View File

@@ -0,0 +1,7 @@
import { readRuntimeConfig } from "./config";
import { startServer } from "./server";
startServer({
config: readRuntimeConfig(),
mode: "development",
});

25
src/server/server.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { RuntimeMode } from "../shared/api";
import { createFetchHandler, type StaticAssets } from "./app";
import { readRuntimeConfig, type RuntimeConfig } from "./config";
export interface StartServerOptions {
config?: RuntimeConfig;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
export function startServer(options: StartServerOptions) {
const config = options.config ?? readRuntimeConfig();
const server = Bun.serve({
hostname: config.host,
port: config.port,
fetch: createFetchHandler({
mode: options.mode,
staticAssets: options.staticAssets,
}),
});
console.log(`Gateway Checker listening on ${server.url}`);
return server;
}

23
src/shared/api.ts Normal file
View File

@@ -0,0 +1,23 @@
export type RuntimeMode = "development" | "production" | "test";
export interface DemoResponse {
message: string;
runtime: {
mode: RuntimeMode;
bunVersion: string;
platform: string;
arch: string;
timestamp: string;
};
}
export interface HealthResponse {
ok: true;
service: "gateway-checker";
timestamp: string;
}
export interface ApiErrorResponse {
error: string;
status: number;
}

91
src/web/app.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import type { DemoResponse } from "../shared/api";
type DemoState =
| { status: "loading" }
| { status: "success"; data: DemoResponse }
| { status: "error"; message: string };
export function App() {
const [demoState, setDemoState] = useState<DemoState>({ status: "loading" });
useEffect(() => {
const abortController = new AbortController();
async function loadDemo() {
try {
const response = await fetch("/api/demo", { signal: abortController.signal });
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`);
}
const data = (await response.json()) as DemoResponse;
setDemoState({ status: "success", data });
} catch (error) {
if (abortController.signal.aborted) return;
setDemoState({
status: "error",
message: error instanceof Error ? error.message : "未知错误",
});
}
}
void loadDemo();
return () => abortController.abort();
}, []);
return (
<main className="shell">
<section className="hero" aria-labelledby="page-title">
<p className="eyebrow">Vite + React + Bun</p>
<h1 id="page-title">Gateway Checker Demo</h1>
<p className="summary">Bun API </p>
</section>
<section className="card" aria-live="polite">
<div className="card-header">
<span className="status-dot" data-state={demoState.status} />
<h2></h2>
</div>
{demoState.status === "loading" ? <p> /api/demo...</p> : null}
{demoState.status === "error" ? (
<div className="error">
<strong></strong>
<p>{demoState.message}</p>
</div>
) : null}
{demoState.status === "success" ? (
<div className="result">
<p className="message">{demoState.data.message}</p>
<dl>
<div>
<dt></dt>
<dd>{demoState.data.runtime.mode}</dd>
</div>
<div>
<dt>Bun </dt>
<dd>{demoState.data.runtime.bunVersion}</dd>
</div>
<div>
<dt></dt>
<dd>
{demoState.data.runtime.platform}/{demoState.data.runtime.arch}
</dd>
</div>
<div>
<dt></dt>
<dd>{demoState.data.runtime.timestamp}</dd>
</div>
</dl>
</div>
) : null}
</section>
</main>
);
}

13
src/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Gateway Checker Vite React Bun executable demo" />
<title>Gateway Checker Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

16
src/web/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./styles.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("找不到前端挂载节点 #root");
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);

169
src/web/styles.css Normal file
View File

@@ -0,0 +1,169 @@
:root {
color: #102033;
background: #edf3f8;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
.shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 460px);
gap: 32px;
align-items: center;
min-height: 100vh;
padding: 56px;
background:
radial-gradient(circle at top left, rgba(55, 125, 255, 0.18), transparent 34rem),
linear-gradient(135deg, #f8fbff 0%, #e3edf7 100%);
}
.hero,
.card {
border: 1px solid rgba(49, 83, 126, 0.14);
border-radius: 28px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 24px 80px rgba(34, 57, 91, 0.16);
backdrop-filter: blur(18px);
}
.hero {
padding: 48px;
}
.eyebrow {
margin: 0 0 18px;
color: #356dd2;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
max-width: 760px;
margin-bottom: 20px;
font-size: clamp(3rem, 8vw, 7rem);
line-height: 0.9;
letter-spacing: -0.08em;
}
.summary {
max-width: 620px;
margin-bottom: 0;
color: #42546c;
font-size: 1.2rem;
line-height: 1.8;
}
.card {
padding: 32px;
}
.card-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 24px;
}
.card-header h2 {
margin: 0;
font-size: 1.25rem;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: #f5a524;
box-shadow: 0 0 0 8px rgba(245, 165, 36, 0.14);
}
.status-dot[data-state="success"] {
background: #1fbf75;
box-shadow: 0 0 0 8px rgba(31, 191, 117, 0.14);
}
.status-dot[data-state="error"] {
background: #e5484d;
box-shadow: 0 0 0 8px rgba(229, 72, 77, 0.14);
}
.error {
padding: 16px;
border: 1px solid rgba(229, 72, 77, 0.25);
border-radius: 18px;
color: #9f2228;
background: rgba(255, 240, 240, 0.8);
}
.error p,
.message {
margin-bottom: 0;
}
.result {
display: grid;
gap: 24px;
}
.message {
color: #1c3f73;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.6;
}
dl {
display: grid;
gap: 12px;
margin: 0;
}
dl div {
display: grid;
gap: 4px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(236, 243, 252, 0.74);
}
dt {
color: #61728a;
font-size: 0.78rem;
}
dd {
margin: 0;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (max-width: 860px) {
.shell {
grid-template-columns: 1fr;
padding: 24px;
}
.hero,
.card {
padding: 28px;
border-radius: 22px;
}
}

69
tests/server/app.test.ts Normal file
View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test";
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
const staticAssets: StaticAssets = {
indexHtml: new Blob(["<!doctype html><title>Gateway Checker Demo</title><div id=\"root\"></div>"], {
type: "text/html",
}),
files: {
"/assets/app.js": new Blob(["console.log('demo');"], { type: "text/javascript" }),
},
};
describe("Bun fullstack runtime", () => {
const fetchHandler = createFetchHandler({ mode: "test", staticAssets });
test("/api/demo 返回 JSON demo 响应", async () => {
const response = await fetchHandler(new Request("http://localhost/api/demo"));
const body = await response.json();
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(body.message).toContain("/api/demo");
expect(body.runtime.mode).toBe("test");
});
test("/health 返回机器可读健康检查", async () => {
const response = await fetchHandler(new Request("http://localhost/health"));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.service).toBe("gateway-checker");
});
test("未知 /api/* 路由返回 JSON 404", async () => {
const response = await fetchHandler(new Request("http://localhost/api/missing"));
const body = await response.json();
expect(response.status).toBe(404);
expect(response.headers.get("content-type")).toContain("application/json");
expect(body.status).toBe(404);
});
test("生产根路径返回前端入口", async () => {
const response = await fetchHandler(new Request("http://localhost/"));
const body = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(body).toContain("Gateway Checker Demo");
});
test("生产静态资源返回正确内容类型", async () => {
const response = await fetchHandler(new Request("http://localhost/assets/app.js"));
const body = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/javascript");
expect(body).toContain("demo");
});
test("前端路由 fallback 到入口 HTML", async () => {
const response = await fetchHandler(new Request("http://localhost/dashboard"));
const body = await response.text();
expect(response.status).toBe(200);
expect(body).toContain("Gateway Checker Demo");
});
});

View File

@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import { readRuntimeConfig } from "../../src/server/config";
describe("runtime config", () => {
test("默认使用 127.0.0.1:3000", () => {
expect(readRuntimeConfig([], {})).toEqual({ host: "127.0.0.1", port: 3000 });
});
test("CLI 参数优先于环境变量", () => {
expect(readRuntimeConfig(["--host", "0.0.0.0", "--port", "4001"], { HOST: "127.0.0.1", PORT: "3001" })).toEqual({
host: "0.0.0.0",
port: 4001,
});
});
test("支持 inline CLI 参数", () => {
expect(readRuntimeConfig(["--host=localhost", "--port=4002"], {})).toEqual({
host: "localhost",
port: 4002,
});
});
test("拒绝无效端口", () => {
expect(() => readRuntimeConfig(["--port", "invalid"], {})).toThrow("无效端口");
});
});

30
tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun", "vite/client"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

25
vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const backendPort = Number(process.env.BACKEND_PORT ?? process.env.PORT ?? 3000);
export default defineConfig({
root: "src/web",
plugins: [react()],
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
proxy: {
"/api": {
target: `http://127.0.0.1:${backendPort}`,
changeOrigin: true,
},
},
},
build: {
outDir: "../../dist/web",
emptyOutDir: true,
assetsDir: "assets",
},
});