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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-fullstack-scaffold",
3
- "version": "0.5.2",
3
+ "version": "0.5.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-fullstack-scaffold": "dist/cli/index.js"
@@ -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
- 'local-rules/limit-type-complexity': ['warn', { maxRouteChainLength: 15 }],
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
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "biomimic-todo-app",
3
3
  "private": true,
4
- "version": "0.5.2",
4
+ "version": "0.5.6",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "biomimic": "./dist/cli/index.js"
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @framework-baseline 1a361bf1bb0e5343
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
- 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 })
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
- ReactDOM.createRoot(document.getElementById('root')!).render(
71
- <React.StrictMode>
72
- <RootApp />
73
- </React.StrictMode>
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>(null)
10
- const [loading, setLoading] = useState(true)
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(true)
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
+ }
@@ -12,6 +12,9 @@ import type { Todo } from '@shared/schemas'
12
12
 
13
13
  export interface SSRData {
14
14
  todos?: Todo[]
15
+ /** content 模块 ISR 数据(列表/详情首帧,页面从 __SSR_DATA__ 读取) */
16
+ contents?: unknown[]
17
+ content?: Record<string, unknown> | null
15
18
  [key: string]: unknown
16
19
  }
17
20
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @framework-baseline cec621fae360ba2b
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 e350401421193896
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 { renderPage } from '@server/core/ssr-renderer'
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
- renderPage(pathname)
134
- .then(rendered => {
135
- isrCache.store(pathname, rendered.html).catch(e => {
136
- console.warn('ISR cache store (background revalidation) failed:', e)
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 rendered = await renderPage(pathname)
146
- isrCache.store(pathname, rendered.html).catch(e => {
147
- console.warn('ISR cache store failed:', e)
148
- })
149
- return c.html(rendered.html)
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