create-fullstack-scaffold 0.5.0 → 0.5.2
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 +1 -1
- package/template/drizzle.config.ts +12 -4
- package/template/package.json +1 -1
- package/template/src/server/config.ts +1 -3
- package/template/src/server/core/__tests__/isr-cache-cf.test.ts +96 -0
- package/template/src/server/core/isr-cache.ts +55 -6
- package/template/src/server/entries/cloudflare.ts +20 -7
- package/template/src/server/entries/node.ts +3 -1
package/package.json
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import { defineConfig } from 'drizzle-kit'
|
|
2
|
-
import {
|
|
1
|
+
import { defineConfig } from 'drizzle-kit'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// fresh clone 时 data/ 不存在,libsql 直接报 error 14(连不上库文件)。
|
|
5
|
+
// 在配置加载阶段就确保目录存在,令 `npm run db:push` / `npm run dev`
|
|
6
|
+
// 真正开箱即用(README 的 zero-config 承诺)
|
|
7
|
+
mkdirSync('data', { recursive: true })
|
|
8
|
+
import { getDatabaseConfig } from './src/server/db/config'
|
|
9
|
+
|
|
10
|
+
const config = getDatabaseConfig()
|
|
5
11
|
|
|
6
12
|
export default defineConfig({
|
|
7
13
|
schema: './src/server/db/schema/index.ts',
|
|
8
14
|
out: './drizzle',
|
|
9
15
|
dialect: 'sqlite',
|
|
10
16
|
dbCredentials: {
|
|
17
|
+
// 与运行时保持同一库文件:drizzle-kit 在无 NODE_ENV 下执行时
|
|
18
|
+
// config.sqlitePath 会解析为 development.db,这里不能写死 app.db
|
|
11
19
|
url: config.sqlitePath || './data/app.db',
|
|
12
20
|
},
|
|
13
|
-
})
|
|
21
|
+
})
|
package/template/package.json
CHANGED
|
@@ -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,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline 4fce8de3d919d2fb
|
|
3
|
+
|
|
4
|
+
* CloudflareCacheStore 单测:索引清单(manifest)驱动的 purgePattern。
|
|
5
|
+
*
|
|
6
|
+
* 背景:Cache API 没有 list(),purgePattern 曾是恒不执行的死代码——
|
|
7
|
+
* CF 生产上内容更新后陈旧详情页服务到自然过期。现实现用 isr:__index__
|
|
8
|
+
* 键记录已缓存 pathname,pattern 据此真清剿。此文件验证该行为。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
12
|
+
|
|
13
|
+
/** 极简 Cache API mock:Map<urlString, Response> */
|
|
14
|
+
function makeFakeCaches() {
|
|
15
|
+
const store = new Map<string, Response>()
|
|
16
|
+
const cache = {
|
|
17
|
+
async match(req: Request | string) {
|
|
18
|
+
const key = typeof req === 'string' ? req : req.url
|
|
19
|
+
return store.get(key) ?? undefined
|
|
20
|
+
},
|
|
21
|
+
async put(req: Request | string, res: Response) {
|
|
22
|
+
const key = typeof req === 'string' ? req : req.url
|
|
23
|
+
store.set(key, res)
|
|
24
|
+
},
|
|
25
|
+
async delete(req: Request | string) {
|
|
26
|
+
const key = typeof req === 'string' ? req : req.url
|
|
27
|
+
return store.delete(key)
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
return { caches: { open: async () => cache }, store }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('CloudflareCacheStore purgePattern (manifest)', () => {
|
|
34
|
+
let fake: ReturnType<typeof makeFakeCaches>
|
|
35
|
+
let originalCaches: unknown
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
fake = makeFakeCaches()
|
|
39
|
+
originalCaches = (globalThis as { caches?: unknown }).caches
|
|
40
|
+
;(globalThis as { caches?: unknown }).caches = fake.caches as never
|
|
41
|
+
const mod = await import('@server/core/isr-cache')
|
|
42
|
+
// 重新构造以绑定 mock caches
|
|
43
|
+
const { createISRCache } = mod
|
|
44
|
+
const cache = createISRCache({ maxAge: 60, staleWhileRevalidate: 60 })
|
|
45
|
+
;(globalThis as Record<string, unknown>).__testCache = cache
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
;(globalThis as { caches?: unknown }).caches = originalCaches as never
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function getCache() {
|
|
53
|
+
return (globalThis as Record<string, unknown>).__testCache as {
|
|
54
|
+
store: (key: string, html: string) => Promise<void>
|
|
55
|
+
purgePattern: (pattern: string) => Promise<void>
|
|
56
|
+
lookup: (pathname: string) => Promise<{ status: string }>
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('purgePattern 清剿匹配键并保留其余(索引驱动,非死代码)', async () => {
|
|
61
|
+
const c = getCache()
|
|
62
|
+
await c.store('/', 'home')
|
|
63
|
+
await c.store('/content', 'list')
|
|
64
|
+
await c.store('/content/123', 'detail-123')
|
|
65
|
+
await c.store('/content/456', 'detail-456')
|
|
66
|
+
await c.store('/todos', 'todos')
|
|
67
|
+
|
|
68
|
+
await c.purgePattern('isr:/content/*')
|
|
69
|
+
|
|
70
|
+
expect((await c.lookup('/content/123')).status).toBe('miss')
|
|
71
|
+
expect((await c.lookup('/content/456')).status).toBe('miss')
|
|
72
|
+
// 列表页本身不匹配 isr:/content/*(无尾斜杠通配不到裸键)
|
|
73
|
+
expect((await c.lookup('/todos')).status).toBe('fresh')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('正则元字符键名不误伤(escapeForPattern)', async () => {
|
|
77
|
+
const c = getCache()
|
|
78
|
+
await c.store('/content/1+2', 'plus')
|
|
79
|
+
await c.store('/content/abc', 'plain')
|
|
80
|
+
|
|
81
|
+
// '1+2' 中的 + 若未转义会被当量词,误匹配 '12' 等——这里验证只清目标
|
|
82
|
+
await c.purgePattern('isr:/content/1+2')
|
|
83
|
+
|
|
84
|
+
expect((await c.lookup('/content/1+2')).status).toBe('miss')
|
|
85
|
+
expect((await c.lookup('/content/abc')).status).toBe('fresh')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('purge 单键后索引同步收缩,后续 purgePattern 不再删幽灵键', async () => {
|
|
89
|
+
const c = getCache()
|
|
90
|
+
await c.store('/a', 'a')
|
|
91
|
+
await c.store('/b', 'b')
|
|
92
|
+
await c.purgePattern('isr:/a')
|
|
93
|
+
// 无异常即通过(索引写回路径被走到)
|
|
94
|
+
expect((await c.lookup('/b')).status).toBe('fresh')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -130,6 +130,33 @@ class CloudflareCacheStore implements ISRCacheStore {
|
|
|
130
130
|
return { html, createdAt, revalidateAt }
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Cache API 没有 list():用专用索引键记录所有已缓存 pathname(JSON 数组),
|
|
134
|
+
// purgePattern 据此清剿。跨 isolate 各自记账,最终一致(各自清理各自的键,
|
|
135
|
+
// 键相同即覆盖,无重复副作用)。
|
|
136
|
+
private static readonly INDEX_KEY = 'isr:__index__'
|
|
137
|
+
|
|
138
|
+
private async readIndex(): Promise<string[]> {
|
|
139
|
+
const cache = await this.getCache()
|
|
140
|
+
const res = await cache.match(this.toUrl(CloudflareCacheStore.INDEX_KEY))
|
|
141
|
+
if (!res) return []
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(await res.text())
|
|
144
|
+
return Array.isArray(parsed) ? parsed : []
|
|
145
|
+
} catch {
|
|
146
|
+
return []
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async writeIndex(keys: string[]): Promise<void> {
|
|
151
|
+
const cache = await this.getCache()
|
|
152
|
+
// 索引不必常驻新鲜——purge 类操作低频,短 TTL 控制体积
|
|
153
|
+
const body = JSON.stringify([...new Set(keys)])
|
|
154
|
+
await cache.put(
|
|
155
|
+
this.toUrl(CloudflareCacheStore.INDEX_KEY),
|
|
156
|
+
new Response(body, { headers: { 'Content-Type': 'application/json' } })
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
133
160
|
async set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void> {
|
|
134
161
|
const cache = await this.getCache()
|
|
135
162
|
const url = this.toUrl(key)
|
|
@@ -147,26 +174,48 @@ class CloudflareCacheStore implements ISRCacheStore {
|
|
|
147
174
|
})
|
|
148
175
|
|
|
149
176
|
await cache.put(url, response)
|
|
177
|
+
|
|
178
|
+
const index = await this.readIndex()
|
|
179
|
+
if (!index.includes(key)) {
|
|
180
|
+
index.push(key)
|
|
181
|
+
await this.writeIndex(index)
|
|
182
|
+
}
|
|
150
183
|
}
|
|
151
184
|
|
|
152
185
|
async purge(key: string): Promise<void> {
|
|
153
186
|
const cache = await this.getCache()
|
|
154
187
|
const url = this.toUrl(key)
|
|
155
188
|
await cache.delete(url)
|
|
189
|
+
|
|
190
|
+
const index = await this.readIndex()
|
|
191
|
+
if (index.includes(key)) {
|
|
192
|
+
await this.writeIndex(index.filter(k => k !== key))
|
|
193
|
+
}
|
|
156
194
|
}
|
|
157
195
|
|
|
158
196
|
async purgePattern(pattern: string): Promise<void> {
|
|
197
|
+
// 此前为永远不执行的死代码(allKeys 恒空)——CF 上内容更新后
|
|
198
|
+
// 陈旧详情页会一直服务到自然过期。现按索引清单真清剿。
|
|
199
|
+
const regex = new RegExp('^' + this.escapeForPattern(pattern).replace(/\*/g, '.*') + '$')
|
|
200
|
+
const index = await this.readIndex()
|
|
159
201
|
const cache = await this.getCache()
|
|
160
|
-
const
|
|
161
|
-
const allKeys: Request[] = []
|
|
202
|
+
const survivors: string[] = []
|
|
162
203
|
|
|
163
|
-
for (const
|
|
164
|
-
const url = new URL(request.url)
|
|
165
|
-
const key = `isr:${url.pathname}`
|
|
204
|
+
for (const key of index) {
|
|
166
205
|
if (regex.test(key)) {
|
|
167
|
-
await cache.delete(
|
|
206
|
+
await cache.delete(this.toUrl(key))
|
|
207
|
+
} else {
|
|
208
|
+
survivors.push(key)
|
|
168
209
|
}
|
|
169
210
|
}
|
|
211
|
+
if (survivors.length !== index.length) {
|
|
212
|
+
await this.writeIndex(survivors)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private escapeForPattern(pattern: string): string {
|
|
217
|
+
// 转义除 * 外的正则元字符,防 'content/1+2' 之类键名误匹配
|
|
218
|
+
return pattern.replace(/[.+?^${}()|[\]]/g, '\\$&')
|
|
170
219
|
}
|
|
171
220
|
}
|
|
172
221
|
|
|
@@ -135,17 +135,30 @@ export default {
|
|
|
135
135
|
},
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
// 单飞:同 isolate 内并发 stale 请求共享同一次重建,防缓存击穿
|
|
139
|
+
const inflightRevalidations = new Map<string, Promise<void>>()
|
|
140
|
+
|
|
141
|
+
function regeneratePage(
|
|
139
142
|
pathname: string,
|
|
140
143
|
env: CloudflareBindings,
|
|
141
144
|
request: Request
|
|
142
145
|
): Promise<void> {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
const existing = inflightRevalidations.get(pathname)
|
|
147
|
+
if (existing) return existing
|
|
148
|
+
|
|
149
|
+
const task = (async () => {
|
|
150
|
+
try {
|
|
151
|
+
const html = await renderISRForRoute(pathname, env, request)
|
|
152
|
+
await isrCache.store(pathname, html)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error('ISR regeneration failed:', error)
|
|
155
|
+
} finally {
|
|
156
|
+
inflightRevalidations.delete(pathname)
|
|
157
|
+
}
|
|
158
|
+
})()
|
|
159
|
+
|
|
160
|
+
inflightRevalidations.set(pathname, task)
|
|
161
|
+
return task
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
async function renderISRForRoute(
|
|
@@ -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
|
|