create-fullstack-scaffold 0.1.1 → 0.2.0
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 +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- package/template/package-lock.json +0 -14554
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline b820abc546e81b6c
|
|
3
|
+
*
|
|
4
|
+
* ISR (Incremental Static Regeneration) cache layer.
|
|
5
|
+
* Supports Cloudflare Cache API and in-memory fallback.
|
|
6
|
+
*
|
|
7
|
+
* @framework-modify
|
|
8
|
+
* @reason 移除未使用的 fullOptions 变量,修复 TypeScript strict 检查
|
|
9
|
+
* @impact 不影响功能,仅清理代码
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ISRCacheEntry {
|
|
13
|
+
html: string
|
|
14
|
+
createdAt: number
|
|
15
|
+
revalidateAt: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ISRLookupResult {
|
|
19
|
+
status: 'fresh' | 'stale' | 'miss'
|
|
20
|
+
html: string | null
|
|
21
|
+
entry?: ISRCacheEntry
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ISRCacheOptions {
|
|
25
|
+
maxAge?: number
|
|
26
|
+
staleWhileRevalidate?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_MAX_AGE = 60
|
|
30
|
+
const DEFAULT_STALE_WHILE_REVALIDATE = 300
|
|
31
|
+
|
|
32
|
+
const ISR_ROUTES = ['/', '/todos', '/content', '/notifications', '/websocket']
|
|
33
|
+
|
|
34
|
+
const ISR_ROUTE_PREFIXES = ['/content/']
|
|
35
|
+
|
|
36
|
+
export function isISRRoute(pathname: string): boolean {
|
|
37
|
+
if (ISR_ROUTES.includes(pathname)) return true
|
|
38
|
+
for (const prefix of ISR_ROUTE_PREFIXES) {
|
|
39
|
+
if (pathname.startsWith(prefix)) return true
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function generateCacheKey(pathname: string): string {
|
|
45
|
+
const normalized = pathname === '/' ? '/index' : pathname.replace(/\/$/, '')
|
|
46
|
+
return `isr:${normalized}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ISRCacheStore {
|
|
50
|
+
get(key: string): Promise<ISRCacheEntry | null>
|
|
51
|
+
set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void>
|
|
52
|
+
purge(key: string): Promise<void>
|
|
53
|
+
purgePattern(pattern: string): Promise<void>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class MemoryCacheStore implements ISRCacheStore {
|
|
57
|
+
private cache = new Map<string, ISRCacheEntry>()
|
|
58
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
59
|
+
|
|
60
|
+
constructor() {
|
|
61
|
+
this.timer = setInterval(() => this.cleanup(), 60000)
|
|
62
|
+
if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
|
|
63
|
+
this.timer.unref()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private cleanup(): void {
|
|
68
|
+
const now = Date.now()
|
|
69
|
+
for (const [key, entry] of this.cache) {
|
|
70
|
+
if (now > entry.revalidateAt + 60000) {
|
|
71
|
+
this.cache.delete(key)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async get(key: string): Promise<ISRCacheEntry | null> {
|
|
77
|
+
return this.cache.get(key) ?? null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void> {
|
|
81
|
+
const now = Date.now()
|
|
82
|
+
this.cache.set(key, {
|
|
83
|
+
html,
|
|
84
|
+
createdAt: now,
|
|
85
|
+
revalidateAt: now + options.maxAge * 1000,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async purge(key: string): Promise<void> {
|
|
90
|
+
this.cache.delete(key)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async purgePattern(pattern: string): Promise<void> {
|
|
94
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
|
|
95
|
+
for (const key of this.cache.keys()) {
|
|
96
|
+
if (regex.test(key)) {
|
|
97
|
+
this.cache.delete(key)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
destroy(): void {
|
|
103
|
+
if (this.timer) {
|
|
104
|
+
clearInterval(this.timer)
|
|
105
|
+
this.timer = null
|
|
106
|
+
}
|
|
107
|
+
this.cache.clear()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
class CloudflareCacheStore implements ISRCacheStore {
|
|
112
|
+
private cache: Cache | null = null
|
|
113
|
+
private origin = 'https://isr.local'
|
|
114
|
+
|
|
115
|
+
private async getCache(): Promise<Cache> {
|
|
116
|
+
if (!this.cache) {
|
|
117
|
+
this.cache = await caches.open('isr-pages')
|
|
118
|
+
}
|
|
119
|
+
return this.cache
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private toUrl(key: string): string {
|
|
123
|
+
return `${this.origin}${key.replace(/^isr:/, '')}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async get(key: string): Promise<ISRCacheEntry | null> {
|
|
127
|
+
const cache = await this.getCache()
|
|
128
|
+
const url = this.toUrl(key)
|
|
129
|
+
const response = await cache.match(url)
|
|
130
|
+
if (!response) return null
|
|
131
|
+
|
|
132
|
+
const html = await response.text()
|
|
133
|
+
const createdAt = Number(response.headers.get('x-isr-created-at') || '0')
|
|
134
|
+
const revalidateAt = Number(response.headers.get('x-isr-revalidate-at') || '0')
|
|
135
|
+
|
|
136
|
+
return { html, createdAt, revalidateAt }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void> {
|
|
140
|
+
const cache = await this.getCache()
|
|
141
|
+
const url = this.toUrl(key)
|
|
142
|
+
const now = Date.now()
|
|
143
|
+
|
|
144
|
+
const response = new Response(html, {
|
|
145
|
+
headers: {
|
|
146
|
+
'Content-Type': 'text/html;charset=UTF-8',
|
|
147
|
+
'x-isr-created-at': String(now),
|
|
148
|
+
'x-isr-revalidate-at': String(now + options.maxAge * 1000),
|
|
149
|
+
'x-isr-max-age': String(options.maxAge),
|
|
150
|
+
'x-isr-stale-while-revalidate': String(options.staleWhileRevalidate),
|
|
151
|
+
'Cache-Control': `public, max-age=${options.maxAge}, s-maxage=${options.maxAge}, stale-while-revalidate=${options.staleWhileRevalidate}`,
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
await cache.put(url, response)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async purge(key: string): Promise<void> {
|
|
159
|
+
const cache = await this.getCache()
|
|
160
|
+
const url = this.toUrl(key)
|
|
161
|
+
await cache.delete(url)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async purgePattern(pattern: string): Promise<void> {
|
|
165
|
+
const cache = await this.getCache()
|
|
166
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
|
|
167
|
+
const allKeys: Request[] = []
|
|
168
|
+
|
|
169
|
+
for (const request of allKeys) {
|
|
170
|
+
const url = new URL(request.url)
|
|
171
|
+
const key = `isr:${url.pathname}`
|
|
172
|
+
if (regex.test(key)) {
|
|
173
|
+
await cache.delete(request)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface ISRCache {
|
|
180
|
+
lookup(pathname: string): Promise<ISRLookupResult>
|
|
181
|
+
store(pathname: string, html: string, options?: ISRCacheOptions): Promise<void>
|
|
182
|
+
purge(pathname: string): Promise<void>
|
|
183
|
+
purgePattern(pattern: string): Promise<void>
|
|
184
|
+
getStore(): ISRCacheStore
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createISRCache(options?: ISRCacheOptions): ISRCache {
|
|
188
|
+
const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE
|
|
189
|
+
const staleWhileRevalidate = options?.staleWhileRevalidate ?? DEFAULT_STALE_WHILE_REVALIDATE
|
|
190
|
+
|
|
191
|
+
const isCloudflare = typeof caches !== 'undefined' && typeof caches.open === 'function'
|
|
192
|
+
const store: ISRCacheStore = isCloudflare ? new CloudflareCacheStore() : new MemoryCacheStore()
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
async lookup(pathname: string): Promise<ISRLookupResult> {
|
|
196
|
+
const key = generateCacheKey(pathname)
|
|
197
|
+
const entry = await store.get(key)
|
|
198
|
+
|
|
199
|
+
if (!entry) {
|
|
200
|
+
return { status: 'miss', html: null }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const now = Date.now()
|
|
204
|
+
const staleTime = entry.revalidateAt + staleWhileRevalidate * 1000
|
|
205
|
+
|
|
206
|
+
if (now <= entry.revalidateAt) {
|
|
207
|
+
return { status: 'fresh', html: entry.html, entry }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (now <= staleTime) {
|
|
211
|
+
return { status: 'stale', html: entry.html, entry }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { status: 'miss', html: null }
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async store(pathname: string, html: string, opts?: ISRCacheOptions): Promise<void> {
|
|
218
|
+
const key = generateCacheKey(pathname)
|
|
219
|
+
const storeOpts = {
|
|
220
|
+
maxAge: opts?.maxAge ?? maxAge,
|
|
221
|
+
staleWhileRevalidate: opts?.staleWhileRevalidate ?? staleWhileRevalidate,
|
|
222
|
+
}
|
|
223
|
+
await store.set(key, html, storeOpts)
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async purge(pathname: string): Promise<void> {
|
|
227
|
+
const key = generateCacheKey(pathname)
|
|
228
|
+
await store.purge(key)
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async purgePattern(pattern: string): Promise<void> {
|
|
232
|
+
await store.purgePattern(pattern)
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
getStore(): ISRCacheStore {
|
|
236
|
+
return store
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline a124c5bce28416ca
|
|
3
|
+
*
|
|
4
|
+
* @framework-modify
|
|
5
|
+
* @reason 自动生成的基准更新
|
|
6
|
+
* @impact 无功能影响
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ISR cache invalidation utilities.
|
|
11
|
+
* Call these from service layer when content changes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ISRCache } from './isr-cache'
|
|
15
|
+
|
|
16
|
+
let _cache: ISRCache | null = null
|
|
17
|
+
|
|
18
|
+
export function setISRCache(cache: ISRCache): void {
|
|
19
|
+
_cache = cache
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getISRCache(): ISRCache | null {
|
|
23
|
+
return _cache
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function purgePage(pathname: string): Promise<void> {
|
|
27
|
+
if (!_cache) return
|
|
28
|
+
await _cache.purge(pathname)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function purgeContentPages(): Promise<void> {
|
|
32
|
+
if (!_cache) return
|
|
33
|
+
await _cache.purge('/content')
|
|
34
|
+
await _cache.purgePattern('isr:/content/*')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function purgeAllPages(): Promise<void> {
|
|
38
|
+
if (!_cache) return
|
|
39
|
+
await _cache.purge('/')
|
|
40
|
+
await _cache.purge('/todos')
|
|
41
|
+
await _cache.purge('/content')
|
|
42
|
+
await _cache.purge('/notifications')
|
|
43
|
+
await _cache.purge('/websocket')
|
|
44
|
+
await _cache.purgePattern('isr:/content/*')
|
|
45
|
+
}
|
|
@@ -169,13 +169,20 @@ export async function validateModules(): Promise<{
|
|
|
169
169
|
const modulePath = join(MODULES_DIR, actualDir)
|
|
170
170
|
|
|
171
171
|
if (manifest.routes.client) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
172
|
+
const clientRoutes = Array.isArray(manifest.routes.client)
|
|
173
|
+
? manifest.routes.client
|
|
174
|
+
: manifest.routes.client
|
|
175
|
+
? [manifest.routes.client]
|
|
176
|
+
: []
|
|
177
|
+
for (const route of clientRoutes) {
|
|
178
|
+
const filePath = resolve(modulePath, route.importPath + '.ts')
|
|
179
|
+
if (!existsSync(filePath)) {
|
|
180
|
+
errors.push({
|
|
181
|
+
module: name,
|
|
182
|
+
type: 'route-file',
|
|
183
|
+
message: `client route file not found: ${route.importPath}.ts`,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
179
186
|
}
|
|
180
187
|
}
|
|
181
188
|
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline cabb57f8f865d571
|
|
3
|
+
*
|
|
4
|
+
* SSR renderer for ISR. Generates HTML shells with SEO meta tags.
|
|
5
|
+
* Phase 1: Shell rendering with meta tags (no React renderToString).
|
|
6
|
+
* Phase 2 (future): Full renderToString with data-driven content.
|
|
7
|
+
*
|
|
8
|
+
* @framework-modify
|
|
9
|
+
* @reason 修复 RouteConfig.pattern 类型从 string 改为 RegExp,修复 TypeScript 类型错误
|
|
10
|
+
* @impact 不影响运行时行为,仅修复类型定义
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface RouteMeta {
|
|
14
|
+
title: string
|
|
15
|
+
description: string
|
|
16
|
+
ogType?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RouteConfig {
|
|
20
|
+
path: string
|
|
21
|
+
pattern: RegExp
|
|
22
|
+
meta: RouteMeta
|
|
23
|
+
loader?: (params: Record<string, string>) => Promise<Record<string, unknown>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RenderResult {
|
|
27
|
+
html: string
|
|
28
|
+
status: number
|
|
29
|
+
headers: Record<string, string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STATIC_ROUTES: RouteConfig[] = [
|
|
33
|
+
{
|
|
34
|
+
path: '/',
|
|
35
|
+
pattern: /^\/$/,
|
|
36
|
+
meta: {
|
|
37
|
+
title: 'Todo List - Biomimic App',
|
|
38
|
+
description: 'A full-stack application template with React and Hono',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
path: '/todos',
|
|
43
|
+
pattern: /^\/todos$/,
|
|
44
|
+
meta: {
|
|
45
|
+
title: 'Todo List - Biomimic App',
|
|
46
|
+
description: 'Manage your todos with real-time updates',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
path: '/notifications',
|
|
51
|
+
pattern: /^\/notifications$/,
|
|
52
|
+
meta: {
|
|
53
|
+
title: 'Notifications - Biomimic App',
|
|
54
|
+
description: 'Real-time notifications via Server-Sent Events',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
path: '/websocket',
|
|
59
|
+
pattern: /^\/websocket$/,
|
|
60
|
+
meta: {
|
|
61
|
+
title: 'WebSocket Demo - Biomimic App',
|
|
62
|
+
description: 'Type-safe WebSocket communication demo',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
path: '/content',
|
|
67
|
+
pattern: /^\/content$/,
|
|
68
|
+
meta: {
|
|
69
|
+
title: '内容中心 - Biomimic App',
|
|
70
|
+
description: 'Content management with categories and search',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
path: '/content/:id',
|
|
75
|
+
pattern: /^\/content\/([^/]+)$/,
|
|
76
|
+
meta: {
|
|
77
|
+
title: '内容详情 - Biomimic App',
|
|
78
|
+
description: 'View content details',
|
|
79
|
+
},
|
|
80
|
+
loader: async params => {
|
|
81
|
+
try {
|
|
82
|
+
// Load content module dynamically (may not exist in all presets)
|
|
83
|
+
// Use require() to avoid TypeScript checking module existence at compile time
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
|
+
const contentModule = require('@server/module-content/services/content-service') as {
|
|
86
|
+
getContentById: (
|
|
87
|
+
id: string
|
|
88
|
+
) => Promise<{ id: number; title: string; content?: string } | null>
|
|
89
|
+
}
|
|
90
|
+
const content = await contentModule.getContentById(params.id)
|
|
91
|
+
if (content) {
|
|
92
|
+
return {
|
|
93
|
+
__meta: {
|
|
94
|
+
title: `${content.title} - Biomimic App`,
|
|
95
|
+
description: content.content?.substring(0, 160) || 'View content details',
|
|
96
|
+
},
|
|
97
|
+
content,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// DB not available, return default meta
|
|
102
|
+
}
|
|
103
|
+
return {}
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
export function matchRoute(
|
|
109
|
+
pathname: string
|
|
110
|
+
): { route: RouteConfig; params: Record<string, string> } | null {
|
|
111
|
+
for (const route of STATIC_ROUTES) {
|
|
112
|
+
const match = pathname.match(route.pattern)
|
|
113
|
+
if (match) {
|
|
114
|
+
const params: Record<string, string> = {}
|
|
115
|
+
if (route.path === '/content/:id' && match[1]) {
|
|
116
|
+
params.id = match[1]
|
|
117
|
+
}
|
|
118
|
+
return { route, params }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function renderPage(
|
|
125
|
+
pathname: string,
|
|
126
|
+
options?: {
|
|
127
|
+
assetHash?: string
|
|
128
|
+
data?: Record<string, unknown>
|
|
129
|
+
}
|
|
130
|
+
): Promise<RenderResult> {
|
|
131
|
+
const matched = matchRoute(pathname)
|
|
132
|
+
|
|
133
|
+
if (!matched) {
|
|
134
|
+
return renderFallback()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { route, params } = matched
|
|
138
|
+
let meta = { ...route.meta }
|
|
139
|
+
let pageData: Record<string, unknown> = {}
|
|
140
|
+
|
|
141
|
+
if (route.loader) {
|
|
142
|
+
try {
|
|
143
|
+
const loaderResult = await route.loader(params)
|
|
144
|
+
if (loaderResult.__meta) {
|
|
145
|
+
meta = { ...meta, ...(loaderResult.__meta as RouteMeta) }
|
|
146
|
+
delete loaderResult.__meta
|
|
147
|
+
}
|
|
148
|
+
pageData = loaderResult
|
|
149
|
+
} catch {
|
|
150
|
+
// Loader failed, use default meta
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (options?.data) {
|
|
155
|
+
pageData = { ...pageData, ...options.data }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const html = renderShell({
|
|
159
|
+
title: meta.title,
|
|
160
|
+
description: meta.description,
|
|
161
|
+
ogType: meta.ogType || 'website',
|
|
162
|
+
data: pageData,
|
|
163
|
+
pathname,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
html,
|
|
168
|
+
status: 200,
|
|
169
|
+
headers: {
|
|
170
|
+
'Content-Type': 'text/html;charset=UTF-8',
|
|
171
|
+
'X-ISR-Rendered': 'true',
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface ShellOptions {
|
|
177
|
+
title: string
|
|
178
|
+
description: string
|
|
179
|
+
ogType: string
|
|
180
|
+
data: Record<string, unknown>
|
|
181
|
+
pathname: string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderShell(options: ShellOptions): string {
|
|
185
|
+
const dataJson = JSON.stringify(options.data)
|
|
186
|
+
const escapedJson = dataJson.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
187
|
+
|
|
188
|
+
return `<!DOCTYPE html>
|
|
189
|
+
<html lang="en">
|
|
190
|
+
<head>
|
|
191
|
+
<meta charset="UTF-8" />
|
|
192
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
193
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
194
|
+
<title>${escapeHtml(options.title)}</title>
|
|
195
|
+
<meta name="description" content="${escapeHtml(options.description)}" />
|
|
196
|
+
<meta property="og:title" content="${escapeHtml(options.title)}" />
|
|
197
|
+
<meta property="og:description" content="${escapeHtml(options.description)}" />
|
|
198
|
+
<meta property="og:type" content="${escapeHtml(options.ogType)}" />
|
|
199
|
+
<meta property="og:url" content="${escapeHtml(options.pathname)}" />
|
|
200
|
+
<meta name="generator" content="ISR" />
|
|
201
|
+
</head>
|
|
202
|
+
<body>
|
|
203
|
+
<div id="root"></div>
|
|
204
|
+
<script>window.__SSR_DATA__ = ${escapedJson};window.__SSR_PATH__ = ${JSON.stringify(options.pathname)};</script>
|
|
205
|
+
<script type="module" src="/src/client/main.tsx"></script>
|
|
206
|
+
</body>
|
|
207
|
+
</html>`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderFallback(): RenderResult {
|
|
211
|
+
return {
|
|
212
|
+
html: `<!DOCTYPE html>
|
|
213
|
+
<html lang="en">
|
|
214
|
+
<head>
|
|
215
|
+
<meta charset="UTF-8" />
|
|
216
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
217
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
218
|
+
<title>Biomimic App</title>
|
|
219
|
+
<meta name="description" content="Biomimic App - A full-stack application template" />
|
|
220
|
+
</head>
|
|
221
|
+
<body>
|
|
222
|
+
<div id="root"></div>
|
|
223
|
+
<script type="module" src="/src/client/main.tsx"></script>
|
|
224
|
+
</body>
|
|
225
|
+
</html>`,
|
|
226
|
+
status: 200,
|
|
227
|
+
headers: {
|
|
228
|
+
'Content-Type': 'text/html;charset=UTF-8',
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function escapeHtml(str: string): string {
|
|
234
|
+
return str
|
|
235
|
+
.replace(/&/g, '&')
|
|
236
|
+
.replace(/</g, '<')
|
|
237
|
+
.replace(/>/g, '>')
|
|
238
|
+
.replace(/"/g, '"')
|
|
239
|
+
.replace(/'/g, ''')
|
|
240
|
+
}
|