create-fullstack-scaffold 0.5.6 → 0.5.7

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.6",
3
+ "version": "0.5.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-fullstack-scaffold": "dist/cli/index.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "biomimic-todo-app",
3
3
  "private": true,
4
- "version": "0.5.6",
4
+ "version": "0.5.7",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "biomimic": "./dist/cli/index.js"
@@ -105,10 +105,6 @@
105
105
  "@eslint/js": "^9.39.4",
106
106
  "@hono/vite-dev-server": "^0.23.0",
107
107
  "@playwright/test": "^1.60.0",
108
- "@prerenderer/renderer-jsdom": "^1.1.9",
109
- "@prerenderer/renderer-puppeteer": "^1.2.4",
110
- "puppeteer": "^24.43.1",
111
- "@prerenderer/rollup-plugin": "^0.3.12",
112
108
  "@tailwindcss/postcss": "^4.2.1",
113
109
  "@testing-library/dom": "^10.4.1",
114
110
  "@testing-library/jest-dom": "^6.8.0",
@@ -50,6 +50,11 @@ export function renderSSR(pathname: string, data: SSRData): SSRRenderResult {
50
50
  // 4. Render with helmet context to extract head tags
51
51
  const helmetContext: Record<string, unknown> = {}
52
52
 
53
+ // 服务端把 ISR 数据挂到 globalThis:组件的 ssrInitial*() 在 renderToString
54
+ // 期间与浏览器水合同源读取(globalThis === window),首帧即渲染真实内容
55
+ const globalData = globalThis as { __SSR_DATA__?: unknown }
56
+ globalData.__SSR_DATA__ = data
57
+
53
58
  try {
54
59
  // 页面级 SSR:ISR 路由有静态同构组件(ssr-pages)——渲染真实页面
55
60
  // 内容而非 lazy Loading 壳(SEO 核心);其余路由回退 AppRoutes 壳。
@@ -78,7 +83,8 @@ export function renderSSR(pathname: string, data: SSRData): SSRRenderResult {
78
83
 
79
84
  return { html, helmet: { title: titleStr, meta: metaStr } }
80
85
  } finally {
81
- // 6. Restore store state
86
+ // 6. Restore store state + 清理服务端数据挂载(防跨请求泄漏)
82
87
  restoreEntryStores(snapshot)
88
+ delete globalData.__SSR_DATA__
83
89
  }
84
90
  }
@@ -85,11 +85,3 @@ if (hasSsrMarkup) {
85
85
  </React.StrictMode>
86
86
  )
87
87
  }
88
-
89
- if (typeof window !== 'undefined') {
90
- requestAnimationFrame(() => {
91
- setTimeout(() => {
92
- document.dispatchEvent(new CustomEvent('prerender-ready'))
93
- }, 100)
94
- })
95
- }
@@ -7,7 +7,7 @@ import { apiClient } from '@client/services/apiClient'
7
7
  // SSR 首帧数据:ISR 服务端写入 __SSR_DATA__.content(详情页按 id 匹配)
