create-fullstack-scaffold 0.4.25 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +432 -15
- package/dist/cli/index.js.map +1 -1
- package/package.json +10 -13
- package/template/drizzle.config.ts +10 -4
- package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
- package/template/eslint-rules/no-cross-module-service-import.js +4 -0
- package/template/eslint-rules/no-merged-api-type-export.js +190 -0
- package/template/eslint.config.js +3 -0
- package/template/package.json +9 -6
- package/template/scripts/sync-agent-hooks.mjs +189 -0
- package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
- package/template/src/admin/pages/ContentPage.tsx +1 -1
- package/template/src/admin/pages/DisputesPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +1 -1
- package/template/src/admin/pages/TicketsPage.tsx +1 -1
- package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
- package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
- package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
- package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
- package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
- package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
- package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
- package/template/src/admin/services/apiClient.ts +2 -3
- package/template/src/cli/modules/content/index.ts +3 -3
- package/template/src/cli/modules/dispute/index.ts +3 -3
- package/template/src/cli/modules/ticket/index.ts +3 -3
- package/template/src/cli/modules/todo/index.ts +1 -1
- package/template/src/cli/rpc/client.ts +3 -4
- package/template/src/cli/rpc/index.ts +1 -1
- package/template/src/client/App.tsx +3 -39
- package/template/src/client/AppRoutes.tsx +53 -0
- package/template/src/client/entry-server.tsx +75 -0
- package/template/src/client/main.tsx +3 -1
- package/template/src/client/pages/SearchPage.tsx +1 -1
- package/template/src/client/services/apiClient.ts +12 -4
- package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
- package/template/src/client/stores/entry-stores.ts +36 -0
- package/template/src/client/stores/notificationStore.ts +4 -4
- package/template/src/client/stores/todoStore.ts +2 -2
- package/template/src/merchant/pages/DisputesPage.tsx +3 -3
- package/template/src/merchant/pages/OrdersPage.tsx +1 -1
- package/template/src/merchant/pages/ProductsPage.tsx +1 -1
- package/template/src/merchant/pages/SettingsPage.tsx +1 -1
- package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
- package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
- package/template/src/server/app.ts +6 -4
- package/template/src/server/core/__tests__/isr-cache-cf.test.ts +96 -0
- package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
- package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
- package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
- package/template/src/server/core/isr-cache.ts +61 -18
- package/template/src/server/core/isr-invalidation.ts +6 -12
- package/template/src/server/core/isr-registry.ts +104 -0
- package/template/src/server/core/isr-renderer.ts +75 -0
- package/template/src/server/db/schema/contents.ts +28 -20
- package/template/src/server/db/schema/disputes.ts +30 -22
- package/template/src/server/db/schema/notifications.ts +20 -14
- package/template/src/server/db/schema/orders.ts +23 -16
- package/template/src/server/db/schema/plugins.ts +56 -38
- package/template/src/server/db/schema/products.ts +24 -17
- package/template/src/server/db/schema/tickets.ts +44 -31
- package/template/src/server/db/schema/todos.ts +26 -18
- package/template/src/server/entries/cloudflare.ts +97 -21
- package/template/src/server/entries/node.ts +0 -2
- package/template/src/server/index.ts +0 -1
- package/template/src/server/isr-modules.ts +10 -0
- package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
- package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
- package/template/src/server/module-admin/services/admin-service.ts +57 -13
- package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
- package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
- package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
- package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
- package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
- package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
- package/template/src/server/module-content/isr.ts +63 -0
- package/template/src/server/module-content/routes/content-routes.ts +10 -10
- package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
- package/template/src/server/module-content/routes/topics-routes.ts +3 -0
- package/template/src/server/module-content/services/content-service.ts +23 -10
- package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
- package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
- package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
- package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
- package/template/src/server/module-file/routes/file-routes.ts +3 -0
- package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
- package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
- package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
- package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
- package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
- package/template/src/server/module-order/routes/cart-routes.ts +3 -0
- package/template/src/server/module-order/routes/order-routes.ts +9 -4
- package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
- package/template/src/server/module-order/services/order-service.ts +25 -13
- package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
- package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
- package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
- package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
- package/template/src/server/module-permission/routes/role-routes.ts +3 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
- package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
- package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
- package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
- package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
- package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
- package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
- package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
- package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
- package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
- package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
- package/template/src/server/module-todos/isr.ts +41 -0
- package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
- package/template/src/server/module-todos/services/todo-service.ts +31 -10
- package/template/src/server/route-registry.ts +0 -4
- package/template/src/server/rpc-merge.ts +27 -0
- package/template/src/server/rpc-surface.ts +117 -0
- package/template/src/server/rpc-type-canary.ts +80 -0
- package/template/src/server/test-utils/test-client.ts +7 -9
- package/template/src/server/test-utils/test-isr-helper.ts +140 -0
- package/template/src/shared/modules/content/schemas.ts +14 -0
- package/template/src/shared/modules/dispute/schemas.ts +14 -0
- package/template/src/shared/modules/order/schemas.ts +9 -1
- package/template/src/shared/modules/ticket/schemas.ts +14 -0
- package/template/src/shared/modules/todos/index.ts +4 -0
- package/template/src/shared/modules/todos/schemas.ts +14 -0
- package/template/src/shared/schemas/index.ts +18 -0
- package/template/tsup.config.ts +48 -1
- package/template/vitest.config.ts +19 -3
- package/template/vitest.setup.ts +68 -5
- package/template/patches/typescript+5.9.3.patch +0 -24
- /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
|
@@ -1,46 +1,10 @@
|
|
|
1
|
-
import { BrowserRouter
|
|
2
|
-
import {
|
|
3
|
-
import { PresetProvider } from './contexts/PresetContext'
|
|
4
|
-
import { getPresetUIConfig, type RouteDef } from './preset-ui-config'
|
|
1
|
+
import { BrowserRouter } from 'react-router-dom'
|
|
2
|
+
import { AppRoutes } from './AppRoutes'
|
|
5
3
|
|
|
6
4
|
export const App: React.FC<{ presetId?: string }> = ({ presetId = 'todo' }) => {
|
|
7
|
-
const config = getPresetUIConfig(presetId)
|
|
8
|
-
const { theme, desktopNav, mobileTabs, defaultRoute, routes, layout, navigation } = config
|
|
9
|
-
|
|
10
5
|
return (
|
|
11
6
|
<BrowserRouter>
|
|
12
|
-
<
|
|
13
|
-
<Layout
|
|
14
|
-
preset={presetId}
|
|
15
|
-
layout={layout}
|
|
16
|
-
theme={theme}
|
|
17
|
-
navigation={navigation}
|
|
18
|
-
desktopNav={desktopNav}
|
|
19
|
-
mobileTabs={mobileTabs}
|
|
20
|
-
>
|
|
21
|
-
<Routes>
|
|
22
|
-
{defaultRoute !== '/' && (
|
|
23
|
-
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
|
24
|
-
)}
|
|
25
|
-
{routes
|
|
26
|
-
.filter(
|
|
27
|
-
(r): r is RouteDef & { component: NonNullable<RouteDef['component']> } =>
|
|
28
|
-
r.component !== null
|
|
29
|
-
)
|
|
30
|
-
.map(route => (
|
|
31
|
-
<Route key={route.path} path={route.path} element={<route.component />} />
|
|
32
|
-
))}
|
|
33
|
-
<Route
|
|
34
|
-
path="*"
|
|
35
|
-
element={
|
|
36
|
-
<div className="flex items-center justify-center min-h-[50vh] text-gray-400">
|
|
37
|
-
404 - Page not found
|
|
38
|
-
</div>
|
|
39
|
-
}
|
|
40
|
-
/>
|
|
41
|
-
</Routes>
|
|
42
|
-
</Layout>
|
|
43
|
-
</PresetProvider>
|
|
7
|
+
<AppRoutes presetId={presetId} />
|
|
44
8
|
</BrowserRouter>
|
|
45
9
|
)
|
|
46
10
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Suspense } from 'react'
|
|
2
|
+
import { Routes, Route, Navigate } from 'react-router-dom'
|
|
3
|
+
import { Layout } from './Layout'
|
|
4
|
+
import { PresetProvider } from './contexts/PresetContext'
|
|
5
|
+
import { getPresetUIConfig, type RouteDef } from './preset-ui-config'
|
|
6
|
+
|
|
7
|
+
export const AppRoutes: React.FC<{ presetId?: string }> = ({ presetId = 'todo' }) => {
|
|
8
|
+
const config = getPresetUIConfig(presetId)
|
|
9
|
+
const { theme, desktopNav, mobileTabs, defaultRoute, routes, layout, navigation } = config
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<PresetProvider value={presetId}>
|
|
13
|
+
<Layout
|
|
14
|
+
preset={presetId}
|
|
15
|
+
layout={layout}
|
|
16
|
+
theme={theme}
|
|
17
|
+
navigation={navigation}
|
|
18
|
+
desktopNav={desktopNav}
|
|
19
|
+
mobileTabs={mobileTabs}
|
|
20
|
+
>
|
|
21
|
+
<Suspense
|
|
22
|
+
fallback={
|
|
23
|
+
<div className="flex items-center justify-center min-h-[50vh] text-gray-400">
|
|
24
|
+
Loading...
|
|
25
|
+
</div>
|
|
26
|
+
}
|
|
27
|
+
>
|
|
28
|
+
<Routes>
|
|
29
|
+
{defaultRoute !== '/' && (
|
|
30
|
+
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
|
31
|
+
)}
|
|
32
|
+
{routes
|
|
33
|
+
.filter(
|
|
34
|
+
(r): r is RouteDef & { component: NonNullable<RouteDef['component']> } =>
|
|
35
|
+
r.component !== null
|
|
36
|
+
)
|
|
37
|
+
.map(route => (
|
|
38
|
+
<Route key={route.path} path={route.path} element={<route.component />} />
|
|
39
|
+
))}
|
|
40
|
+
<Route
|
|
41
|
+
path="*"
|
|
42
|
+
element={
|
|
43
|
+
<div className="flex items-center justify-center min-h-[50vh] text-gray-400">
|
|
44
|
+
404 - Page not found
|
|
45
|
+
</div>
|
|
46
|
+
}
|
|
47
|
+
/>
|
|
48
|
+
</Routes>
|
|
49
|
+
</Suspense>
|
|
50
|
+
</Layout>
|
|
51
|
+
</PresetProvider>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline 1a361bf1bb0e5343
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SSR Entry Point — used by ISR pipeline to render real React components.
|
|
7
|
+
*
|
|
8
|
+
* This module:
|
|
9
|
+
* 1. Pre-populates Zustand stores with ISR-fetched data (via generated entry-stores)
|
|
10
|
+
* 2. Renders the app to HTML string via renderToString
|
|
11
|
+
* 3. Restores stores after render
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React from 'react'
|
|
15
|
+
import { renderToString } from 'react-dom/server'
|
|
16
|
+
import { StaticRouter } from 'react-router-dom'
|
|
17
|
+
import { HelmetProvider } from 'react-helmet-async'
|
|
18
|
+
|
|
19
|
+
import { AppRoutes } from './AppRoutes'
|
|
20
|
+
import {
|
|
21
|
+
snapshotEntryStores,
|
|
22
|
+
seedEntryStores,
|
|
23
|
+
restoreEntryStores,
|
|
24
|
+
type SSRData,
|
|
25
|
+
} from './stores/entry-stores'
|
|
26
|
+
|
|
27
|
+
interface SSRRenderResult {
|
|
28
|
+
html: string
|
|
29
|
+
helmet: {
|
|
30
|
+
title: string
|
|
31
|
+
meta: string
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Render the app to HTML string for a given pathname.
|
|
37
|
+
* Pre-populates stores with ISR data so components render real content.
|
|
38
|
+
*/
|
|
39
|
+
export function renderSSR(pathname: string, data: SSRData): SSRRenderResult {
|
|
40
|
+
// 1. Snapshot current store state (for cleanup)
|
|
41
|
+
const snapshot = snapshotEntryStores()
|
|
42
|
+
|
|
43
|
+
// 2. Pre-populate stores with ISR data (no-op when preset has no seedable stores)
|
|
44
|
+
seedEntryStores(data)
|
|
45
|
+
|
|
46
|
+
// 3. Set default preset
|
|
47
|
+
const preset = 'todo'
|
|
48
|
+
|
|
49
|
+
// 4. Render with helmet context to extract head tags
|
|
50
|
+
const helmetContext: Record<string, unknown> = {}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const html = renderToString(
|
|
54
|
+
React.createElement(
|
|
55
|
+
HelmetProvider,
|
|
56
|
+
{ context: helmetContext },
|
|
57
|
+
React.createElement(
|
|
58
|
+
StaticRouter,
|
|
59
|
+
{ location: pathname },
|
|
60
|
+
React.createElement(AppRoutes, { presetId: preset })
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// 5. Extract helmet data
|
|
66
|
+
const helmet = (helmetContext as { helmet?: Record<string, unknown> }).helmet || {}
|
|
67
|
+
const titleStr = helmet.title?.toString() || ''
|
|
68
|
+
const metaStr = helmet.meta?.toString() || ''
|
|
69
|
+
|
|
70
|
+
return { html, helmet: { title: titleStr, meta: metaStr } }
|
|
71
|
+
} finally {
|
|
72
|
+
// 6. Restore store state
|
|
73
|
+
restoreEntryStores(snapshot)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -7,7 +7,9 @@ import './index.css'
|
|
|
7
7
|
|
|
8
8
|
const preset = import.meta.env.VITE_PRESET || 'todo'
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Demo 登录令牌:仅供开发服务器开箱体验(e2e 也依赖);生产构建
|
|
11
|
+
// (import.meta.env.DEV === false)不注入,用户走正常注册/登录
|
|
12
|
+
if (preset !== 'saas' && import.meta.env.DEV) {
|
|
11
13
|
try {
|
|
12
14
|
const raw = localStorage.getItem('auth-token')
|
|
13
15
|
const parsed = raw ? JSON.parse(raw) : null
|
|
@@ -28,7 +28,7 @@ export const SearchPage: React.FC = () => {
|
|
|
28
28
|
if (searchQuery) {
|
|
29
29
|
searchPlugins(searchQuery, 1)
|
|
30
30
|
}
|
|
31
|
-
}, [selectedCategory])
|
|
31
|
+
}, [selectedCategory, searchQuery, searchPlugins])
|
|
32
32
|
|
|
33
33
|
const handleSearch = (e: FormEvent) => {
|
|
34
34
|
e.preventDefault()
|
|
@@ -5,16 +5,20 @@
|
|
|
5
5
|
* @impact 影响所有客户端 API 请求,需要用户登录后才能访问受保护的接口
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { hc } from 'hono/client'
|
|
9
8
|
import { WSClientImpl } from '@shared/core/ws-client'
|
|
10
9
|
import { SSEClientImpl } from '@shared/core/sse-client'
|
|
11
|
-
import
|
|
10
|
+
import { createApiFacade } from '@server/rpc-surface'
|
|
12
11
|
|
|
13
|
-
const
|
|
12
|
+
const isBrowser = typeof window !== 'undefined'
|
|
13
|
+
|
|
14
|
+
const baseUrl = isBrowser
|
|
15
|
+
? import.meta.env.API_BASE_URL || window.location.origin
|
|
16
|
+
: 'http://localhost:3010'
|
|
14
17
|
|
|
15
18
|
const TOKEN_KEY = 'auth-token'
|
|
16
19
|
|
|
17
20
|
function getAuthToken(): string | null {
|
|
21
|
+
if (!isBrowser) return null
|
|
18
22
|
try {
|
|
19
23
|
const stored = localStorage.getItem(TOKEN_KEY)
|
|
20
24
|
if (stored) {
|
|
@@ -31,6 +35,10 @@ function getAuthToken(): string | null {
|
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
const authenticatedFetch = (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
38
|
+
if (!isBrowser) {
|
|
39
|
+
return fetch(url, init)
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
const token = getAuthToken()
|
|
35
43
|
const headers = new Headers(init?.headers)
|
|
36
44
|
|
|
@@ -54,7 +62,7 @@ const authenticatedFetch = (url: string | URL | Request, init?: RequestInit): Pr
|
|
|
54
62
|
})
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
export const apiClient =
|
|
65
|
+
export const apiClient = createApiFacade(baseUrl, {
|
|
58
66
|
fetch: authenticatedFetch as typeof fetch,
|
|
59
67
|
webSocket: url => {
|
|
60
68
|
const token = getAuthToken()
|
|
@@ -117,7 +117,10 @@ describe('Todo Store', () => {
|
|
|
117
117
|
describe('fetchTodos', () => {
|
|
118
118
|
it('should fetch todos successfully with array response', async () => {
|
|
119
119
|
const todos = [createMockTodo({ id: 1 }), createMockTodo({ id: 2 })]
|
|
120
|
-
mockJson.mockResolvedValue({
|
|
120
|
+
mockJson.mockResolvedValue({
|
|
121
|
+
success: true,
|
|
122
|
+
data: { todos, total: todos.length, page: 1, limit: 20 },
|
|
123
|
+
})
|
|
121
124
|
|
|
122
125
|
await useTodoStore.getState().fetchTodos()
|
|
123
126
|
|
|
@@ -128,7 +131,10 @@ describe('Todo Store', () => {
|
|
|
128
131
|
})
|
|
129
132
|
|
|
130
133
|
it('should handle empty array response', async () => {
|
|
131
|
-
mockJson.mockResolvedValue({
|
|
134
|
+
mockJson.mockResolvedValue({
|
|
135
|
+
success: true,
|
|
136
|
+
data: { todos: [], total: 0, page: 1, limit: 20 },
|
|
137
|
+
})
|
|
132
138
|
|
|
133
139
|
await useTodoStore.getState().fetchTodos()
|
|
134
140
|
|
|
@@ -176,7 +182,10 @@ describe('Todo Store', () => {
|
|
|
176
182
|
const fetchPromise = useTodoStore.getState().fetchTodos()
|
|
177
183
|
expect(useTodoStore.getState().loading).toBe(true)
|
|
178
184
|
|
|
179
|
-
resolvePromise({
|
|
185
|
+
resolvePromise({
|
|
186
|
+
json: () =>
|
|
187
|
+
Promise.resolve({ success: true, data: { todos: [], total: 0, page: 1, limit: 20 } }),
|
|
188
|
+
})
|
|
180
189
|
await fetchPromise
|
|
181
190
|
|
|
182
191
|
expect(useTodoStore.getState().loading).toBe(false)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline entry-stores-v1
|
|
3
|
+
*
|
|
4
|
+
* Entry SSR store 种子(全量版)。
|
|
5
|
+
* 本文件在模板仓库中为全量实现;CLI 脚手架时会按 preset 重新生成
|
|
6
|
+
* (src/generators/entry-stores.ts),preset 不含 todo 模块时为空实现,
|
|
7
|
+
* 避免 entry-server 悬空导入导致构建失败。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useTodoStore } from './todoStore'
|
|
11
|
+
import type { Todo } from '@shared/schemas'
|
|
12
|
+
|
|
13
|
+
export interface SSRData {
|
|
14
|
+
todos?: Todo[]
|
|
15
|
+
[key: string]: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EntryStoreSnapshot {
|
|
19
|
+
todos: Todo[]
|
|
20
|
+
loading: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function snapshotEntryStores(): EntryStoreSnapshot {
|
|
24
|
+
const state = useTodoStore.getState()
|
|
25
|
+
return { todos: state.todos, loading: state.loading }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function seedEntryStores(data: SSRData): void {
|
|
29
|
+
if (data.todos) {
|
|
30
|
+
useTodoStore.setState({ todos: data.todos, loading: false })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function restoreEntryStores(snapshot: EntryStoreSnapshot): void {
|
|
35
|
+
useTodoStore.setState({ todos: snapshot.todos, loading: snapshot.loading })
|
|
36
|
+
}
|
|
@@ -154,20 +154,20 @@ export const useNotificationStore = create<NotificationState>(set => ({
|
|
|
154
154
|
|
|
155
155
|
conn.onStatusChange(status => {
|
|
156
156
|
if (import.meta.env.DEV) {
|
|
157
|
-
console.
|
|
157
|
+
console.debug('[SSE] Status changed:', status)
|
|
158
158
|
}
|
|
159
159
|
set({ sseConnected: status === 'open' })
|
|
160
160
|
})
|
|
161
161
|
|
|
162
162
|
conn.on('connected', payload => {
|
|
163
163
|
if (import.meta.env.DEV) {
|
|
164
|
-
console.
|
|
164
|
+
console.debug('[SSE] Connected:', payload)
|
|
165
165
|
}
|
|
166
166
|
})
|
|
167
167
|
|
|
168
168
|
conn.on('notification', notification => {
|
|
169
169
|
if (import.meta.env.DEV) {
|
|
170
|
-
console.
|
|
170
|
+
console.debug('[SSE] Received notification:', notification)
|
|
171
171
|
}
|
|
172
172
|
set(state => {
|
|
173
173
|
if (state.notifications.some(n => n.id === notification.id)) {
|
|
@@ -186,7 +186,7 @@ export const useNotificationStore = create<NotificationState>(set => ({
|
|
|
186
186
|
|
|
187
187
|
sseClient = conn
|
|
188
188
|
if (import.meta.env.DEV) {
|
|
189
|
-
console.
|
|
189
|
+
console.debug('[SSE] Client initialized')
|
|
190
190
|
}
|
|
191
191
|
} catch (error) {
|
|
192
192
|
console.error('[SSE] Failed to connect:', error)
|
|
@@ -27,10 +27,10 @@ export const useTodoStore = create<TodoState>(set => ({
|
|
|
27
27
|
fetchTodos: async () => {
|
|
28
28
|
set({ loading: true, error: null })
|
|
29
29
|
try {
|
|
30
|
-
const response = await apiClient.api.todos.$get()
|
|
30
|
+
const response = await apiClient.api.todos.$get({ query: {} })
|
|
31
31
|
const result = await response.json()
|
|
32
32
|
if (result.success) {
|
|
33
|
-
set({ todos: result.data, loading: false })
|
|
33
|
+
set({ todos: result.data.todos, loading: false })
|
|
34
34
|
} else {
|
|
35
35
|
set({ error: result.error, loading: false })
|
|
36
36
|
}
|
|
@@ -15,7 +15,7 @@ export const DisputesPage: FC = () => {
|
|
|
15
15
|
const fetchDisputes = useCallback(async () => {
|
|
16
16
|
setLoading(true)
|
|
17
17
|
try {
|
|
18
|
-
// @ts-expect-error
|
|
18
|
+
// @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
|
|
19
19
|
const response = await apiClient.api.merchant.disputes.$get()
|
|
20
20
|
const result = await response.json()
|
|
21
21
|
if (result.success === true && result.data) {
|
|
@@ -35,7 +35,7 @@ export const DisputesPage: FC = () => {
|
|
|
35
35
|
|
|
36
36
|
const handleResolve = async (disputeId: string) => {
|
|
37
37
|
try {
|
|
38
|
-
// @ts-expect-error
|
|
38
|
+
// @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
|
|
39
39
|
await apiClient.api.merchant.disputes[':id'].resolve.$post({
|
|
40
40
|
param: { id: disputeId },
|
|
41
41
|
})
|
|
@@ -47,7 +47,7 @@ export const DisputesPage: FC = () => {
|
|
|
47
47
|
|
|
48
48
|
const handleClose = async (disputeId: string) => {
|
|
49
49
|
try {
|
|
50
|
-
// @ts-expect-error
|
|
50
|
+
// @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
|
|
51
51
|
await apiClient.api.merchant.disputes[':id'].close.$post({
|
|
52
52
|
param: { id: disputeId },
|
|
53
53
|
})
|
|
@@ -16,7 +16,7 @@ export const OrdersPage: FC = () => {
|
|
|
16
16
|
const fetchOrders = useCallback(async () => {
|
|
17
17
|
setLoading(true)
|
|
18
18
|
try {
|
|
19
|
-
// @ts-expect-error
|
|
19
|
+
// @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
|
|
20
20
|
const response = await apiClient.api.merchant.orders.$get()
|
|
21
21
|
const result = await response.json()
|
|
22
22
|
if (result.success === true && result.data) {
|
|
@@ -17,7 +17,7 @@ export const ProductsPage: FC = () => {
|
|
|
17
17
|
const fetchProducts = useCallback(async () => {
|
|
18
18
|
setLoading(true)
|
|
19
19
|
try {
|
|
20
|
-
// @ts-expect-error
|
|
20
|
+
// @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
|
|
21
21
|
const response = await apiClient.api.merchant.products.$get()
|
|
22
22
|
const result = await response.json()
|
|
23
23
|
if (result.success === true && result.data) {
|
|
@@ -11,7 +11,7 @@ export const SettingsPage: FC = () => {
|
|
|
11
11
|
|
|
12
12
|
const handleSave = async (values: unknown) => {
|
|
13
13
|
try {
|
|
14
|
-
// @ts-expect-error
|
|
14
|
+
// @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
|
|
15
15
|
await apiClient.api.merchant.settings.$put({ json: values })
|
|
16
16
|
message.success('Settings saved successfully')
|
|
17
17
|
} catch {
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISR Full Flow Integration Test
|
|
3
|
+
*
|
|
4
|
+
* Simulates the complete request lifecycle:
|
|
5
|
+
* 1. First visit → miss → render → cache store → response
|
|
6
|
+
* 2. Second visit → fresh cache hit → instant response
|
|
7
|
+
* 3. After maxAge → stale → return cached + background revalidate
|
|
8
|
+
* 4. After purge → miss → re-render with new data
|
|
9
|
+
* 5. DB error → graceful fallback to shell
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
13
|
+
import { createISRCache } from '@server/core/isr-cache'
|
|
14
|
+
import { isrRegistry } from '@server/core/isr-registry'
|
|
15
|
+
import { renderISRPage } from '@server/core/isr-renderer'
|
|
16
|
+
import { purgePage, setISRCache, purgeAllPages } from '@server/core/isr-invalidation'
|
|
17
|
+
|
|
18
|
+
// We test with a mock module — no real DB needed
|
|
19
|
+
describe('ISR Full Flow Integration', () => {
|
|
20
|
+
let cache: ReturnType<typeof createISRCache>
|
|
21
|
+
let fetchCounter: { value: number }
|
|
22
|
+
let mockData: Array<{ id: number; title: string; status: string }>
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockData = [
|
|
26
|
+
{ id: 1, title: 'Task 1', status: 'completed' },
|
|
27
|
+
{ id: 2, title: 'Task 2', status: 'pending' },
|
|
28
|
+
]
|
|
29
|
+
fetchCounter = { value: 0 }
|
|
30
|
+
cache = createISRCache({ maxAge: 1, staleWhileRevalidate: 2 })
|
|
31
|
+
setISRCache(cache)
|
|
32
|
+
|
|
33
|
+
// Register into GLOBAL registry (so purgeAllPages works)
|
|
34
|
+
isrRegistry.register({
|
|
35
|
+
module: 'test',
|
|
36
|
+
match: '/test',
|
|
37
|
+
fetch: async () => {
|
|
38
|
+
fetchCounter.value++
|
|
39
|
+
return { items: mockData }
|
|
40
|
+
},
|
|
41
|
+
meta: () => ({ title: 'Test Page', description: 'Test description' }),
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await cache.purgePattern('isr:*')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Helper: simulate a full ISR request
|
|
51
|
+
*/
|
|
52
|
+
async function isrRequest(
|
|
53
|
+
pathname: string,
|
|
54
|
+
template:
|
|
55
|
+
| string
|
|
56
|
+
| null = '<!DOCTYPE html><html><head><title>Old</title></head><body><div id="root"></div></body></html>'
|
|
57
|
+
): Promise<{ html: string; status: string; fetched: number }> {
|
|
58
|
+
const fetchedBefore = fetchCounter.value
|
|
59
|
+
|
|
60
|
+
// 1. Check cache
|
|
61
|
+
const cached = await cache.lookup(pathname)
|
|
62
|
+
|
|
63
|
+
if (cached.status === 'fresh' && cached.html) {
|
|
64
|
+
return { html: cached.html, status: 'fresh', fetched: fetchedBefore }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (cached.status === 'stale' && cached.html) {
|
|
68
|
+
// Background revalidate (simulated)
|
|
69
|
+
const entry = isrRegistry.match(pathname)!
|
|
70
|
+
const data = await entry.fetch(pathname, {})
|
|
71
|
+
const meta = entry.meta(data, pathname)
|
|
72
|
+
const html = renderISRPage({ template, meta })
|
|
73
|
+
await cache.store(pathname, html)
|
|
74
|
+
return { html: cached.html, status: 'stale', fetched: fetchCounter.value }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Miss: render fresh
|
|
78
|
+
const entry = isrRegistry.match(pathname)
|
|
79
|
+
if (!entry) throw new Error(`No handler for ${pathname}`)
|
|
80
|
+
|
|
81
|
+
const data = await entry.fetch(pathname, {})
|
|
82
|
+
const meta = entry.meta(data, pathname)
|
|
83
|
+
const html = renderISRPage({ template, meta })
|
|
84
|
+
await cache.store(pathname, html)
|
|
85
|
+
|
|
86
|
+
return { html, status: 'miss', fetched: fetchCounter.value }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('Step 1: First visit (cold cache)', () => {
|
|
90
|
+
it('should render fresh HTML on first visit', async () => {
|
|
91
|
+
const result = await isrRequest('/test')
|
|
92
|
+
|
|
93
|
+
expect(result.status).toBe('miss')
|
|
94
|
+
expect(result.fetched).toBe(1)
|
|
95
|
+
expect(result.html).toContain('<title>Test Page</title>')
|
|
96
|
+
expect(result.html).toContain('name="generator" content="ISR-SSG"')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should store result in cache after first visit', async () => {
|
|
100
|
+
await isrRequest('/test')
|
|
101
|
+
const cached = await cache.lookup('/test')
|
|
102
|
+
expect(cached.status).toBe('fresh')
|
|
103
|
+
expect(cached.html).not.toBeNull()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('Step 2: Second visit (warm cache)', () => {
|
|
108
|
+
it('should serve from cache without re-fetching', async () => {
|
|
109
|
+
// First visit
|
|
110
|
+
await isrRequest('/test')
|
|
111
|
+
expect(fetchCounter.value).toBe(1)
|
|
112
|
+
|
|
113
|
+
// Second visit
|
|
114
|
+
const result = await isrRequest('/test')
|
|
115
|
+
expect(result.status).toBe('fresh')
|
|
116
|
+
expect(fetchCounter.value).toBe(1) // No additional fetch
|
|
117
|
+
expect(result.html).toContain('<title>Test Page</title>')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should be faster (no fetch call)', async () => {
|
|
121
|
+
await isrRequest('/test')
|
|
122
|
+
const fetchedBefore = fetchCounter.value
|
|
123
|
+
|
|
124
|
+
await isrRequest('/test')
|
|
125
|
+
expect(fetchCounter.value).toBe(fetchedBefore) // No new fetch
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('Step 3: After maxAge (stale)', () => {
|
|
130
|
+
it('should return stale cache and background revalidate', async () => {
|
|
131
|
+
// First visit
|
|
132
|
+
await isrRequest('/test')
|
|
133
|
+
|
|
134
|
+
// Wait for cache to become stale (maxAge=1s)
|
|
135
|
+
await new Promise(r => setTimeout(r, 1200))
|
|
136
|
+
|
|
137
|
+
const result = await isrRequest('/test')
|
|
138
|
+
expect(result.status).toBe('stale')
|
|
139
|
+
// Should have revalidated in background
|
|
140
|
+
expect(fetchCounter.value).toBe(2)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('Step 4: After purge', () => {
|
|
145
|
+
it('should re-render after purge with updated data', async () => {
|
|
146
|
+
// First visit with v1 data
|
|
147
|
+
await isrRequest('/test')
|
|
148
|
+
expect(fetchCounter.value).toBe(1)
|
|
149
|
+
|
|
150
|
+
// Update data
|
|
151
|
+
mockData.push({ id: 3, title: 'Task 3', status: 'pending' })
|
|
152
|
+
|
|
153
|
+
// Purge
|
|
154
|
+
await purgePage('/test')
|
|
155
|
+
|
|
156
|
+
// Next visit should re-render
|
|
157
|
+
const result = await isrRequest('/test')
|
|
158
|
+
expect(result.status).toBe('miss')
|
|
159
|
+
expect(fetchCounter.value).toBe(2)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should support purgeAllPages', async () => {
|
|
163
|
+
await isrRequest('/test')
|
|
164
|
+
|
|
165
|
+
await purgeAllPages()
|
|
166
|
+
|
|
167
|
+
const cached = await cache.lookup('/test')
|
|
168
|
+
expect(cached.status).toBe('miss')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('Step 5: DB error handling', () => {
|
|
173
|
+
it('should handle fetch error gracefully', async () => {
|
|
174
|
+
// Replace fetcher with one that throws
|
|
175
|
+
isrRegistry.clear()
|
|
176
|
+
isrRegistry.register({
|
|
177
|
+
module: 'test',
|
|
178
|
+
match: '/test',
|
|
179
|
+
fetch: async () => {
|
|
180
|
+
throw new Error('DB unavailable')
|
|
181
|
+
},
|
|
182
|
+
meta: () => ({ title: 'Test', description: 'desc' }),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// The CF entry wraps this in try/catch, but at the module level
|
|
186
|
+
// the error propagates. The entry catches it and falls back.
|
|
187
|
+
await expect(isrRequest('/test')).rejects.toThrow('DB unavailable')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should serve stale cache even if fresh render fails', async () => {
|
|
191
|
+
// First render succeeds with good data
|
|
192
|
+
isrRegistry.clear()
|
|
193
|
+
isrRegistry.register({
|
|
194
|
+
module: 'test',
|
|
195
|
+
match: '/test',
|
|
196
|
+
fetch: async () => ({ items: mockData }),
|
|
197
|
+
meta: () => ({ title: 'Test', description: 'desc' }),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
await isrRequest('/test')
|
|
201
|
+
const cached = await cache.lookup('/test')
|
|
202
|
+
expect(cached.status).toBe('fresh')
|
|
203
|
+
|
|
204
|
+
// Wait for stale
|
|
205
|
+
await new Promise(r => setTimeout(r, 1200))
|
|
206
|
+
|
|
207
|
+
// Even if data changes, stale cache is served
|
|
208
|
+
const result = await isrRequest('/test')
|
|
209
|
+
expect(result.status).toBe('stale')
|
|
210
|
+
expect(result.html).toContain('<title>Test</title>')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('Multiple routes', () => {
|
|
215
|
+
it('should cache routes independently', async () => {
|
|
216
|
+
isrRegistry.clear()
|
|
217
|
+
isrRegistry.registerMany([
|
|
218
|
+
{
|
|
219
|
+
module: 'a',
|
|
220
|
+
match: '/a',
|
|
221
|
+
fetch: async () => ({ items: [{ id: 1, title: 'A1', status: 'pending' }] }),
|
|
222
|
+
meta: () => ({ title: 'A', description: 'a' }),
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
module: 'b',
|
|
226
|
+
match: '/b',
|
|
227
|
+
fetch: async () => ({ items: [{ id: 2, title: 'B2', status: 'pending' }] }),
|
|
228
|
+
meta: () => ({ title: 'B', description: 'b' }),
|
|
229
|
+
},
|
|
230
|
+
])
|
|
231
|
+
|
|
232
|
+
const aResult = await isrRequest('/a')
|
|
233
|
+
const bResult = await isrRequest('/b')
|
|
234
|
+
|
|
235
|
+
expect(aResult.html).toContain('<title>A</title>')
|
|
236
|
+
expect(bResult.html).toContain('<title>B</title>')
|
|
237
|
+
|
|
238
|
+
// Purge /a doesn't affect /b
|
|
239
|
+
await cache.purge('/a')
|
|
240
|
+
expect((await cache.lookup('/a')).status).toBe('miss')
|
|
241
|
+
expect((await cache.lookup('/b')).status).toBe('fresh')
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('Non-ISR routes', () => {
|
|
246
|
+
it('should not match unregistered routes', () => {
|
|
247
|
+
expect(isrRegistry.match('/unknown')).toBeNull()
|
|
248
|
+
expect(isrRegistry.isISRRoute('/unknown')).toBe(false)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
})
|