@ubean/routes 0.2.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/LICENSE +21 -0
- package/dist/index.d.ts +334 -0
- package/dist/index.js +1128 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
import { addRoute, createRouter, findRoute } from "rou3";
|
|
2
|
+
import { validateParams } from "@ubean/scan";
|
|
3
|
+
import { isServerAction, matchAnyGlob } from "@ubean/shared";
|
|
4
|
+
import { parseFormActionName, runAction } from "@ubean/actions";
|
|
5
|
+
import { openAPIRouteHandler } from "hono-openapi";
|
|
6
|
+
//#region src/handler.ts
|
|
7
|
+
function isResponse(value) {
|
|
8
|
+
return value instanceof Response;
|
|
9
|
+
}
|
|
10
|
+
function convertReturnValue(value) {
|
|
11
|
+
if (isResponse(value)) return value;
|
|
12
|
+
if (typeof value === "string") return new Response(value, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
13
|
+
return new Response(JSON.stringify(value), { headers: { "Content-Type": "application/json; charset=utf-8" } });
|
|
14
|
+
}
|
|
15
|
+
function isMetaHandler(h) {
|
|
16
|
+
return typeof h === "function" && h.__brand === "meta";
|
|
17
|
+
}
|
|
18
|
+
function defineHandler(...handlers) {
|
|
19
|
+
if (handlers.length === 0) throw new Error("defineHandler requires at least one handler");
|
|
20
|
+
const metaList = [];
|
|
21
|
+
const middlewares = [];
|
|
22
|
+
const finalHandler = handlers[handlers.length - 1];
|
|
23
|
+
for (let i = 0; i < handlers.length - 1; i++) {
|
|
24
|
+
const h = handlers[i];
|
|
25
|
+
if (isMetaHandler(h)) metaList.push(h.meta);
|
|
26
|
+
else middlewares.push(h);
|
|
27
|
+
}
|
|
28
|
+
const routeMeta = Object.assign({ requiresAuth: true }, ...metaList);
|
|
29
|
+
const wrappedFinal = async (c) => {
|
|
30
|
+
return convertReturnValue(await finalHandler(c, async () => {}));
|
|
31
|
+
};
|
|
32
|
+
const allHandlers = [...middlewares, wrappedFinal];
|
|
33
|
+
Object.defineProperty(allHandlers, "__routeMeta", {
|
|
34
|
+
value: routeMeta,
|
|
35
|
+
enumerable: false,
|
|
36
|
+
configurable: false
|
|
37
|
+
});
|
|
38
|
+
return allHandlers;
|
|
39
|
+
}
|
|
40
|
+
function isHandlerChain(obj) {
|
|
41
|
+
return Array.isArray(obj) && obj.__routeMeta !== void 0;
|
|
42
|
+
}
|
|
43
|
+
function extractRouteMeta(handlers) {
|
|
44
|
+
return handlers.__routeMeta ?? { requiresAuth: true };
|
|
45
|
+
}
|
|
46
|
+
function defineHandlerMeta(meta) {
|
|
47
|
+
const fn = async (_c, next) => next();
|
|
48
|
+
Object.assign(fn, {
|
|
49
|
+
__brand: "meta",
|
|
50
|
+
meta
|
|
51
|
+
});
|
|
52
|
+
return fn;
|
|
53
|
+
}
|
|
54
|
+
function defineMiddleware(handler) {
|
|
55
|
+
return handler;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/server-router.ts
|
|
59
|
+
var UbeanRouter = class {
|
|
60
|
+
apiContext;
|
|
61
|
+
middlewares;
|
|
62
|
+
pages;
|
|
63
|
+
layouts;
|
|
64
|
+
constructor() {
|
|
65
|
+
this.apiContext = createRouter();
|
|
66
|
+
this.middlewares = [];
|
|
67
|
+
this.pages = /* @__PURE__ */ new Map();
|
|
68
|
+
this.layouts = /* @__PURE__ */ new Map();
|
|
69
|
+
}
|
|
70
|
+
addApiRoute(route) {
|
|
71
|
+
const data = {
|
|
72
|
+
method: route.method?.toUpperCase() || "ALL",
|
|
73
|
+
path: route.route,
|
|
74
|
+
id: `${route.method}:${route.route}`,
|
|
75
|
+
filePath: route.fullPath
|
|
76
|
+
};
|
|
77
|
+
addRoute(this.apiContext, data.method, route.route, data);
|
|
78
|
+
}
|
|
79
|
+
addMiddleware(mw) {
|
|
80
|
+
this.middlewares.push({
|
|
81
|
+
path: "/**",
|
|
82
|
+
filePath: mw.fullPath,
|
|
83
|
+
order: mw.order,
|
|
84
|
+
global: mw.global
|
|
85
|
+
});
|
|
86
|
+
this.middlewares.sort((a, b) => a.order - b.order);
|
|
87
|
+
}
|
|
88
|
+
addPage(page) {
|
|
89
|
+
this.pages.set(page.name, {
|
|
90
|
+
name: page.name,
|
|
91
|
+
path: page.route,
|
|
92
|
+
filePath: page.fullPath,
|
|
93
|
+
layout: page.layout,
|
|
94
|
+
reuseTarget: page.reuseTarget
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
addLayout(layout) {
|
|
98
|
+
this.layouts.set(layout.name, {
|
|
99
|
+
name: layout.name,
|
|
100
|
+
filePath: layout.fullPath,
|
|
101
|
+
isDefault: layout.isDefault
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
matchApi(method, path) {
|
|
105
|
+
return findRoute(this.apiContext, method.toUpperCase(), path)?.data;
|
|
106
|
+
}
|
|
107
|
+
getMiddlewares() {
|
|
108
|
+
return [...this.middlewares];
|
|
109
|
+
}
|
|
110
|
+
getPages() {
|
|
111
|
+
return [...this.pages.values()];
|
|
112
|
+
}
|
|
113
|
+
getPage(name) {
|
|
114
|
+
return this.pages.get(name);
|
|
115
|
+
}
|
|
116
|
+
getLayout(name) {
|
|
117
|
+
return this.layouts.get(name);
|
|
118
|
+
}
|
|
119
|
+
getDefaultLayout() {
|
|
120
|
+
return [...this.layouts.values()].find((l) => l.isDefault);
|
|
121
|
+
}
|
|
122
|
+
getLayouts() {
|
|
123
|
+
return [...this.layouts.values()];
|
|
124
|
+
}
|
|
125
|
+
getPageRouteNames() {
|
|
126
|
+
return [...this.pages.keys()];
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
let _router = null;
|
|
130
|
+
function useRouter() {
|
|
131
|
+
if (!_router) _router = new UbeanRouter();
|
|
132
|
+
return _router;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 创建服务端 rou3 router 实例(无参数)。
|
|
136
|
+
*
|
|
137
|
+
* 命名说明:与 `@ubean/client` 的 `createUbeanRouter`(Vue Router 工厂,
|
|
138
|
+
* 接受 options)曾是同名冲突,服务端版本改名 `createServerRouter` 彻底消歧。
|
|
139
|
+
*/
|
|
140
|
+
function createServerRouter() {
|
|
141
|
+
_router = new UbeanRouter();
|
|
142
|
+
return _router;
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/bot-detection.ts
|
|
146
|
+
const BOT_PATTERN = new RegExp([
|
|
147
|
+
"googlebot",
|
|
148
|
+
"bingbot",
|
|
149
|
+
"baiduspider",
|
|
150
|
+
"yandexbot",
|
|
151
|
+
"duckduckbot",
|
|
152
|
+
"slurp",
|
|
153
|
+
"sogou",
|
|
154
|
+
"exabot",
|
|
155
|
+
"facebot",
|
|
156
|
+
"ia_archiver",
|
|
157
|
+
"applebot",
|
|
158
|
+
"bytespider",
|
|
159
|
+
"petalbot",
|
|
160
|
+
"facebookexternalhit",
|
|
161
|
+
"twitterbot",
|
|
162
|
+
"linkedinbot",
|
|
163
|
+
"slacker",
|
|
164
|
+
"slackbot",
|
|
165
|
+
"telegrambot",
|
|
166
|
+
"whatsapp",
|
|
167
|
+
"skypeuripreview",
|
|
168
|
+
"discordbot",
|
|
169
|
+
"pinterestbot",
|
|
170
|
+
"redditbot",
|
|
171
|
+
"crawler",
|
|
172
|
+
"spider",
|
|
173
|
+
"bot/",
|
|
174
|
+
"bot;",
|
|
175
|
+
"preview",
|
|
176
|
+
"fetcher",
|
|
177
|
+
"scraper",
|
|
178
|
+
"ahrefsbot",
|
|
179
|
+
"semrushbot",
|
|
180
|
+
"mj12bot",
|
|
181
|
+
"dotbot",
|
|
182
|
+
"pingdom",
|
|
183
|
+
"gtmetrix",
|
|
184
|
+
"pagespeed",
|
|
185
|
+
"googlestackdrivermonitoring"
|
|
186
|
+
].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "i");
|
|
187
|
+
/**
|
|
188
|
+
* 判断 User-Agent 是否为爬虫/社交预览机器人。
|
|
189
|
+
*
|
|
190
|
+
* @param ua User-Agent 字符串(来自 `c.req.header('user-agent')`)
|
|
191
|
+
* @returns `true` 表示疑似爬虫,应降级为缓冲渲染以保证 metadata 完整
|
|
192
|
+
*/
|
|
193
|
+
function isBotUserAgent(ua) {
|
|
194
|
+
if (!ua) return false;
|
|
195
|
+
return BOT_PATTERN.test(ua);
|
|
196
|
+
}
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/page-actions.ts
|
|
199
|
+
/**
|
|
200
|
+
* Shared redirect/non-redirect Response handling for both the legacy
|
|
201
|
+
* `mod.action(c)` path and the `mod.actions` map path.
|
|
202
|
+
*
|
|
203
|
+
* Returns a `Response` to return to the client, or `null` to fall through
|
|
204
|
+
* (caller leaves `actionResult` as the Response for downstream handling).
|
|
205
|
+
*/
|
|
206
|
+
function handleActionResponse(c, response, isPagesReq) {
|
|
207
|
+
if (response.status >= 300 && response.status < 400) {
|
|
208
|
+
const redirectUrl = response.headers.get("Location");
|
|
209
|
+
if (redirectUrl) {
|
|
210
|
+
if (isPagesReq(c)) return c.json({ redirect: redirectUrl }, {
|
|
211
|
+
status: 200,
|
|
212
|
+
headers: { "X-Ubean-Redirect": redirectUrl }
|
|
213
|
+
});
|
|
214
|
+
return response;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (isPagesReq(c)) return response;
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Run a `ServerAction` (from a page module's `actions` map) against the
|
|
222
|
+
* current Hono context via `runAction` from `@ubean/actions`.
|
|
223
|
+
*
|
|
224
|
+
* Returns one of:
|
|
225
|
+
* - `{ response }` — handler returned/threw a `Response` (redirect)
|
|
226
|
+
* - `{ errors }` — validation failure, `fail()` from the handler, or a
|
|
227
|
+
* thrown error (mapped to `{ _error: message }`)
|
|
228
|
+
* - `{ data }` — successful return value (merged into page props)
|
|
229
|
+
*/
|
|
230
|
+
async function runServerAction(action, c) {
|
|
231
|
+
const result = await runAction(action, c);
|
|
232
|
+
if (result.response) return { response: result.response };
|
|
233
|
+
if (result.errors) return { errors: result.errors ?? {} };
|
|
234
|
+
if (result.error) return { errors: { _error: result.error.message } };
|
|
235
|
+
return { data: result.data };
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/route-rules.ts
|
|
239
|
+
function compileRulePath(pattern) {
|
|
240
|
+
const DOUBLE = "__DWC__";
|
|
241
|
+
const SINGLE = "__SWC__";
|
|
242
|
+
let s = pattern;
|
|
243
|
+
s = s.replace(/\/\*\*/g, `/${DOUBLE}`);
|
|
244
|
+
s = s.replace(/\*/g, SINGLE);
|
|
245
|
+
s = s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
246
|
+
s = s.replace(new RegExp(SINGLE, "g"), "[^/]*");
|
|
247
|
+
s = s.replace(new RegExp(`/${DOUBLE}`, "g"), "(?:/.*)?");
|
|
248
|
+
return new RegExp(`^${s}$`);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* 排序权重计算:提升更"具体"的规则优先匹配。
|
|
252
|
+
*
|
|
253
|
+
* P9-03 引入 `ssr`/`prerender`/`isr` 字段后,具体路径(如 `/admin/*`)应优先于
|
|
254
|
+
* 通配路径(如 `/**`)被匹配,以便 per-route 字段优先级正确。
|
|
255
|
+
*
|
|
256
|
+
* P9-04 扩展:`ppr` 字段同样参与 specificity 计算。
|
|
257
|
+
*/
|
|
258
|
+
function ruleSpecificity(rule) {
|
|
259
|
+
return (rule.redirect ? 3 : 0) + (rule.rewrite ? 2 : 0) + (rule.headers ? 1 : 0) + (rule.ssr !== void 0 ? 1 : 0) + (rule.isr !== void 0 ? 1 : 0) + (rule.prerender !== void 0 ? 1 : 0) + (rule.ppr !== void 0 ? 1 : 0);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* 路径特异性计算:作为 rule specificity 的 tiebreaker。
|
|
263
|
+
*
|
|
264
|
+
* - 字面段越多越具体(`/a/b/c` > `/a/b` > `/a`)
|
|
265
|
+
* - 通配符降低特异性(`**` 比 `*` 更不具体)
|
|
266
|
+
* - 用于在 rule specificity 相同时,让更具体的路径优先匹配
|
|
267
|
+
* (如 `/admin/profile` 优先于 `/admin/**`)
|
|
268
|
+
*/
|
|
269
|
+
function pathSpecificity(pattern) {
|
|
270
|
+
const segments = pattern.split("/").filter((s) => s.length > 0);
|
|
271
|
+
let score = 0;
|
|
272
|
+
for (const seg of segments) if (seg === "**") score -= 10;
|
|
273
|
+
else if (seg.includes("*")) score -= 1;
|
|
274
|
+
else score += 1;
|
|
275
|
+
return score;
|
|
276
|
+
}
|
|
277
|
+
function compileRouteRules(rules) {
|
|
278
|
+
return Object.entries(rules).map(([path, rule]) => ({
|
|
279
|
+
path,
|
|
280
|
+
pattern: compileRulePath(path),
|
|
281
|
+
rule
|
|
282
|
+
})).sort((a, b) => {
|
|
283
|
+
const ruleDiff = ruleSpecificity(b.rule) - ruleSpecificity(a.rule);
|
|
284
|
+
if (ruleDiff !== 0) return ruleDiff;
|
|
285
|
+
return pathSpecificity(b.path) - pathSpecificity(a.path);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* 规范化 `isr` 字段为 `IsrRule` 对象形式。
|
|
290
|
+
* `number` → `{ ttl: n }`,`undefined` → `undefined`。
|
|
291
|
+
*/
|
|
292
|
+
function normalizeIsrRule(isr) {
|
|
293
|
+
if (isr === void 0) return void 0;
|
|
294
|
+
if (typeof isr === "number") return { ttl: isr };
|
|
295
|
+
return isr;
|
|
296
|
+
}
|
|
297
|
+
function matchRouteRules(path, compiledRules) {
|
|
298
|
+
const matched = {};
|
|
299
|
+
for (const { pattern, rule } of compiledRules) if (pattern.test(path)) {
|
|
300
|
+
if (rule.headers) matched.headers = {
|
|
301
|
+
...matched.headers,
|
|
302
|
+
...rule.headers
|
|
303
|
+
};
|
|
304
|
+
if (rule.redirect && !matched.redirect) matched.redirect = rule.redirect;
|
|
305
|
+
if (rule.rewrite && !matched.rewrite) matched.rewrite = rule.rewrite;
|
|
306
|
+
if (rule.cache) matched.cache = {
|
|
307
|
+
...matched.cache,
|
|
308
|
+
...rule.cache
|
|
309
|
+
};
|
|
310
|
+
if (rule.ssr !== void 0 && matched.ssr === void 0) matched.ssr = rule.ssr;
|
|
311
|
+
if (rule.prerender !== void 0 && matched.prerender === void 0) matched.prerender = rule.prerender;
|
|
312
|
+
if (rule.isr !== void 0 && matched.isr === void 0) matched.isr = rule.isr;
|
|
313
|
+
if (rule.ppr !== void 0 && matched.ppr === void 0) matched.ppr = rule.ppr;
|
|
314
|
+
}
|
|
315
|
+
return matched;
|
|
316
|
+
}
|
|
317
|
+
function resolveRedirectUrl(redirect, currentPath) {
|
|
318
|
+
if (typeof redirect === "string") return {
|
|
319
|
+
url: redirect,
|
|
320
|
+
statusCode: 307
|
|
321
|
+
};
|
|
322
|
+
let url = redirect.to;
|
|
323
|
+
if (url.startsWith("./") || url.startsWith("../")) {
|
|
324
|
+
const base = currentPath.replace(/\/[^/]*$/, "/");
|
|
325
|
+
url = new URL(url, `http://localhost${base}`).pathname;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
url,
|
|
329
|
+
statusCode: redirect.statusCode || 307
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function buildCacheControlHeader(cache) {
|
|
333
|
+
if (!cache) return null;
|
|
334
|
+
const parts = [];
|
|
335
|
+
if (cache.ttl !== void 0) {
|
|
336
|
+
parts.push(`public, max-age=${cache.ttl}`);
|
|
337
|
+
if (cache.swr) parts.push(`stale-while-revalidate=${cache.ttl}`);
|
|
338
|
+
}
|
|
339
|
+
return parts.length > 0 ? parts.join(", ") : null;
|
|
340
|
+
}
|
|
341
|
+
function createRouteRulesMiddleware(rules) {
|
|
342
|
+
const compiled = compileRouteRules(rules);
|
|
343
|
+
return async function routeRulesMiddleware(c, next) {
|
|
344
|
+
const path = c.req.path;
|
|
345
|
+
const matched = matchRouteRules(path, compiled);
|
|
346
|
+
if (Object.keys(matched).length > 0) c.set("routeRule", matched);
|
|
347
|
+
if (Object.keys(matched).length === 0) {
|
|
348
|
+
await next();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (matched.redirect) {
|
|
352
|
+
const { url, statusCode } = resolveRedirectUrl(matched.redirect, path);
|
|
353
|
+
return c.redirect(url, statusCode);
|
|
354
|
+
}
|
|
355
|
+
if (matched.headers) for (const [key, value] of Object.entries(matched.headers)) c.header(key, value);
|
|
356
|
+
const cacheHeader = buildCacheControlHeader(matched.cache);
|
|
357
|
+
if (cacheHeader) c.header("Cache-Control", cacheHeader);
|
|
358
|
+
await next();
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/isr.ts
|
|
363
|
+
const ISR_KEY_PREFIX = "isr:";
|
|
364
|
+
const REVALIDATING = /* @__PURE__ */ new Set();
|
|
365
|
+
function buildIsrCacheKey(pathname) {
|
|
366
|
+
return `${ISR_KEY_PREFIX}${pathname}`;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 读取缓存的 ISR entry(仅未过期)。
|
|
370
|
+
*/
|
|
371
|
+
async function getIsrCache(store, pathname) {
|
|
372
|
+
const key = buildIsrCacheKey(pathname);
|
|
373
|
+
const entry = await store.get(key);
|
|
374
|
+
if (!entry) return void 0;
|
|
375
|
+
return {
|
|
376
|
+
html: new TextDecoder().decode(entry.body),
|
|
377
|
+
status: entry.status,
|
|
378
|
+
headers: entry.headers
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* 读取缓存的 ISR entry(即使已过期,用于 SWR)。
|
|
383
|
+
* 后端不支持 `peek` 时退化为 `get`(无 SWR)。
|
|
384
|
+
*/
|
|
385
|
+
async function getStaleIsrCache(store, pathname) {
|
|
386
|
+
if (!store.peek) return getIsrCache(store, pathname);
|
|
387
|
+
const key = buildIsrCacheKey(pathname);
|
|
388
|
+
const entry = await store.peek(key);
|
|
389
|
+
if (!entry) return void 0;
|
|
390
|
+
return {
|
|
391
|
+
html: new TextDecoder().decode(entry.body),
|
|
392
|
+
status: entry.status,
|
|
393
|
+
headers: entry.headers
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* 写入 ISR 缓存。
|
|
398
|
+
*/
|
|
399
|
+
async function setIsrCache(store, pathname, entry, ttl) {
|
|
400
|
+
const key = buildIsrCacheKey(pathname);
|
|
401
|
+
const body = new TextEncoder().encode(entry.html).buffer;
|
|
402
|
+
await store.set(key, {
|
|
403
|
+
body,
|
|
404
|
+
headers: entry.headers,
|
|
405
|
+
status: entry.status,
|
|
406
|
+
statusText: ""
|
|
407
|
+
}, ttl);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* 失效指定路径的 ISR 缓存。需要调用方提供 store(避免本包依赖全局单例)。
|
|
411
|
+
*/
|
|
412
|
+
async function invalidateIsrCache(store, pathname) {
|
|
413
|
+
return store.delete(buildIsrCacheKey(pathname));
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* 失效所有匹配模式的 ISR 缓存。仅对内存存储有效(读取底层 Map)。
|
|
417
|
+
*/
|
|
418
|
+
async function invalidateIsrCachePattern(store, pattern) {
|
|
419
|
+
const memStore = store;
|
|
420
|
+
if (!memStore.store) return 0;
|
|
421
|
+
let count = 0;
|
|
422
|
+
for (const k of Array.from(memStore.store.keys())) {
|
|
423
|
+
if (!k.startsWith(ISR_KEY_PREFIX)) continue;
|
|
424
|
+
const path = k.slice(4);
|
|
425
|
+
if (pattern.test(path)) {
|
|
426
|
+
await store.delete(k);
|
|
427
|
+
count++;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return count;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* 判断 entry 是否已过期。配合 `getStaleIsrCache` 用于 SWR 判定。
|
|
434
|
+
* 仅内存存储可用 —— 其他后端在 `getStaleIsrCache` 已退化为 `get`(永远 fresh),
|
|
435
|
+
* 不会进入此分支。
|
|
436
|
+
*/
|
|
437
|
+
function isIsrEntryStale(pathname, store) {
|
|
438
|
+
const memStore = store;
|
|
439
|
+
if (!memStore.store) return false;
|
|
440
|
+
const entry = memStore.store.get(buildIsrCacheKey(pathname));
|
|
441
|
+
if (!entry?.expiresAt) return false;
|
|
442
|
+
return Date.now() > entry.expiresAt;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* ISR 主入口:检查缓存 → 命中则返回,未命中/过期则调用 render 重新生成。
|
|
446
|
+
*
|
|
447
|
+
* 返回 `Response` 表示 ISR 处理完成(直接返回给客户端),
|
|
448
|
+
* 返回 `undefined` 表示无 ISR 规则或调用方应自行处理(由 `serveIsr` 内部逻辑决定)。
|
|
449
|
+
*
|
|
450
|
+
* 实现说明:使用 `peek`(若可用)而非 `get` 来读取缓存,避免 `get` 在
|
|
451
|
+
* 过期时删除 entry 导致 SWR 无法读取旧内容。后端不支持 `peek` 时退化为 `get`
|
|
452
|
+
* (SWR 自动降级为同步重新生成)。
|
|
453
|
+
*/
|
|
454
|
+
async function serveIsr(_c, options) {
|
|
455
|
+
const store = options.store;
|
|
456
|
+
const { pathname, rule, render } = options;
|
|
457
|
+
const key = buildIsrCacheKey(pathname);
|
|
458
|
+
const rawEntry = store.peek ? await store.peek(key) : await store.get(key);
|
|
459
|
+
if (rawEntry) {
|
|
460
|
+
const isStale = Date.now() > rawEntry.expiresAt;
|
|
461
|
+
const html = new TextDecoder().decode(rawEntry.body);
|
|
462
|
+
if (!isStale) {
|
|
463
|
+
const headers = new Headers(rawEntry.headers);
|
|
464
|
+
headers.set("X-ISR", "HIT");
|
|
465
|
+
return new Response(html, {
|
|
466
|
+
status: rawEntry.status,
|
|
467
|
+
headers
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (rule.swr) {
|
|
471
|
+
const revalKey = key;
|
|
472
|
+
if (!REVALIDATING.has(revalKey)) {
|
|
473
|
+
REVALIDATING.add(revalKey);
|
|
474
|
+
render().then(async (result) => {
|
|
475
|
+
await setIsrCache(store, pathname, {
|
|
476
|
+
html: result.html,
|
|
477
|
+
status: result.status ?? 200,
|
|
478
|
+
headers: result.headers ?? { "Content-Type": "text/html; charset=utf-8" }
|
|
479
|
+
}, rule.ttl);
|
|
480
|
+
}).catch(() => {}).finally(() => {
|
|
481
|
+
REVALIDATING.delete(revalKey);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
const headers = new Headers(rawEntry.headers);
|
|
485
|
+
headers.set("X-ISR", "STALE");
|
|
486
|
+
return new Response(html, {
|
|
487
|
+
status: rawEntry.status,
|
|
488
|
+
headers
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const result = await render();
|
|
493
|
+
const status = result.status ?? 200;
|
|
494
|
+
const headers = result.headers ?? { "Content-Type": "text/html; charset=utf-8" };
|
|
495
|
+
if (status >= 200 && status < 300) await setIsrCache(store, pathname, {
|
|
496
|
+
html: result.html,
|
|
497
|
+
status,
|
|
498
|
+
headers
|
|
499
|
+
}, rule.ttl);
|
|
500
|
+
const respHeaders = new Headers(headers);
|
|
501
|
+
respHeaders.set("X-ISR", "MISS");
|
|
502
|
+
return new Response(result.html, {
|
|
503
|
+
status,
|
|
504
|
+
headers: respHeaders
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* 从 context 读取匹配到的 routeRule,提取 ISR 规则。
|
|
509
|
+
* 返回 `undefined` 表示该路由未启用 ISR。
|
|
510
|
+
*/
|
|
511
|
+
function getIsrRuleFromContext(c) {
|
|
512
|
+
const rule = c.get("routeRule");
|
|
513
|
+
if (!rule?.isr) return void 0;
|
|
514
|
+
return normalizeIsrRule(rule.isr);
|
|
515
|
+
}
|
|
516
|
+
//#endregion
|
|
517
|
+
//#region src/router.ts
|
|
518
|
+
const HTTP_METHODS = [
|
|
519
|
+
"GET",
|
|
520
|
+
"POST",
|
|
521
|
+
"PUT",
|
|
522
|
+
"PATCH",
|
|
523
|
+
"DELETE",
|
|
524
|
+
"HEAD",
|
|
525
|
+
"OPTIONS"
|
|
526
|
+
];
|
|
527
|
+
function normalizeMethod(m) {
|
|
528
|
+
if (!m) return void 0;
|
|
529
|
+
const upper = m.toUpperCase();
|
|
530
|
+
return HTTP_METHODS.includes(upper) ? upper : void 0;
|
|
531
|
+
}
|
|
532
|
+
function convertUbeanRoutePath(path) {
|
|
533
|
+
let honoPath = path;
|
|
534
|
+
if (/\/\*\*:/.test(honoPath)) honoPath = "*";
|
|
535
|
+
honoPath = honoPath.replace(/\[(\.\.\.)?([^\]]+)\]/g, (_m, rest, name) => {
|
|
536
|
+
if (rest) return `*${name}`;
|
|
537
|
+
return `:${name}`;
|
|
538
|
+
});
|
|
539
|
+
return honoPath;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Sort page routes so catch-all routes (`/**:slug`, converted to Hono `*`)
|
|
543
|
+
* are registered last. Hono's RegExpRouter matches in registration order,
|
|
544
|
+
* so a catch-all registered before a specific path (e.g. `/`) would swallow
|
|
545
|
+
* it. File-system sort order puts `[...slug].vue` before `index.vue`
|
|
546
|
+
* (`[` < `i`), which is wrong for Hono — this sort corrects it.
|
|
547
|
+
*
|
|
548
|
+
* Stable: non-catch-all pages keep their original relative order.
|
|
549
|
+
*/
|
|
550
|
+
function sortPagesForRegistration(pages) {
|
|
551
|
+
return [...pages].sort((a, b) => {
|
|
552
|
+
const aCatchall = /\/\*\*:/.test(a.route);
|
|
553
|
+
const bCatchall = /\/\*\*:/.test(b.route);
|
|
554
|
+
if (aCatchall && !bCatchall) return 1;
|
|
555
|
+
if (!aCatchall && bCatchall) return -1;
|
|
556
|
+
return 0;
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
function middlewarePathToHonoPath(relativePath) {
|
|
560
|
+
const segments = relativePath.replace(/\.(mjs|js|jsx|cjs|ts|tsx|mts|cts)$/i, "").split("/");
|
|
561
|
+
if (segments.length === 1) return "/*";
|
|
562
|
+
return `${`/${segments.slice(0, -1).join("/")}`}/*`;
|
|
563
|
+
}
|
|
564
|
+
function matchMiddlewarePath(mountPath, routePath) {
|
|
565
|
+
if (mountPath === "*" || mountPath === "/*") return true;
|
|
566
|
+
if (mountPath.endsWith("/*")) {
|
|
567
|
+
const prefix = mountPath.slice(0, -1);
|
|
568
|
+
return routePath === prefix.slice(0, -1) || routePath.startsWith(prefix);
|
|
569
|
+
}
|
|
570
|
+
return routePath === mountPath;
|
|
571
|
+
}
|
|
572
|
+
function resolveRouteExport(mod, method) {
|
|
573
|
+
const namedHandler = mod[method];
|
|
574
|
+
if (isHandlerChain(namedHandler)) return namedHandler;
|
|
575
|
+
if (typeof namedHandler === "function") return namedHandler;
|
|
576
|
+
const lowerHandler = mod[method.toLowerCase()];
|
|
577
|
+
if (isHandlerChain(lowerHandler)) return lowerHandler;
|
|
578
|
+
if (typeof lowerHandler === "function") return lowerHandler;
|
|
579
|
+
if (isHandlerChain(mod.default)) return mod.default;
|
|
580
|
+
if (typeof mod.default === "function") return mod.default;
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
let _pagesMod;
|
|
584
|
+
let _i18nMod;
|
|
585
|
+
async function loadPages() {
|
|
586
|
+
if (_pagesMod !== void 0) return _pagesMod;
|
|
587
|
+
try {
|
|
588
|
+
_pagesMod = await import("@ubean/pages");
|
|
589
|
+
} catch {
|
|
590
|
+
_pagesMod = null;
|
|
591
|
+
}
|
|
592
|
+
return _pagesMod;
|
|
593
|
+
}
|
|
594
|
+
async function loadI18n() {
|
|
595
|
+
if (_i18nMod !== void 0) return _i18nMod;
|
|
596
|
+
try {
|
|
597
|
+
_i18nMod = await import("@ubean/i18n");
|
|
598
|
+
} catch {
|
|
599
|
+
_i18nMod = null;
|
|
600
|
+
}
|
|
601
|
+
return _i18nMod;
|
|
602
|
+
}
|
|
603
|
+
async function registerApiRoutes(app, options) {
|
|
604
|
+
const { routes, middleware, routeLoaders, middlewareLoaders } = options;
|
|
605
|
+
const sortedMiddleware = [...middleware].sort((a, b) => a.order - b.order);
|
|
606
|
+
const loadedUserMiddleware = [];
|
|
607
|
+
for (const mw of sortedMiddleware) {
|
|
608
|
+
await app.hooks.callHook("middleware:register", mw);
|
|
609
|
+
const loader = middlewareLoaders[mw.relativePath];
|
|
610
|
+
if (loader) try {
|
|
611
|
+
const mod = await loader();
|
|
612
|
+
if (mod.default) {
|
|
613
|
+
const mountPath = mw.global ? "*" : middlewarePathToHonoPath(mw.relativePath);
|
|
614
|
+
loadedUserMiddleware.push({
|
|
615
|
+
mountPath,
|
|
616
|
+
handler: mod.default
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
} catch {}
|
|
620
|
+
}
|
|
621
|
+
function getMatchingMiddleware(routePath) {
|
|
622
|
+
return loadedUserMiddleware.filter((mw) => matchMiddlewarePath(mw.mountPath, routePath)).map((mw) => mw.handler);
|
|
623
|
+
}
|
|
624
|
+
const routesByPath = /* @__PURE__ */ new Map();
|
|
625
|
+
const routeFileGroups = /* @__PURE__ */ new Map();
|
|
626
|
+
for (const route of routes) {
|
|
627
|
+
const key = route.relativePath;
|
|
628
|
+
const group = routeFileGroups.get(key);
|
|
629
|
+
if (group) group.push(route);
|
|
630
|
+
else routeFileGroups.set(key, [route]);
|
|
631
|
+
}
|
|
632
|
+
for (const [relativePath, routeGroup] of routeFileGroups) {
|
|
633
|
+
const loader = routeLoaders[relativePath];
|
|
634
|
+
if (!loader) continue;
|
|
635
|
+
const first = routeGroup[0];
|
|
636
|
+
const honoPath = convertUbeanRoutePath(first.route);
|
|
637
|
+
let pathMethods = routesByPath.get(honoPath);
|
|
638
|
+
if (!pathMethods) {
|
|
639
|
+
pathMethods = /* @__PURE__ */ new Map();
|
|
640
|
+
routesByPath.set(honoPath, pathMethods);
|
|
641
|
+
}
|
|
642
|
+
let mod;
|
|
643
|
+
try {
|
|
644
|
+
mod = await loader();
|
|
645
|
+
} catch {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
for (const route of routeGroup) {
|
|
649
|
+
const method = normalizeMethod(route.method);
|
|
650
|
+
if (!method) continue;
|
|
651
|
+
const resolved = resolveRouteExport(mod, method);
|
|
652
|
+
if (resolved) pathMethods.set(method, {
|
|
653
|
+
definition: resolved,
|
|
654
|
+
fileMeta: first.fileMeta,
|
|
655
|
+
matchers: first.matchers
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
for (const [honoPath, pathMethods] of routesByPath) for (const [method, entry] of pathMethods) {
|
|
660
|
+
const { definition, fileMeta, matchers } = entry;
|
|
661
|
+
const matchingMiddleware = getMatchingMiddleware(honoPath);
|
|
662
|
+
const matcherMiddleware = async (c, next) => {
|
|
663
|
+
if (!matchers) {
|
|
664
|
+
await next();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const params = c.req.param();
|
|
668
|
+
if (!validateParams(matchers, params)) return c.json({
|
|
669
|
+
error: "Not Found",
|
|
670
|
+
path: c.req.path,
|
|
671
|
+
method: c.req.method
|
|
672
|
+
}, 404);
|
|
673
|
+
await next();
|
|
674
|
+
};
|
|
675
|
+
if (isHandlerChain(definition)) {
|
|
676
|
+
const routeMeta = { ...extractRouteMeta(definition) };
|
|
677
|
+
if (fileMeta) Object.assign(routeMeta, fileMeta);
|
|
678
|
+
await app.hooks.callHook("route:register", {
|
|
679
|
+
method,
|
|
680
|
+
path: honoPath,
|
|
681
|
+
handler: definition,
|
|
682
|
+
meta: routeMeta
|
|
683
|
+
});
|
|
684
|
+
const metaMiddleware = async (c, next) => {
|
|
685
|
+
c.set("route", {
|
|
686
|
+
meta: routeMeta,
|
|
687
|
+
path: c.req.path,
|
|
688
|
+
method: c.req.method
|
|
689
|
+
});
|
|
690
|
+
await next();
|
|
691
|
+
};
|
|
692
|
+
const honoHandlers = [
|
|
693
|
+
metaMiddleware,
|
|
694
|
+
matcherMiddleware,
|
|
695
|
+
...matchingMiddleware,
|
|
696
|
+
...definition
|
|
697
|
+
];
|
|
698
|
+
app.on(method.toLowerCase(), honoPath, ...honoHandlers);
|
|
699
|
+
} else {
|
|
700
|
+
const routeMeta = {
|
|
701
|
+
requiresAuth: true,
|
|
702
|
+
...fileMeta
|
|
703
|
+
};
|
|
704
|
+
const metaMiddleware = async (c, next) => {
|
|
705
|
+
c.set("route", {
|
|
706
|
+
meta: routeMeta,
|
|
707
|
+
path: c.req.path,
|
|
708
|
+
method: c.req.method
|
|
709
|
+
});
|
|
710
|
+
await next();
|
|
711
|
+
};
|
|
712
|
+
const handlerWrapper = async (c) => {
|
|
713
|
+
const result = await definition(c);
|
|
714
|
+
if (result instanceof Response) return result;
|
|
715
|
+
if (typeof result === "string") return new Response(result, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
716
|
+
return new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json; charset=utf-8" } });
|
|
717
|
+
};
|
|
718
|
+
await app.hooks.callHook("route:register", {
|
|
719
|
+
method,
|
|
720
|
+
path: honoPath,
|
|
721
|
+
handler: definition,
|
|
722
|
+
meta: routeMeta
|
|
723
|
+
});
|
|
724
|
+
const honoHandlers = [
|
|
725
|
+
metaMiddleware,
|
|
726
|
+
matcherMiddleware,
|
|
727
|
+
...matchingMiddleware,
|
|
728
|
+
handlerWrapper
|
|
729
|
+
];
|
|
730
|
+
app.on(method.toLowerCase(), honoPath, ...honoHandlers);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
async function registerPageRoutes(app, options) {
|
|
735
|
+
const { pages, layouts = [], middleware, middlewareLoaders, i18nConfig } = options;
|
|
736
|
+
const pagesMod = await loadPages();
|
|
737
|
+
if (!pagesMod) return;
|
|
738
|
+
const sortedMiddleware = [...middleware].sort((a, b) => a.order - b.order);
|
|
739
|
+
const loadedUserMiddleware = [];
|
|
740
|
+
for (const mw of sortedMiddleware) {
|
|
741
|
+
const loader = middlewareLoaders[mw.relativePath];
|
|
742
|
+
if (loader) try {
|
|
743
|
+
const mod = await loader();
|
|
744
|
+
if (mod.default) {
|
|
745
|
+
const mountPath = mw.global ? "*" : middlewarePathToHonoPath(mw.relativePath);
|
|
746
|
+
loadedUserMiddleware.push({
|
|
747
|
+
mountPath,
|
|
748
|
+
handler: mod.default
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
} catch {}
|
|
752
|
+
}
|
|
753
|
+
function getMatchingMiddleware(routePath) {
|
|
754
|
+
return loadedUserMiddleware.filter((mw) => matchMiddlewarePath(mw.mountPath, routePath)).map((mw) => mw.handler);
|
|
755
|
+
}
|
|
756
|
+
function getLocalePrefixedPaths(honoPath) {
|
|
757
|
+
if (!i18nConfig) return [];
|
|
758
|
+
const { strategy, defaultLocale, locales = [] } = i18nConfig;
|
|
759
|
+
if (strategy === "no_prefix") return [];
|
|
760
|
+
const result = [];
|
|
761
|
+
for (const locale of locales) {
|
|
762
|
+
if (strategy === "prefix_except_default" && locale === defaultLocale) continue;
|
|
763
|
+
const cleanPath = honoPath === "/" ? "" : honoPath;
|
|
764
|
+
result.push({
|
|
765
|
+
locale,
|
|
766
|
+
path: `/${locale}${cleanPath}`
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
return result;
|
|
770
|
+
}
|
|
771
|
+
const hasDefaultLayout = layouts.some((l) => l.isDefault);
|
|
772
|
+
const { isPagesRequest, pageJsonResponse, renderPage, renderPageToStream } = pagesMod;
|
|
773
|
+
const pageLoaders = options.pageLoaders;
|
|
774
|
+
const pageRenderer = options.pageRenderer;
|
|
775
|
+
const pageAssetTags = options.pageAssetTags;
|
|
776
|
+
const ssrExclude = options.ssrExclude ?? [];
|
|
777
|
+
const streaming = options.streaming === true;
|
|
778
|
+
const botFallback = options.botFallback !== false;
|
|
779
|
+
const cacheStore = options.cacheStore;
|
|
780
|
+
const colorModeScript = options.colorModeScript;
|
|
781
|
+
const injectColorMode = (html) => colorModeScript ? html.replace("<head>", `<head>\n ${colorModeScript}`) : html;
|
|
782
|
+
async function handlePageRequest(c, page, method, loaderKey) {
|
|
783
|
+
c.set("route", {
|
|
784
|
+
meta: { requiresAuth: page.pageMeta?.requiresAuth ?? true },
|
|
785
|
+
path: c.req.path,
|
|
786
|
+
method: c.req.method
|
|
787
|
+
});
|
|
788
|
+
let loader;
|
|
789
|
+
let mod;
|
|
790
|
+
let loaderResult;
|
|
791
|
+
let actionResult;
|
|
792
|
+
let actionErrors = null;
|
|
793
|
+
if (pageLoaders) {
|
|
794
|
+
loader = pageLoaders[loaderKey ?? page.relativePath];
|
|
795
|
+
if (loader) try {
|
|
796
|
+
mod = await loader();
|
|
797
|
+
} catch {}
|
|
798
|
+
}
|
|
799
|
+
if (method === "POST") {
|
|
800
|
+
const actionsExport = mod?.actions;
|
|
801
|
+
if (actionsExport && typeof actionsExport === "object") {
|
|
802
|
+
const action = actionsExport[parseFormActionName(c.req.url)];
|
|
803
|
+
if (action && isServerAction(action)) {
|
|
804
|
+
const res = await runServerAction(action, c);
|
|
805
|
+
if (res.response) {
|
|
806
|
+
const handled = handleActionResponse(c, res.response, isPagesRequest);
|
|
807
|
+
if (handled) return handled;
|
|
808
|
+
actionResult = res.response;
|
|
809
|
+
} else if (res.errors) actionErrors = res.errors;
|
|
810
|
+
else actionResult = res.data;
|
|
811
|
+
}
|
|
812
|
+
} else if (mod?.action && typeof mod.action === "function") try {
|
|
813
|
+
actionResult = await mod.action(c);
|
|
814
|
+
if (actionResult instanceof Response) {
|
|
815
|
+
const handled = handleActionResponse(c, actionResult, isPagesRequest);
|
|
816
|
+
if (handled) return handled;
|
|
817
|
+
}
|
|
818
|
+
if (actionResult && typeof actionResult === "object" && "errors" in actionResult) {
|
|
819
|
+
actionErrors = actionResult.errors;
|
|
820
|
+
actionResult = void 0;
|
|
821
|
+
}
|
|
822
|
+
} catch (err) {
|
|
823
|
+
if (err instanceof Response) {
|
|
824
|
+
const handled = handleActionResponse(c, err, isPagesRequest);
|
|
825
|
+
if (handled) return handled;
|
|
826
|
+
return err;
|
|
827
|
+
}
|
|
828
|
+
if (isPagesRequest(c)) return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
829
|
+
actionErrors = { _error: err instanceof Error ? err.message : String(err) };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (mod?.loader && typeof mod.loader === "function") try {
|
|
833
|
+
loaderResult = await mod.loader(c);
|
|
834
|
+
if (loaderResult instanceof Response) return loaderResult;
|
|
835
|
+
} catch {}
|
|
836
|
+
const props = { ...loaderResult };
|
|
837
|
+
if (actionResult !== void 0 && actionResult && typeof actionResult === "object") Object.assign(props, actionResult);
|
|
838
|
+
const pageObj = {
|
|
839
|
+
component: page.reuseTarget || page.name,
|
|
840
|
+
props,
|
|
841
|
+
params: c.req.param(),
|
|
842
|
+
url: (c.get("pathWithoutLocale") || c.req.path) + (c.req.url.includes("?") ? new URL(c.req.url).search : ""),
|
|
843
|
+
layout: page.layout === false ? false : page.layout || page.pageMeta?.layout || (hasDefaultLayout ? "default" : false),
|
|
844
|
+
errors: actionErrors,
|
|
845
|
+
head: page.pageMeta?.head
|
|
846
|
+
};
|
|
847
|
+
if (isPagesRequest(c)) return pageJsonResponse(pageObj);
|
|
848
|
+
if (method === "POST" && actionResult instanceof Response) return actionResult;
|
|
849
|
+
const currentLocale = c.get("locale") || "";
|
|
850
|
+
const renderContext = {
|
|
851
|
+
locale: currentLocale,
|
|
852
|
+
localeDir: "ltr"
|
|
853
|
+
};
|
|
854
|
+
const i18nMod = await loadI18n();
|
|
855
|
+
if (i18nMod && currentLocale) {
|
|
856
|
+
renderContext.localeDir = i18nMod.getLocaleDir(currentLocale);
|
|
857
|
+
renderContext.messages = i18nMod.getLocaleMessages(currentLocale);
|
|
858
|
+
renderContext.availableLocales = i18nMod.getRegisteredLocalesMeta();
|
|
859
|
+
}
|
|
860
|
+
const routeRule = c.get("routeRule");
|
|
861
|
+
const perRouteSsr = routeRule?.ssr;
|
|
862
|
+
const isrRule = normalizeIsrRule(routeRule?.isr);
|
|
863
|
+
const pprEnabled = routeRule?.ppr === true;
|
|
864
|
+
let excluded = matchAnyGlob(page.route, ssrExclude);
|
|
865
|
+
let routeStreaming = streaming;
|
|
866
|
+
if (perRouteSsr === false) excluded = true;
|
|
867
|
+
else if (perRouteSsr === true) excluded = false;
|
|
868
|
+
else if (perRouteSsr === "streaming") {
|
|
869
|
+
excluded = false;
|
|
870
|
+
routeStreaming = true;
|
|
871
|
+
} else if (pprEnabled) {
|
|
872
|
+
excluded = false;
|
|
873
|
+
routeStreaming = true;
|
|
874
|
+
}
|
|
875
|
+
const renderer = excluded ? null : pageRenderer ?? null;
|
|
876
|
+
if (isrRule && cacheStore && method === "GET") {
|
|
877
|
+
const pathname = new URL(c.req.url).pathname;
|
|
878
|
+
const isrResponse = await serveIsr(c, {
|
|
879
|
+
pathname,
|
|
880
|
+
rule: isrRule,
|
|
881
|
+
store: cacheStore,
|
|
882
|
+
render: async () => {
|
|
883
|
+
return {
|
|
884
|
+
html: await renderPage(pageObj, pageAssetTags ?? {}, renderer, "app", renderContext),
|
|
885
|
+
status: 200,
|
|
886
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
if (isrResponse) return isrResponse;
|
|
891
|
+
}
|
|
892
|
+
const userAgent = c.req.header("user-agent");
|
|
893
|
+
const isBot = botFallback && isBotUserAgent(userAgent);
|
|
894
|
+
if (routeStreaming && renderer && !isBot) {
|
|
895
|
+
const stream = renderPageToStream(pageObj, pageAssetTags ?? {}, renderer, "app", renderContext);
|
|
896
|
+
const headers = { "Content-Type": "text/html; charset=utf-8" };
|
|
897
|
+
if (pprEnabled) headers["X-PPR"] = "true";
|
|
898
|
+
return c.body(stream, {
|
|
899
|
+
headers,
|
|
900
|
+
...method === "POST" && actionErrors ? { status: 422 } : {}
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
const html = await renderPage(pageObj, pageAssetTags ?? {}, renderer, "app", renderContext);
|
|
904
|
+
return c.html(injectColorMode(html), method === "POST" && actionErrors ? { status: 422 } : void 0);
|
|
905
|
+
}
|
|
906
|
+
const pageByName = /* @__PURE__ */ new Map();
|
|
907
|
+
for (const p of pages) pageByName.set(p.name, p);
|
|
908
|
+
const sortedPages = sortPagesForRegistration(pages);
|
|
909
|
+
for (const page of sortedPages) {
|
|
910
|
+
const honoPath = convertUbeanRoutePath(page.route);
|
|
911
|
+
const matchingMiddleware = getMatchingMiddleware(honoPath);
|
|
912
|
+
const pageMeta = { requiresAuth: page.pageMeta?.requiresAuth ?? true };
|
|
913
|
+
const metaMiddleware = async (c, next) => {
|
|
914
|
+
c.set("route", {
|
|
915
|
+
meta: pageMeta,
|
|
916
|
+
path: c.req.path,
|
|
917
|
+
method: c.req.method
|
|
918
|
+
});
|
|
919
|
+
await next();
|
|
920
|
+
};
|
|
921
|
+
const matcherMiddleware = async (c, next) => {
|
|
922
|
+
if (!page.matchers) {
|
|
923
|
+
await next();
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const params = c.req.param();
|
|
927
|
+
if (!validateParams(page.matchers, params)) return c.json({
|
|
928
|
+
error: "Not Found",
|
|
929
|
+
path: c.req.path,
|
|
930
|
+
method: c.req.method
|
|
931
|
+
}, 404);
|
|
932
|
+
await next();
|
|
933
|
+
};
|
|
934
|
+
const loaderKey = page.isReuse && page.reuseTarget ? pageByName.get(page.reuseTarget)?.relativePath ?? page.relativePath : page.relativePath;
|
|
935
|
+
const pageHandler = (method) => async (c) => handlePageRequest(c, page, method, loaderKey);
|
|
936
|
+
app.on(["GET"], honoPath, metaMiddleware, matcherMiddleware, ...matchingMiddleware, pageHandler("GET"));
|
|
937
|
+
app.on(["POST"], honoPath, metaMiddleware, matcherMiddleware, ...matchingMiddleware, pageHandler("POST"));
|
|
938
|
+
for (const { path: localePath } of getLocalePrefixedPaths(honoPath)) {
|
|
939
|
+
const localeMetaMiddleware = async (c, next) => {
|
|
940
|
+
c.set("route", {
|
|
941
|
+
meta: pageMeta,
|
|
942
|
+
path: c.req.path,
|
|
943
|
+
method: c.req.method
|
|
944
|
+
});
|
|
945
|
+
await next();
|
|
946
|
+
};
|
|
947
|
+
app.on(["GET"], localePath, localeMetaMiddleware, matcherMiddleware, ...matchingMiddleware, pageHandler("GET"));
|
|
948
|
+
app.on(["POST"], localePath, localeMetaMiddleware, matcherMiddleware, ...matchingMiddleware, pageHandler("POST"));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (options.notFoundPage) {
|
|
952
|
+
const notFoundPage = options.notFoundPage;
|
|
953
|
+
const pageAssetTagsOpt = options.pageAssetTags;
|
|
954
|
+
const _ssrExclude = options.ssrExclude ?? [];
|
|
955
|
+
const pageRendererOpt = options.pageRenderer;
|
|
956
|
+
app.get("*", async (c) => {
|
|
957
|
+
const path = c.req.path;
|
|
958
|
+
if (path.startsWith("/api/") || path.startsWith("/_")) return c.json({
|
|
959
|
+
error: "Not Found",
|
|
960
|
+
path,
|
|
961
|
+
method: c.req.method
|
|
962
|
+
}, 404);
|
|
963
|
+
if (isPagesRequest(c)) return pageJsonResponse({
|
|
964
|
+
component: "NotFound",
|
|
965
|
+
props: {},
|
|
966
|
+
params: {},
|
|
967
|
+
url: path + (c.req.url.includes("?") ? new URL(c.req.url).search : ""),
|
|
968
|
+
layout: (Array.isArray(notFoundPage.layout) ? notFoundPage.layout[0] : notFoundPage.layout) ?? (layouts.some((l) => l.isDefault) ? "default" : false),
|
|
969
|
+
errors: null,
|
|
970
|
+
head: notFoundPage.pageMeta?.head
|
|
971
|
+
});
|
|
972
|
+
const currentLocale = c.get("locale") || "";
|
|
973
|
+
const renderContext = {
|
|
974
|
+
locale: currentLocale,
|
|
975
|
+
localeDir: "ltr"
|
|
976
|
+
};
|
|
977
|
+
const i18nMod = await loadI18n();
|
|
978
|
+
if (i18nMod && currentLocale) {
|
|
979
|
+
renderContext.localeDir = i18nMod.getLocaleDir(currentLocale);
|
|
980
|
+
renderContext.messages = i18nMod.getLocaleMessages(currentLocale);
|
|
981
|
+
renderContext.availableLocales = i18nMod.getRegisteredLocalesMeta();
|
|
982
|
+
}
|
|
983
|
+
const pageObj = {
|
|
984
|
+
component: "NotFound",
|
|
985
|
+
props: {},
|
|
986
|
+
params: {},
|
|
987
|
+
url: path + (c.req.url.includes("?") ? new URL(c.req.url).search : ""),
|
|
988
|
+
layout: notFoundPage.layout ?? (layouts.some((l) => l.isDefault) ? "default" : false),
|
|
989
|
+
errors: null,
|
|
990
|
+
head: notFoundPage.pageMeta?.head
|
|
991
|
+
};
|
|
992
|
+
const renderer = matchAnyGlob("/*", _ssrExclude) ? null : pageRendererOpt ?? null;
|
|
993
|
+
const userAgent = c.req.header("user-agent");
|
|
994
|
+
const isBot = botFallback && isBotUserAgent(userAgent);
|
|
995
|
+
if (streaming && renderer && !isBot) {
|
|
996
|
+
const stream = renderPageToStream(pageObj, pageAssetTagsOpt ?? {}, renderer, "app", renderContext);
|
|
997
|
+
return c.body(stream, {
|
|
998
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
999
|
+
status: 404
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
const html = await renderPage(pageObj, pageAssetTagsOpt ?? {}, renderer, "app", renderContext);
|
|
1003
|
+
return c.html(injectColorMode(html), 404);
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
async function registerRoutes(app, options) {
|
|
1008
|
+
await registerApiRoutes(app, options);
|
|
1009
|
+
await registerPageRoutes(app, options);
|
|
1010
|
+
}
|
|
1011
|
+
function createRouteLoader(importMetaGlob) {
|
|
1012
|
+
return Object.fromEntries(Object.entries(importMetaGlob).map(([key, loader]) => {
|
|
1013
|
+
return [key.replace(/^\.\//, ""), loader];
|
|
1014
|
+
}));
|
|
1015
|
+
}
|
|
1016
|
+
//#endregion
|
|
1017
|
+
//#region src/internal-fetch.ts
|
|
1018
|
+
const FETCHER_KEY = "__ubean_internal_fetcher__";
|
|
1019
|
+
function setInternalFetcher(fetcher) {
|
|
1020
|
+
globalThis[FETCHER_KEY] = fetcher;
|
|
1021
|
+
}
|
|
1022
|
+
function getInternalFetcher() {
|
|
1023
|
+
return globalThis[FETCHER_KEY] || null;
|
|
1024
|
+
}
|
|
1025
|
+
function clearInternalFetcher() {
|
|
1026
|
+
delete globalThis[FETCHER_KEY];
|
|
1027
|
+
}
|
|
1028
|
+
const FORWARD_HEADER_DEFAULTS = [
|
|
1029
|
+
"cookie",
|
|
1030
|
+
"authorization",
|
|
1031
|
+
"x-request-id",
|
|
1032
|
+
"x-forwarded-for",
|
|
1033
|
+
"x-forwarded-proto",
|
|
1034
|
+
"x-real-ip",
|
|
1035
|
+
"accept-language",
|
|
1036
|
+
"user-agent"
|
|
1037
|
+
];
|
|
1038
|
+
function applyContextHeaders(headers, c, forwardHeaders) {
|
|
1039
|
+
const names = forwardHeaders || FORWARD_HEADER_DEFAULTS;
|
|
1040
|
+
for (const name of names) {
|
|
1041
|
+
const value = c.req.header(name);
|
|
1042
|
+
if (value) headers.set(name, value);
|
|
1043
|
+
}
|
|
1044
|
+
const requestId = c.get("requestId");
|
|
1045
|
+
if (requestId) headers.set("x-request-id", requestId);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* 创建进程内调度适配器。
|
|
1049
|
+
*
|
|
1050
|
+
* 将注册的 Hono `app.fetch` 包装为 `@soybeanjs/fetch` 的 `FetchAdapter`,
|
|
1051
|
+
* 使其可通过 `createRequest` / `createTypedClient` / `toFlatTypedClient` 等 API
|
|
1052
|
+
* 进行进程内调度(不发起新的网络请求)。
|
|
1053
|
+
*
|
|
1054
|
+
* 消费者可直接组合使用:
|
|
1055
|
+
* ```typescript
|
|
1056
|
+
* import { createInternalAdapter } from '@ubean/routes';
|
|
1057
|
+
* import { createRequest, createTypedClient } from '@soybeanjs/fetch';
|
|
1058
|
+
*
|
|
1059
|
+
* const adapter = createInternalAdapter(c);
|
|
1060
|
+
* const request = createRequest({ adapter, retry: { retries: 0 } }, { isBackendSuccess: () => true });
|
|
1061
|
+
* const api = createTypedClient<paths>(request);
|
|
1062
|
+
* ```
|
|
1063
|
+
*
|
|
1064
|
+
* @param c - Hono Context(可选,用于转发 cookie/authorization 等请求头)
|
|
1065
|
+
* @param options - 选项(`headers` 自定义额外 header,`forwardHeaders` 自定义转发 header 列表)
|
|
1066
|
+
*/
|
|
1067
|
+
function createInternalAdapter(c, options) {
|
|
1068
|
+
const forwardHeaders = options?.forwardHeaders;
|
|
1069
|
+
const extraHeaders = options?.headers;
|
|
1070
|
+
return async (url, init) => {
|
|
1071
|
+
const fetcher = getInternalFetcher();
|
|
1072
|
+
if (!fetcher) throw new Error("[ubean] createInternalAdapter: fetcher not registered. Call setInternalFetcher() with your app.fetch first.");
|
|
1073
|
+
const headers = new Headers(init.headers);
|
|
1074
|
+
headers.set("x-internal-request", "1");
|
|
1075
|
+
if (c) applyContextHeaders(headers, c, forwardHeaders);
|
|
1076
|
+
if (extraHeaders) for (const [k, v] of Object.entries(extraHeaders)) headers.set(k, v);
|
|
1077
|
+
const requestUrl = url.startsWith("http") ? url : new URL(url, "http://internal");
|
|
1078
|
+
return fetcher(new Request(requestUrl, {
|
|
1079
|
+
...init,
|
|
1080
|
+
headers
|
|
1081
|
+
}));
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
//#endregion
|
|
1085
|
+
//#region src/openapi.ts
|
|
1086
|
+
function registerOpenAPIRoutes(app, options = {}) {
|
|
1087
|
+
const { scalarPath = "/_scalar", openAPIPath = "/_openapi.json", title = "API Reference" } = options;
|
|
1088
|
+
const openapiHandler = openAPIRouteHandler(app, { documentation: {
|
|
1089
|
+
info: {
|
|
1090
|
+
title: options.title || "UBEAN API",
|
|
1091
|
+
version: options.version || "1.0.0",
|
|
1092
|
+
description: options.description
|
|
1093
|
+
},
|
|
1094
|
+
servers: options.baseURL ? [{ url: options.baseURL }] : void 0
|
|
1095
|
+
} });
|
|
1096
|
+
app.get(openAPIPath, openapiHandler);
|
|
1097
|
+
app.get(scalarPath, (c) => {
|
|
1098
|
+
const scalarHTML = `<!doctype html>
|
|
1099
|
+
<html>
|
|
1100
|
+
<head>
|
|
1101
|
+
<meta charset="utf-8" />
|
|
1102
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1103
|
+
<title>${title}</title>
|
|
1104
|
+
<style>
|
|
1105
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1106
|
+
html, body { width: 100%; height: 100%; }
|
|
1107
|
+
body { background: #0f0f12; }
|
|
1108
|
+
</style>
|
|
1109
|
+
</head>
|
|
1110
|
+
<body>
|
|
1111
|
+
<script
|
|
1112
|
+
id="api-reference"
|
|
1113
|
+
data-url="${openAPIPath}"
|
|
1114
|
+
data-configuration='{
|
|
1115
|
+
"theme": "purple",
|
|
1116
|
+
"layout": "modern",
|
|
1117
|
+
"hideClientButton": false,
|
|
1118
|
+
"defaultHttpClient": { "targetKey": "shell", "clientKey": "curl" }
|
|
1119
|
+
}'
|
|
1120
|
+
><\/script>
|
|
1121
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"><\/script>
|
|
1122
|
+
</body>
|
|
1123
|
+
</html>`;
|
|
1124
|
+
return c.html(scalarHTML);
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
//#endregion
|
|
1128
|
+
export { UbeanRouter, buildIsrCacheKey, clearInternalFetcher, compileRouteRules, createInternalAdapter, createRouteLoader, createRouteRulesMiddleware, createServerRouter, defineHandler, defineHandlerMeta, defineMiddleware, extractRouteMeta, getInternalFetcher, getIsrCache, getIsrRuleFromContext, getStaleIsrCache, invalidateIsrCache, invalidateIsrCachePattern, isBotUserAgent, isHandlerChain, isIsrEntryStale, matchRouteRules, normalizeIsrRule, registerApiRoutes, registerOpenAPIRoutes, registerPageRoutes, registerRoutes, serveIsr, setInternalFetcher, setIsrCache, sortPagesForRegistration, useRouter };
|