8
8
  function ssrInitialContent(id?: string): Content | null {
9
9
  try {
10
- const d = (window as unknown as { __SSR_DATA__?: { content?: Content | null } }).__SSR_DATA__
10
+ const d = (globalThis as { __SSR_DATA__?: { content?: Content | null } }).__SSR_DATA__
11
11
  const c = d?.content ?? null
12
12
  return c && (!id || c.id === id) ? c : null
13
13
  } catch {
@@ -17,7 +17,7 @@ const CATEGORIES: { value: ContentCategory | ''; label: string }[] = [
17
17
  // 直接用它渲染(水合一致、爬虫可见真实内容),effect 再刷新
18
18
  function ssrInitialContents(): Content[] {
19
19
  try {
20
- const d = (window as unknown as { __SSR_DATA__?: { contents?: Content[] } }).__SSR_DATA__
20
+ const d = (globalThis as { __SSR_DATA__?: { contents?: Content[] } }).__SSR_DATA__
21
21
  return d?.contents ?? []
22
22
  } catch {
23
23
  return []
@@ -22,6 +22,19 @@ import type { Todo } from '@shared/schemas'
22
22
 
23
23
  type FilterType = 'all' | 'pending' | 'in_progress' | 'completed'
24
24
 
25
+ // SSR 首帧数据:ISR 管线把 registry fetch 的数据写进 __SSR_DATA__,
26
+ // 服务端 renderToString 与浏览器水合从同一 globalThis 读取。zustand v5 的
27
+ // 服务端快照固定为 getInitialState(setState 种子对 SSR 不可见),首帧
28
+ // 列表必须从这里兜底,挂载后 effect 再走 store 刷新
29
+ function ssrInitialTodos(): Todo[] {
30
+ try {
31
+ const d = (globalThis as { __SSR_DATA__?: { todos?: Todo[] } }).__SSR_DATA__
32
+ return d?.todos ?? []
33
+ } catch {
34
+ return []
35
+ }
36
+ }
37
+
25
38
  export const TodoPage: React.FC = () => {
26
39
  const todos = useTodoStore(state => state.todos)
27
40
  const loading = useTodoStore(state => state.loading)
@@ -37,6 +50,9 @@ export const TodoPage: React.FC = () => {
37
50
 
38
51
  const [newTodoTitle, setNewTodoTitle] = useState('')
39
52
  const [newTodoDescription, setNewTodoDescription] = useState('')
53
+ const [ssrTodos] = useState<Todo[]>(ssrInitialTodos)
54
+ // store 一旦有数据(挂载后 fetch 完成)即以 store 为准,首帧用 SSR 数据兜底
55
+ const visibleTodos = todos.length > 0 ? todos : ssrTodos
40
56
  const [filter, setFilter] = useState<FilterType>('all')
41
57
  const [expandedTodoId, setExpandedTodoId] = useState<number | null>(null)
42
58
  const fileInputRefs = useRef<Map<number, HTMLInputElement>>(new Map())
@@ -95,7 +111,7 @@ export const TodoPage: React.FC = () => {
95
111
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
96
112
  }
97
113
 
98
- const filteredTodos = todos.filter(todo => {
114
+ const filteredTodos = visibleTodos.filter(todo => {
99
115
  if (filter === 'all') return true
100
116
  return todo.status === filter
101
117
  })
@@ -174,17 +190,17 @@ export const TodoPage: React.FC = () => {
174
190
  </div>
175
191
  )}
176
192
 
177
- {loading && todos.length === 0 && (
193
+ {loading && visibleTodos.length === 0 && (
178
194
  <div className="flex items-center justify-center py-12" data-testid="loading-indicator">
179
195
  <LoadingSpinner size="lg" />
180
196
  </div>
181
197
  )}
182
198
 
183
- {todos.length > 0 && (
199
+ {visibleTodos.length > 0 && (
184
200
  <div className="mb-6 flex items-center justify-between">
185
201
  <div className="flex items-center gap-2" data-testid="todo-count">
186
202
  <span className="text-sm text-gray-500">Total:</span>
187
- <span className="text-sm font-medium text-gray-900">{todos.length}</span>
203
+ <span className="text-sm font-medium text-gray-900">{visibleTodos.length}</span>
188
204
  </div>
189
205
  <div className="flex items-center gap-2">
190
206
  <button
@@ -1,20 +1,11 @@
1
1
  /**
2
- * SSR 静态页面注册表(替代 preset-ui-config 的 lazy 组件用于服务端渲染)。
2
+ * SSR 静态页面注册表(模板全量版;CLI preset 重新生成,只导入实际存在的页面)。
3
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 体积。
4
+ * renderToString 解析不了 React.lazy——ISR 路由的页面级 SSR 依赖这里的
5
+ * 静态同构组件,页面内容才真正渲染进 HTML(SEO/首屏的核心价值)。
13
6
  */
14
7
 
15
8
  import type { ComponentType } from 'react'
16
-
17
- // 按需静态导入 ISR 场景的页面(内容型、需要 SEO 的页面)
18
9
  import { TodoPage } from './pages/TodoPage'
19
10
  import { ContentListPage } from './pages/ContentListPage'
20
11
  import { ContentDetailPage } from './pages/ContentDetailPage'
@@ -22,11 +13,6 @@ import { TopicsPage } from './pages/TopicsPage'
22
13
 
23
14
  export type SsrPageComponent = ComponentType
24
15
 
25
- /**
26
- * 路由 → 同构组件映射。
27
- * 未列出的路由(管理后台、登录页等)SSR 输出布局壳——它们本来就不
28
- * 需要 SEO(登录后才可见),客户端 lazy 照常工作。
29
- */
30
16
  export const SSR_PAGES: Record<string, SsrPageComponent> = {
31
17
  '/todos': TodoPage,
32
18
  '/content': ContentListPage,
@@ -34,11 +20,14 @@ export const SSR_PAGES: Record<string, SsrPageComponent> = {
34
20
  '/topics': TopicsPage,
35
21
  }
36
22
 
37
- /** ISR 路径是否有关联的静态组件(决定 body 渲染还是仅 meta) */
23
+ const SSR_PATTERNS: Array<{ prefix: string; component: SsrPageComponent }> = [
24
+ { prefix: '/content/', component: ContentDetailPage },
25
+ ]
26
+
38
27
  export function getSsrPage(pathname: string): SsrPageComponent | null {
39
28
  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']
29
+ for (const p of SSR_PATTERNS) {
30
+ if (pathname.startsWith(p.prefix)) return p.component
31
+ }
43
32
  return null
44
33
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @framework-baseline 159d5e23f19e9793
2
+ * @framework-baseline 91c7b6f83b7c9913
3
3
  * @framework-modify
4
4
  * @reason 添加 SPA 前端路由处理,区分开发/生产环境
5
5
  * @impact 新增前端路由处理逻辑,/admin/* 返回 admin.html,其他路由返回 index.html
@@ -17,22 +17,10 @@ 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
+ // Side-effect: 注册 ISR 路由到全局 registry(Node 入口此前漏了这一行——
21
+ // registry 空 → isISRRoute 恒 false → ISR 永远不触发,SSR 壳照旧)
22
+ import '@server/isr-modules'
20
23
  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
24
  import { isrRegistry, type ISRRouterContext } from '@server/core/isr-registry'
37
25
  import { setISRCache } from '@server/core/isr-invalidation'
38
26
  import { setRuntimeAdapter } from '@server/core/runtime'
@@ -217,9 +205,28 @@ export async function createServer() {
217
205
  * 页面级渲染 → renderISRPage 注入 body + meta + __SSR_DATA__。
218
206
  * 返回 null 表示无注册路由/渲染失败,调用方回退 SPA index.html。
219
207
  */
208
+ type SSRRenderer = (pathname: string, data: unknown) => { html: string } | null
209
+
210
+ // 渲染桥按需加载:静态 import 会把整个 React SSR 图(react-router-dom 等
211
+ // CJS 依赖)拉进 vite dev 的 SSR 模块图,dev server 全路由 500;动态加载
212
+ // 只在生产渲染 ISR 路由时触发,dev 下失败则回退 SPA 壳。
213
+ let cachedRenderer: SSRRenderer | null | undefined
214
+ async function loadSSRRenderer(): Promise<SSRRenderer | null> {
215
+ if (cachedRenderer !== undefined) return cachedRenderer
216
+ try {
217
+ const mod = await import('@server/ssr-bridge')
218
+ cachedRenderer = mod.renderSSR
219
+ } catch {
220
+ cachedRenderer = null
221
+ }
222
+ return cachedRenderer
223
+ }
224
+
220
225
  async function renderISRForRoute(pathname: string): Promise<string | null> {
221
226
  const entry = isrRegistry.match(pathname)
222
227
  if (!entry) return null
228
+ const renderSSR = await loadSSRRenderer()
229
+ if (!renderSSR) return null // 无 client 的 preset(cli-only 等)回退 SPA 壳
223
230
  const ctx: ISRRouterContext = { db: await getDb(), env: {} }
224
231
  let data: unknown = {}
225
232
  let meta = { title: 'App', description: '' }
@@ -229,10 +236,9 @@ async function renderISRForRoute(pathname: string): Promise<string | null> {
229
236
  } catch {
230
237
  // DB 错误——回退默认 meta 继续渲染壳
231
238
  }
232
- const renderSSR = await loadRenderSSR()
233
- if (!renderSSR) return null // 无 client(cli-only 等)——调用方回退 SPA
234
239
  try {
235
- const ssr = renderSSR(pathname, data as never)
240
+ const ssr = renderSSR(pathname, data)
241
+ if (!ssr) return null // 渲染桥返回 null(无 client 变体)
236
242
  return renderISRPage({ template: indexHtml, body: ssr.html, meta, data })
237
243
  } catch (e) {
238
244
  console.warn('ISR render failed:', e)
@@ -0,0 +1,10 @@
1
+ /**
2
+ * SSR 渲染桥(本文件在模板中为全量版;CLI 脚手架时按 preset 重新生成:
3
+ * cli-only 等无 client 的 preset 导出 null,避免悬空导入)。
4
+ */
5
+ import { renderSSR as renderSSRImpl } from '@client/entry-server'
6
+
7
+ // data 由 ISR registry fetch 提供(形状随模块而异),renderSSR 内部自取所需字段
8
+ export function renderSSR(pathname: string, data: unknown): { html: string } {
9
+ return renderSSRImpl(pathname, data as Parameters<typeof renderSSRImpl>[1])
10
+ }
@@ -1,5 +1,4 @@
1
1
  import path from 'path'
2
- import { existsSync } from 'fs'
3
2
  import { defineConfig, type Plugin } from 'vite'
4
3
  import devServer from '@hono/vite-dev-server'
5
4
  import { websocketPlugin, dbPlugin } from './vite-plugins'
@@ -20,64 +19,9 @@ if (process.env.ANALYZE === 'true') {
20
19
  // rollup-plugin-visualizer not installed — run: npm install -D rollup-plugin-visualizer
21
20
  }
22
21
  }
23
- // Prerender is optional only needed during production build with puppeteer + Chrome installed
24
- let prerender: typeof import('@prerenderer/rollup-plugin').default | undefined
25
- let puppeteerRenderer: typeof import('@prerenderer/renderer-puppeteer').default | undefined
26
- try {
27
- const prerenderMod = await import('@prerenderer/rollup-plugin')
28
- prerender = prerenderMod.default
29
- const rendererMod = await import('@prerenderer/renderer-puppeteer')
30
- puppeteerRenderer = rendererMod.default
31
- // Verify Chrome is actually available at runtime
32
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
33
- // @ts-ignore — puppeteer is an optional dependency, may not be installed
34
- const pupMod = await import('puppeteer')
35
- const chromePath: string = await pupMod.executablePath()
36
- if (!existsSync(chromePath)) {
37
- prerender = undefined
38
- puppeteerRenderer = undefined
39
- }
40
- } catch {
41
- // puppeteer/Chrome not available — prerendering disabled
42
- }
43
-
44
- async function getPrerenderRoutes(): Promise<string[]> {
45
- const staticRoutes = ['/', '/todos', '/notifications', '/websocket', '/content']
46
-
47
- try {
48
- const { createClient } = await import('@libsql/client')
49
- const dbCandidates = [
50
- path.resolve(process.cwd(), 'data/production.db'),
51
- path.resolve(process.cwd(), 'data/app.db'),
52
- path.resolve(process.cwd(), 'data/development.db'),
53
- ]
54
-
55
- for (const dbPath of dbCandidates) {
56
- if (!existsSync(dbPath)) continue
57
-
58
- const client = createClient({ url: `file:${dbPath}` })
59
- try {
60
- const rs = await client.execute(
61
- "SELECT id FROM contents WHERE status = 'published' ORDER BY created_at DESC"
62
- )
63
- client.close()
64
-
65
- if (rs.rows.length > 0) {
66
- const contentRoutes = rs.rows.map(row => `/content/content-${row.id}`)
67
- return [...staticRoutes, ...contentRoutes]
68
- }
69
- } catch {
70
- client.close()
71
- }
72
- }
73
- } catch {
74
- // fallback
75
- }
76
-
77
- return staticRoutes
78
- }
79
-
80
- const routes = await getPrerenderRoutes()
22
+ // 说明:构建期 puppeteer 预渲染已移除——ISR 运行时管线(registry fetch +
23
+ // renderSSR + __SSR_DATA__ 注入)在请求时产出新鲜 HTML,构建期快照反而会
24
+ // 以空数据/错误文本固化页面并在 serveStatic 下遮蔽运行时 ISR。
81
25
 
82
26
  export default defineConfig({
83
27
  server: {
@@ -117,20 +61,7 @@ export default defineConfig({
117
61
  'vendor-hono': ['hono'],
118
62
  'vendor-zustand': ['zustand'],
119
63
  },
120
- plugins: [
121
- ...(prerender && puppeteerRenderer
122
- ? [
123
- prerender({
124
- routes,
125
- renderer: new puppeteerRenderer({
126
- renderAfterDocumentEvent: 'prerender-ready',
127
- renderAfterTime: 5000,
128
- }),
129
- }),
130
- ]
131
- : []),
132
- ...(visualizerPlugin ? [visualizerPlugin()] : []),
133
- ],
64
+ plugins: [...(visualizerPlugin ? [visualizerPlugin()] : [])],
134
65
  },
135
66
  onwarn(warning, defaultHandler) {
136
67
  // Suppress antd "use client" directive warnings (React Server Components marker)