chore: initialize frontend project
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
GO_BACKEND_URL=http://127.0.0.1:8080
|
||||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.nitro
|
||||||
|
.tanstack
|
||||||
|
.wrangler
|
||||||
|
.output
|
||||||
|
.vinxi
|
||||||
|
__unconfig*
|
||||||
|
todos.json
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.cta.json
|
||||||
|
.codex-dev-job-id
|
||||||
82
AGENTS.md
Normal file
82
AGENTS.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<!-- intent-skills:start -->
|
||||||
|
# Skill mappings - load `use` with `pnpm dlx @tanstack/intent@latest load <use>`.
|
||||||
|
skills:
|
||||||
|
- when: "Install TanStack Devtools, pick framework adapter (React/Vue/Solid/Preact), register plugins via plugins prop, configure shell (position, hotkeys, theme, hideUntilHover, requireUrlFlag, eventBusConfig). TanStackDevtools component, defaultOpen, localStorage persistence."
|
||||||
|
use: "@tanstack/devtools#devtools-app-setup"
|
||||||
|
- when: "Publish plugin to npm and submit to TanStack Devtools Marketplace. PluginMetadata registry format, plugin-registry.ts, pluginImport (importName, type), requires (packageName, minVersion), framework tagging, multi-framework submissions, featured plugins."
|
||||||
|
use: "@tanstack/devtools#devtools-marketplace"
|
||||||
|
- when: "Build devtools panel components that display emitted event data. Listen via EventClient.on(), handle theme (light/dark), use @tanstack/devtools-ui components. Plugin registration (name, render, id, defaultOpen), lifecycle (mount, activate, destroy), max 3 active plugins. Two paths: Solid.js core with devtools-ui for multi-framework support, or framework-specific panels."
|
||||||
|
use: "@tanstack/devtools#devtools-plugin-panel"
|
||||||
|
- when: "Handle devtools in production vs development. removeDevtoolsOnBuild, devDependency vs regular dependency, conditional imports, NoOp plugin variants for tree-shaking, non-Vite production exclusion patterns."
|
||||||
|
use: "@tanstack/devtools#devtools-production"
|
||||||
|
- when: "Two-way event patterns between devtools panel and application. App-to-devtools observation, devtools-to-app commands, time-travel debugging with snapshots and revert. structuredClone for snapshot safety, distinct event suffixes for observation vs commands, serializable payloads only."
|
||||||
|
use: "@tanstack/devtools-event-client#devtools-bidirectional"
|
||||||
|
- when: "Create typed EventClient for a library. Define event maps with typed payloads, pluginId auto-prepend namespacing, emit()/on()/onAll()/onAllPluginEvents() API. Connection lifecycle (5 retries, 300ms), event queuing, enabled/disabled state, SSR fallbacks, singleton pattern. Unique pluginId requirement to avoid event collisions."
|
||||||
|
use: "@tanstack/devtools-event-client#devtools-event-client"
|
||||||
|
- when: "Analyze library codebase for critical architecture and debugging points, add strategic event emissions. Identify middleware boundaries, state transitions, lifecycle hooks. Consolidate events (1 not 15), debounce high-frequency updates, DRY shared payload fields, guard emit() for production. Transparent server/client event bridging."
|
||||||
|
use: "@tanstack/devtools-event-client#devtools-instrumentation"
|
||||||
|
- when: "Configure @tanstack/devtools-vite for source inspection (data-tsd-source, inspectHotkey, ignore patterns), console piping (client-to-server, server-to-client, levels), enhanced logging, server event bus (port, host, HTTPS), production stripping (removeDevtoolsOnBuild), editor integration (launch-editor, custom editor.open). Must be FIRST plugin in Vite config. Vite ^6 || ^7 only."
|
||||||
|
use: "@tanstack/devtools-vite#devtools-vite-plugin"
|
||||||
|
- when: "Step-by-step migration from Next.js App Router to TanStack Start: route definition conversion, API mapping, server function conversion from Server Actions, middleware conversion, data fetching pattern changes."
|
||||||
|
use: "@tanstack/react-start#lifecycle/migrate-from-nextjs"
|
||||||
|
- when: "React bindings for TanStack Start: createStart, StartClient, StartServer, React-specific imports, re-exports from @tanstack/react-router, full project setup with React, useServerFn hook."
|
||||||
|
use: "@tanstack/react-start#react-start"
|
||||||
|
- when: "Implement, review, debug, and refactor TanStack Start React Server Components in React 19 apps. Use when tasks mention @tanstack/react-start/rsc, renderServerComponent, createCompositeComponent, CompositeComponent, renderToReadableStream, createFromReadableStream, createFromFetch, Composite Components, React Flight streams, loader or query owned RSC caching, router.invalidate, structuralSharing: false, selective SSR, stale names like renderRsc or .validator, or migration from Next App Router RSC patterns. Do not use for generic SSR or non-TanStack RSC frameworks except brief comparison."
|
||||||
|
use: "@tanstack/react-start#react-start/server-components"
|
||||||
|
- when: "Framework-agnostic core concepts for TanStack Router: route trees, createRouter, createRoute, createRootRoute, createRootRouteWithContext, addChildren, Register type declaration, route matching, route sorting, file naming conventions. Entry point for all router skills."
|
||||||
|
use: "@tanstack/router-core#router-core"
|
||||||
|
- when: "Route protection with beforeLoad, redirect()/throw redirect(), isRedirect helper, authenticated layout routes (_authenticated), non-redirect auth (inline login), RBAC with roles and permissions, auth provider integration (Auth0, Clerk, Supabase), router context for auth state."
|
||||||
|
use: "@tanstack/router-core#router-core/auth-and-guards"
|
||||||
|
- when: "Automatic code splitting (autoCodeSplitting), .lazy.tsx convention, createLazyFileRoute, createLazyRoute, lazyRouteComponent, getRouteApi for typed hooks in split files, codeSplitGroupings per-route override, splitBehavior programmatic config, critical vs non-critical properties."
|
||||||
|
use: "@tanstack/router-core#router-core/code-splitting"
|
||||||
|
- when: "Route loader option, loaderDeps for cache keys, staleTime/gcTime/ defaultPreloadStaleTime SWR caching, pendingComponent/pendingMs/ pendingMinMs, errorComponent/onError/onCatch, beforeLoad, router context and createRootRouteWithContext DI pattern, router.invalidate, Await component, deferred data loading with unawaited promises."
|
||||||
|
use: "@tanstack/router-core#router-core/data-loading"
|
||||||
|
- when: "Link component, useNavigate, Navigate component, router.navigate, ToOptions/NavigateOptions/LinkOptions, from/to relative navigation, activeOptions/activeProps, preloading (intent/viewport/render), preloadDelay, navigation blocking (useBlocker, Block), createLink, linkOptions helper, scroll restoration, MatchRoute."
|
||||||
|
use: "@tanstack/router-core#router-core/navigation"
|
||||||
|
- when: "notFound() function, notFoundComponent, defaultNotFoundComponent, notFoundMode (fuzzy/root), errorComponent, CatchBoundary, CatchNotFound, isNotFound, NotFoundRoute (deprecated), route masking (mask option, createRouteMask, unmaskOnReload)."
|
||||||
|
use: "@tanstack/router-core#router-core/not-found-and-errors"
|
||||||
|
- when: "Dynamic path segments ($paramName), splat routes ($ / _splat), optional params ({-$paramName}), prefix/suffix patterns ({$param}.ext), useParams, params.parse/stringify, pathParamsAllowedCharacters, i18n locale patterns."
|
||||||
|
use: "@tanstack/router-core#router-core/path-params"
|
||||||
|
- when: "validateSearch, search param validation with Zod/Valibot/ArkType adapters, fallback(), search middlewares (retainSearchParams, stripSearchParams), custom serialization (parseSearch, stringifySearch), search param inheritance, loaderDeps for cache keys, reading and writing search params."
|
||||||
|
use: "@tanstack/router-core#router-core/search-params"
|
||||||
|
- when: "Non-streaming and streaming SSR, RouterClient/RouterServer, renderRouterToString/renderRouterToStream, createRequestHandler, defaultRenderHandler/defaultStreamHandler, HeadContent/Scripts components, head route option (meta/links/styles/scripts), ScriptOnce, automatic loader dehydration/hydration, memory history on server, data serialization, document head management."
|
||||||
|
use: "@tanstack/router-core#router-core/ssr"
|
||||||
|
- when: "Full type inference philosophy (never cast, never annotate inferred values), Register module declaration, from narrowing on hooks and Link, strict:false for shared components, getRouteApi for code-split typed access, addChildren with object syntax for TS perf, LinkProps and ValidateLinkOptions type utilities, as const satisfies pattern."
|
||||||
|
use: "@tanstack/router-core#router-core/type-safety"
|
||||||
|
- when: "TanStack Router bundler plugin for route generation and automatic code splitting. Supports Vite, Webpack, Rspack, and esbuild. Configures autoCodeSplitting, routesDirectory, target framework, and code split groupings."
|
||||||
|
use: "@tanstack/router-plugin#router-plugin"
|
||||||
|
- when: "Programmatic route tree building as an alternative to filesystem conventions: rootRoute, index, route, layout, physical, defineVirtualSubtreeConfig. Use with TanStack Router plugin's virtualRouteConfig option."
|
||||||
|
use: "@tanstack/virtual-file-routes#virtual-file-routes"
|
||||||
|
<!-- intent-skills:end -->
|
||||||
|
|
||||||
|
# 项目上下文:my-tanstack-app (BFF + Go 后端版本)
|
||||||
|
|
||||||
|
## 项目初始化信息
|
||||||
|
* **脚手架命令:** `npx @tanstack/cli@latest create my-tanstack-app --agent --tailwind --add-ons tanstack-query`
|
||||||
|
* **模板类型:** Blank 纯净模板。
|
||||||
|
* **技术栈方案:** TanStack Start (React 19), TanStack Query, Tailwind CSS, pnpm.
|
||||||
|
* **部署目标:** 自建云服务器 (VPS),运行于标准的 Node.js 环境。
|
||||||
|
|
||||||
|
## 核心架构决策
|
||||||
|
1. **BFF 架构分层:** 本项目作为无状态的 Web 渲染与数据转发层(BFF)。不包含任何数据库(MySQL)驱动与凭证,不直接操作底层数据。
|
||||||
|
2. **异构系统数据消费:** 前端或服务端渲染所需的一切核心业务数据,均通过网络请求(HTTP/RESTful)异步调用自主部署的 **Go 语言后端服务**。
|
||||||
|
3. **数据流控制:** 路由级的数据预取优先在 `loader` 中通过 TanStack Query 执行,利用 `dehydrate` / `hydrate` 机制确保 SSR 数据无缝传递至客户端。
|
||||||
|
|
||||||
|
## 环境变量要求 (.env)
|
||||||
|
在 VPS 生产环境及本地开发环境中配置:
|
||||||
|
* `NODE_ENV`: `production` 或 `development`
|
||||||
|
* `PORT`: `3000` (前端服务运行端口)
|
||||||
|
* `GO_BACKEND_URL`: `http://127.0.0.1:8080` (指向你自建的 Go 后端服务的内部/外部基准地址)
|
||||||
|
|
||||||
|
## 部署与运维笔记
|
||||||
|
* **打包模式:** 修改 `app.config.ts` 中的 target 预设为 `node`(基于 Nitro 引擎进行 Node 兼容构建)。
|
||||||
|
* **运行机制:** 产物通过 `node` 或 `pm2` 在 VPS 上独立拉起进程。建议在前端挂载 Nginx 负责 443 端口 SSL 证书卸载与静态资源(`.js`, `.css`)的高效分发。
|
||||||
|
|
||||||
|
## 踩坑与注意点 (Known Gotchas)
|
||||||
|
* **跨域与内网穿透:** 在 SSR 阶段,Node.js 发起请求的源是服务器本身(需通过 `process.env.GO_BACKEND_URL` 访问内网);在客户端阶段,浏览器发起请求需要能够跨域或通过反向代理正确路由到 Go 服务上。推荐在 Node 侧统一使用 `createServerFn` 做一层 API Proxy 转发。
|
||||||
|
|
||||||
|
## 后续推进计划
|
||||||
|
- [ ] 确保 `app.config.ts` 已切换为 `node` 独立运行预设。
|
||||||
|
- [ ] 运行 `pnpm install` 冻结当前依赖树。
|
||||||
|
- [ ] 封装 `src/utils/api.ts` 统一劫持 `GO_BACKEND_URL`。
|
||||||
|
- [ ] 在 `src/routes/index.tsx` 的 `loader` 中,编写第一个通过 TanStack Query 获取 Go 后端商品或任务列表的接口。
|
||||||
207
README.md
Normal file
207
README.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
Welcome to your new TanStack Start app!
|
||||||
|
|
||||||
|
# Getting Started
|
||||||
|
|
||||||
|
To run this application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
# Building For Production
|
||||||
|
|
||||||
|
To build this application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||||
|
|
||||||
|
### Removing Tailwind CSS
|
||||||
|
|
||||||
|
If you prefer not to use Tailwind CSS:
|
||||||
|
|
||||||
|
1. Remove the demo pages in `src/routes/demo/`
|
||||||
|
2. Replace the Tailwind import in `src/styles.css` with your own styles
|
||||||
|
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
|
||||||
|
4. Uninstall the packages: `pnpm add @tailwindcss/vite tailwindcss --dev`
|
||||||
|
|
||||||
|
|
||||||
|
## Deploy with Nitro
|
||||||
|
|
||||||
|
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
node dist/server/index.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The build output is a self-contained Node server. To deploy, push the `dist/` directory to your host (Render, Fly.io, your own VPS, etc.) and run the server command above.
|
||||||
|
|
||||||
|
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
|
||||||
|
|
||||||
|
### Adding A Route
|
||||||
|
|
||||||
|
To add a new route to your application just add a new file in the `./src/routes` directory.
|
||||||
|
|
||||||
|
TanStack will automatically generate the content of the route file for you.
|
||||||
|
|
||||||
|
Now that you have two routes you can use a `Link` component to navigate between them.
|
||||||
|
|
||||||
|
### Adding Links
|
||||||
|
|
||||||
|
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
```
|
||||||
|
|
||||||
|
Then anywhere in your JSX you can use it like so:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link to="/about">About</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a link that will navigate to the `/about` route.
|
||||||
|
|
||||||
|
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
|
||||||
|
|
||||||
|
### Using A Layout
|
||||||
|
|
||||||
|
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
|
||||||
|
|
||||||
|
Here is an example layout that includes a header:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{ charSet: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
{ title: 'My App' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
shellComponent: ({ children }) => (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<HeadContent />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<Link to="/">Home</Link>
|
||||||
|
<Link to="/about">About</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
||||||
|
|
||||||
|
## Server Functions
|
||||||
|
|
||||||
|
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
|
||||||
|
const getServerTime = createServerFn({
|
||||||
|
method: 'GET',
|
||||||
|
}).handler(async () => {
|
||||||
|
return new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use in a component
|
||||||
|
function MyComponent() {
|
||||||
|
const [time, setTime] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getServerTime().then(setTime)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <div>Server time: {time}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
You can create API routes by using the `server` property in your route definitions:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { json } from '@tanstack/react-start'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/hello')({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: () => json({ message: 'Hello, World!' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Fetching
|
||||||
|
|
||||||
|
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/people')({
|
||||||
|
loader: async () => {
|
||||||
|
const response = await fetch('https://swapi.dev/api/people')
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
component: PeopleComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PeopleComponent() {
|
||||||
|
const data = Route.useLoaderData()
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{data.results.map((person) => (
|
||||||
|
<li key={person.name}>{person.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
|
||||||
|
|
||||||
|
# Demo files
|
||||||
|
|
||||||
|
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
|
||||||
|
|
||||||
|
# Learn More
|
||||||
|
|
||||||
|
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
|
||||||
|
|
||||||
|
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).
|
||||||
53
package.json
Normal file
53
package.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "my-tanstack-app",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"imports": {
|
||||||
|
"#/*": "./src/*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --port 3000",
|
||||||
|
"generate-routes": "tsr generate",
|
||||||
|
"build": "vite build",
|
||||||
|
"start": "node .output/server/index.mjs",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/react-devtools": "latest",
|
||||||
|
"@tanstack/react-query": "latest",
|
||||||
|
"@tanstack/react-query-devtools": "latest",
|
||||||
|
"@tanstack/react-router": "latest",
|
||||||
|
"@tanstack/react-router-devtools": "latest",
|
||||||
|
"@tanstack/react-router-ssr-query": "latest",
|
||||||
|
"@tanstack/react-start": "latest",
|
||||||
|
"@tanstack/router-plugin": "^1.132.0",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tanstack/devtools-vite": "latest",
|
||||||
|
"@tanstack/router-cli": "^1.132.0",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vite": "^8.0.0",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"lightningcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3606
pnpm-lock.yaml
generated
Normal file
3606
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "TanStack App",
|
||||||
|
"name": "Create TanStack App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
65
src/features/tasks/queries.ts
Normal file
65
src/features/tasks/queries.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
import { requestGoBackendData } from '#/utils/api'
|
||||||
|
|
||||||
|
export interface HeroDetail {
|
||||||
|
id: number
|
||||||
|
heroName: string
|
||||||
|
heroCode: string
|
||||||
|
heroAttrLv60: string
|
||||||
|
creator: string
|
||||||
|
createTime: string | null
|
||||||
|
updater: string
|
||||||
|
updateTime: string | null
|
||||||
|
deleted: boolean
|
||||||
|
nickName: string
|
||||||
|
rarity: string
|
||||||
|
role: string
|
||||||
|
zodiac: string
|
||||||
|
headImgUrl: string
|
||||||
|
attribute: string
|
||||||
|
remark: string
|
||||||
|
rawJson: string
|
||||||
|
contentJsonSet: string
|
||||||
|
updateTimeSet: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HERO_ID = 1
|
||||||
|
|
||||||
|
export interface HeroQueryResult {
|
||||||
|
hero: HeroDetail | null
|
||||||
|
heroId: number
|
||||||
|
source: 'backend' | 'fallback'
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHero = createServerFn({ method: 'GET' }).handler(async () => {
|
||||||
|
try {
|
||||||
|
const hero = await requestGoBackendData<HeroDetail | null>(
|
||||||
|
`/heroes/${DEFAULT_HERO_ID}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hero,
|
||||||
|
heroId: DEFAULT_HERO_ID,
|
||||||
|
source: 'backend',
|
||||||
|
} satisfies HeroQueryResult
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
hero: null,
|
||||||
|
heroId: DEFAULT_HERO_ID,
|
||||||
|
source: 'fallback',
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Go backend is unavailable right now.',
|
||||||
|
} satisfies HeroQueryResult
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function heroQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['hero', DEFAULT_HERO_ID],
|
||||||
|
queryFn: () => getHero(),
|
||||||
|
})
|
||||||
|
}
|
||||||
6
src/integrations/tanstack-query/devtools.tsx
Normal file
6
src/integrations/tanstack-query/devtools.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Tanstack Query',
|
||||||
|
render: <ReactQueryDevtoolsPanel />,
|
||||||
|
}
|
||||||
10
src/integrations/tanstack-query/root-provider.tsx
Normal file
10
src/integrations/tanstack-query/root-provider.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export function getContext() {
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default function TanstackQueryProvider() {}
|
||||||
68
src/routeTree.gen.ts
Normal file
68
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths: '/'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to: '/'
|
||||||
|
id: '__root__' | '/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
|
|
||||||
|
import type { getRouter } from './router.tsx'
|
||||||
|
import type { createStart } from '@tanstack/react-start'
|
||||||
|
declare module '@tanstack/react-start' {
|
||||||
|
interface Register {
|
||||||
|
ssr: true
|
||||||
|
router: Awaited<ReturnType<typeof getRouter>>
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/router.tsx
Normal file
31
src/router.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||||
|
import TanstackQueryProvider, {
|
||||||
|
getContext,
|
||||||
|
} from './integrations/tanstack-query/root-provider'
|
||||||
|
|
||||||
|
export function getRouter() {
|
||||||
|
const context = getContext()
|
||||||
|
|
||||||
|
const router = createTanStackRouter({
|
||||||
|
routeTree,
|
||||||
|
context,
|
||||||
|
scrollRestoration: true,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
setupRouterSsrQueryIntegration({ router, queryClient: context.queryClient })
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof getRouter>
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/routes/__root.tsx
Normal file
67
src/routes/__root.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
HeadContent,
|
||||||
|
Scripts,
|
||||||
|
createRootRouteWithContext,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||||
|
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||||
|
|
||||||
|
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
|
||||||
|
|
||||||
|
import appCss from '../styles.css?url'
|
||||||
|
|
||||||
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
interface MyRouterContext {
|
||||||
|
queryClient: QueryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
charSet: 'utf-8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'viewport',
|
||||||
|
content: 'width=device-width, initial-scale=1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'TanStack Start Starter',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: appCss,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
shellComponent: RootDocument,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<HeadContent />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<TanStackDevtools
|
||||||
|
config={{
|
||||||
|
position: 'bottom-right',
|
||||||
|
}}
|
||||||
|
plugins={[
|
||||||
|
{
|
||||||
|
name: 'Tanstack Router',
|
||||||
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
|
},
|
||||||
|
TanStackQueryDevtools,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
138
src/routes/index.tsx
Normal file
138
src/routes/index.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
DEFAULT_HERO_ID,
|
||||||
|
heroQueryOptions,
|
||||||
|
} from '#/features/tasks/queries'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/')({
|
||||||
|
loader: ({ context }) =>
|
||||||
|
context.queryClient.ensureQueryData(heroQueryOptions()),
|
||||||
|
component: Home,
|
||||||
|
})
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const { data } = useSuspenseQuery(heroQueryOptions())
|
||||||
|
const isFallback = data.source === 'fallback'
|
||||||
|
const hero = data.hero
|
||||||
|
const endpoint = `http://127.0.0.1:8080/heroes/${data.heroId}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-stone-950 px-6 py-16 text-stone-50">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-col gap-10">
|
||||||
|
<section className="grid gap-6 rounded-3xl border border-stone-800 bg-[radial-gradient(circle_at_top_left,_rgba(245,158,11,0.25),_transparent_30%),linear-gradient(135deg,_rgba(28,25,23,0.98),_rgba(12,10,9,0.92))] p-8 shadow-2xl shadow-amber-950/20">
|
||||||
|
<span className="w-fit rounded-full border border-amber-400/30 bg-amber-300/10 px-3 py-1 text-sm text-amber-200">
|
||||||
|
TanStack Start BFF
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="max-w-3xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
|
||||||
|
Frontend now renders against the backend hero contract
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-2xl text-base leading-7 text-stone-300 sm:text-lg">
|
||||||
|
The UI no longer calls the non-existent `/tasks` endpoint. It now
|
||||||
|
reads `GET /heroes/:id` through a server function and unwraps the
|
||||||
|
backend response envelope consistently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-semibold text-stone-100">Hero Detail</h2>
|
||||||
|
<span className="text-sm text-stone-400">Hero ID: {data.heroId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFallback ? (
|
||||||
|
<div className="grid gap-4 rounded-3xl border border-amber-500/30 bg-amber-500/10 p-6 text-stone-100">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-amber-200">
|
||||||
|
Go backend is temporarily unavailable
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm leading-6 text-stone-300">
|
||||||
|
The frontend is running, but the API pointed to by
|
||||||
|
`GO_BACKEND_URL` is not returning hero data right now.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-stone-700/80 bg-stone-950/60 p-4 text-sm text-stone-300">
|
||||||
|
<p className="font-medium text-stone-100">Fallback Details</p>
|
||||||
|
<p className="mt-2 break-words text-stone-400">
|
||||||
|
{data.message ?? 'No additional error details were returned.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 text-sm text-stone-300 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-900/70 p-4">
|
||||||
|
<p className="font-medium text-stone-100">Frontend</p>
|
||||||
|
<p className="mt-2 text-stone-400">TanStack Start BFF is up</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-900/70 p-4">
|
||||||
|
<p className="font-medium text-stone-100">Backend Endpoint</p>
|
||||||
|
<p className="mt-2 text-stone-400">{endpoint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-900/70 p-4">
|
||||||
|
<p className="font-medium text-stone-100">Recovery</p>
|
||||||
|
<p className="mt-2 text-stone-400">Restart Go service and refresh</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : hero ? (
|
||||||
|
<div className="grid gap-4 rounded-3xl border border-stone-800 bg-stone-900/80 p-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-2xl font-semibold text-stone-100">
|
||||||
|
{hero.heroName || 'Unnamed Hero'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-stone-400">
|
||||||
|
Code: {hero.heroCode || '-'} | Nickname: {hero.nickName || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-xs font-medium text-emerald-300">
|
||||||
|
Backend Connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid gap-4 text-sm text-stone-300 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
|
||||||
|
<dt className="text-stone-500">Attribute</dt>
|
||||||
|
<dd className="mt-2 text-stone-100">{hero.attribute || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
|
||||||
|
<dt className="text-stone-500">Role</dt>
|
||||||
|
<dd className="mt-2 text-stone-100">{hero.role || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
|
||||||
|
<dt className="text-stone-500">Rarity</dt>
|
||||||
|
<dd className="mt-2 text-stone-100">{hero.rarity || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
|
||||||
|
<dt className="text-stone-500">Zodiac</dt>
|
||||||
|
<dd className="mt-2 text-stone-100">{hero.zodiac || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
|
||||||
|
<dt className="text-stone-500">Lv60 Stats</dt>
|
||||||
|
<dd className="mt-2 text-stone-100">{hero.heroAttrLv60 || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
|
||||||
|
<dt className="text-stone-500">Updated At</dt>
|
||||||
|
<dd className="mt-2 text-stone-100">{hero.updateTime || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4 text-sm text-stone-300">
|
||||||
|
<p className="font-medium text-stone-100">Remark</p>
|
||||||
|
<p className="mt-2 leading-6 text-stone-400">{hero.remark || 'No remark provided.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-stone-700 bg-stone-900/50 p-8 text-sm text-stone-400">
|
||||||
|
The backend responded, but `GET /heroes/{DEFAULT_HERO_ID}` did not
|
||||||
|
return a hero record.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/styles.css
Normal file
17
src/styles.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
64
src/utils/api.test.ts
Normal file
64
src/utils/api.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { BackendRequestError, requestGoBackendData } from './api'
|
||||||
|
|
||||||
|
const originalBackendUrl = process.env.GO_BACKEND_URL
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.GO_BACKEND_URL = originalBackendUrl
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BackendRequestError', () => {
|
||||||
|
it('should preserve status and details', () => {
|
||||||
|
const error = new BackendRequestError('request failed', 502, 'bad gateway')
|
||||||
|
|
||||||
|
expect(error.name).toBe('BackendRequestError')
|
||||||
|
expect(error.message).toBe('request failed')
|
||||||
|
expect(error.status).toBe(502)
|
||||||
|
expect(error.details).toBe('bad gateway')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('requestGoBackendData', () => {
|
||||||
|
it('should unwrap backend response data', async () => {
|
||||||
|
process.env.GO_BACKEND_URL = 'http://127.0.0.1:8080'
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
code: 'OK',
|
||||||
|
message: 'success',
|
||||||
|
data: { id: 1, heroName: 'Ras' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestGoBackendData<{ id: number; heroName: string }>('/heroes/1'),
|
||||||
|
).resolves.toEqual({
|
||||||
|
id: 1,
|
||||||
|
heroName: 'Ras',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw for backend business errors', async () => {
|
||||||
|
process.env.GO_BACKEND_URL = 'http://127.0.0.1:8080'
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
code: 'ERROR',
|
||||||
|
message: 'hero not found',
|
||||||
|
data: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(requestGoBackendData('/heroes/999')).rejects.toMatchObject({
|
||||||
|
name: 'BackendRequestError',
|
||||||
|
status: 200,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
72
src/utils/api.ts
Normal file
72
src/utils/api.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export class BackendRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
readonly details?: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'BackendRequestError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendResponse<T> {
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredEnv(name: 'GO_BACKEND_URL') {
|
||||||
|
const value = process.env[name]
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackendUrl(path: string) {
|
||||||
|
return new URL(path, getRequiredEnv('GO_BACKEND_URL')).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestGoBackend<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<BackendResponse<T>> {
|
||||||
|
const response = await fetch(buildBackendUrl(path), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const details = await response.text()
|
||||||
|
|
||||||
|
throw new BackendRequestError(
|
||||||
|
`Go backend request failed: ${response.status} ${response.statusText}`,
|
||||||
|
response.status,
|
||||||
|
details,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<BackendResponse<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestGoBackendData<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await requestGoBackend<T>(path, init)
|
||||||
|
|
||||||
|
if (response.code !== 'OK') {
|
||||||
|
throw new BackendRequestError(
|
||||||
|
`Go backend business error: ${response.message}`,
|
||||||
|
200,
|
||||||
|
JSON.stringify(response),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"paths": {
|
||||||
|
"#/*": ["./src/*"],
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
}
|
||||||
|
}
|
||||||
3
tsr.config.json
Normal file
3
tsr.config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"target": "react"
|
||||||
|
}
|
||||||
21
vite.config.ts
Normal file
21
vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { devtools } from '@tanstack/devtools-vite'
|
||||||
|
|
||||||
|
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||||
|
|
||||||
|
import viteReact from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { nitro } from 'nitro/vite'
|
||||||
|
|
||||||
|
const config = defineConfig({
|
||||||
|
resolve: { tsconfigPaths: true },
|
||||||
|
plugins: [
|
||||||
|
devtools(),
|
||||||
|
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
|
||||||
|
tailwindcss(),
|
||||||
|
tanstackStart(),
|
||||||
|
viteReact(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default config
|
||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user