commit 5b412c624d31e727c1203091d29f536aeff0390b Author: lanyuanxiaoyao Date: Sat May 9 12:25:39 2026 +0800 feat: 搭建前后端可执行程序示例 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..593b2cd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..874fa8e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +严格遵守openspec/config.yaml中context声明的项目规范 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..874fa8e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +严格遵守openspec/config.yaml中context声明的项目规范 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..275800d --- /dev/null +++ b/README.md @@ -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。 diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..7280011 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..2b3fe0c --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +import "./src/server/dev.ts"; diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..95b0c16 --- /dev/null +++ b/openspec/config.yaml @@ -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: + - 一行一个任务,严禁任务内容跨行 diff --git a/openspec/specs/frontend-development-workflow/spec.md b/openspec/specs/frontend-development-workflow/spec.md new file mode 100644 index 0000000..0a74e22 --- /dev/null +++ b/openspec/specs/frontend-development-workflow/spec.md @@ -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 不要求将后端运行时实现打包进前端 diff --git a/openspec/specs/fullstack-app-runtime/spec.md b/openspec/specs/fullstack-app-runtime/spec.md new file mode 100644 index 0000000..a516196 --- /dev/null +++ b/openspec/specs/fullstack-app-runtime/spec.md @@ -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 文档 diff --git a/openspec/specs/single-executable-packaging/spec.md b/openspec/specs/single-executable-packaging/spec.md new file mode 100644 index 0000000..8189335 --- /dev/null +++ b/openspec/specs/single-executable-packaging/spec.md @@ -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 使构建或测试命令失败 diff --git a/package.json b/package.json new file mode 100644 index 0000000..1324456 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..4d2b0aa --- /dev/null +++ b/scripts/build.ts @@ -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 { + 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("/"); +} diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..ca4de2c --- /dev/null +++ b/scripts/dev.ts @@ -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); +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts new file mode 100644 index 0000000..a89ca0d --- /dev/null +++ b/scripts/smoke.ts @@ -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(`${baseUrl}/health`, 200); + assert(health.ok === true, "健康检查响应缺少 ok=true"); + + const demo = await expectJson(`${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 { + 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(url: string, status: number): Promise { + 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 { + 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 | null): Promise { + if (!stream) return ""; + + return new Response(stream).text(); +} diff --git a/src/server/app.ts b/src/server/app.ts new file mode 100644 index 0000000..46cdc37 --- /dev/null +++ b/src/server/app.ts @@ -0,0 +1,111 @@ +import type { ApiErrorResponse, DemoResponse, HealthResponse, RuntimeMode } from "../shared/api"; + +export interface StaticAssets { + indexHtml: Blob; + files: Record; +} + +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"; +} diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..f31e70c --- /dev/null +++ b/src/server/config.ts @@ -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 = 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; +} diff --git a/src/server/dev.ts b/src/server/dev.ts new file mode 100644 index 0000000..29dbe1a --- /dev/null +++ b/src/server/dev.ts @@ -0,0 +1,7 @@ +import { readRuntimeConfig } from "./config"; +import { startServer } from "./server"; + +startServer({ + config: readRuntimeConfig(), + mode: "development", +}); diff --git a/src/server/server.ts b/src/server/server.ts new file mode 100644 index 0000000..fa3a9ac --- /dev/null +++ b/src/server/server.ts @@ -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; +} diff --git a/src/shared/api.ts b/src/shared/api.ts new file mode 100644 index 0000000..1daeb39 --- /dev/null +++ b/src/shared/api.ts @@ -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; +} diff --git a/src/web/app.tsx b/src/web/app.tsx new file mode 100644 index 0000000..2f14f66 --- /dev/null +++ b/src/web/app.tsx @@ -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({ 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 ( +
+
+

Vite + React + Bun

+

Gateway Checker Demo

+

这个页面用于验证前端开发、Bun 后端 API 和单可执行文件打包链路已经跑通。

+
+ +
+
+ +

后端连接状态

+
+ + {demoState.status === "loading" ?

正在请求 /api/demo...

: null} + + {demoState.status === "error" ? ( +
+ 请求失败 +

{demoState.message}

+
+ ) : null} + + {demoState.status === "success" ? ( +
+

{demoState.data.message}

+
+
+
运行模式
+
{demoState.data.runtime.mode}
+
+
+
Bun 版本
+
{demoState.data.runtime.bunVersion}
+
+
+
平台
+
+ {demoState.data.runtime.platform}/{demoState.data.runtime.arch} +
+
+
+
响应时间
+
{demoState.data.runtime.timestamp}
+
+
+
+ ) : null} +
+
+ ); +} diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..a2d1c56 --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Gateway Checker Demo + + +
+ + + diff --git a/src/web/main.tsx b/src/web/main.tsx new file mode 100644 index 0000000..38cb9cd --- /dev/null +++ b/src/web/main.tsx @@ -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( + + + , +); diff --git a/src/web/styles.css b/src/web/styles.css new file mode 100644 index 0000000..45fccc1 --- /dev/null +++ b/src/web/styles.css @@ -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; + } +} diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts new file mode 100644 index 0000000..06fc401 --- /dev/null +++ b/tests/server/app.test.ts @@ -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(["Gateway Checker Demo
"], { + 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"); + }); +}); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts new file mode 100644 index 0000000..ba48a4d --- /dev/null +++ b/tests/server/config.test.ts @@ -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("无效端口"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..612e431 --- /dev/null +++ b/tsconfig.json @@ -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 + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..500cc9b --- /dev/null +++ b/vite.config.ts @@ -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", + }, +});