create-fullstack-scaffold 0.5.1 → 0.5.3
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/drizzle.config.ts +2 -0
- 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/config.ts +1 -3
- 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 +3 -1
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
|
+
}
|
|
@@ -66,9 +66,7 @@ export function getAppConfig(): AppConfig {
|
|
|
66
66
|
database: {
|
|
67
67
|
driver: isCloudflare ? 'd1' : dbDriver,
|
|
68
68
|
sqlitePath:
|
|
69
|
-
typeof process !== 'undefined'
|
|
70
|
-
? process.env.SQLITE_PATH || `./data/${nodeEnv}.db`
|
|
71
|
-
: undefined,
|
|
69
|
+
typeof process !== 'undefined' ? process.env.SQLITE_PATH || './data/app.db' : undefined,
|
|
72
70
|
mysqlHost: typeof process !== 'undefined' ? process.env.MYSQL_HOST || 'localhost' : undefined,
|
|
73
71
|
mysqlPort:
|
|
74
72
|
typeof process !== 'undefined' ? parseInt(process.env.MYSQL_PORT || '3306', 10) : undefined,
|
|
@@ -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 }
|
|
@@ -202,7 +202,9 @@ export async function startServer() {
|
|
|
202
202
|
await initializeDatabase()
|
|
203
203
|
bootstrapLog.info({}, 'Database ready')
|
|
204
204
|
} catch (err) {
|
|
205
|
-
|
|
205
|
+
// pino 走异步 thread-stream transport,process.exit 前不 flush——
|
|
206
|
+
// 生产致命错误必须同步 console.error,否则静默死(exit 1 零输出)
|
|
207
|
+
console.error('Database initialization failed:', err)
|
|
206
208
|
process.exit(1)
|
|
207
209
|
}
|
|
208
210
|
|