create-fullstack-scaffold 0.4.25 → 0.5.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 +432 -15
- package/dist/cli/index.js.map +1 -1
- package/package.json +10 -13
- package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
- package/template/eslint-rules/no-cross-module-service-import.js +4 -0
- package/template/eslint-rules/no-merged-api-type-export.js +190 -0
- package/template/eslint.config.js +3 -0
- package/template/package.json +9 -6
- package/template/scripts/sync-agent-hooks.mjs +189 -0
- package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
- package/template/src/admin/pages/ContentPage.tsx +1 -1
- package/template/src/admin/pages/DisputesPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +1 -1
- package/template/src/admin/pages/TicketsPage.tsx +1 -1
- package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
- package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
- package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
- package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
- package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
- package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
- package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
- package/template/src/admin/services/apiClient.ts +2 -3
- package/template/src/cli/modules/content/index.ts +3 -3
- package/template/src/cli/modules/dispute/index.ts +3 -3
- package/template/src/cli/modules/ticket/index.ts +3 -3
- package/template/src/cli/modules/todo/index.ts +1 -1
- package/template/src/cli/rpc/client.ts +3 -4
- package/template/src/cli/rpc/index.ts +1 -1
- package/template/src/client/App.tsx +3 -39
- package/template/src/client/AppRoutes.tsx +53 -0
- package/template/src/client/entry-server.tsx +75 -0
- package/template/src/client/main.tsx +3 -1
- package/template/src/client/pages/SearchPage.tsx +1 -1
- package/template/src/client/services/apiClient.ts +12 -4
- package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
- package/template/src/client/stores/entry-stores.ts +36 -0
- package/template/src/client/stores/notificationStore.ts +4 -4
- package/template/src/client/stores/todoStore.ts +2 -2
- package/template/src/merchant/pages/DisputesPage.tsx +3 -3
- package/template/src/merchant/pages/OrdersPage.tsx +1 -1
- package/template/src/merchant/pages/ProductsPage.tsx +1 -1
- package/template/src/merchant/pages/SettingsPage.tsx +1 -1
- package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
- package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
- package/template/src/server/app.ts +6 -4
- package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
- package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
- package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
- package/template/src/server/core/isr-cache.ts +6 -12
- package/template/src/server/core/isr-invalidation.ts +6 -12
- package/template/src/server/core/isr-registry.ts +104 -0
- package/template/src/server/core/isr-renderer.ts +75 -0
- package/template/src/server/db/schema/contents.ts +28 -20
- package/template/src/server/db/schema/disputes.ts +30 -22
- package/template/src/server/db/schema/notifications.ts +20 -14
- package/template/src/server/db/schema/orders.ts +23 -16
- package/template/src/server/db/schema/plugins.ts +56 -38
- package/template/src/server/db/schema/products.ts +24 -17
- package/template/src/server/db/schema/tickets.ts +44 -31
- package/template/src/server/db/schema/todos.ts +26 -18
- package/template/src/server/entries/cloudflare.ts +82 -19
- package/template/src/server/entries/node.ts +0 -2
- package/template/src/server/index.ts +0 -1
- package/template/src/server/isr-modules.ts +10 -0
- package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
- package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
- package/template/src/server/module-admin/services/admin-service.ts +57 -13
- package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
- package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
- package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
- package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
- package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
- package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
- package/template/src/server/module-content/isr.ts +63 -0
- package/template/src/server/module-content/routes/content-routes.ts +10 -10
- package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
- package/template/src/server/module-content/routes/topics-routes.ts +3 -0
- package/template/src/server/module-content/services/content-service.ts +23 -10
- package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
- package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
- package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
- package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
- package/template/src/server/module-file/routes/file-routes.ts +3 -0
- package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
- package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
- package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
- package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
- package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
- package/template/src/server/module-order/routes/cart-routes.ts +3 -0
- package/template/src/server/module-order/routes/order-routes.ts +9 -4
- package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
- package/template/src/server/module-order/services/order-service.ts +25 -13
- package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
- package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
- package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
- package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
- package/template/src/server/module-permission/routes/role-routes.ts +3 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
- package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
- package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
- package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
- package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
- package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
- package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
- package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
- package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
- package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
- package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
- package/template/src/server/module-todos/isr.ts +41 -0
- package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
- package/template/src/server/module-todos/services/todo-service.ts +31 -10
- package/template/src/server/route-registry.ts +0 -4
- package/template/src/server/rpc-merge.ts +27 -0
- package/template/src/server/rpc-surface.ts +117 -0
- package/template/src/server/rpc-type-canary.ts +80 -0
- package/template/src/server/test-utils/test-client.ts +7 -9
- package/template/src/server/test-utils/test-isr-helper.ts +140 -0
- package/template/src/shared/modules/content/schemas.ts +14 -0
- package/template/src/shared/modules/dispute/schemas.ts +14 -0
- package/template/src/shared/modules/order/schemas.ts +9 -1
- package/template/src/shared/modules/ticket/schemas.ts +14 -0
- package/template/src/shared/modules/todos/index.ts +4 -0
- package/template/src/shared/modules/todos/schemas.ts +14 -0
- package/template/src/shared/schemas/index.ts +18 -0
- package/template/tsup.config.ts +48 -1
- package/template/vitest.config.ts +19 -3
- package/template/vitest.setup.ts +68 -5
- package/template/patches/typescript+5.9.3.patch +0 -24
- /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
|
@@ -42,7 +42,11 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
|
|
|
42
42
|
return c.json({ status: 'ok', timestamp: new Date().toISOString(), db: 'not configured' })
|
|
43
43
|
}
|
|
44
44
|
})
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
// 测试辅助端点:仅开发/测试环境注册。生产(含 Cloudflare 构建——tsup 将
|
|
47
|
+
// process.env.NODE_ENV 静态替换为 "production")不暴露这个清库端点。
|
|
48
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
49
|
+
app.post('/api/__test__/cleanup', async c => {
|
|
46
50
|
try {
|
|
47
51
|
const { cleanupTestDatabase } = await import('./db/test-setup')
|
|
48
52
|
await cleanupTestDatabase()
|
|
@@ -52,6 +56,7 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
|
|
|
52
56
|
return c.json({ success: false as const, message: 'Failed to cleanup database' }, 500)
|
|
53
57
|
}
|
|
54
58
|
})
|
|
59
|
+
}
|
|
55
60
|
|
|
56
61
|
autoRegisterRealtime(app as unknown as Parameters<typeof autoRegisterRealtime>[0])
|
|
57
62
|
|
|
@@ -100,6 +105,3 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
|
|
|
100
105
|
|
|
101
106
|
return app
|
|
102
107
|
}
|
|
103
|
-
export type AdminApiType = typeof adminApiRoutes
|
|
104
|
-
export type ClientApiType = typeof clientApiRoutes
|
|
105
|
-
export type AppType = ReturnType<typeof createApp>
|
|
@@ -1,117 +1,121 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @framework-baseline
|
|
3
|
-
* @framework-modify
|
|
4
|
-
* @reason 移除未使用的 vi import,修复 TypeScript strict 检查
|
|
5
|
-
* @impact 不影响功能,仅清理代码
|
|
2
|
+
* @framework-baseline 68a6e13bffb67f13
|
|
6
3
|
*/
|
|
7
4
|
|
|
8
5
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
describe('isISRRoute', () => {
|
|
12
|
-
it('matches static ISR routes', () => {
|
|
13
|
-
expect(isISRRoute('/')).toBe(true)
|
|
14
|
-
expect(isISRRoute('/todos')).toBe(true)
|
|
15
|
-
expect(isISRRoute('/content')).toBe(true)
|
|
16
|
-
expect(isISRRoute('/notifications')).toBe(true)
|
|
17
|
-
expect(isISRRoute('/websocket')).toBe(true)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('matches dynamic content routes', () => {
|
|
21
|
-
expect(isISRRoute('/content/123')).toBe(true)
|
|
22
|
-
expect(isISRRoute('/content/content-456')).toBe(true)
|
|
23
|
-
expect(isISRRoute('/content/abc-def')).toBe(true)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it('does not match non-ISR routes', () => {
|
|
27
|
-
expect(isISRRoute('/api/todos')).toBe(false)
|
|
28
|
-
expect(isISRRoute('/api/health')).toBe(false)
|
|
29
|
-
expect(isISRRoute('/assets/main.js')).toBe(false)
|
|
30
|
-
expect(isISRRoute('/admin/dashboard')).toBe(false)
|
|
31
|
-
expect(isISRRoute('/health')).toBe(false)
|
|
32
|
-
expect(isISRRoute('/vite.svg')).toBe(false)
|
|
33
|
-
})
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
describe('generateCacheKey', () => {
|
|
37
|
-
it('normalizes root path', () => {
|
|
38
|
-
expect(generateCacheKey('/')).toBe('isr:/index')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('generates keys for regular paths', () => {
|
|
42
|
-
expect(generateCacheKey('/todos')).toBe('isr:/todos')
|
|
43
|
-
expect(generateCacheKey('/content/123')).toBe('isr:/content/123')
|
|
44
|
-
})
|
|
6
|
+
import { createISRCache } from '@server/core/isr-cache'
|
|
45
7
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
})
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
describe('ISRCache (memory)', () => {
|
|
52
|
-
let cache: ISRCache
|
|
8
|
+
describe('ISR Cache', () => {
|
|
9
|
+
let cache: ReturnType<typeof createISRCache>
|
|
53
10
|
|
|
54
11
|
beforeEach(() => {
|
|
55
|
-
cache = createISRCache({ maxAge: 1, staleWhileRevalidate:
|
|
12
|
+
cache = createISRCache({ maxAge: 1, staleWhileRevalidate: 2 })
|
|
56
13
|
})
|
|
57
14
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
15
|
+
describe('lookup — no cache', () => {
|
|
16
|
+
it('should return miss for uncached path', async () => {
|
|
17
|
+
const result = await cache.lookup('/todos')
|
|
18
|
+
expect(result.status).toBe('miss')
|
|
19
|
+
expect(result.html).toBeNull()
|
|
20
|
+
})
|
|
62
21
|
})
|
|
63
22
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
23
|
+
describe('store → lookup fresh', () => {
|
|
24
|
+
it('should return fresh after store', async () => {
|
|
25
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
26
|
+
const result = await cache.lookup('/todos')
|
|
27
|
+
expect(result.status).toBe('fresh')
|
|
28
|
+
expect(result.html).toBe('<html>todos</html>')
|
|
29
|
+
})
|
|
69
30
|
})
|
|
70
31
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
32
|
+
describe('store → wait → lookup stale', () => {
|
|
33
|
+
it('should return stale after maxAge', async () => {
|
|
34
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
35
|
+
// Wait beyond maxAge (1s) but within staleWhileRevalidate (1+2=3s)
|
|
36
|
+
await new Promise(r => setTimeout(r, 1200))
|
|
37
|
+
const result = await cache.lookup('/todos')
|
|
38
|
+
expect(result.status).toBe('stale')
|
|
39
|
+
expect(result.html).toBe('<html>todos</html>')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
77
42
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
43
|
+
describe('store → wait → lookup miss (expired)', () => {
|
|
44
|
+
it('should return miss after maxAge + staleWhileRevalidate', async () => {
|
|
45
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
46
|
+
// Wait beyond both maxAge and staleWhileRevalidate
|
|
47
|
+
await new Promise(r => setTimeout(r, 3500))
|
|
48
|
+
const result = await cache.lookup('/todos')
|
|
49
|
+
expect(result.status).toBe('miss')
|
|
50
|
+
expect(result.html).toBeNull()
|
|
51
|
+
})
|
|
81
52
|
})
|
|
82
53
|
|
|
83
|
-
|
|
84
|
-
|
|
54
|
+
describe('purge', () => {
|
|
55
|
+
it('should purge specific path', async () => {
|
|
56
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
57
|
+
await cache.store('/content', '<html>content</html>')
|
|
85
58
|
|
|
86
|
-
|
|
59
|
+
await cache.purge('/todos')
|
|
87
60
|
|
|
88
|
-
|
|
61
|
+
expect((await cache.lookup('/todos')).status).toBe('miss')
|
|
62
|
+
expect((await cache.lookup('/content')).status).toBe('fresh')
|
|
63
|
+
})
|
|
89
64
|
|
|
90
|
-
|
|
91
|
-
|
|
65
|
+
it('should not error on purge of uncached path', async () => {
|
|
66
|
+
await expect(cache.purge('/never-cached')).resolves.not.toThrow()
|
|
67
|
+
})
|
|
92
68
|
})
|
|
93
69
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
70
|
+
describe('purgePattern', () => {
|
|
71
|
+
it('should purge matching pattern', async () => {
|
|
72
|
+
await cache.store('/content', '<html>list</html>')
|
|
73
|
+
await cache.store('/content/article-1', '<html>a1</html>')
|
|
74
|
+
await cache.store('/content/article-2', '<html>a2</html>')
|
|
75
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
76
|
+
|
|
77
|
+
await cache.purgePattern('isr:/content/*')
|
|
78
|
+
|
|
79
|
+
// /content (exact) should still be cached
|
|
80
|
+
expect((await cache.lookup('/content')).status).toBe('fresh')
|
|
81
|
+
// /content/* should be purged
|
|
82
|
+
expect((await cache.lookup('/content/article-1')).status).toBe('miss')
|
|
83
|
+
expect((await cache.lookup('/content/article-2')).status).toBe('miss')
|
|
84
|
+
// /todos should be unaffected
|
|
85
|
+
expect((await cache.lookup('/todos')).status).toBe('fresh')
|
|
86
|
+
})
|
|
102
87
|
})
|
|
103
88
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
89
|
+
describe('full lifecycle', () => {
|
|
90
|
+
it('miss → store → fresh → purge → miss', async () => {
|
|
91
|
+
// Step 1: Initial miss
|
|
92
|
+
let result = await cache.lookup('/todos')
|
|
93
|
+
expect(result.status).toBe('miss')
|
|
94
|
+
|
|
95
|
+
// Step 2: Store
|
|
96
|
+
await cache.store('/todos', '<html>todos v1</html>')
|
|
97
|
+
|
|
98
|
+
// Step 3: Fresh hit
|
|
99
|
+
result = await cache.lookup('/todos')
|
|
100
|
+
expect(result.status).toBe('fresh')
|
|
101
|
+
expect(result.html).toBe('<html>todos v1</html>')
|
|
102
|
+
|
|
103
|
+
// Step 4: Purge
|
|
104
|
+
await cache.purge('/todos')
|
|
105
|
+
|
|
106
|
+
// Step 5: Miss again
|
|
107
|
+
result = await cache.lookup('/todos')
|
|
108
|
+
expect(result.status).toBe('miss')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('store v1 → purge → store v2 → returns v2', async () => {
|
|
112
|
+
await cache.store('/todos', '<html>v1</html>')
|
|
113
|
+
await cache.purge('/todos')
|
|
114
|
+
await cache.store('/todos', '<html>v2</html>')
|
|
115
|
+
|
|
116
|
+
const result = await cache.lookup('/todos')
|
|
117
|
+
expect(result.status).toBe('fresh')
|
|
118
|
+
expect(result.html).toBe('<html>v2</html>')
|
|
119
|
+
})
|
|
116
120
|
})
|
|
117
121
|
})
|
|
@@ -15,6 +15,25 @@ import {
|
|
|
15
15
|
} from '@server/core/isr-invalidation'
|
|
16
16
|
import { createISRCache } from '@server/core/isr-cache'
|
|
17
17
|
|
|
18
|
+
// Side-effect: 注册 ISR 路由到全局 registry(purgeAllPages 遍历它)。
|
|
19
|
+
// todos/content 在部分 preset 中不存在 —— 均为可选导入
|
|
20
|
+
const todosIsrModule = '@server/module-todos/isr'
|
|
21
|
+
let hasTodosModule = false
|
|
22
|
+
try {
|
|
23
|
+
await import(/* @vite-ignore */ todosIsrModule)
|
|
24
|
+
hasTodosModule = true
|
|
25
|
+
} catch {
|
|
26
|
+
// preset 无 todos 模块(forum/xbrowser 等)
|
|
27
|
+
}
|
|
28
|
+
const contentIsrModule = '@server/module-content/isr'
|
|
29
|
+
let hasContentModule = false
|
|
30
|
+
try {
|
|
31
|
+
await import(/* @vite-ignore */ contentIsrModule)
|
|
32
|
+
hasContentModule = true
|
|
33
|
+
} catch {
|
|
34
|
+
// preset 无 content 模块
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
describe('isr-invalidation', () => {
|
|
19
38
|
it('returns null cache when not set', () => {
|
|
20
39
|
expect(getISRCache()).toBeNull()
|
|
@@ -63,10 +82,16 @@ describe('isr-invalidation', () => {
|
|
|
63
82
|
|
|
64
83
|
await purgeAllPages()
|
|
65
84
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
if (hasTodosModule) {
|
|
86
|
+
// '/' 与 '/todos' 路由由 todos 模块注册,仅含 todos 的 preset 可断言
|
|
87
|
+
expect((await cache.lookup('/')).status).toBe('miss')
|
|
88
|
+
expect((await cache.lookup('/todos')).status).toBe('miss')
|
|
89
|
+
}
|
|
90
|
+
if (hasContentModule) {
|
|
91
|
+
// content 相关页仅在 preset 含 content 模块时被注册、才可被清理
|
|
92
|
+
expect((await cache.lookup('/content')).status).toBe('miss')
|
|
93
|
+
expect((await cache.lookup('/content/123')).status).toBe('miss')
|
|
94
|
+
}
|
|
70
95
|
})
|
|
71
96
|
})
|
|
72
97
|
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline 560bce7dbeafc84a
|
|
3
|
+
*
|
|
4
|
+
* @framework-modify
|
|
5
|
+
* @reason prettier 格式化导致内容哈希与初始基准漂移,补记说明
|
|
6
|
+
* @impact 仅格式,无逻辑改动
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
10
|
+
import { createISRRegistry } from '@server/core/isr-registry'
|
|
11
|
+
|
|
12
|
+
describe('ISR Registry', () => {
|
|
13
|
+
let registry: ReturnType<typeof createISRRegistry>
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
registry = createISRRegistry()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('register', () => {
|
|
20
|
+
it('should register a single route', () => {
|
|
21
|
+
registry.register({
|
|
22
|
+
module: 'todos',
|
|
23
|
+
match: '/todos',
|
|
24
|
+
fetch: async () => ({}),
|
|
25
|
+
meta: () => ({ title: 'Todos', description: 'Todo list' }),
|
|
26
|
+
})
|
|
27
|
+
expect(registry.getAll()).toHaveLength(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should register multiple routes', () => {
|
|
31
|
+
registry.registerMany([
|
|
32
|
+
{
|
|
33
|
+
module: 'todos',
|
|
34
|
+
match: '/todos',
|
|
35
|
+
fetch: async () => ({}),
|
|
36
|
+
meta: () => ({ title: 'Todos', description: '' }),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
module: 'content',
|
|
40
|
+
match: '/content',
|
|
41
|
+
fetch: async () => ({}),
|
|
42
|
+
meta: () => ({ title: 'Content', description: '' }),
|
|
43
|
+
},
|
|
44
|
+
])
|
|
45
|
+
expect(registry.getAll()).toHaveLength(2)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should update existing entry on re-register', () => {
|
|
49
|
+
registry.register({
|
|
50
|
+
module: 'todos',
|
|
51
|
+
match: '/todos',
|
|
52
|
+
fetch: async () => ({}),
|
|
53
|
+
meta: () => ({ title: 'v1', description: '' }),
|
|
54
|
+
})
|
|
55
|
+
registry.register({
|
|
56
|
+
module: 'todos',
|
|
57
|
+
match: '/todos',
|
|
58
|
+
fetch: async () => ({}),
|
|
59
|
+
meta: () => ({ title: 'v2', description: '' }),
|
|
60
|
+
})
|
|
61
|
+
expect(registry.getAll()).toHaveLength(1)
|
|
62
|
+
expect(registry.getAll()[0].meta({}, '')).toEqual({ title: 'v2', description: '' })
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('match — exact paths', () => {
|
|
67
|
+
it('should match exact path', () => {
|
|
68
|
+
registry.register({
|
|
69
|
+
module: 'todos',
|
|
70
|
+
match: '/todos',
|
|
71
|
+
fetch: async () => ({}),
|
|
72
|
+
meta: () => ({ title: '', description: '' }),
|
|
73
|
+
})
|
|
74
|
+
const entry = registry.match('/todos')
|
|
75
|
+
expect(entry).not.toBeNull()
|
|
76
|
+
expect(entry!.module).toBe('todos')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should not match wrong exact path', () => {
|
|
80
|
+
registry.register({
|
|
81
|
+
module: 'todos',
|
|
82
|
+
match: '/todos',
|
|
83
|
+
fetch: async () => ({}),
|
|
84
|
+
meta: () => ({ title: '', description: '' }),
|
|
85
|
+
})
|
|
86
|
+
expect(registry.match('/orders')).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should match root /', () => {
|
|
90
|
+
registry.register({
|
|
91
|
+
module: 'home',
|
|
92
|
+
match: '/',
|
|
93
|
+
fetch: async () => ({}),
|
|
94
|
+
meta: () => ({ title: '', description: '' }),
|
|
95
|
+
})
|
|
96
|
+
expect(registry.match('/')).not.toBeNull()
|
|
97
|
+
expect(registry.match('/todos')).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('match — prefix paths', () => {
|
|
102
|
+
it('should match prefix /content/', () => {
|
|
103
|
+
registry.register({
|
|
104
|
+
module: 'content',
|
|
105
|
+
match: '/content/',
|
|
106
|
+
fetch: async () => ({}),
|
|
107
|
+
meta: () => ({ title: '', description: '' }),
|
|
108
|
+
})
|
|
109
|
+
expect(registry.match('/content/content-1')).not.toBeNull()
|
|
110
|
+
expect(registry.match('/content/any-id')).not.toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should not match prefix for unrelated path', () => {
|
|
114
|
+
registry.register({
|
|
115
|
+
module: 'content',
|
|
116
|
+
match: '/content/',
|
|
117
|
+
fetch: async () => ({}),
|
|
118
|
+
meta: () => ({ title: '', description: '' }),
|
|
119
|
+
})
|
|
120
|
+
expect(registry.match('/todos')).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should prefer exact match over prefix', () => {
|
|
124
|
+
registry.register({
|
|
125
|
+
module: 'content-detail',
|
|
126
|
+
match: '/content/',
|
|
127
|
+
fetch: async () => ({}),
|
|
128
|
+
meta: () => ({ title: 'detail', description: '' }),
|
|
129
|
+
})
|
|
130
|
+
registry.register({
|
|
131
|
+
module: 'content-list',
|
|
132
|
+
match: '/content',
|
|
133
|
+
fetch: async () => ({}),
|
|
134
|
+
meta: () => ({ title: 'list', description: '' }),
|
|
135
|
+
})
|
|
136
|
+
const exact = registry.match('/content')
|
|
137
|
+
const prefix = registry.match('/content/article-1')
|
|
138
|
+
expect(exact!.module).toBe('content-list')
|
|
139
|
+
expect(prefix!.module).toBe('content-detail')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('match — function matchers', () => {
|
|
144
|
+
it('should match via function', () => {
|
|
145
|
+
registry.register({
|
|
146
|
+
module: 'custom',
|
|
147
|
+
match: pathname => pathname.startsWith('/shop/'),
|
|
148
|
+
fetch: async () => ({}),
|
|
149
|
+
meta: () => ({ title: '', description: '' }),
|
|
150
|
+
})
|
|
151
|
+
expect(registry.match('/shop/product-1')).not.toBeNull()
|
|
152
|
+
expect(registry.match('/shop/')).not.toBeNull()
|
|
153
|
+
expect(registry.match('/other')).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('isISRRoute', () => {
|
|
158
|
+
it('should return true for registered routes', () => {
|
|
159
|
+
registry.register({
|
|
160
|
+
module: 'todos',
|
|
161
|
+
match: '/todos',
|
|
162
|
+
fetch: async () => ({}),
|
|
163
|
+
meta: () => ({ title: '', description: '' }),
|
|
164
|
+
})
|
|
165
|
+
expect(registry.isISRRoute('/todos')).toBe(true)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should return false for unregistered routes', () => {
|
|
169
|
+
expect(registry.isISRRoute('/unknown')).toBe(false)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('getExactPaths', () => {
|
|
174
|
+
it('should return all exact path strings', () => {
|
|
175
|
+
registry.registerMany([
|
|
176
|
+
{
|
|
177
|
+
module: 'a',
|
|
178
|
+
match: '/a',
|
|
179
|
+
fetch: async () => ({}),
|
|
180
|
+
meta: () => ({ title: '', description: '' }),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
module: 'b',
|
|
184
|
+
match: '/b/',
|
|
185
|
+
fetch: async () => ({}),
|
|
186
|
+
meta: () => ({ title: '', description: '' }),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
module: 'c',
|
|
190
|
+
match: '/c',
|
|
191
|
+
fetch: async () => ({}),
|
|
192
|
+
meta: () => ({ title: '', description: '' }),
|
|
193
|
+
},
|
|
194
|
+
])
|
|
195
|
+
const paths = registry.getExactPaths()
|
|
196
|
+
expect(paths).toContain('/a')
|
|
197
|
+
expect(paths).toContain('/b/')
|
|
198
|
+
expect(paths).toContain('/c')
|
|
199
|
+
expect(paths).toHaveLength(3)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('clear', () => {
|
|
204
|
+
it('should remove all entries', () => {
|
|
205
|
+
registry.register({
|
|
206
|
+
module: 'a',
|
|
207
|
+
match: '/a',
|
|
208
|
+
fetch: async () => ({}),
|
|
209
|
+
meta: () => ({ title: '', description: '' }),
|
|
210
|
+
})
|
|
211
|
+
registry.clear()
|
|
212
|
+
expect(registry.getAll()).toHaveLength(0)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline b75ca79fd266283d
|
|
3
|
+
*
|
|
4
|
+
* @framework-modify
|
|
5
|
+
* @reason 断言对齐实现:generator meta 值为 ISR-SSG(isr-renderer.ts:65)
|
|
6
|
+
* @impact 仅测试断言,无运行时影响
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { renderISRPage, escapeHtml } from '@server/core/isr-renderer'
|
|
11
|
+
|
|
12
|
+
describe('ISR Renderer', () => {
|
|
13
|
+
describe('escapeHtml', () => {
|
|
14
|
+
it('should escape & < > "', () => {
|
|
15
|
+
expect(escapeHtml('a&b<c>d"e')).toBe('a&b<c>d"e')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should escape empty string', () => {
|
|
19
|
+
expect(escapeHtml('')).toBe('')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should escape multiple occurrences', () => {
|
|
23
|
+
expect(escapeHtml('<<>>')).toBe('<<>>')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should not alter plain text', () => {
|
|
27
|
+
expect(escapeHtml('hello world')).toBe('hello world')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('renderISRPage — with template', () => {
|
|
32
|
+
const template = `<!DOCTYPE html>
|
|
33
|
+
<html lang="en">
|
|
34
|
+
<head>
|
|
35
|
+
<meta charset="UTF-8" />
|
|
36
|
+
<title>Original Title</title>
|
|
37
|
+
<meta name="description" content="original desc" />
|
|
38
|
+
<meta property="og:title" content="original" />
|
|
39
|
+
<meta property="og:description" content="original" />
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
<div id="root"></div>
|
|
43
|
+
</body>
|
|
44
|
+
</html>`
|
|
45
|
+
|
|
46
|
+
it('should inject title', () => {
|
|
47
|
+
const html = renderISRPage({
|
|
48
|
+
template,
|
|
49
|
+
meta: { title: 'New Title', description: 'new desc' },
|
|
50
|
+
})
|
|
51
|
+
expect(html).toContain('<title>New Title</title>')
|
|
52
|
+
expect(html).not.toContain('Original Title')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should inject meta description', () => {
|
|
56
|
+
const html = renderISRPage({
|
|
57
|
+
template,
|
|
58
|
+
meta: { title: 'T', description: 'New Description' },
|
|
59
|
+
})
|
|
60
|
+
expect(html).toContain('name="description" content="New Description"')
|
|
61
|
+
expect(html).not.toContain('content="original desc"')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should inject og:title and og:description', () => {
|
|
65
|
+
const html = renderISRPage({
|
|
66
|
+
template,
|
|
67
|
+
meta: { title: 'OG Title', description: 'OG Desc' },
|
|
68
|
+
})
|
|
69
|
+
expect(html).toContain('property="og:title" content="OG Title"')
|
|
70
|
+
expect(html).toContain('property="og:description" content="OG Desc"')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should inject generator=ISR', () => {
|
|
74
|
+
const html = renderISRPage({
|
|
75
|
+
template,
|
|
76
|
+
meta: { title: 'T', description: 'D' },
|
|
77
|
+
})
|
|
78
|
+
expect(html).toContain('name="generator" content="ISR-SSG"')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should keep root div empty (meta-only)', () => {
|
|
82
|
+
const html = renderISRPage({
|
|
83
|
+
template,
|
|
84
|
+
meta: { title: 'T', description: 'D' },
|
|
85
|
+
})
|
|
86
|
+
expect(html).toContain('<div id="root"></div>')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should escape HTML in meta values', () => {
|
|
90
|
+
const html = renderISRPage({
|
|
91
|
+
template,
|
|
92
|
+
meta: { title: '<script>alert(1)</script>', description: '<b>bold</b>' },
|
|
93
|
+
})
|
|
94
|
+
expect(html).not.toContain('<script>alert(1)</script>')
|
|
95
|
+
expect(html).toContain('<script>alert(1)</script>')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('renderISRPage — no template (fallback)', () => {
|
|
100
|
+
it('should generate standalone HTML', () => {
|
|
101
|
+
const html = renderISRPage({
|
|
102
|
+
template: null,
|
|
103
|
+
meta: { title: 'Standalone', description: 'Fallback mode' },
|
|
104
|
+
})
|
|
105
|
+
expect(html).toContain('<!DOCTYPE html>')
|
|
106
|
+
expect(html).toContain('<title>Standalone</title>')
|
|
107
|
+
expect(html).toContain('<div id="root"></div>')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should include all meta tags in fallback', () => {
|
|
111
|
+
const html = renderISRPage({
|
|
112
|
+
template: null,
|
|
113
|
+
meta: { title: 'T', description: 'D' },
|
|
114
|
+
})
|
|
115
|
+
expect(html).toContain('name="description"')
|
|
116
|
+
expect(html).toContain('property="og:title"')
|
|
117
|
+
expect(html).toContain('property="og:description"')
|
|
118
|
+
expect(html).toContain('name="generator" content="ISR-SSG"')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* Supports Cloudflare Cache API and in-memory fallback.
|
|
6
6
|
*
|
|
7
7
|
* @framework-modify
|
|
8
|
-
* @reason
|
|
9
|
-
* @impact
|
|
8
|
+
* @reason 模块化改造:移除硬编码路由,路由判断委托给 isrRegistry
|
|
9
|
+
* @impact isr-invalidation 也需要同步改造
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { isrRegistry } from './isr-registry'
|
|
13
|
+
|
|
12
14
|
export interface ISRCacheEntry {
|
|
13
15
|
html: string
|
|
14
16
|
createdAt: number
|
|
@@ -29,16 +31,8 @@ export interface ISRCacheOptions {
|
|
|
29
31
|
const DEFAULT_MAX_AGE = 60
|
|
30
32
|
const DEFAULT_STALE_WHILE_REVALIDATE = 300
|
|
31
33
|
|
|
32
|
-
const ISR_ROUTES = ['/', '/todos', '/content', '/notifications', '/websocket']
|
|
33
|
-
|
|
34
|
-
const ISR_ROUTE_PREFIXES = ['/content/']
|
|
35
|
-
|
|
36
34
|
export function isISRRoute(pathname: string): boolean {
|
|
37
|
-
|
|
38
|
-
for (const prefix of ISR_ROUTE_PREFIXES) {
|
|
39
|
-
if (pathname.startsWith(prefix)) return true
|
|
40
|
-
}
|
|
41
|
-
return false
|
|
35
|
+
return isrRegistry.isISRRoute(pathname)
|
|
42
36
|
}
|
|
43
37
|
|
|
44
38
|
export function generateCacheKey(pathname: string): string {
|
|
@@ -110,7 +104,7 @@ class MemoryCacheStore implements ISRCacheStore {
|
|
|
110
104
|
|
|
111
105
|
class CloudflareCacheStore implements ISRCacheStore {
|
|
112
106
|
private cache: Cache | null = null
|
|
113
|
-
private origin = 'https://isr.local'
|
|
107
|
+
private origin = 'https://isr-v7.local'
|
|
114
108
|
|
|
115
109
|
private async getCache(): Promise<Cache> {
|
|
116
110
|
if (!this.cache) {
|