create-fullstack-scaffold 0.5.2 → 0.5.6
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 +72 -0
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
- package/template/eslint.config.js +4 -1
- package/template/package.json +1 -1
- package/template/src/client/entry-server.tsx +20 -11
- package/template/src/client/main.tsx +18 -5
- package/template/src/client/pages/ContentDetailPage.tsx +13 -2
- package/template/src/client/pages/ContentListPage.tsx +13 -2
- package/template/src/client/ssr-pages.ts +44 -0
- package/template/src/client/stores/entry-stores.ts +3 -0
- package/template/src/server/core/isr-renderer.ts +8 -2
- package/template/src/server/entries/cloudflare.ts +2 -2
- package/template/src/server/entries/node.ts +60 -11
package/package.json
CHANGED
|
@@ -114,6 +114,7 @@ export default tseslint.config(
|
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
files: ['src/server/**/*.ts'],
|
|
117
|
+
ignores: ['src/server/route-registry.ts'],
|
|
117
118
|
rules: {
|
|
118
119
|
'no-console': 'error',
|
|
119
120
|
'local-rules/require-hono-chain-syntax': 'error',
|
|
@@ -128,7 +129,9 @@ export default tseslint.config(
|
|
|
128
129
|
'local-rules/no-new-old-service-naming': 'error',
|
|
129
130
|
'local-rules/no-cross-module-service-import': 'error',
|
|
130
131
|
'local-rules/route-location': 'error',
|
|
131
|
-
|
|
132
|
+
// TS2589 防线收紧:类型出口已由 no-merged-api-type-export 封死,
|
|
133
|
+
// 链长阈值从 warn/15 收紧到 error/5(超出即架构回归信号)
|
|
134
|
+
'local-rules/limit-type-complexity': ['error', { maxRouteChainLength: 5 }],
|
|
132
135
|
'local-rules/no-merged-api-type-export': 'error',
|
|
133
136
|
},
|
|
134
137
|
},
|
package/template/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @framework-baseline
|
|
2
|
+
* @framework-baseline 00b6a72a77e03864
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -17,6 +17,7 @@ import { StaticRouter } from 'react-router-dom'
|
|
|
17
17
|
import { HelmetProvider } from 'react-helmet-async'
|
|
18
18
|
|
|
19
19
|
import { AppRoutes } from './AppRoutes'
|
|
20
|
+
import { getSsrPage } from './ssr-pages'
|
|
20
21
|
import {
|
|
21
22
|
snapshotEntryStores,
|
|
22
23
|
seedEntryStores,
|
|
@@ -50,17 +51,25 @@ export function renderSSR(pathname: string, data: SSRData): SSRRenderResult {
|
|
|
50
51
|
const helmetContext: Record<string, unknown> = {}
|
|
51
52
|
|
|
52
53
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
{
|
|
60
|
-
React.createElement(
|
|
54
|
+
// 页面级 SSR:ISR 路由有静态同构组件(ssr-pages)——渲染真实页面
|
|
55
|
+
// 内容而非 lazy Loading 壳(SEO 核心);其余路由回退 AppRoutes 壳。
|
|
56
|
+
const SsrPage = getSsrPage(pathname)
|
|
57
|
+
const body = SsrPage
|
|
58
|
+
? React.createElement(
|
|
59
|
+
HelmetProvider,
|
|
60
|
+
{ context: helmetContext },
|
|
61
|
+
React.createElement(StaticRouter, { location: pathname }, React.createElement(SsrPage))
|
|
61
62
|
)
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
: React.createElement(
|
|
64
|
+
HelmetProvider,
|
|
65
|
+
{ context: helmetContext },
|
|
66
|
+
React.createElement(
|
|
67
|
+
StaticRouter,
|
|
68
|
+
{ location: pathname },
|
|
69
|
+
React.createElement(AppRoutes, { presetId: preset })
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
const html = renderToString(body)
|
|
64
73
|
|
|
65
74
|
// 5. Extract helmet data
|
|
66
75
|
const helmet = (helmetContext as { helmet?: Record<string, unknown> }).helmet || {}
|
|
@@ -67,11 +67,24 @@ const RootApp = () => {
|
|
|
67
67
|
)
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
70
|
+
// 水合:SSR(ISR 管线)已输出 #root 内的 HTML——hydrateRoot 复用而非
|
|
71
|
+
// 重画(SEO/首屏收益成立的前提)。无 SSR 的普通访问 fallback createRoot。
|
|
72
|
+
const rootEl = document.getElementById('root')!
|
|
73
|
+
const hasSsrMarkup = rootEl.hasChildNodes()
|
|
74
|
+
if (hasSsrMarkup) {
|
|
75
|
+
ReactDOM.hydrateRoot(
|
|
76
|
+
rootEl,
|
|
77
|
+
<React.StrictMode>
|
|
78
|
+
<RootApp />
|
|
79
|
+
</React.StrictMode>
|
|
80
|
+
)
|
|
81
|
+
} else {
|
|
82
|
+
ReactDOM.createRoot(rootEl).render(
|
|
83
|
+
<React.StrictMode>
|
|
84
|
+
<RootApp />
|
|
85
|
+
</React.StrictMode>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
75
88
|
|
|
76
89
|
if (typeof window !== 'undefined') {
|
|
77
90
|
requestAnimationFrame(() => {
|
|
@@ -4,10 +4,21 @@ import { Helmet } from 'react-helmet-async'
|
|
|
4
4
|
import type { Content } from '@shared/modules/content'
|
|
5
5
|
import { apiClient } from '@client/services/apiClient'
|
|
6
6
|
|
|
7
|
+
// SSR 首帧数据:ISR 服务端写入 __SSR_DATA__.content(详情页按 id 匹配)
|
|
8
|
+
function ssrInitialContent(id?: string): Content | null {
|
|
9
|
+
try {
|
|
10
|
+
const d = (window as unknown as { __SSR_DATA__?: { content?: Content | null } }).__SSR_DATA__
|
|
11
|
+
const c = d?.content ?? null
|
|
12
|
+
return c && (!id || c.id === id) ? c : null
|
|
13
|
+
} catch {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
export const ContentDetailPage: React.FC = () => {
|
|
8
19
|
const { id } = useParams<{ id: string }>()
|
|
9
|
-
const [content, setContent] = useState<Content | null>(
|
|
10
|
-
const [loading, setLoading] = useState(
|
|
20
|
+
const [content, setContent] = useState<Content | null>(() => ssrInitialContent(id))
|
|
21
|
+
const [loading, setLoading] = useState(() => ssrInitialContent(id) === null)
|
|
11
22
|
const [error, setError] = useState<string | null>(null)
|
|
12
23
|
|
|
13
24
|
const fetchContent = useCallback(async (contentId: string) => {
|
|
@@ -13,9 +13,20 @@ const CATEGORIES: { value: ContentCategory | ''; label: string }[] = [
|
|
|
13
13
|
{ value: 'policy', label: '政策' },
|
|
14
14
|
]
|
|
15
15
|
|
|
16
|
+
// SSR 首帧数据:ISR 管线在服务端把数据写进 __SSR_DATA__,客户端首帧
|
|
17
|
+
// 直接用它渲染(水合一致、爬虫可见真实内容),effect 再刷新
|
|
18
|
+
function ssrInitialContents(): Content[] {
|
|
19
|
+
try {
|
|
20
|
+
const d = (window as unknown as { __SSR_DATA__?: { contents?: Content[] } }).__SSR_DATA__
|
|
21
|
+
return d?.contents ?? []
|
|
22
|
+
} catch {
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
export const ContentListPage: React.FC = () => {
|
|
17
|
-
const [contents, setContents] = useState<Content[]>(
|
|
18
|
-
const [loading, setLoading] = useState(
|
|
28
|
+
const [contents, setContents] = useState<Content[]>(ssrInitialContents)
|
|
29
|
+
const [loading, setLoading] = useState(ssrInitialContents().length === 0)
|
|
19
30
|
const [error, setError] = useState<string | null>(null)
|
|
20
31
|
const [category, setCategory] = useState<ContentCategory | ''>('')
|
|
21
32
|
const [search, setSearch] = useState('')
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR 静态页面注册表(替代 preset-ui-config 的 lazy 组件用于服务端渲染)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:renderToString 是同步的,解析不了 React.lazy 的动态
|
|
5
|
+
* import——直接用 preset-ui-config 会让 SSR body 永远是 Suspense 的
|
|
6
|
+
* Loading 壳。这里用静态 import 提供同构组件映射,页面内容在服务端
|
|
7
|
+
* 真正渲染进 HTML(ISR/SEO 的核心价值所在)。
|
|
8
|
+
*
|
|
9
|
+
* 客户端不走这里(CSR 仍用 lazy 代码分割),水合由 entry-server 渲染的
|
|
10
|
+
* HTML + main.tsx 的 hydrateRoot 完成——两侧组件树来自同一源码,静态
|
|
11
|
+
* 导入版多打一份包只进 server bundle(tsup 的 CF 构建),不影响 client
|
|
12
|
+
* chunk 体积。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ComponentType } from 'react'
|
|
16
|
+
|
|
17
|
+
// 按需静态导入 ISR 场景的页面(内容型、需要 SEO 的页面)
|
|
18
|
+
import { TodoPage } from './pages/TodoPage'
|
|
19
|
+
import { ContentListPage } from './pages/ContentListPage'
|
|
20
|
+
import { ContentDetailPage } from './pages/ContentDetailPage'
|
|
21
|
+
import { TopicsPage } from './pages/TopicsPage'
|
|
22
|
+
|
|
23
|
+
export type SsrPageComponent = ComponentType
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 路由 → 同构组件映射。
|
|
27
|
+
* 未列出的路由(管理后台、登录页等)SSR 输出布局壳——它们本来就不
|
|
28
|
+
* 需要 SEO(登录后才可见),客户端 lazy 照常工作。
|
|
29
|
+
*/
|
|
30
|
+
export const SSR_PAGES: Record<string, SsrPageComponent> = {
|
|
31
|
+
'/todos': TodoPage,
|
|
32
|
+
'/content': ContentListPage,
|
|
33
|
+
'/content/:id': ContentDetailPage,
|
|
34
|
+
'/topics': TopicsPage,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** ISR 路径是否有关联的静态组件(决定 body 渲染还是仅 meta) */
|
|
38
|
+
export function getSsrPage(pathname: string): SsrPageComponent | null {
|
|
39
|
+
if (SSR_PAGES[pathname]) return SSR_PAGES[pathname]
|
|
40
|
+
// 详情页模式 /content/123
|
|
41
|
+
const m = pathname.match(/^\/content\/[^/]+$/)
|
|
42
|
+
if (m) return SSR_PAGES['/content/:id']
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @framework-baseline
|
|
2
|
+
* @framework-baseline 108b720f9d939fcd
|
|
3
3
|
*
|
|
4
4
|
*
|
|
5
5
|
* @framework-modify
|
|
@@ -32,6 +32,8 @@ export interface ISRRenderOptions {
|
|
|
32
32
|
body?: string
|
|
33
33
|
/** Meta tags */
|
|
34
34
|
meta: ISRPageMeta
|
|
35
|
+
/** ISR 抓取数据:序列化进 window.__SSR_DATA__ 供客户端首帧渲染(水合一致) */
|
|
36
|
+
data?: unknown
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
@@ -39,7 +41,7 @@ export interface ISRRenderOptions {
|
|
|
39
41
|
* If template is null, generates a minimal standalone HTML.
|
|
40
42
|
*/
|
|
41
43
|
export function renderISRPage(opts: ISRRenderOptions): string {
|
|
42
|
-
const { template, body = '', meta } = opts
|
|
44
|
+
const { template, body = '', meta, data } = opts
|
|
43
45
|
const safeTitle = escapeHtml(meta.title)
|
|
44
46
|
const safeDesc = escapeHtml(meta.description)
|
|
45
47
|
|
|
@@ -54,6 +56,10 @@ export function renderISRPage(opts: ISRRenderOptions): string {
|
|
|
54
56
|
` <meta name="description" content="${safeDesc}" />\n <meta property="og:title" content="${safeTitle}" />\n <meta property="og:description" content="${safeDesc}" />\n <meta name="generator" content="ISR-SSG" />\n </head>`
|
|
55
57
|
)
|
|
56
58
|
html = html.replace('<div id="root"></div>', `<div id="root">${body}</div>`)
|
|
59
|
+
if (data !== undefined) {
|
|
60
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c')
|
|
61
|
+
html = html.replace('</body>', `<script>window.__SSR_DATA__=${json};</script></body>`)
|
|
62
|
+
}
|
|
57
63
|
return html
|
|
58
64
|
}
|
|
59
65
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @framework-baseline
|
|
2
|
+
* @framework-baseline e6d62c9969957f22
|
|
3
3
|
* @framework-modify
|
|
4
4
|
* @reason 模块化 ISR 改造 + SSR 渲染:调用 renderSSR 生成 React body,注入到 ISR 模板
|
|
5
5
|
* @impact CF 入口集成 React SSR,ISR 同时负责 SEO meta 标签和 body 渲染
|
|
@@ -212,7 +212,7 @@ async function renderISRForRoute(
|
|
|
212
212
|
// Fallback: empty body, SPA will hydrate
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
return renderISRPage({ template: cachedTemplate, body, meta })
|
|
215
|
+
return renderISRPage({ template: cachedTemplate, body, meta, data })
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
export { isrCache }
|
|
@@ -17,7 +17,23 @@ import { logger } from '../utils/logger'
|
|
|
17
17
|
import { createApp } from '../app'
|
|
18
18
|
import { getDb, runMigrations } from '../db'
|
|
19
19
|
import { createISRCache, isISRRoute } from '@server/core/isr-cache'
|
|
20
|
-
import {
|
|
20
|
+
import { renderISRPage } from '@server/core/isr-renderer'
|
|
21
|
+
// @client/entry-server 仅含 client 的 preset 存在;cli-only 会因静态
|
|
22
|
+
// 导入悬空(verify 链已拦过一次)。运行时按需加载,缺省回退壳渲染。
|
|
23
|
+
type RenderSSRFn = (pathname: string, data: never) => { html: string }
|
|
24
|
+
|
|
25
|
+
async function loadRenderSSR(): Promise<RenderSSRFn | null> {
|
|
26
|
+
try {
|
|
27
|
+
// 变量说明符:tsc 不做模块解析(cli-only 无 client 目录时字面量
|
|
28
|
+
// 动态导入也会被 tsc 拦——与 vitest.config 的 react 插件同法)
|
|
29
|
+
const mod = '@client/entry-server'
|
|
30
|
+
const m = (await import(/* @vite-ignore */ mod)) as { renderSSR: RenderSSRFn }
|
|
31
|
+
return m.renderSSR
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
import { isrRegistry, type ISRRouterContext } from '@server/core/isr-registry'
|
|
21
37
|
import { setISRCache } from '@server/core/isr-invalidation'
|
|
22
38
|
import { setRuntimeAdapter } from '@server/core/runtime'
|
|
23
39
|
import { getNodeRuntimeAdapter } from '@server/core/runtime-node'
|
|
@@ -130,11 +146,13 @@ app.get('*', async c => {
|
|
|
130
146
|
}
|
|
131
147
|
|
|
132
148
|
if (result.status === 'stale' && result.html) {
|
|
133
|
-
|
|
134
|
-
.then(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
149
|
+
renderISRForRoute(pathname)
|
|
150
|
+
.then(html => {
|
|
151
|
+
if (html) {
|
|
152
|
+
isrCache.store(pathname, html).catch(e => {
|
|
153
|
+
console.warn('ISR cache store (background revalidation) failed:', e)
|
|
154
|
+
})
|
|
155
|
+
}
|
|
138
156
|
})
|
|
139
157
|
.catch(e => {
|
|
140
158
|
console.warn('ISR background render failed:', e)
|
|
@@ -142,11 +160,14 @@ app.get('*', async c => {
|
|
|
142
160
|
return c.html(result.html)
|
|
143
161
|
}
|
|
144
162
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
163
|
+
const html = await renderISRForRoute(pathname)
|
|
164
|
+
if (html) {
|
|
165
|
+
isrCache.store(pathname, html).catch(e => {
|
|
166
|
+
console.warn('ISR cache store failed:', e)
|
|
167
|
+
})
|
|
168
|
+
return c.html(html)
|
|
169
|
+
}
|
|
170
|
+
return c.html(indexHtml)
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
return c.html(indexHtml)
|
|
@@ -191,6 +212,34 @@ export async function createServer() {
|
|
|
191
212
|
return { server, port: config.port }
|
|
192
213
|
}
|
|
193
214
|
|
|
215
|
+
/**
|
|
216
|
+
* 真 ISR 渲染(与 CF 入口同管线):registry fetch 数据 → renderSSR
|
|
217
|
+
* 页面级渲染 → renderISRPage 注入 body + meta + __SSR_DATA__。
|
|
218
|
+
* 返回 null 表示无注册路由/渲染失败,调用方回退 SPA index.html。
|
|
219
|
+
*/
|
|
220
|
+
async function renderISRForRoute(pathname: string): Promise<string | null> {
|
|
221
|
+
const entry = isrRegistry.match(pathname)
|
|
222
|
+
if (!entry) return null
|
|
223
|
+
const ctx: ISRRouterContext = { db: await getDb(), env: {} }
|
|
224
|
+
let data: unknown = {}
|
|
225
|
+
let meta = { title: 'App', description: '' }
|
|
226
|
+
try {
|
|
227
|
+
data = await entry.fetch(pathname, ctx)
|
|
228
|
+
meta = entry.meta(data, pathname)
|
|
229
|
+
} catch {
|
|
230
|
+
// DB 错误——回退默认 meta 继续渲染壳
|
|
231
|
+
}
|
|
232
|
+
const renderSSR = await loadRenderSSR()
|
|
233
|
+
if (!renderSSR) return null // 无 client(cli-only 等)——调用方回退 SPA
|
|
234
|
+
try {
|
|
235
|
+
const ssr = renderSSR(pathname, data as never)
|
|
236
|
+
return renderISRPage({ template: indexHtml, body: ssr.html, meta, data })
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.warn('ISR render failed:', e)
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
194
243
|
export async function startServer() {
|
|
195
244
|
const bootstrapLog = logger.bootstrap()
|
|
196
245
|
|